From 1b171c7b52be98fedf9d4895ab261fb2aaf9f12e Mon Sep 17 00:00:00 2001 From: hl-valdemar Date: Thu, 22 Oct 2026 16:07:22 +0200 Subject: [PATCH] fix: reload tint colors while running --- README.md | 4 +- Sources/Conjure/ActionPill.swift | 40 ++++ .../ApplicationActionsController.swift | 58 +++-- Sources/Conjure/ConjureApp.swift | 1 + Sources/Conjure/EmojiPickerController.swift | 25 ++- Sources/Conjure/LauncherAlignmentGuides.swift | 20 +- Sources/Conjure/LauncherBackground.swift | 122 +++++++++-- .../Conjure/LauncherWindowController.swift | 65 +++++- Sources/Conjure/NotesController.swift | 15 +- Sources/Conjure/NotesTextView.swift | 65 +++++- .../Conjure/SettingsWindowController.swift | 17 ++ Sources/Conjure/ShortcutIndicator.swift | 14 +- Sources/Conjure/ThemeObservation.swift | 69 ++++++ Sources/Conjure/TranslationController.swift | 25 +++ .../TranslationSettingsController.swift | 11 + .../Conjure/UninstallReviewController.swift | 29 ++- .../LiveColorSchemeTests.swift | 207 ++++++++++++++++++ .../ConjureKitTests/LiveThemeViewsTests.swift | 151 +++++++++++++ 18 files changed, 877 insertions(+), 61 deletions(-) create mode 100644 Sources/Conjure/ThemeObservation.swift create mode 100644 Tests/ConjureKitTests/LiveColorSchemeTests.swift create mode 100644 Tests/ConjureKitTests/LiveThemeViewsTests.swift diff --git a/README.md b/README.md index 7f1a03d..c3b7049 100644 --- a/README.md +++ b/README.md @@ -165,13 +165,13 @@ accent = "#82aaff" # Optional personal override Precedence is personal `[colors]` → tint-generated colors → built-in sunset colors. Omit `[colors]` to follow tint without overrides. Omit `theme`, or set it to `"sunset"` before any table, to ignore the generated palette. tint never overwrites `config.toml`; edit that file, not the generated `theme.json`. Both processes must use the same configuration directory. -Conjure reads colors once at launch. Quit and reopen it after editing configuration or applying a tint theme, including automatic light/dark switches. Invalid TOML, invalid generated JSON, unknown keys, unsupported themes, invalid colors, or a missing selected tint palette cause a full fallback to sunset and a file-specific error in Settings. Normal launches open Settings to show the error; login launches leave it available for the next time Settings opens. +Conjure watches `config.toml` and the generated `theme.json` while running. Applying a tint theme, including automatic system light/dark switches, updates existing windows and tools without restarting or clearing searches, notes, selections, or translation results. Personal configuration edits also apply live. In-place saves and atomic file replacements are supported, including configuration directories created after launch. Invalid configuration at startup falls back to sunset and reports a file-specific error in Settings; invalid live edits keep the last valid palette and log the diagnostic, then recover automatically when the file is fixed. ## System theme Search for **Toggle System Theme**, identified by its centered 🌗 icon (try `theme` or `system`), and press Return to switch macOS from light to dark appearance or back. Selecting it in the launcher dismisses Conjure on success. Its global shortcut runs in the background without presenting a window or changing the current launcher view. You can favorite it or assign a shortcut in Settings; existing theme keybindings and favorites survive the rename. -The first use may ask permission to control **System Events**. Allow it under **System Settings → Privacy & Security → Automation → Conjure**. If permission is denied or macOS reports an error, Conjure shows the failure in the visible launcher or a standalone alert when the launcher is hidden. Conjure's own launcher keeps its configured colors and existing presentation; switching macOS appearance does not reload its palette. +The first use may ask permission to control **System Events**. Allow it under **System Settings → Privacy & Security → Automation → Conjure**. If permission is denied or macOS reports an error, Conjure shows the failure in the visible launcher or a standalone alert when the launcher is hidden. With `theme = "tint"`, Conjure follows the palette generated by tint's automatic appearance switching; its frosted-glass treatment and layout remain unchanged. Commands explicitly declare `Application.Command.presentsView`. The shared dispatcher routes action-only commands before any launcher presentation; new commands must choose their behavior in the exhaustive metadata switch. Command IDs remain independent of display names so renaming does not reset saved shortcuts, favorites, or usage. diff --git a/Sources/Conjure/ActionPill.swift b/Sources/Conjure/ActionPill.swift index 4da954c..53092d2 100644 --- a/Sources/Conjure/ActionPill.swift +++ b/Sources/Conjure/ActionPill.swift @@ -2,6 +2,7 @@ import AppKit @MainActor final class ActionPill: NSView { + private var themeObservation: ThemeObservation? init(primaryButton: NSButton, actionsButton: NSButton, primaryCommand: String? = nil) { super.init(frame: .zero) wantsLayer = true @@ -37,7 +38,46 @@ final class ActionPill: NSView { actionKeys.centerYAnchor.constraint(equalTo: centerYAnchor), actionKeys.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -12), ]) + themeObservation = ThemeObservation { [weak self, weak primaryButton, weak actionsButton] in + self?.layer?.borderColor = ThemePalette.accent.withAlphaComponent(0.35).cgColor + self?.layer?.backgroundColor = ThemePalette.background.withAlphaComponent(0.25).cgColor + primaryButton?.contentTintColor = ThemePalette.text + actionsButton?.contentTintColor = ThemePalette.text + } } required init?(coder: NSCoder) { fatalError("Conjure creates action pills programmatically") } } + +/// Keeps palette subscriptions scoped to the existing control's lifetime. +@MainActor +final class ThemeObservation { + nonisolated(unsafe) private let token: NSObjectProtocol + + init(_ apply: @escaping @MainActor () -> Void) { + apply() + token = NotificationCenter.default.addObserver( + forName: ThemePalette.didChangeNotification, object: nil, queue: .main + ) { _ in + MainActor.assumeIsolated { apply() } + } + } + + deinit { NotificationCenter.default.removeObserver(token) } +} + +extension NSSearchField { + /// Recolors the current presentation without changing the query or field-editor selection. + func applyTheme() { + textColor = ThemePalette.text + if let placeholderAttributedString { + let placeholder = NSMutableAttributedString(attributedString: placeholderAttributedString) + placeholder.addAttribute(.foregroundColor, value: ThemePalette.secondaryText, + range: NSRange(location: 0, length: placeholder.length)) + self.placeholderAttributedString = placeholder + } + if let editor = currentEditor() as? NSTextView { + editor.textColor = ThemePalette.text + } + } +} diff --git a/Sources/Conjure/ApplicationActionsController.swift b/Sources/Conjure/ApplicationActionsController.swift index ed15f6c..5eefde3 100644 --- a/Sources/Conjure/ApplicationActionsController.swift +++ b/Sources/Conjure/ApplicationActionsController.swift @@ -4,6 +4,7 @@ import ConjureKit @MainActor final class ApplicationActionsController: NSViewController, NSSearchFieldDelegate, NSTableViewDataSource, NSTableViewDelegate { private var search: ActionsSearchField! + private var themeObservation: ThemeObservation? private var background: LauncherBackground! private var entries: [ActionEntry] = [] private var visibleEntries: [Int] = [] @@ -74,23 +75,23 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat selectionScroll = nil selectionViewportHeight = nil entries = [ - ActionEntry(title: "Open", symbol: "arrow.up.forward.app", color: ThemePalette.text), - ActionEntry(title: "Favorite", symbol: "star", color: ThemePalette.text), - ActionEntry(title: "Show in Finder", symbol: "finder", color: ThemePalette.text), - ActionEntry(title: "Reset ranking", symbol: "arrow.counterclockwise", color: ThemePalette.text), - ActionEntry(title: "Quit application", symbol: "stop.circle", color: ThemePalette.text), - ActionEntry(title: "Uninstall application", symbol: "trash", color: ThemePalette.error), - ActionEntry(title: "Copy Emoji", symbol: "doc.on.doc", color: ThemePalette.text), - ActionEntry(title: "Change source language…", symbol: "character.bubble", color: ThemePalette.text), - ActionEntry(title: "Change target language…", symbol: "character.bubble", color: ThemePalette.text), - ActionEntry(title: "Swap languages", symbol: "arrow.left.arrow.right", color: ThemePalette.text), - ActionEntry(title: "MyMemory (no key)", symbol: "checkmark", color: ThemePalette.text), - ActionEntry(title: "OpenAI · gpt-6-luna", symbol: "checkmark", color: ThemePalette.text), - ActionEntry(title: "Browse Notes", symbol: "list.bullet", color: ThemePalette.text, commandKey: "P"), - ActionEntry(title: "New Note", symbol: "square.and.pencil", color: ThemePalette.text, commandKey: "N"), - ActionEntry(title: "Delete Note", symbol: "trash", color: ThemePalette.error), - ActionEntry(title: "Copy as Markdown", symbol: "doc.on.doc", color: ThemePalette.text), - ActionEntry(title: "Export as Markdown…", symbol: "square.and.arrow.up", color: ThemePalette.text) + ActionEntry(title: "Open", symbol: "arrow.up.forward.app"), + ActionEntry(title: "Favorite", symbol: "star"), + ActionEntry(title: "Show in Finder", symbol: "finder"), + ActionEntry(title: "Reset ranking", symbol: "arrow.counterclockwise"), + ActionEntry(title: "Quit application", symbol: "stop.circle"), + ActionEntry(title: "Uninstall application", symbol: "trash", destructive: true), + ActionEntry(title: "Copy Emoji", symbol: "doc.on.doc"), + ActionEntry(title: "Change source language…", symbol: "character.bubble"), + ActionEntry(title: "Change target language…", symbol: "character.bubble"), + ActionEntry(title: "Swap languages", symbol: "arrow.left.arrow.right"), + ActionEntry(title: "MyMemory (no key)", symbol: "checkmark"), + ActionEntry(title: "OpenAI · gpt-6-luna", symbol: "checkmark"), + ActionEntry(title: "Browse Notes", symbol: "list.bullet", commandKey: "P"), + ActionEntry(title: "New Note", symbol: "square.and.pencil", commandKey: "N"), + ActionEntry(title: "Delete Note", symbol: "trash", destructive: true), + ActionEntry(title: "Copy as Markdown", symbol: "doc.on.doc"), + ActionEntry(title: "Export as Markdown…", symbol: "square.and.arrow.up") ] view = ActionsPanelView() view.wantsLayer = true @@ -212,6 +213,13 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat selectionViewportHeight = viewportHeight } updateSelection() + themeObservation = ThemeObservation { [weak self, weak content] in + guard let self else { return } + self.search.applyTheme() + self.applicationLabel.textColor = ThemePalette.secondaryText + self.empty.textColor = ThemePalette.secondaryText + content?.layer?.borderColor = ThemePalette.secondaryText.withAlphaComponent(0.22).cgColor + } } func show(for application: Application, relativeTo anchor: NSView) { @@ -558,8 +566,10 @@ private struct ActionEntry { let icon: IconView let returnKey: ShortcutIndicator let hasCommandKey: Bool + private var themeObservation: ThemeObservation? - init(title: String, symbol: String, color: NSColor, commandKey: String? = nil) { + init(title: String, symbol: String, destructive: Bool = false, commandKey: String? = nil) { + let color = destructive ? ThemePalette.error : ThemePalette.text hasCommandKey = commandKey != nil returnKey = commandKey.map { ShortcutIndicator("⌘", $0) } ?? ShortcutIndicator("↵") let font = NSFont.systemFont(ofSize: LauncherRowView.textSize, weight: .medium) @@ -590,12 +600,22 @@ private struct ActionEntry { returnKey.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -12), returnKey.centerYAnchor.constraint(equalTo: view.centerYAnchor) ]) + themeObservation = ThemeObservation { [weak button, weak icon] in + let color = destructive ? ThemePalette.error : ThemePalette.text + button?.contentTintColor = color + if let button { + let title = NSMutableAttributedString(attributedString: button.attributedTitle) + title.addAttribute(.foregroundColor, value: color, range: NSRange(location: 0, length: title.length)) + button.attributedTitle = title + } + icon?.contentTintColor = color + } } } @MainActor private final class SelectionCell: NSView { - let entry = ActionEntry(title: "", symbol: "checkmark", color: ThemePalette.text) + let entry = ActionEntry(title: "", symbol: "checkmark") init() { super.init(frame: .zero) diff --git a/Sources/Conjure/ConjureApp.swift b/Sources/Conjure/ConjureApp.swift index aec6fd9..2bf714e 100644 --- a/Sources/Conjure/ConjureApp.swift +++ b/Sources/Conjure/ConjureApp.swift @@ -6,6 +6,7 @@ import ConjureKit struct ConjureApp { static func main() { let application = NSApplication.shared + ThemePalette.startObserving() let delegate = ApplicationDelegate() application.delegate = delegate application.setActivationPolicy(.accessory) diff --git a/Sources/Conjure/EmojiPickerController.swift b/Sources/Conjure/EmojiPickerController.swift index 9d4a1f8..c1ab5aa 100644 --- a/Sources/Conjure/EmojiPickerController.swift +++ b/Sources/Conjure/EmojiPickerController.swift @@ -8,6 +8,7 @@ final class EmojiPickerController: NSViewController, NSCollectionViewDataSource, private let category = NSPopUpButton() private let status = NSTextField(labelWithString: "") private let empty = NSTextField(labelWithString: "No matching emoji") + private var themeObservation: ThemeObservation? private let copyButton = NSButton(title: "Copy and Paste", target: nil, action: nil) private let actionsButton = NSButton(title: "Actions", target: nil, action: nil) private let actions = ApplicationActionsController() @@ -93,6 +94,11 @@ final class EmojiPickerController: NSViewController, NSCollectionViewDataSource, footer.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: -8), footer.heightAnchor.constraint(equalToConstant: 36), ]) + themeObservation = ThemeObservation { [weak self] in + self?.category.contentTintColor = ThemePalette.secondaryText + self?.empty.textColor = ThemePalette.secondaryText + self?.status.textColor = ThemePalette.secondaryText + } inputChanged() } @@ -308,6 +314,9 @@ final class EmojiPickerController: NSViewController, NSCollectionViewDataSource, @MainActor private final class EmojiSectionHeader: NSView { private let label = NSTextField(labelWithString: "") + private var themeObservation: ThemeObservation? + private var title = "" + private var count = 0 override init(frame frameRect: NSRect) { super.init(frame: frameRect) @@ -317,11 +326,17 @@ private final class EmojiSectionHeader: NSView { label.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 16), label.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -8), ]) + themeObservation = ThemeObservation { [weak self] in + guard let self else { return } + self.setTitle(self.title, count: self.count) + } } required init?(coder: NSCoder) { fatalError("Conjure creates emoji headers programmatically") } func setTitle(_ title: String, count: Int) { + self.title = title + self.count = count let text = NSMutableAttributedString(string: title, attributes: [ .font: NSFont.systemFont(ofSize: 13), .foregroundColor: ThemePalette.secondaryText, ]) @@ -348,7 +363,6 @@ private final class EmojiItem: NSCollectionViewItem { } func highlight(_ selected: Bool) { - button.layer?.backgroundColor = (selected ? ThemePalette.selectionFill : ThemePalette.text.withAlphaComponent(0.045)).cgColor button.setAccessibilitySelected(selected) button.selected = selected } @@ -361,6 +375,14 @@ private final class EmojiButton: NSButton { var selected = false { didSet { updateBorder() } } private var hovered = false { didSet { updateBorder() } } private var hoverTracking: NSTrackingArea? + private var themeObservation: ThemeObservation? + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + themeObservation = ThemeObservation { [weak self] in self?.updateBorder() } + } + + required init?(coder: NSCoder) { fatalError("Conjure creates emoji buttons programmatically") } override func updateTrackingAreas() { super.updateTrackingAreas() @@ -377,6 +399,7 @@ private final class EmojiButton: NSButton { override func mouseExited(with event: NSEvent) { hovered = false } private func updateBorder() { + layer?.backgroundColor = (selected ? ThemePalette.selectionFill : ThemePalette.text.withAlphaComponent(0.045)).cgColor layer?.borderWidth = selected || hovered ? 0.5 : 0 layer?.borderColor = (selected ? ThemePalette.accent.withAlphaComponent(0.8) : ThemePalette.secondaryText.withAlphaComponent(0.35)).cgColor diff --git a/Sources/Conjure/LauncherAlignmentGuides.swift b/Sources/Conjure/LauncherAlignmentGuides.swift index b6fb692..b4ab24f 100644 --- a/Sources/Conjure/LauncherAlignmentGuides.swift +++ b/Sources/Conjure/LauncherAlignmentGuides.swift @@ -58,9 +58,21 @@ private final class AlignmentGuideView: NSView { var snapsHorizontally = false var snapsVertically = false private static let dash: [CGFloat] = [1, 5] - private static let muted = ThemePalette.accent.withAlphaComponent(0.4).cgColor - private static let aligned = ThemePalette.accent.withAlphaComponent(0.95).cgColor + private var muted = ThemePalette.accent.withAlphaComponent(0.4).cgColor + private var aligned = ThemePalette.accent.withAlphaComponent(0.95).cgColor private static let shadow = NSColor.black.withAlphaComponent(0.6).cgColor + private var themeObservation: ThemeObservation? + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + themeObservation = ThemeObservation { [weak self] in + self?.muted = ThemePalette.accent.withAlphaComponent(0.4).cgColor + self?.aligned = ThemePalette.accent.withAlphaComponent(0.95).cgColor + self?.needsDisplay = true + } + } + + required init?(coder: NSCoder) { fatalError("Conjure creates alignment guides programmatically") } override func hitTest(_ point: NSPoint) -> NSView? { nil } @@ -75,14 +87,14 @@ private final class AlignmentGuideView: NSView { context.setLineDash(phase: 0, lengths: Self.dash) context.setShadow(offset: .zero, blur: 2, color: Self.shadow) - context.setStrokeColor(snapsHorizontally ? Self.aligned : Self.muted) + context.setStrokeColor(snapsHorizontally ? aligned : muted) context.move(to: NSPoint(x: targetFrame.minX, y: bounds.minY)) context.addLine(to: NSPoint(x: targetFrame.minX, y: bounds.maxY)) context.move(to: NSPoint(x: targetFrame.maxX, y: bounds.minY)) context.addLine(to: NSPoint(x: targetFrame.maxX, y: bounds.maxY)) context.strokePath() - context.setStrokeColor(snapsVertically ? Self.aligned : Self.muted) + context.setStrokeColor(snapsVertically ? aligned : muted) context.move(to: NSPoint(x: bounds.minX, y: targetFrame.maxY)) context.addLine(to: NSPoint(x: bounds.maxX, y: targetFrame.maxY)) context.strokePath() diff --git a/Sources/Conjure/LauncherBackground.swift b/Sources/Conjure/LauncherBackground.swift index c06a18f..86f041c 100644 --- a/Sources/Conjure/LauncherBackground.swift +++ b/Sources/Conjure/LauncherBackground.swift @@ -2,39 +2,131 @@ import AppKit @MainActor enum ThemePalette { - private static let loaded: (scheme: ColorScheme, error: String?) = { + struct State { + let scheme: ColorScheme + var error: String? + var usesFallback: Bool + let background: NSColor + let selectionFill: NSColor + let selectionBorder: NSColor + let keyBackground: NSColor + + init(scheme: ColorScheme, error: String? = nil, usesFallback: Bool = false) { + self.scheme = scheme + self.error = error + self.usesFallback = usesFallback + background = scheme.background.withAlphaComponent(0.55) + selectionFill = scheme.accent.withAlphaComponent(0.12) + selectionBorder = scheme.accent.withAlphaComponent(0.30) + keyBackground = scheme.keyBackground.withAlphaComponent(0.5) + } + } + + static var loaded: State = { do { - return (try ColorScheme.load(from: ColorScheme.configurationDirectory), nil) + return State(scheme: try ColorScheme.load(from: ColorScheme.configurationDirectory)) } catch { - return (.sunset, "Color configuration: \(error.localizedDescription)\nUsing the built-in sunset palette. Fix the file and restart Conjure.") + return State(scheme: .sunset, + error: "Color configuration: \(error.localizedDescription)\nUsing the built-in sunset palette. Fix the file to apply changes.", + usesFallback: true) } }() - static let configurationError = loaded.error - static let background = loaded.scheme.background.withAlphaComponent(0.55) - static let text = loaded.scheme.text - static let secondaryText = loaded.scheme.secondaryText - static let accent = loaded.scheme.accent - static let selectionFill = accent.withAlphaComponent(0.12) - static let selectionBorder = accent.withAlphaComponent(0.30) - static let glow = loaded.scheme.glow - static let keyBackground = loaded.scheme.keyBackground.withAlphaComponent(0.5) - static let error = loaded.scheme.error + private static var observation: ColorSchemeObservation? + + static let didChangeNotification = Notification.Name("ConjureThemePaletteDidChange") + static var configurationError: String? { loaded.error } + static var background: NSColor { loaded.background } + static var text: NSColor { loaded.scheme.text } + static var secondaryText: NSColor { loaded.scheme.secondaryText } + static var accent: NSColor { loaded.scheme.accent } + static var selectionFill: NSColor { loaded.selectionFill } + static var selectionBorder: NSColor { loaded.selectionBorder } + static var glow: NSColor { loaded.scheme.glow } + static var keyBackground: NSColor { loaded.keyBackground } + static var error: NSColor { loaded.scheme.error } + + static func startObserving() { + guard observation == nil else { return } + let directory = ColorScheme.configurationDirectory + observation = ColorSchemeObservation(directory: directory) { + let previousError = configurationError + reload(from: directory) + if let diagnostic = configurationError, diagnostic != previousError { + NSLog("%@", diagnostic) + } + } + reload(from: directory) + } + + @discardableResult + static func reload(from directory: URL = ColorScheme.configurationDirectory) -> Bool { + do { + let scheme = try ColorScheme.load(from: directory) + let previous = loaded.scheme + guard scheme.background != previous.background || scheme.text != previous.text + || scheme.secondaryText != previous.secondaryText || scheme.accent != previous.accent + || scheme.glow != previous.glow || scheme.keyBackground != previous.keyBackground + || scheme.error != previous.error else { + loaded.error = nil + loaded.usesFallback = false + return false + } + loaded = State(scheme: scheme) + NotificationCenter.default.post(name: didChangeNotification, object: nil) + return true + } catch { + let recovery = loaded.usesFallback + ? "Using the built-in sunset palette." + : "Keeping the last valid palette." + loaded.error = "Color configuration: \(error.localizedDescription)\n\(recovery) Fix the file to apply changes." + return false + } + } } @MainActor final class LauncherBackground: NSView { weak var canvas: NSView? - private let amber = NSGradient(colors: [ + private var amber = NSGradient(colors: [ ThemePalette.accent.withAlphaComponent(0.18), ThemePalette.accent.withAlphaComponent(0.065), ThemePalette.accent.withAlphaComponent(0), ])! - private let ember = NSGradient(colors: [ + private var ember = NSGradient(colors: [ ThemePalette.glow.withAlphaComponent(0.26), ThemePalette.glow.withAlphaComponent(0.10), ThemePalette.glow.withAlphaComponent(0), ])! + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + observePalette() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + observePalette() + } + + private func observePalette() { + NotificationCenter.default.addObserver(self, selector: #selector(paletteChanged), + name: ThemePalette.didChangeNotification, object: nil) + } + + @objc private func paletteChanged() { + amber = NSGradient(colors: [ + ThemePalette.accent.withAlphaComponent(0.18), + ThemePalette.accent.withAlphaComponent(0.065), + ThemePalette.accent.withAlphaComponent(0), + ])! + ember = NSGradient(colors: [ + ThemePalette.glow.withAlphaComponent(0.26), + ThemePalette.glow.withAlphaComponent(0.10), + ThemePalette.glow.withAlphaComponent(0), + ])! + needsDisplay = true + } + override func hitTest(_ point: NSPoint) -> NSView? { nil } override func draw(_ dirtyRect: NSRect) { diff --git a/Sources/Conjure/LauncherWindowController.swift b/Sources/Conjure/LauncherWindowController.swift index cd0aa2b..069f03e 100644 --- a/Sources/Conjure/LauncherWindowController.swift +++ b/Sources/Conjure/LauncherWindowController.swift @@ -6,6 +6,7 @@ import ApplicationServices @MainActor final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSearchFieldDelegate, NSTableViewDataSource, NSTableViewDelegate { private let searchField = NSSearchField() + private var themeObservation: ThemeObservation? private let searchSymbol = IconView(font: .systemFont(ofSize: 20)) private let commandBack = NSButton(title: "", target: nil, action: nil) private let translationHeader = NSStackView() @@ -400,6 +401,17 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe } window.initialFirstResponder = searchField launcherViews = [header, sectionLabel, scrollView, emptyLabel, footer] + themeObservation = ThemeObservation { [weak self, weak content] in + guard let self else { return } + self.searchField.applyTheme() + (self.searchField.currentEditor() as? NSTextView)?.insertionPointColor = ThemePalette.accent + self.searchSymbol.contentTintColor = ThemePalette.accent + self.commandBack.contentTintColor = ThemePalette.secondaryText + for label in [self.sectionLabel, self.statusLabel, self.emptyLabel] { + label.textColor = ThemePalette.secondaryText + } + content?.layer?.borderColor = ThemePalette.secondaryText.withAlphaComponent(0.22).cgColor + } } private func refreshApplications() { @@ -1015,12 +1027,28 @@ final class ApplicationCell: NSTableCellView { private static let opaqueMask = [NSColor.black.cgColor, NSColor.black.cgColor] private static let fadedMask = [NSColor.black.cgColor, NSColor.clear.cgColor] private static let timing = CAMediaTimingFunction(name: .easeOut) - static let applicationKind = NSAttributedString(string: "Application", attributes: [ + private static var kindColor = ThemePalette.secondaryText + private static var cachedApplicationKind = NSAttributedString(string: "Application", attributes: [ .font: NSFont.systemFont(ofSize: LauncherRowView.textSize), .foregroundColor: ThemePalette.secondaryText ]) - static let commandKind = NSAttributedString(string: "Command", attributes: applicationKind.attributes(at: 0, effectiveRange: nil)) - static let favoriteKind = favorited(applicationKind) - static let favoriteCommandKind = favorited(commandKind) + private static var cachedCommandKind = NSAttributedString(string: "Command", attributes: cachedApplicationKind.attributes(at: 0, effectiveRange: nil)) + private static var cachedFavoriteKind = favorited(cachedApplicationKind) + private static var cachedFavoriteCommandKind = favorited(cachedCommandKind) + static var applicationKind: NSAttributedString { refreshKinds(); return cachedApplicationKind } + static var commandKind: NSAttributedString { refreshKinds(); return cachedCommandKind } + static var favoriteKind: NSAttributedString { refreshKinds(); return cachedFavoriteKind } + static var favoriteCommandKind: NSAttributedString { refreshKinds(); return cachedFavoriteCommandKind } + + private static func refreshKinds() { + guard kindColor != ThemePalette.secondaryText else { return } + kindColor = ThemePalette.secondaryText + cachedApplicationKind = NSAttributedString(string: "Application", attributes: [ + .font: NSFont.systemFont(ofSize: LauncherRowView.textSize), .foregroundColor: kindColor + ]) + cachedCommandKind = NSAttributedString(string: "Command", attributes: cachedApplicationKind.attributes(at: 0, effectiveRange: nil)) + cachedFavoriteKind = favorited(cachedApplicationKind) + cachedFavoriteCommandKind = favorited(cachedCommandKind) + } private static func favorited(_ kind: NSAttributedString) -> NSAttributedString { let font = kind.attribute(.font, at: 0, effectiveRange: nil) as! NSFont @@ -1041,12 +1069,13 @@ final class ApplicationCell: NSTableCellView { let locationLabel = NSTextField(labelWithString: "") let kindLabel = NSTextField(labelWithString: "Application") let numberHint = ShortcutIndicator( - "1", backgroundColor: ThemePalette.keyBackground, + "1", hasBackground: true, fontSize: 10, keySize: 20 ) private let kindFade = CAGradientLayer() private var numberHintVisible = false private var hintGeneration = 0 + private var themeObservation: ThemeObservation? override init(frame frameRect: NSRect) { super.init(frame: frameRect) @@ -1094,10 +1123,28 @@ final class ApplicationCell: NSTableCellView { numberHint.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -8), numberHint.centerYAnchor.constraint(equalTo: centerYAnchor), ]) + themeObservation = ThemeObservation { [weak self] in self?.applyTheme() } } required init?(coder: NSCoder) { fatalError("Conjure creates application cells programmatically") } + private func applyTheme() { + Self.refreshKinds() + textField?.textColor = ThemePalette.text + locationLabel.textColor = ThemePalette.secondaryText + let kind = kindLabel.stringValue + let favorite = kindLabel.attributedStringValue.length > 0 + && kindLabel.attributedStringValue.attribute(.attachment, at: 0, effectiveRange: nil) != nil + if kind.hasSuffix("Application") { + kindLabel.attributedStringValue = favorite ? Self.favoriteKind : Self.applicationKind + } else if kind.hasSuffix("Command") { + kindLabel.attributedStringValue = favorite ? Self.favoriteCommandKind : Self.commandKind + } else { + kindLabel.textColor = ThemePalette.secondaryText + } + if imageView?.contentTintColor != nil { imageView?.contentTintColor = ThemePalette.accent } + } + override func layout() { super.layout() updateKindFadeGeometry() @@ -1400,6 +1447,14 @@ final class LauncherRowView: NSTableRowView { static let rowHeight: CGFloat = 38 static let textSize: CGFloat = 14 static let iconToTextSpacing: CGFloat = 12 + private var themeObservation: ThemeObservation? + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + themeObservation = ThemeObservation { [weak self] in self?.needsDisplay = true } + } + + required init?(coder: NSCoder) { fatalError("Conjure creates result rows programmatically") } var topSpacing: CGFloat = 0 { didSet { needsLayout = true; needsDisplay = true } } diff --git a/Sources/Conjure/NotesController.swift b/Sources/Conjure/NotesController.swift index 5cf93d9..33fa1bd 100644 --- a/Sources/Conjure/NotesController.swift +++ b/Sources/Conjure/NotesController.swift @@ -20,6 +20,7 @@ final class NotesController: NSViewController, NSTextViewDelegate { private let actions = ApplicationActionsController() private let editor = NotesTextView() private let status = NSTextField(labelWithString: "") + private var themeObservation: ThemeObservation? private let noteTitle = NSTextField(labelWithString: "Untitled Note") private let vimButton = NSButton(title: "Vim Off", target: nil, action: nil) private let formatting = NSStackView() @@ -185,6 +186,15 @@ final class NotesController: NSViewController, NSTextViewDelegate { scroll.heightAnchor.constraint(greaterThanOrEqualToConstant: 80), footer.heightAnchor.constraint(equalToConstant: 36), ]) + themeObservation = ThemeObservation { [weak self] in + guard let self else { return } + self.noteTitle.textColor = ThemePalette.secondaryText + self.vimButton.contentTintColor = ThemePalette.secondaryText + self.status.textColor = ThemePalette.error + for case let button as NSButton in self.formatting.arrangedSubviews { + button.contentTintColor = ThemePalette.secondaryText + } + } guard let store else { editor.isEditable = false for control in [browseButton, actionsButton] { control.isEnabled = false } @@ -371,11 +381,12 @@ final class NotesController: NSViewController, NSTextViewDelegate { alert.informativeText = "This permanently deletes the note and its embedded images. This cannot be undone." alert.addButton(withTitle: "Cancel").keyEquivalent = "\u{1b}" let delete = alert.addButton(withTitle: "Delete Note") - delete.contentTintColor = ThemePalette.error + let theme = ThemeObservation { [weak delete] in delete?.contentTintColor = ThemePalette.error } delete.keyEquivalent = "\r" alert.window.initialFirstResponder = delete alert.window.defaultButtonCell = delete.cell as? NSButtonCell - alert.beginSheetModal(for: window) { [weak self] response in + alert.beginSheetModal(for: window) { [weak self, theme] response in + defer { withExtendedLifetime(theme) {} } guard let self else { return } defer { self.focusEditor() } guard response == .alertSecondButtonReturn, self.selectedID == id, diff --git a/Sources/Conjure/NotesTextView.swift b/Sources/Conjure/NotesTextView.swift index 6490c4b..434a30a 100644 --- a/Sources/Conjure/NotesTextView.swift +++ b/Sources/Conjure/NotesTextView.swift @@ -17,6 +17,60 @@ final class NotesTextView: NSTextView { private var markerColumn: CGFloat = 22 private var checkboxes: [NSButton] = [] private var listParagraphs: [(range: NSRange, kind: String, depth: Int, number: Int, checked: Bool)] = [] + private var themeObservation: ThemeObservation? + + override init(frame frameRect: NSRect = .zero) { + super.init(frame: frameRect) + } + + override init(frame frameRect: NSRect, textContainer container: NSTextContainer?) { + super.init(frame: frameRect, textContainer: container) + themeObservation = ThemeObservation { [weak self] in self?.applyTheme() } + } + + required init?(coder: NSCoder) { fatalError("Conjure creates note editors programmatically") } + + /// Only visual attributes change here; semantic marks, selection and undo sessions stay intact. + private func applyTheme() { + insertionPointColor = ThemePalette.accent + selectedTextAttributes = [.backgroundColor: ThemePalette.selectionFill] + let typing = currentTypingAttributes + if let storage = textStorage { + let full = NSRange(location: 0, length: storage.length) + edits.disableUndoRegistration() + storage.beginEditing() + storage.enumerateAttributes(in: full) { attributes, range, _ in + let codeBlock = attributes[NoteAttributes.block] as? String == "code" + let inlineCode = attributes[NoteAttributes.inlineCode] as? Bool == true + storage.addAttribute(.foregroundColor, + value: inlineCode || attributes[.link] != nil ? ThemePalette.accent : ThemePalette.text, + range: range) + if codeBlock || inlineCode { + storage.addAttribute(.backgroundColor, value: ThemePalette.selectionFill, range: range) + } + } + applyCodeColors(to: storage) + storage.endEditing() + edits.enableUndoRegistration() + } + setTypingAttributes(typing) + for button in checkboxes { button.contentTintColor = ThemePalette.accent } + needsDisplay = true + } + + private func applyCodeColors(to storage: NSTextStorage) { + let source = storage.string as NSString + storage.enumerateAttribute(NoteAttributes.block, in: NSRange(location: 0, length: storage.length)) { value, range, _ in + guard value as? String == "code" else { return } + let code = source.substring(with: range) + let language = storage.attribute(NoteAttributes.language, at: range.location, effectiveRange: nil) as? String + guard let highlighted = Self.highlighter.highlight(code, as: language), highlighted.string == code else { return } + highlighted.enumerateAttribute(.foregroundColor, in: NSRange(location: 0, length: highlighted.length)) { value, run, _ in + if let value { storage.addAttribute(.foregroundColor, value: value, + range: NSRange(location: range.location + run.location, length: run.length)) } + } + } + } var vimState = NotesVimState() { didSet { if oldValue.mode != vimState.mode { needsDisplay = true } } } @@ -517,16 +571,7 @@ final class NotesTextView: NSTextView { } index = NSMaxRange(row) } - storage.enumerateAttribute(NoteAttributes.block, in: full) { value, range, _ in - guard value as? String == "code" else { return } - let code = source.substring(with: range) - let language = storage.attribute(NoteAttributes.language, at: range.location, effectiveRange: nil) as? String - guard let highlighted = Self.highlighter.highlight(code, as: language), highlighted.string == code else { return } - highlighted.enumerateAttribute(.foregroundColor, in: NSRange(location: 0, length: highlighted.length)) { value, run, _ in - if let value { storage.addAttribute(.foregroundColor, value: value, - range: NSRange(location: range.location + run.location, length: run.length)) } - } - } + applyCodeColors(to: storage) storage.enumerateAttribute(.attachment, in: full) { value, _, _ in guard let attachment = value as? NSTextAttachment, let cell = attachment.attachmentCell as? NSTextAttachmentCell, let image = cell.image else { return } diff --git a/Sources/Conjure/SettingsWindowController.swift b/Sources/Conjure/SettingsWindowController.swift index 748f876..b02cd05 100644 --- a/Sources/Conjure/SettingsWindowController.swift +++ b/Sources/Conjure/SettingsWindowController.swift @@ -19,6 +19,8 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate { private let recoveryLabel = NSTextField(wrappingLabelWithString: "") private let recoveryButton = NSButton(title: "Retry uninstall recovery…", target: nil, action: nil) private var recoveryReport: UninstallReport? + private var themeObservation: ThemeObservation? + private var loginStatusIsError = false var retryUninstallRecovery: (() async -> UninstallReport)? init(shortcuts: GlobalShortcuts) { @@ -197,6 +199,18 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate { for label in [instructions, statusLabel, errorLabel, spotlight, loginStatus, recoveryLabel] { label.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true } + themeObservation = ThemeObservation { [weak self] in + guard let self else { return } + for label in [heading, loginHeading, translationHeading] { label.textColor = ThemePalette.text } + for label in [instructions, self.statusLabel, spotlight] { label.textColor = ThemePalette.secondaryText } + self.errorLabel.textColor = ThemePalette.error + self.loginStatus.textColor = self.loginStatusIsError ? ThemePalette.error : ThemePalette.secondaryText + self.recoveryLabel.textColor = self.recoveryReport?.needsRecovery == true ? ThemePalette.error : ThemePalette.secondaryText + for button in [self.recorder, self.commandSpaceButton, self.clearButton, keyboardSettings, + self.loginToggle, self.loginSettings, self.recoveryButton] { + button.contentTintColor = ThemePalette.accent + } + } } func setUninstallRecoveryReport(_ report: UninstallReport) { @@ -230,6 +244,7 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate { private func refreshLogin() { let status = SMAppService.mainApp.status loginStatus.textColor = ThemePalette.secondaryText + loginStatusIsError = false loginToggle.state = (status == .enabled || status == .requiresApproval) ? .on : .off loginSettings.isHidden = status != .requiresApproval switch status { @@ -244,6 +259,7 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate { @unknown default: loginStatus.stringValue = "Launch at login status is unavailable." loginStatus.textColor = ThemePalette.error + loginStatusIsError = true } } @@ -260,6 +276,7 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate { refreshLogin() loginStatus.stringValue = "Could not change launch at login: \(error.localizedDescription)" loginStatus.textColor = ThemePalette.error + loginStatusIsError = true } } diff --git a/Sources/Conjure/ShortcutIndicator.swift b/Sources/Conjure/ShortcutIndicator.swift index 01437f8..ff9586d 100644 --- a/Sources/Conjure/ShortcutIndicator.swift +++ b/Sources/Conjure/ShortcutIndicator.swift @@ -3,7 +3,8 @@ import CoreText @MainActor final class ShortcutIndicator: NSStackView { - init(_ keys: String..., backgroundColor: NSColor? = nil, fontSize: CGFloat = 12, keySize: CGFloat = 18) { + private var themeObservation: ThemeObservation? + init(_ keys: String..., hasBackground: Bool = false, fontSize: CGFloat = 12, keySize: CGFloat = 18) { super.init(frame: .zero) orientation = .horizontal alignment = .centerY @@ -21,7 +22,7 @@ final class ShortcutIndicator: NSStackView { badge.layer?.cornerRadius = 5 badge.layer?.borderWidth = 1 badge.layer?.borderColor = ThemePalette.secondaryText.withAlphaComponent(0.35).cgColor - badge.layer?.backgroundColor = backgroundColor?.cgColor + badge.layer?.backgroundColor = hasBackground ? ThemePalette.keyBackground.cgColor : nil badge.setAccessibilityElement(false) badge.translatesAutoresizingMaskIntoConstraints = false NSLayoutConstraint.activate([ @@ -30,6 +31,15 @@ final class ShortcutIndicator: NSStackView { ]) addArrangedSubview(badge) } + themeObservation = ThemeObservation { [weak self] in + guard let self else { return } + for case let badge as NSTextField in self.arrangedSubviews { + badge.textColor = ThemePalette.secondaryText + badge.layer?.borderColor = ThemePalette.secondaryText.withAlphaComponent(0.35).cgColor + badge.layer?.backgroundColor = hasBackground ? ThemePalette.keyBackground.cgColor : nil + badge.needsDisplay = true + } + } } func setSingleKey(_ key: String) { diff --git a/Sources/Conjure/ThemeObservation.swift b/Sources/Conjure/ThemeObservation.swift new file mode 100644 index 0000000..91af072 --- /dev/null +++ b/Sources/Conjure/ThemeObservation.swift @@ -0,0 +1,69 @@ +import AppKit +import Darwin + +@MainActor +final class ColorSchemeObservation { + private struct Watch: @unchecked Sendable { + let source: any DispatchSourceFileSystemObject + let device: dev_t + let inode: ino_t + } + + private let directory: URL + private let onChange: @MainActor () -> Void + private var watches: [String: Watch] = [:] + + init(directory: URL, onChange: @escaping @MainActor () -> Void) { + self.directory = directory.standardizedFileURL + self.onChange = onChange + refreshWatches() + } + + deinit { + for watch in watches.values { watch.source.cancel() } + } + + private func filesChanged() { + refreshWatches() + onChange() + } + + private func refreshWatches() { + var ancestor = directory + var isDirectory: ObjCBool = false + while !FileManager.default.fileExists(atPath: ancestor.path, isDirectory: &isDirectory) + || !isDirectory.boolValue { + let parent = ancestor.deletingLastPathComponent() + guard parent.path != ancestor.path else { break } + ancestor = parent + } + let urls = [ancestor, ancestor.deletingLastPathComponent(), + directory.appendingPathComponent("config.toml"), directory.appendingPathComponent("theme.json")] + var obsolete = Set(watches.keys) + for url in urls { + let path = url.path + let descriptor = open(path, O_EVTONLY | O_CLOEXEC) + guard descriptor >= 0 else { continue } + var identity = stat() + guard fstat(descriptor, &identity) == 0 else { + close(descriptor) + continue + } + obsolete.remove(path) + if let existing = watches[path], existing.device == identity.st_dev, existing.inode == identity.st_ino { + close(descriptor) + continue + } + watches.removeValue(forKey: path)?.source.cancel() + let source = DispatchSource.makeFileSystemObjectSource(fileDescriptor: descriptor, + eventMask: [.write, .extend, .delete, .rename, .attrib, .revoke], queue: .main) + source.setEventHandler { [weak self] in + Task { @MainActor [weak self] in self?.filesChanged() } + } + source.setCancelHandler { close(descriptor) } + watches[path] = Watch(source: source, device: identity.st_dev, inode: identity.st_ino) + source.resume() + } + for path in obsolete { watches.removeValue(forKey: path)?.source.cancel() } + } +} diff --git a/Sources/Conjure/TranslationController.swift b/Sources/Conjure/TranslationController.swift index cde97ad..260c472 100644 --- a/Sources/Conjure/TranslationController.swift +++ b/Sources/Conjure/TranslationController.swift @@ -36,6 +36,9 @@ final class TranslationController: NSViewController { private let output = NSTextView() private let settingsButton = NSButton(title: "Open Translate Settings", target: nil, action: nil) private let status = NSTextField(wrappingLabelWithString: "") + private var statusIsError = false + private var outputSecondaryRange: NSRange? + private var themeObservation: ThemeObservation? private let translateButton = NSButton(title: "Translate", target: nil, action: nil) private var task: Task? var input: (() -> String)? @@ -179,6 +182,24 @@ final class TranslationController: NSViewController { footer.heightAnchor.constraint(equalToConstant: 36), scroll.heightAnchor.constraint(greaterThanOrEqualToConstant: 100), ]) + themeObservation = ThemeObservation { [weak self, weak chevron] in + guard let self else { return } + self.providerButton.contentTintColor = ThemePalette.text + self.languageButton.contentTintColor = ThemePalette.text + chevron?.contentTintColor = ThemePalette.secondaryText + self.stylePill(self.providerControl) + self.sourcePreview.textColor = ThemePalette.secondaryText + self.status.textColor = self.statusIsError ? ThemePalette.error : ThemePalette.secondaryText + if let storage = self.output.textStorage { + storage.beginEditing() + storage.addAttribute(.foregroundColor, value: ThemePalette.text, + range: NSRange(location: 0, length: storage.length)) + if let range = self.outputSecondaryRange { + storage.addAttribute(.foregroundColor, value: ThemePalette.secondaryText, range: range) + } + storage.endEditing() + } + } inputChanged() } @@ -266,6 +287,7 @@ final class TranslationController: NSViewController { func inputChanged() { cancelRequest() output.string = "" + outputSecondaryRange = nil let isEmpty = (input?() ?? "").trimmingCharacters(in: .whitespacesAndNewlines).isEmpty if textToTranslate != nil { translateButton.title = "Translate" @@ -307,6 +329,7 @@ final class TranslationController: NSViewController { } private func setStatus(_ message: String, isError: Bool = false) { + statusIsError = isError status.stringValue = message status.textColor = isError ? ThemePalette.error : ThemePalette.secondaryText } @@ -362,6 +385,7 @@ final class TranslationController: NSViewController { } } output.string = "" + outputSecondaryRange = nil setStatus("Translating…") translateButton.isEnabled = false let myMemoryTranslate = self.myMemory @@ -379,6 +403,7 @@ final class TranslationController: NSViewController { .foregroundColor: ThemePalette.text, ]) if !myMemory, let separator = translation.range(of: "\n\n") { + self.outputSecondaryRange = NSRange(separator.upperBound.. = [] @@ -176,6 +178,18 @@ final class UninstallReviewController: NSViewController, NSSearchFieldDelegate, footer.bottomAnchor.constraint(equalTo: content.bottomAnchor, constant: -8), footer.heightAnchor.constraint(equalToConstant: 36) ]) + themeObservation = ThemeObservation { [weak self, weak context, weak guidance, weak pill] in + guard let self else { return } + context?.textColor = ThemePalette.text + guidance?.textColor = ThemePalette.secondaryText + self.search.applyTheme() + (self.search.currentEditor() as? NSTextView)?.insertionPointColor = ThemePalette.accent + self.status.textColor = self.statusIsError ? ThemePalette.error : ThemePalette.secondaryText + self.back.contentTintColor = ThemePalette.secondaryText + self.remove.contentTintColor = ThemePalette.error + pill?.layer?.borderColor = ThemePalette.accent.withAlphaComponent(0.35).cgColor + pill?.layer?.backgroundColor = ThemePalette.background.withAlphaComponent(0.25).cgColor + } } func applyDiscoveryResult(_ result: Result<[UninstallCandidate], Error>, preservingSelection: Bool = false) { @@ -205,6 +219,7 @@ final class UninstallReviewController: NSViewController, NSSearchFieldDelegate, private func updateStatus() { status.textColor = ThemePalette.secondaryText + statusIsError = false remove.isEnabled = false switch discoveryState { case .loading: @@ -212,6 +227,7 @@ final class UninstallReviewController: NSViewController, NSSearchFieldDelegate, case .failed(let message): status.stringValue = message status.textColor = ThemePalette.error + statusIsError = true case .ready: let chosen = candidates.filter { selected.contains($0.url) } let size = chosen.compactMap(\.size).reduce(Int64(0), +) @@ -260,7 +276,7 @@ final class UninstallReviewController: NSViewController, NSSearchFieldDelegate, cell.toggle.isEnabled = candidate.isSelectable && !busy cell.toggle.setAccessibilityLabel("Select \(candidate.url.lastPathComponent)") cell.name.stringValue = candidate.url.lastPathComponent - cell.name.textColor = candidate.isSelectable ? ThemePalette.text : ThemePalette.secondaryText + cell.selectable = candidate.isSelectable let path = (candidate.url.deletingLastPathComponent().path as NSString).abbreviatingWithTildeInPath cell.detail.stringValue = candidate.reason.map { "\(path) · \($0)" } ?? path cell.size.stringValue = candidate.size.map { ByteCountFormatter.string(fromByteCount: $0, countStyle: .file) } ?? "—" @@ -319,6 +335,7 @@ final class UninstallReviewController: NSViewController, NSSearchFieldDelegate, } do { status.textColor = ThemePalette.secondaryText + statusIsError = false status.stringValue = "Waiting for application to quit…" for app in running where !app.isTerminated { guard app.terminate() else { throw ReviewError("\(app.localizedName ?? application.name) could not quit. No files were moved.") } @@ -369,6 +386,7 @@ final class UninstallReviewController: NSViewController, NSSearchFieldDelegate, if canRecover { alert.addButton(withTitle: "Retry recovery") } guard await alert.beginSheetModal(for: window) == .alertSecondButtonReturn, canRecover else { return } status.textColor = ThemePalette.secondaryText + statusIsError = false status.stringValue = "Recovering pending uninstall…" let report = await uninstaller.recover() changed?() @@ -425,6 +443,14 @@ private final class UninstallFileCell: NSTableCellView { let detail = NSTextField(labelWithString: "") let size = NSTextField(labelWithString: "") let fileIcon: IconView + private var themeObservation: ThemeObservation? + var selectable = true { didSet { applyTheme() } } + + private func applyTheme() { + name.textColor = selectable ? ThemePalette.text : ThemePalette.secondaryText + detail.textColor = ThemePalette.secondaryText + size.textColor = ThemePalette.secondaryText + } init() { let font = NSFont.systemFont(ofSize: 14, weight: .medium) @@ -456,6 +482,7 @@ private final class UninstallFileCell: NSTableCellView { row.topAnchor.constraint(equalTo: topAnchor, constant: 4), row.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -4) ]) + themeObservation = ThemeObservation { [weak self] in self?.applyTheme() } } required init?(coder: NSCoder) { fatalError("Conjure creates uninstall rows programmatically") } diff --git a/Tests/ConjureKitTests/LiveColorSchemeTests.swift b/Tests/ConjureKitTests/LiveColorSchemeTests.swift new file mode 100644 index 0000000..96fd21e --- /dev/null +++ b/Tests/ConjureKitTests/LiveColorSchemeTests.swift @@ -0,0 +1,207 @@ +import AppKit +import Testing +@testable import Conjure + +@Test @MainActor +func livePaletteReloadsGeneratedColorsKeepsOverridesAndRetainsLastValidPalette() throws { + let original = ThemePalette.loaded + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { + ThemePalette.loaded = original + try? FileManager.default.removeItem(at: directory) + } + let config = directory.appendingPathComponent("config.toml") + let theme = directory.appendingPathComponent("theme.json") + ThemePalette.reload(from: directory) + let white = NSColor(srgbRed: 1, green: 1, blue: 1, alpha: 1) + let black = NSColor(srgbRed: 0, green: 0, blue: 0, alpha: 1) + let changes = PaletteChanges() + let observer = NotificationCenter.default.addObserver( + forName: ThemePalette.didChangeNotification, object: nil, queue: .main + ) { _ in + MainActor.assumeIsolated { + changes.count += 1 + changes.background = ThemePalette.background + changes.accent = ThemePalette.accent + } + } + defer { NotificationCenter.default.removeObserver(observer) } + try Data("theme = 'tint'\ncolors.accent = '#ffffff'\n".utf8).write(to: config) + try Data(##"{"colors":{"background":"#123456","accent":"#ff0000","text":"#000000"}}"##.utf8) + .write(to: theme) + #expect(ThemePalette.reload(from: directory)) + let firstBackground = ThemePalette.background + #expect(ThemePalette.accent == white) + #expect(ThemePalette.text == black) + #expect(changes.count == 1) + #expect(changes.background == ThemePalette.background) + #expect(changes.accent == ThemePalette.accent) + #expect(ThemePalette.background.alphaComponent == 0.55) + #expect(ThemePalette.selectionFill.alphaComponent == 0.12) + #expect(ThemePalette.selectionBorder.alphaComponent == 0.30) + #expect(ThemePalette.keyBackground.alphaComponent == 0.5) + #expect(!ThemePalette.reload(from: directory)) + #expect(changes.count == 1) + + try Data(##"{"colors":{"background":"#abcdef","accent":"#00ff00","text":"#ffffff"}}"##.utf8) + .write(to: theme, options: .atomic) + #expect(ThemePalette.reload(from: directory)) + #expect(ThemePalette.background != firstBackground) + #expect(ThemePalette.accent == white) + #expect(ThemePalette.text == white) + #expect(changes.count == 2) + let lastBackground = ThemePalette.background + + try Data("partial JSON".utf8).write(to: theme) + #expect(!ThemePalette.reload(from: directory)) + #expect(ThemePalette.background == lastBackground) + #expect(ThemePalette.text == white) + #expect(changes.count == 2) + #expect(ThemePalette.configurationError?.contains(theme.path) == true) + + try Data(##"{"colors":{"background":"#abcdef","text":"#ffffff"}}"##.utf8).write(to: theme) + #expect(!ThemePalette.reload(from: directory)) + #expect(ThemePalette.configurationError == nil) + #expect(changes.count == 2) + + try Data("colors.accent = '#invalid'\n".utf8).write(to: config) + #expect(!ThemePalette.reload(from: directory)) + #expect(ThemePalette.background == lastBackground) + #expect(ThemePalette.configurationError?.contains(config.path) == true) + #expect(changes.count == 2) + + try Data("theme = 'sunset'\n".utf8).write(to: config) + #expect(ThemePalette.reload(from: directory)) + #expect(ThemePalette.background == ColorScheme.sunset.background.withAlphaComponent(0.55)) + #expect(ThemePalette.accent == ColorScheme.sunset.accent) + #expect(changes.count == 3) + #expect(ThemePalette.configurationError == nil) +} + +@MainActor private final class PaletteChanges { + var count = 0 + var background: NSColor? + var accent: NSColor? +} + +@Test(arguments: ["config.toml", "theme.json"]) @MainActor +func liveObservationDetectsInPlaceWritesAndRearmsAfterAtomicReplacement(_ filename: String) async throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + let directory = root.appendingPathComponent("conjure") + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let file = directory.appendingPathComponent(filename) + try Data("initial".utf8).write(to: file) + let events = AsyncStream.makeStream() + let observation = ColorSchemeObservation(directory: directory) { + events.continuation.yield((try? String(contentsOf: file, encoding: .utf8)) ?? "") + } + defer { + events.continuation.finish() + withExtendedLifetime(observation) {} + } + + try overwriteInPlace("in-place", at: file) + try await expectObserved("in-place", from: events.stream) + try Data("atomic".utf8).write(to: file, options: .atomic) + try await expectObserved("atomic", from: events.stream) + try overwriteInPlace("after-replacement", at: file) + try await expectObserved("after-replacement", from: events.stream) +} + +@Test @MainActor +func liveObservationFindsInitiallyMissingDirectoriesAndRecoversAfterRemoval() async throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let missingAncestor = root.appendingPathComponent("missing") + let directory = missingAncestor.appendingPathComponent("conjure") + let file = directory.appendingPathComponent("theme.json") + let events = AsyncStream.makeStream() + let observation = ColorSchemeObservation(directory: directory) { + events.continuation.yield((try? String(contentsOf: file, encoding: .utf8)) ?? "") + } + defer { + events.continuation.finish() + withExtendedLifetime(observation) {} + } + + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + try Data("created".utf8).write(to: file) + try await expectObserved("created", from: events.stream) + try overwriteInPlace("created-then-edited", at: file) + try await expectObserved("created-then-edited", from: events.stream) + + try FileManager.default.removeItem(at: missingAncestor) + try await expectObserved("", from: events.stream) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + try Data("recreated".utf8).write(to: file, options: .atomic) + try await expectObserved("recreated", from: events.stream) + try overwriteInPlace("recreated-then-edited", at: file) + try await expectObserved("recreated-then-edited", from: events.stream) +} + +private func overwriteInPlace(_ value: String, at url: URL) throws { + let handle = try FileHandle(forWritingTo: url) + defer { try? handle.close() } + try handle.truncate(atOffset: 0) + try handle.write(contentsOf: Data(value.utf8)) +} + +private struct ObservationTimeout: Error {} + +private func expectObserved(_ expected: String, from events: AsyncStream) async throws { + try await withThrowingTaskGroup(of: Void.self) { group in + group.addTask { + for await value in events { + if value == expected { return } + } + throw CancellationError() + } + group.addTask { + // Only a failure deadline: event delivery, not a sleep, advances the test. + try await Task.sleep(for: .seconds(5)) + throw ObservationTimeout() + } + defer { group.cancelAll() } + _ = try await group.next() + } +} + +@Test @MainActor +func liveBackgroundRebuildsCachedGradientsWithoutReplacingTheView() throws { + _ = NSApplication.shared + let original = ThemePalette.loaded + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 750, height: 480), + styleMask: .borderless, backing: .buffered, defer: false) + window.isReleasedWhenClosed = false + defer { + ThemePalette.loaded = original + window.orderOut(nil) + try? FileManager.default.removeItem(at: directory) + } + let config = directory.appendingPathComponent("config.toml") + try Data("colors.accent = '#ff0000'\ncolors.glow = '#ff0000'\n".utf8).write(to: config) + ThemePalette.reload(from: directory) + let background = LauncherBackground(frame: NSRect(x: 0, y: 0, width: 750, height: 480)) + window.contentView?.addSubview(background) + func glowColor() throws -> NSColor { + let bitmap = try #require(background.bitmapImageRepForCachingDisplay(in: background.bounds)) + background.cacheDisplay(in: background.bounds, to: bitmap) + return try #require(bitmap.colorAt(x: Int(CGFloat(bitmap.pixelsWide) * 0.58), + y: Int(CGFloat(bitmap.pixelsHigh) * 0.06))?.usingColorSpace(.deviceRGB)) + } + let before = try glowColor() + let backgroundColor = ThemePalette.background + background.needsDisplay = false + try Data("colors.accent = '#00ff00'\ncolors.glow = '#00ff00'\n".utf8).write(to: config) + #expect(ThemePalette.reload(from: directory)) + #expect(ThemePalette.background == backgroundColor) + #expect(background.needsDisplay) + let after = try glowColor() + #expect(before.redComponent > after.redComponent) + #expect(after.greenComponent > before.greenComponent) +} diff --git a/Tests/ConjureKitTests/LiveThemeViewsTests.swift b/Tests/ConjureKitTests/LiveThemeViewsTests.swift new file mode 100644 index 0000000..e3e3add --- /dev/null +++ b/Tests/ConjureKitTests/LiveThemeViewsTests.swift @@ -0,0 +1,151 @@ +import AppKit +import ConjureKit +import Testing +@testable import Conjure + +@Test @MainActor +func livePaletteRepaintsNotesWithoutChangingContentSelectionOrUndo() throws { + _ = NSApplication.shared + let originalPalette = ThemePalette.loaded + defer { + ThemePalette.loaded = originalPalette + NotificationCenter.default.post(name: ThemePalette.didChangeNotification, object: nil) + } + let directory = try liveViewThemeDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + ThemePalette.reload(from: directory) + + let editor = NotesTextView(frame: NSRect(x: 0, y: 0, width: 500, height: 300)) + editor.isRichText = true + let delegate = LiveThemeTextDelegate() + editor.delegate = delegate + let document = NSMutableAttributedString(string: "plain code\nlet answer = 42\n") + let inline = NSRange(location: 6, length: 4) + let code = (document.string as NSString).paragraphRange(for: NSRange(location: 11, length: 0)) + document.addAttribute(NoteAttributes.inlineCode, value: true, range: inline) + document.addAttribute(NoteAttributes.bold, value: true, range: NSRange(location: 0, length: 5)) + document.addAttribute(NoteAttributes.block, value: "code", range: code) + document.addAttribute(NoteAttributes.language, value: "swift", range: code) + editor.setDocumentContents(document) + editor.beginInsertionSession() + editor.insertText("!", replacementRange: NSRange(location: 0, length: 0)) + let selection = NSRange(location: 2, length: 3) + editor.setSelectedRange(selection) + let contents = editor.string + let grouping = editor.undoManager?.groupingLevel + let oldAccent = editor.insertionPointColor + let codeColor = editor.textStorage?.attribute(.foregroundColor, at: code.location + 1, effectiveRange: nil) as? NSColor + let boldFont = editor.textStorage?.attribute(.font, at: 1, effectiveRange: nil) as? NSFont + let changeCount = delegate.changeCount + + try writeLiveViewTheme(in: directory) + #expect(ThemePalette.reload(from: directory)) + + #expect(editor.insertionPointColor == ThemePalette.accent) + #expect(editor.insertionPointColor != oldAccent) + #expect(editor.selectedTextAttributes[.backgroundColor] as? NSColor == ThemePalette.selectionFill) + #expect(editor.textStorage?.attribute(.foregroundColor, at: 1, effectiveRange: nil) as? NSColor == ThemePalette.text) + #expect(editor.textStorage?.attribute(.foregroundColor, at: inline.location + 1, effectiveRange: nil) as? NSColor == ThemePalette.accent) + #expect(editor.textStorage?.attribute(.backgroundColor, at: inline.location + 1, effectiveRange: nil) as? NSColor == ThemePalette.selectionFill) + #expect(editor.textStorage?.attribute(.backgroundColor, at: code.location + 1, effectiveRange: nil) as? NSColor == ThemePalette.selectionFill) + #expect(editor.textStorage?.attribute(.foregroundColor, at: code.location + 1, effectiveRange: nil) as? NSColor == codeColor) + #expect(editor.textStorage?.attribute(.font, at: 1, effectiveRange: nil) as? NSFont == boldFont) + #expect(editor.textStorage?.attribute(NoteAttributes.inlineCode, at: inline.location + 1, effectiveRange: nil) as? Bool == true) + #expect(editor.textStorage?.attribute(NoteAttributes.language, at: code.location + 1, effectiveRange: nil) as? String == "swift") + #expect(editor.string == contents) + #expect(editor.selectedRange() == selection) + #expect(editor.undoManager?.groupingLevel == grouping) + #expect(delegate.changeCount == changeCount) + editor.endInsertionSession() + #expect(editor.undoManager?.canUndo == true) + editor.undoManager?.undo() + #expect(editor.string == document.string) +} + +@Test @MainActor +func livePaletteRepaintsLauncherAndFavoriteBadgesWithoutResettingSearch() throws { + _ = NSApplication.shared + let originalPalette = ThemePalette.loaded + defer { + ThemePalette.loaded = originalPalette + NotificationCenter.default.post(name: ThemePalette.didChangeNotification, object: nil) + } + let directory = try liveViewThemeDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + ThemePalette.reload(from: directory) + let suite = "Conjure.LiveThemeViews.\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suite)) + defer { defaults.removePersistentDomain(forName: suite) } + let launcher = LauncherWindowController( + history: LaunchHistory(defaults: defaults, now: { Date(timeIntervalSinceReferenceDate: 12_000) }), + defaults: defaults, notesDirectory: directory.appendingPathComponent("notes")) + let window = try #require(launcher.window) + let content = try #require(window.contentView) + let views = liveThemedDescendants(content) + let search = try #require(views.compactMap { $0 as? NSSearchField }.first) + let table = try #require(views.compactMap { $0 as? NSTableView }.first) + search.stringValue = "notes" + launcher.controlTextDidChange(Notification(name: NSControl.textDidChangeNotification, object: search)) + let rows = table.numberOfRows + let selected = table.selectedRowIndexes + let placeholder = search.placeholderAttributedString?.string + window.makeFirstResponder(search) + let fieldEditor = try #require(search.currentEditor() as? NSTextView) + fieldEditor.setSelectedRange(NSRange(location: 1, length: 2)) + let selection = fieldEditor.selectedRange() + let pill = try #require(views.compactMap { $0 as? ActionPill }.first) + let oldBorder = pill.layer?.borderColor + let cell = ApplicationCell(frame: .zero) + cell.kindLabel.attributedStringValue = ApplicationCell.favoriteCommandKind + cell.setNumberHint("3", animated: false) + let numberHint = try #require(liveThemedDescendants(cell.numberHint).compactMap { $0 as? NSTextField }.first) + + try writeLiveViewTheme(in: directory) + #expect(ThemePalette.reload(from: directory)) + + #expect(search.stringValue == "notes") + #expect(search.placeholderAttributedString?.string == placeholder) + #expect(table.numberOfRows == rows) + #expect(table.selectedRowIndexes == selected) + #expect(search.currentEditor() === fieldEditor) + #expect(fieldEditor.selectedRange() == selection) + #expect(search.textColor == ThemePalette.text) + #expect(fieldEditor.insertionPointColor == ThemePalette.accent) + #expect(search.placeholderAttributedString?.attribute(.foregroundColor, at: 0, effectiveRange: nil) as? NSColor == ThemePalette.secondaryText) + #expect(pill.layer?.borderColor == ThemePalette.accent.withAlphaComponent(0.35).cgColor) + #expect(pill.layer?.borderColor != oldBorder) + #expect(cell.kindLabel.attributedStringValue.attribute(.foregroundColor, at: cell.kindLabel.attributedStringValue.length - 1, effectiveRange: nil) as? NSColor == ThemePalette.secondaryText) + #expect(!cell.numberHint.isHidden) + #expect(numberHint.stringValue == "3") + #expect(numberHint.layer?.backgroundColor == ThemePalette.keyBackground.cgColor) +} + +private func liveViewThemeDirectory() throws -> URL { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + return directory +} + +private func writeLiveViewTheme(in directory: URL) throws { + try Data(""" + [colors] + background = "#ffffff" + text = "#112233" + secondaryText = "#445566" + accent = "#1234ab" + glow = "#abcdef" + keyBackground = "#778899" + error = "#aa0011" + """.utf8).write(to: directory.appendingPathComponent("config.toml")) +} + +@MainActor +private func liveThemedDescendants(_ view: NSView) -> [NSView] { + [view] + view.subviews.flatMap { liveThemedDescendants($0) } +} + +@MainActor +private final class LiveThemeTextDelegate: NSObject, NSTextViewDelegate { + var changeCount = 0 + func textDidChange(_ notification: Notification) { changeCount += 1 } +}