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