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
+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") }