fix: reload tint colors while running

This commit is contained in:
2026-10-22 16:07:22 +02:00
parent c48ef4c018
commit 1b171c7b52
18 changed files with 877 additions and 61 deletions
+2 -2
View File
@@ -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. 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 ## 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. 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. 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.
+40
View File
@@ -2,6 +2,7 @@ import AppKit
@MainActor @MainActor
final class ActionPill: NSView { final class ActionPill: NSView {
private var themeObservation: ThemeObservation?
init(primaryButton: NSButton, actionsButton: NSButton, primaryCommand: String? = nil) { init(primaryButton: NSButton, actionsButton: NSButton, primaryCommand: String? = nil) {
super.init(frame: .zero) super.init(frame: .zero)
wantsLayer = true wantsLayer = true
@@ -37,7 +38,46 @@ final class ActionPill: NSView {
actionKeys.centerYAnchor.constraint(equalTo: centerYAnchor), actionKeys.centerYAnchor.constraint(equalTo: centerYAnchor),
actionKeys.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -12), 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") } 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
}
}
}
@@ -4,6 +4,7 @@ import ConjureKit
@MainActor @MainActor
final class ApplicationActionsController: NSViewController, NSSearchFieldDelegate, NSTableViewDataSource, NSTableViewDelegate { final class ApplicationActionsController: NSViewController, NSSearchFieldDelegate, NSTableViewDataSource, NSTableViewDelegate {
private var search: ActionsSearchField! private var search: ActionsSearchField!
private var themeObservation: ThemeObservation?
private var background: LauncherBackground! private var background: LauncherBackground!
private var entries: [ActionEntry] = [] private var entries: [ActionEntry] = []
private var visibleEntries: [Int] = [] private var visibleEntries: [Int] = []
@@ -74,23 +75,23 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat
selectionScroll = nil selectionScroll = nil
selectionViewportHeight = nil selectionViewportHeight = nil
entries = [ entries = [
ActionEntry(title: "Open", symbol: "arrow.up.forward.app", color: ThemePalette.text), ActionEntry(title: "Open", symbol: "arrow.up.forward.app"),
ActionEntry(title: "Favorite", symbol: "star", color: ThemePalette.text), ActionEntry(title: "Favorite", symbol: "star"),
ActionEntry(title: "Show in Finder", symbol: "finder", color: ThemePalette.text), ActionEntry(title: "Show in Finder", symbol: "finder"),
ActionEntry(title: "Reset ranking", symbol: "arrow.counterclockwise", color: ThemePalette.text), ActionEntry(title: "Reset ranking", symbol: "arrow.counterclockwise"),
ActionEntry(title: "Quit application", symbol: "stop.circle", color: ThemePalette.text), ActionEntry(title: "Quit application", symbol: "stop.circle"),
ActionEntry(title: "Uninstall application", symbol: "trash", color: ThemePalette.error), ActionEntry(title: "Uninstall application", symbol: "trash", destructive: true),
ActionEntry(title: "Copy Emoji", symbol: "doc.on.doc", color: ThemePalette.text), ActionEntry(title: "Copy Emoji", symbol: "doc.on.doc"),
ActionEntry(title: "Change source language…", symbol: "character.bubble", color: ThemePalette.text), ActionEntry(title: "Change source language…", symbol: "character.bubble"),
ActionEntry(title: "Change target language…", symbol: "character.bubble", color: ThemePalette.text), ActionEntry(title: "Change target language…", symbol: "character.bubble"),
ActionEntry(title: "Swap languages", symbol: "arrow.left.arrow.right", color: ThemePalette.text), ActionEntry(title: "Swap languages", symbol: "arrow.left.arrow.right"),
ActionEntry(title: "MyMemory (no key)", symbol: "checkmark", color: ThemePalette.text), ActionEntry(title: "MyMemory (no key)", symbol: "checkmark"),
ActionEntry(title: "OpenAI · gpt-6-luna", symbol: "checkmark", color: ThemePalette.text), ActionEntry(title: "OpenAI · gpt-6-luna", symbol: "checkmark"),
ActionEntry(title: "Browse Notes", symbol: "list.bullet", color: ThemePalette.text, commandKey: "P"), ActionEntry(title: "Browse Notes", symbol: "list.bullet", commandKey: "P"),
ActionEntry(title: "New Note", symbol: "square.and.pencil", color: ThemePalette.text, commandKey: "N"), ActionEntry(title: "New Note", symbol: "square.and.pencil", commandKey: "N"),
ActionEntry(title: "Delete Note", symbol: "trash", color: ThemePalette.error), ActionEntry(title: "Delete Note", symbol: "trash", destructive: true),
ActionEntry(title: "Copy as Markdown", symbol: "doc.on.doc", color: ThemePalette.text), ActionEntry(title: "Copy as Markdown", symbol: "doc.on.doc"),
ActionEntry(title: "Export as Markdown…", symbol: "square.and.arrow.up", color: ThemePalette.text) ActionEntry(title: "Export as Markdown…", symbol: "square.and.arrow.up")
] ]
view = ActionsPanelView() view = ActionsPanelView()
view.wantsLayer = true view.wantsLayer = true
@@ -212,6 +213,13 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat
selectionViewportHeight = viewportHeight selectionViewportHeight = viewportHeight
} }
updateSelection() 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) { func show(for application: Application, relativeTo anchor: NSView) {
@@ -558,8 +566,10 @@ private struct ActionEntry {
let icon: IconView let icon: IconView
let returnKey: ShortcutIndicator let returnKey: ShortcutIndicator
let hasCommandKey: Bool 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 hasCommandKey = commandKey != nil
returnKey = commandKey.map { ShortcutIndicator("⌘", $0) } ?? ShortcutIndicator("↵") returnKey = commandKey.map { ShortcutIndicator("⌘", $0) } ?? ShortcutIndicator("↵")
let font = NSFont.systemFont(ofSize: LauncherRowView.textSize, weight: .medium) 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.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -12),
returnKey.centerYAnchor.constraint(equalTo: view.centerYAnchor) 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 @MainActor
private final class SelectionCell: NSView { private final class SelectionCell: NSView {
let entry = ActionEntry(title: "", symbol: "checkmark", color: ThemePalette.text) let entry = ActionEntry(title: "", symbol: "checkmark")
init() { init() {
super.init(frame: .zero) super.init(frame: .zero)
+1
View File
@@ -6,6 +6,7 @@ import ConjureKit
struct ConjureApp { struct ConjureApp {
static func main() { static func main() {
let application = NSApplication.shared let application = NSApplication.shared
ThemePalette.startObserving()
let delegate = ApplicationDelegate() let delegate = ApplicationDelegate()
application.delegate = delegate application.delegate = delegate
application.setActivationPolicy(.accessory) application.setActivationPolicy(.accessory)
+24 -1
View File
@@ -8,6 +8,7 @@ final class EmojiPickerController: NSViewController, NSCollectionViewDataSource,
private let category = NSPopUpButton() private let category = NSPopUpButton()
private let status = NSTextField(labelWithString: "") private let status = NSTextField(labelWithString: "")
private let empty = NSTextField(labelWithString: "No matching emoji") 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 copyButton = NSButton(title: "Copy and Paste", target: nil, action: nil)
private let actionsButton = NSButton(title: "Actions", target: nil, action: nil) private let actionsButton = NSButton(title: "Actions", target: nil, action: nil)
private let actions = ApplicationActionsController() private let actions = ApplicationActionsController()
@@ -93,6 +94,11 @@ final class EmojiPickerController: NSViewController, NSCollectionViewDataSource,
footer.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: -8), footer.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: -8),
footer.heightAnchor.constraint(equalToConstant: 36), 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() inputChanged()
} }
@@ -308,6 +314,9 @@ final class EmojiPickerController: NSViewController, NSCollectionViewDataSource,
@MainActor @MainActor
private final class EmojiSectionHeader: NSView { private final class EmojiSectionHeader: NSView {
private let label = NSTextField(labelWithString: "") private let label = NSTextField(labelWithString: "")
private var themeObservation: ThemeObservation?
private var title = ""
private var count = 0
override init(frame frameRect: NSRect) { override init(frame frameRect: NSRect) {
super.init(frame: frameRect) super.init(frame: frameRect)
@@ -317,11 +326,17 @@ private final class EmojiSectionHeader: NSView {
label.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 16), label.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 16),
label.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -8), 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") } required init?(coder: NSCoder) { fatalError("Conjure creates emoji headers programmatically") }
func setTitle(_ title: String, count: Int) { func setTitle(_ title: String, count: Int) {
self.title = title
self.count = count
let text = NSMutableAttributedString(string: title, attributes: [ let text = NSMutableAttributedString(string: title, attributes: [
.font: NSFont.systemFont(ofSize: 13), .foregroundColor: ThemePalette.secondaryText, .font: NSFont.systemFont(ofSize: 13), .foregroundColor: ThemePalette.secondaryText,
]) ])
@@ -348,7 +363,6 @@ private final class EmojiItem: NSCollectionViewItem {
} }
func highlight(_ selected: Bool) { func highlight(_ selected: Bool) {
button.layer?.backgroundColor = (selected ? ThemePalette.selectionFill : ThemePalette.text.withAlphaComponent(0.045)).cgColor
button.setAccessibilitySelected(selected) button.setAccessibilitySelected(selected)
button.selected = selected button.selected = selected
} }
@@ -361,6 +375,14 @@ private final class EmojiButton: NSButton {
var selected = false { didSet { updateBorder() } } var selected = false { didSet { updateBorder() } }
private var hovered = false { didSet { updateBorder() } } private var hovered = false { didSet { updateBorder() } }
private var hoverTracking: NSTrackingArea? 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() { override func updateTrackingAreas() {
super.updateTrackingAreas() super.updateTrackingAreas()
@@ -377,6 +399,7 @@ private final class EmojiButton: NSButton {
override func mouseExited(with event: NSEvent) { hovered = false } override func mouseExited(with event: NSEvent) { hovered = false }
private func updateBorder() { private func updateBorder() {
layer?.backgroundColor = (selected ? ThemePalette.selectionFill : ThemePalette.text.withAlphaComponent(0.045)).cgColor
layer?.borderWidth = selected || hovered ? 0.5 : 0 layer?.borderWidth = selected || hovered ? 0.5 : 0
layer?.borderColor = (selected ? ThemePalette.accent.withAlphaComponent(0.8) layer?.borderColor = (selected ? ThemePalette.accent.withAlphaComponent(0.8)
: ThemePalette.secondaryText.withAlphaComponent(0.35)).cgColor : ThemePalette.secondaryText.withAlphaComponent(0.35)).cgColor
+16 -4
View File
@@ -58,9 +58,21 @@ private final class AlignmentGuideView: NSView {
var snapsHorizontally = false var snapsHorizontally = false
var snapsVertically = false var snapsVertically = false
private static let dash: [CGFloat] = [1, 5] private static let dash: [CGFloat] = [1, 5]
private static let muted = ThemePalette.accent.withAlphaComponent(0.4).cgColor private var muted = ThemePalette.accent.withAlphaComponent(0.4).cgColor
private static let aligned = ThemePalette.accent.withAlphaComponent(0.95).cgColor private var aligned = ThemePalette.accent.withAlphaComponent(0.95).cgColor
private static let shadow = NSColor.black.withAlphaComponent(0.6).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 } override func hitTest(_ point: NSPoint) -> NSView? { nil }
@@ -75,14 +87,14 @@ private final class AlignmentGuideView: NSView {
context.setLineDash(phase: 0, lengths: Self.dash) context.setLineDash(phase: 0, lengths: Self.dash)
context.setShadow(offset: .zero, blur: 2, color: Self.shadow) 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.move(to: NSPoint(x: targetFrame.minX, y: bounds.minY))
context.addLine(to: NSPoint(x: targetFrame.minX, y: bounds.maxY)) context.addLine(to: NSPoint(x: targetFrame.minX, y: bounds.maxY))
context.move(to: NSPoint(x: targetFrame.maxX, y: bounds.minY)) context.move(to: NSPoint(x: targetFrame.maxX, y: bounds.minY))
context.addLine(to: NSPoint(x: targetFrame.maxX, y: bounds.maxY)) context.addLine(to: NSPoint(x: targetFrame.maxX, y: bounds.maxY))
context.strokePath() 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.move(to: NSPoint(x: bounds.minX, y: targetFrame.maxY))
context.addLine(to: NSPoint(x: bounds.maxX, y: targetFrame.maxY)) context.addLine(to: NSPoint(x: bounds.maxX, y: targetFrame.maxY))
context.strokePath() context.strokePath()
+107 -15
View File
@@ -2,39 +2,131 @@ import AppKit
@MainActor @MainActor
enum ThemePalette { 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 { do {
return (try ColorScheme.load(from: ColorScheme.configurationDirectory), nil) return State(scheme: try ColorScheme.load(from: ColorScheme.configurationDirectory))
} catch { } 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 private static var observation: ColorSchemeObservation?
static let background = loaded.scheme.background.withAlphaComponent(0.55)
static let text = loaded.scheme.text static let didChangeNotification = Notification.Name("ConjureThemePaletteDidChange")
static let secondaryText = loaded.scheme.secondaryText static var configurationError: String? { loaded.error }
static let accent = loaded.scheme.accent static var background: NSColor { loaded.background }
static let selectionFill = accent.withAlphaComponent(0.12) static var text: NSColor { loaded.scheme.text }
static let selectionBorder = accent.withAlphaComponent(0.30) static var secondaryText: NSColor { loaded.scheme.secondaryText }
static let glow = loaded.scheme.glow static var accent: NSColor { loaded.scheme.accent }
static let keyBackground = loaded.scheme.keyBackground.withAlphaComponent(0.5) static var selectionFill: NSColor { loaded.selectionFill }
static let error = loaded.scheme.error 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 @MainActor
final class LauncherBackground: NSView { final class LauncherBackground: NSView {
weak var canvas: NSView? weak var canvas: NSView?
private let amber = NSGradient(colors: [ private var amber = NSGradient(colors: [
ThemePalette.accent.withAlphaComponent(0.18), ThemePalette.accent.withAlphaComponent(0.18),
ThemePalette.accent.withAlphaComponent(0.065), ThemePalette.accent.withAlphaComponent(0.065),
ThemePalette.accent.withAlphaComponent(0), ThemePalette.accent.withAlphaComponent(0),
])! ])!
private let ember = NSGradient(colors: [ private var ember = NSGradient(colors: [
ThemePalette.glow.withAlphaComponent(0.26), ThemePalette.glow.withAlphaComponent(0.26),
ThemePalette.glow.withAlphaComponent(0.10), ThemePalette.glow.withAlphaComponent(0.10),
ThemePalette.glow.withAlphaComponent(0), 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 hitTest(_ point: NSPoint) -> NSView? { nil }
override func draw(_ dirtyRect: NSRect) { override func draw(_ dirtyRect: NSRect) {
+60 -5
View File
@@ -6,6 +6,7 @@ import ApplicationServices
@MainActor @MainActor
final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSearchFieldDelegate, NSTableViewDataSource, NSTableViewDelegate { final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSearchFieldDelegate, NSTableViewDataSource, NSTableViewDelegate {
private let searchField = NSSearchField() private let searchField = NSSearchField()
private var themeObservation: ThemeObservation?
private let searchSymbol = IconView(font: .systemFont(ofSize: 20)) private let searchSymbol = IconView(font: .systemFont(ofSize: 20))
private let commandBack = NSButton(title: "", target: nil, action: nil) private let commandBack = NSButton(title: "", target: nil, action: nil)
private let translationHeader = NSStackView() private let translationHeader = NSStackView()
@@ -400,6 +401,17 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
} }
window.initialFirstResponder = searchField window.initialFirstResponder = searchField
launcherViews = [header, sectionLabel, scrollView, emptyLabel, footer] 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() { private func refreshApplications() {
@@ -1015,12 +1027,28 @@ final class ApplicationCell: NSTableCellView {
private static let opaqueMask = [NSColor.black.cgColor, NSColor.black.cgColor] private static let opaqueMask = [NSColor.black.cgColor, NSColor.black.cgColor]
private static let fadedMask = [NSColor.black.cgColor, NSColor.clear.cgColor] private static let fadedMask = [NSColor.black.cgColor, NSColor.clear.cgColor]
private static let timing = CAMediaTimingFunction(name: .easeOut) 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 .font: NSFont.systemFont(ofSize: LauncherRowView.textSize), .foregroundColor: ThemePalette.secondaryText
]) ])
static let commandKind = NSAttributedString(string: "Command", attributes: applicationKind.attributes(at: 0, effectiveRange: nil)) private static var cachedCommandKind = NSAttributedString(string: "Command", attributes: cachedApplicationKind.attributes(at: 0, effectiveRange: nil))
static let favoriteKind = favorited(applicationKind) private static var cachedFavoriteKind = favorited(cachedApplicationKind)
static let favoriteCommandKind = favorited(commandKind) 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 { private static func favorited(_ kind: NSAttributedString) -> NSAttributedString {
let font = kind.attribute(.font, at: 0, effectiveRange: nil) as! NSFont let font = kind.attribute(.font, at: 0, effectiveRange: nil) as! NSFont
@@ -1041,12 +1069,13 @@ final class ApplicationCell: NSTableCellView {
let locationLabel = NSTextField(labelWithString: "") let locationLabel = NSTextField(labelWithString: "")
let kindLabel = NSTextField(labelWithString: "Application") let kindLabel = NSTextField(labelWithString: "Application")
let numberHint = ShortcutIndicator( let numberHint = ShortcutIndicator(
"1", backgroundColor: ThemePalette.keyBackground, "1", hasBackground: true,
fontSize: 10, keySize: 20 fontSize: 10, keySize: 20
) )
private let kindFade = CAGradientLayer() private let kindFade = CAGradientLayer()
private var numberHintVisible = false private var numberHintVisible = false
private var hintGeneration = 0 private var hintGeneration = 0
private var themeObservation: ThemeObservation?
override init(frame frameRect: NSRect) { override init(frame frameRect: NSRect) {
super.init(frame: frameRect) super.init(frame: frameRect)
@@ -1094,10 +1123,28 @@ final class ApplicationCell: NSTableCellView {
numberHint.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -8), numberHint.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -8),
numberHint.centerYAnchor.constraint(equalTo: centerYAnchor), numberHint.centerYAnchor.constraint(equalTo: centerYAnchor),
]) ])
themeObservation = ThemeObservation { [weak self] in self?.applyTheme() }
} }
required init?(coder: NSCoder) { fatalError("Conjure creates application cells programmatically") } 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() { override func layout() {
super.layout() super.layout()
updateKindFadeGeometry() updateKindFadeGeometry()
@@ -1400,6 +1447,14 @@ final class LauncherRowView: NSTableRowView {
static let rowHeight: CGFloat = 38 static let rowHeight: CGFloat = 38
static let textSize: CGFloat = 14 static let textSize: CGFloat = 14
static let iconToTextSpacing: CGFloat = 12 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 { var topSpacing: CGFloat = 0 {
didSet { needsLayout = true; needsDisplay = true } didSet { needsLayout = true; needsDisplay = true }
} }
+13 -2
View File
@@ -20,6 +20,7 @@ final class NotesController: NSViewController, NSTextViewDelegate {
private let actions = ApplicationActionsController() private let actions = ApplicationActionsController()
private let editor = NotesTextView() private let editor = NotesTextView()
private let status = NSTextField(labelWithString: "") private let status = NSTextField(labelWithString: "")
private var themeObservation: ThemeObservation?
private let noteTitle = NSTextField(labelWithString: "Untitled Note") private let noteTitle = NSTextField(labelWithString: "Untitled Note")
private let vimButton = NSButton(title: "Vim Off", target: nil, action: nil) private let vimButton = NSButton(title: "Vim Off", target: nil, action: nil)
private let formatting = NSStackView() private let formatting = NSStackView()
@@ -185,6 +186,15 @@ final class NotesController: NSViewController, NSTextViewDelegate {
scroll.heightAnchor.constraint(greaterThanOrEqualToConstant: 80), scroll.heightAnchor.constraint(greaterThanOrEqualToConstant: 80),
footer.heightAnchor.constraint(equalToConstant: 36), 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 { guard let store else {
editor.isEditable = false editor.isEditable = false
for control in [browseButton, actionsButton] { control.isEnabled = 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.informativeText = "This permanently deletes the note and its embedded images. This cannot be undone."
alert.addButton(withTitle: "Cancel").keyEquivalent = "\u{1b}" alert.addButton(withTitle: "Cancel").keyEquivalent = "\u{1b}"
let delete = alert.addButton(withTitle: "Delete Note") let delete = alert.addButton(withTitle: "Delete Note")
delete.contentTintColor = ThemePalette.error let theme = ThemeObservation { [weak delete] in delete?.contentTintColor = ThemePalette.error }
delete.keyEquivalent = "\r" delete.keyEquivalent = "\r"
alert.window.initialFirstResponder = delete alert.window.initialFirstResponder = delete
alert.window.defaultButtonCell = delete.cell as? NSButtonCell 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 } guard let self else { return }
defer { self.focusEditor() } defer { self.focusEditor() }
guard response == .alertSecondButtonReturn, self.selectedID == id, guard response == .alertSecondButtonReturn, self.selectedID == id,
+55 -10
View File
@@ -17,6 +17,60 @@ final class NotesTextView: NSTextView {
private var markerColumn: CGFloat = 22 private var markerColumn: CGFloat = 22
private var checkboxes: [NSButton] = [] private var checkboxes: [NSButton] = []
private var listParagraphs: [(range: NSRange, kind: String, depth: Int, number: Int, checked: Bool)] = [] 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() { var vimState = NotesVimState() {
didSet { if oldValue.mode != vimState.mode { needsDisplay = true } } didSet { if oldValue.mode != vimState.mode { needsDisplay = true } }
} }
@@ -517,16 +571,7 @@ final class NotesTextView: NSTextView {
} }
index = NSMaxRange(row) index = NSMaxRange(row)
} }
storage.enumerateAttribute(NoteAttributes.block, in: full) { value, range, _ in applyCodeColors(to: storage)
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)) }
}
}
storage.enumerateAttribute(.attachment, in: full) { value, _, _ in storage.enumerateAttribute(.attachment, in: full) { value, _, _ in
guard let attachment = value as? NSTextAttachment, guard let attachment = value as? NSTextAttachment,
let cell = attachment.attachmentCell as? NSTextAttachmentCell, let image = cell.image else { return } let cell = attachment.attachmentCell as? NSTextAttachmentCell, let image = cell.image else { return }
@@ -19,6 +19,8 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate {
private let recoveryLabel = NSTextField(wrappingLabelWithString: "") private let recoveryLabel = NSTextField(wrappingLabelWithString: "")
private let recoveryButton = NSButton(title: "Retry uninstall recovery…", target: nil, action: nil) private let recoveryButton = NSButton(title: "Retry uninstall recovery…", target: nil, action: nil)
private var recoveryReport: UninstallReport? private var recoveryReport: UninstallReport?
private var themeObservation: ThemeObservation?
private var loginStatusIsError = false
var retryUninstallRecovery: (() async -> UninstallReport)? var retryUninstallRecovery: (() async -> UninstallReport)?
init(shortcuts: GlobalShortcuts) { init(shortcuts: GlobalShortcuts) {
@@ -197,6 +199,18 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate {
for label in [instructions, statusLabel, errorLabel, spotlight, loginStatus, recoveryLabel] { for label in [instructions, statusLabel, errorLabel, spotlight, loginStatus, recoveryLabel] {
label.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true 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) { func setUninstallRecoveryReport(_ report: UninstallReport) {
@@ -230,6 +244,7 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate {
private func refreshLogin() { private func refreshLogin() {
let status = SMAppService.mainApp.status let status = SMAppService.mainApp.status
loginStatus.textColor = ThemePalette.secondaryText loginStatus.textColor = ThemePalette.secondaryText
loginStatusIsError = false
loginToggle.state = (status == .enabled || status == .requiresApproval) ? .on : .off loginToggle.state = (status == .enabled || status == .requiresApproval) ? .on : .off
loginSettings.isHidden = status != .requiresApproval loginSettings.isHidden = status != .requiresApproval
switch status { switch status {
@@ -244,6 +259,7 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate {
@unknown default: @unknown default:
loginStatus.stringValue = "Launch at login status is unavailable." loginStatus.stringValue = "Launch at login status is unavailable."
loginStatus.textColor = ThemePalette.error loginStatus.textColor = ThemePalette.error
loginStatusIsError = true
} }
} }
@@ -260,6 +276,7 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate {
refreshLogin() refreshLogin()
loginStatus.stringValue = "Could not change launch at login: \(error.localizedDescription)" loginStatus.stringValue = "Could not change launch at login: \(error.localizedDescription)"
loginStatus.textColor = ThemePalette.error loginStatus.textColor = ThemePalette.error
loginStatusIsError = true
} }
} }
+12 -2
View File
@@ -3,7 +3,8 @@ import CoreText
@MainActor @MainActor
final class ShortcutIndicator: NSStackView { 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) super.init(frame: .zero)
orientation = .horizontal orientation = .horizontal
alignment = .centerY alignment = .centerY
@@ -21,7 +22,7 @@ final class ShortcutIndicator: NSStackView {
badge.layer?.cornerRadius = 5 badge.layer?.cornerRadius = 5
badge.layer?.borderWidth = 1 badge.layer?.borderWidth = 1
badge.layer?.borderColor = ThemePalette.secondaryText.withAlphaComponent(0.35).cgColor 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.setAccessibilityElement(false)
badge.translatesAutoresizingMaskIntoConstraints = false badge.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([ NSLayoutConstraint.activate([
@@ -30,6 +31,15 @@ final class ShortcutIndicator: NSStackView {
]) ])
addArrangedSubview(badge) 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) { func setSingleKey(_ key: String) {
+69
View File
@@ -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() }
}
}
@@ -36,6 +36,9 @@ final class TranslationController: NSViewController {
private let output = NSTextView() private let output = NSTextView()
private let settingsButton = NSButton(title: "Open Translate Settings", target: nil, action: nil) private let settingsButton = NSButton(title: "Open Translate Settings", target: nil, action: nil)
private let status = NSTextField(wrappingLabelWithString: "") 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 let translateButton = NSButton(title: "Translate", target: nil, action: nil)
private var task: Task<Void, Never>? private var task: Task<Void, Never>?
var input: (() -> String)? var input: (() -> String)?
@@ -179,6 +182,24 @@ final class TranslationController: NSViewController {
footer.heightAnchor.constraint(equalToConstant: 36), footer.heightAnchor.constraint(equalToConstant: 36),
scroll.heightAnchor.constraint(greaterThanOrEqualToConstant: 100), 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() inputChanged()
} }
@@ -266,6 +287,7 @@ final class TranslationController: NSViewController {
func inputChanged() { func inputChanged() {
cancelRequest() cancelRequest()
output.string = "" output.string = ""
outputSecondaryRange = nil
let isEmpty = (input?() ?? "").trimmingCharacters(in: .whitespacesAndNewlines).isEmpty let isEmpty = (input?() ?? "").trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
if textToTranslate != nil { if textToTranslate != nil {
translateButton.title = "Translate" translateButton.title = "Translate"
@@ -307,6 +329,7 @@ final class TranslationController: NSViewController {
} }
private func setStatus(_ message: String, isError: Bool = false) { private func setStatus(_ message: String, isError: Bool = false) {
statusIsError = isError
status.stringValue = message status.stringValue = message
status.textColor = isError ? ThemePalette.error : ThemePalette.secondaryText status.textColor = isError ? ThemePalette.error : ThemePalette.secondaryText
} }
@@ -362,6 +385,7 @@ final class TranslationController: NSViewController {
} }
} }
output.string = "" output.string = ""
outputSecondaryRange = nil
setStatus("Translating…") setStatus("Translating…")
translateButton.isEnabled = false translateButton.isEnabled = false
let myMemoryTranslate = self.myMemory let myMemoryTranslate = self.myMemory
@@ -379,6 +403,7 @@ final class TranslationController: NSViewController {
.foregroundColor: ThemePalette.text, .foregroundColor: ThemePalette.text,
]) ])
if !myMemory, let separator = translation.range(of: "\n\n") { if !myMemory, let separator = translation.range(of: "\n\n") {
self.outputSecondaryRange = NSRange(separator.upperBound..<translation.endIndex, in: translation)
rendered.addAttributes([ rendered.addAttributes([
.foregroundColor: ThemePalette.secondaryText, .foregroundColor: ThemePalette.secondaryText,
.font: NSFont.systemFont(ofSize: 14), .font: NSFont.systemFont(ofSize: 14),
@@ -4,6 +4,8 @@ import AppKit
final class TranslationSettingsController: NSViewController { final class TranslationSettingsController: NSViewController {
private let keyField = NSSecureTextField() private let keyField = NSSecureTextField()
private let status = NSTextField(wrappingLabelWithString: "") private let status = NSTextField(wrappingLabelWithString: "")
private var statusIsError = false
private var themeObservation: ThemeObservation?
private let credentials: TranslationController.Credentials private let credentials: TranslationController.Credentials
init(credentials: TranslationController.Credentials = .keychain) { init(credentials: TranslationController.Credentials = .keychain) {
@@ -55,6 +57,14 @@ final class TranslationSettingsController: NSViewController {
for label in [status, myMemory, openAI] { for label in [status, myMemory, openAI] {
label.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true label.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
} }
themeObservation = ThemeObservation { [weak self] in
save.contentTintColor = ThemePalette.accent
forget.contentTintColor = ThemePalette.accent
myMemory.textColor = ThemePalette.secondaryText
openAI.textColor = ThemePalette.secondaryText
guard let self else { return }
self.status.textColor = self.statusIsError ? ThemePalette.error : ThemePalette.secondaryText
}
do { do {
setStatus(try credentials.load() == nil ? "No OpenAI API key saved." : "OpenAI API key saved in Keychain.") setStatus(try credentials.load() == nil ? "No OpenAI API key saved." : "OpenAI API key saved in Keychain.")
} catch { setStatus(error.localizedDescription, isError: true) } } catch { setStatus(error.localizedDescription, isError: true) }
@@ -84,6 +94,7 @@ final class TranslationSettingsController: NSViewController {
} }
private func setStatus(_ message: String, isError: Bool = false) { private func setStatus(_ message: String, isError: Bool = false) {
statusIsError = isError
status.stringValue = message status.stringValue = message
status.textColor = isError ? ThemePalette.error : ThemePalette.secondaryText status.textColor = isError ? ThemePalette.error : ThemePalette.secondaryText
} }
@@ -16,6 +16,8 @@ final class UninstallReviewController: NSViewController, NSSearchFieldDelegate,
private let status = NSTextField(wrappingLabelWithString: "Finding associated files…") private let status = NSTextField(wrappingLabelWithString: "Finding associated files…")
private let remove = NSButton(title: "Uninstall application", target: nil, action: nil) private let remove = NSButton(title: "Uninstall application", target: nil, action: nil)
private let back = NSButton(title: "Back", target: nil, action: nil) private let back = NSButton(title: "Back", target: nil, action: nil)
private var themeObservation: ThemeObservation?
private var statusIsError = false
private var candidates: [UninstallCandidate] = [] private var candidates: [UninstallCandidate] = []
private var visible: [UninstallCandidate] = [] private var visible: [UninstallCandidate] = []
private var selected: Set<URL> = [] private var selected: Set<URL> = []
@@ -176,6 +178,18 @@ final class UninstallReviewController: NSViewController, NSSearchFieldDelegate,
footer.bottomAnchor.constraint(equalTo: content.bottomAnchor, constant: -8), footer.bottomAnchor.constraint(equalTo: content.bottomAnchor, constant: -8),
footer.heightAnchor.constraint(equalToConstant: 36) 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) { func applyDiscoveryResult(_ result: Result<[UninstallCandidate], Error>, preservingSelection: Bool = false) {
@@ -205,6 +219,7 @@ final class UninstallReviewController: NSViewController, NSSearchFieldDelegate,
private func updateStatus() { private func updateStatus() {
status.textColor = ThemePalette.secondaryText status.textColor = ThemePalette.secondaryText
statusIsError = false
remove.isEnabled = false remove.isEnabled = false
switch discoveryState { switch discoveryState {
case .loading: case .loading:
@@ -212,6 +227,7 @@ final class UninstallReviewController: NSViewController, NSSearchFieldDelegate,
case .failed(let message): case .failed(let message):
status.stringValue = message status.stringValue = message
status.textColor = ThemePalette.error status.textColor = ThemePalette.error
statusIsError = true
case .ready: case .ready:
let chosen = candidates.filter { selected.contains($0.url) } let chosen = candidates.filter { selected.contains($0.url) }
let size = chosen.compactMap(\.size).reduce(Int64(0), +) 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.isEnabled = candidate.isSelectable && !busy
cell.toggle.setAccessibilityLabel("Select \(candidate.url.lastPathComponent)") cell.toggle.setAccessibilityLabel("Select \(candidate.url.lastPathComponent)")
cell.name.stringValue = 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 let path = (candidate.url.deletingLastPathComponent().path as NSString).abbreviatingWithTildeInPath
cell.detail.stringValue = candidate.reason.map { "\(path) · \($0)" } ?? path cell.detail.stringValue = candidate.reason.map { "\(path) · \($0)" } ?? path
cell.size.stringValue = candidate.size.map { ByteCountFormatter.string(fromByteCount: $0, countStyle: .file) } ?? "—" cell.size.stringValue = candidate.size.map { ByteCountFormatter.string(fromByteCount: $0, countStyle: .file) } ?? "—"
@@ -319,6 +335,7 @@ final class UninstallReviewController: NSViewController, NSSearchFieldDelegate,
} }
do { do {
status.textColor = ThemePalette.secondaryText status.textColor = ThemePalette.secondaryText
statusIsError = false
status.stringValue = "Waiting for application to quit…" status.stringValue = "Waiting for application to quit…"
for app in running where !app.isTerminated { for app in running where !app.isTerminated {
guard app.terminate() else { throw ReviewError("\(app.localizedName ?? application.name) could not quit. No files were moved.") } 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") } if canRecover { alert.addButton(withTitle: "Retry recovery") }
guard await alert.beginSheetModal(for: window) == .alertSecondButtonReturn, canRecover else { return } guard await alert.beginSheetModal(for: window) == .alertSecondButtonReturn, canRecover else { return }
status.textColor = ThemePalette.secondaryText status.textColor = ThemePalette.secondaryText
statusIsError = false
status.stringValue = "Recovering pending uninstall…" status.stringValue = "Recovering pending uninstall…"
let report = await uninstaller.recover() let report = await uninstaller.recover()
changed?() changed?()
@@ -425,6 +443,14 @@ private final class UninstallFileCell: NSTableCellView {
let detail = NSTextField(labelWithString: "") let detail = NSTextField(labelWithString: "")
let size = NSTextField(labelWithString: "") let size = NSTextField(labelWithString: "")
let fileIcon: IconView 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() { init() {
let font = NSFont.systemFont(ofSize: 14, weight: .medium) 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.topAnchor.constraint(equalTo: topAnchor, constant: 4),
row.bottomAnchor.constraint(equalTo: bottomAnchor, 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") } required init?(coder: NSCoder) { fatalError("Conjure creates uninstall rows programmatically") }
@@ -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<String>.makeStream()
let observation = ColorSchemeObservation(directory: directory) {
events.continuation.yield((try? String(contentsOf: file, encoding: .utf8)) ?? "<missing>")
}
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<String>.makeStream()
let observation = ColorSchemeObservation(directory: directory) {
events.continuation.yield((try? String(contentsOf: file, encoding: .utf8)) ?? "<missing>")
}
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("<missing>", 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<String>) 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)
}
@@ -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 }
}