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.
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.
+40
View File
@@ -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
}
}
}
@@ -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)
+1
View File
@@ -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)
+24 -1
View File
@@ -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
+16 -4
View File
@@ -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()
+107 -15
View File
@@ -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) {
+60 -5
View File
@@ -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 }
}
+13 -2
View File
@@ -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,
+55 -10
View File
@@ -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 }
@@ -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
}
}
+12 -2
View File
@@ -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) {
+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 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<Void, Never>?
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..<translation.endIndex, in: translation)
rendered.addAttributes([
.foregroundColor: ThemePalette.secondaryText,
.font: NSFont.systemFont(ofSize: 14),
@@ -4,6 +4,8 @@ import AppKit
final class TranslationSettingsController: NSViewController {
private let keyField = NSSecureTextField()
private let status = NSTextField(wrappingLabelWithString: "")
private var statusIsError = false
private var themeObservation: ThemeObservation?
private let credentials: TranslationController.Credentials
init(credentials: TranslationController.Credentials = .keychain) {
@@ -55,6 +57,14 @@ final class TranslationSettingsController: NSViewController {
for label in [status, myMemory, openAI] {
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 {
setStatus(try credentials.load() == nil ? "No OpenAI API key saved." : "OpenAI API key saved in Keychain.")
} catch { setStatus(error.localizedDescription, isError: true) }
@@ -84,6 +94,7 @@ final class TranslationSettingsController: NSViewController {
}
private func setStatus(_ message: String, isError: Bool = false) {
statusIsError = isError
status.stringValue = message
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 remove = NSButton(title: "Uninstall application", 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 visible: [UninstallCandidate] = []
private var selected: Set<URL> = []
@@ -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") }
@@ -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 }
}