feat: configure launcher color scheme

This commit is contained in:
2026-10-22 15:36:44 +02:00
parent 1ec4c0d582
commit be761b4704
21 changed files with 460 additions and 150 deletions
+40 -1
View File
@@ -132,11 +132,50 @@ With Actions open, Escape or the Back chevron closes Actions first. Another Esca
The picker works offline with 1,870 emoji from [gemoji v4.1.0](https://github.com/github/gemoji/tree/v4.1.0). Its [MIT license](Sources/ConjureKit/Resources/LICENSE-gemoji.txt) is included in the application. The picker works offline with 1,870 emoji from [gemoji v4.1.0](https://github.com/github/gemoji/tree/v4.1.0). Its [MIT license](Sources/ConjureKit/Resources/LICENSE-gemoji.txt) is included in the application.
## Color scheme
Create `$XDG_CONFIG_HOME/conjure/config.json`, or `~/.config/conjure/config.json` when `XDG_CONFIG_HOME` is unset, empty, or relative. No configuration is required: Conjure keeps its built-in sunset colors by default.
```json
{
"colors": {
"background": "#171b24",
"text": "#e0e6f0",
"secondaryText": "#a0adc0",
"accent": "#82aaff",
"glow": "#c792ea",
"keyBackground": "#303848",
"error": "#ff5370"
}
}
```
Every color is optional and must be an opaque `#RRGGBB` value. `accent` colors selections, borders, carets, and the primary background glows; `glow` colors the secondary background glow. `keyBackground` colors application-number shortcut badges, and `error` colors errors and destructive actions. Overrides apply throughout the launcher, tools, popups, and Settings wherever those shared colors are used.
Only colors change. Frosted glass, glow positions and sizes, opacity, selection treatment, typography, and layout stay fixed. Native controls, application/emoji icons, and Notes code-syntax highlighting retain their existing styling.
### Follow tint
The sibling [tint](../tint) tool generates `conjure/theme.json` in the same configuration directory when it applies a theme. Rebuild/install the updated tint tool with `make install` in its repository, then apply your chosen pair, for example `tint swamp`. Opt in from Conjure's `config.json`:
```json
{
"theme": "tint",
"colors": {
"accent": "#82aaff"
}
}
```
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"`, to ignore the generated palette. tint never overwrites `config.json`; 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 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.
## 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 existing dark styling. 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.
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.
+3 -3
View File
@@ -7,12 +7,12 @@ final class ActionPill: NSView {
wantsLayer = true wantsLayer = true
layer?.cornerRadius = 18 layer?.cornerRadius = 18
layer?.borderWidth = 0.5 layer?.borderWidth = 0.5
layer?.borderColor = SunsetPalette.accent.withAlphaComponent(0.35).cgColor layer?.borderColor = ThemePalette.accent.withAlphaComponent(0.35).cgColor
layer?.backgroundColor = SunsetPalette.background.withAlphaComponent(0.25).cgColor layer?.backgroundColor = ThemePalette.background.withAlphaComponent(0.25).cgColor
for button in [primaryButton, actionsButton] { for button in [primaryButton, actionsButton] {
button.isBordered = false button.isBordered = false
button.font = .systemFont(ofSize: 12, weight: .medium) button.font = .systemFont(ofSize: 12, weight: .medium)
button.contentTintColor = SunsetPalette.text button.contentTintColor = ThemePalette.text
} }
primaryButton.keyEquivalent = primaryCommand ?? "\r" primaryButton.keyEquivalent = primaryCommand ?? "\r"
primaryButton.keyEquivalentModifierMask = primaryCommand == nil ? [] : .command primaryButton.keyEquivalentModifierMask = primaryCommand == nil ? [] : .command
@@ -74,23 +74,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: SunsetPalette.text), ActionEntry(title: "Open", symbol: "arrow.up.forward.app", color: ThemePalette.text),
ActionEntry(title: "Favorite", symbol: "star", color: SunsetPalette.text), ActionEntry(title: "Favorite", symbol: "star", color: ThemePalette.text),
ActionEntry(title: "Show in Finder", symbol: "finder", color: SunsetPalette.text), ActionEntry(title: "Show in Finder", symbol: "finder", color: ThemePalette.text),
ActionEntry(title: "Reset ranking", symbol: "arrow.counterclockwise", color: SunsetPalette.text), ActionEntry(title: "Reset ranking", symbol: "arrow.counterclockwise", color: ThemePalette.text),
ActionEntry(title: "Quit application", symbol: "stop.circle", color: SunsetPalette.text), ActionEntry(title: "Quit application", symbol: "stop.circle", color: ThemePalette.text),
ActionEntry(title: "Uninstall application", symbol: "trash", color: .systemRed), ActionEntry(title: "Uninstall application", symbol: "trash", color: ThemePalette.error),
ActionEntry(title: "Copy Emoji", symbol: "doc.on.doc", color: SunsetPalette.text), ActionEntry(title: "Copy Emoji", symbol: "doc.on.doc", color: ThemePalette.text),
ActionEntry(title: "Change source language…", symbol: "character.bubble", color: SunsetPalette.text), ActionEntry(title: "Change source language…", symbol: "character.bubble", color: ThemePalette.text),
ActionEntry(title: "Change target language…", symbol: "character.bubble", color: SunsetPalette.text), ActionEntry(title: "Change target language…", symbol: "character.bubble", color: ThemePalette.text),
ActionEntry(title: "Swap languages", symbol: "arrow.left.arrow.right", color: SunsetPalette.text), ActionEntry(title: "Swap languages", symbol: "arrow.left.arrow.right", color: ThemePalette.text),
ActionEntry(title: "MyMemory (no key)", symbol: "checkmark", color: SunsetPalette.text), ActionEntry(title: "MyMemory (no key)", symbol: "checkmark", color: ThemePalette.text),
ActionEntry(title: "OpenAI · gpt-6-luna", symbol: "checkmark", color: SunsetPalette.text), ActionEntry(title: "OpenAI · gpt-6-luna", symbol: "checkmark", color: ThemePalette.text),
ActionEntry(title: "Browse Notes", symbol: "list.bullet", color: SunsetPalette.text, commandKey: "P"), ActionEntry(title: "Browse Notes", symbol: "list.bullet", color: ThemePalette.text, commandKey: "P"),
ActionEntry(title: "New Note", symbol: "square.and.pencil", color: SunsetPalette.text, commandKey: "N"), ActionEntry(title: "New Note", symbol: "square.and.pencil", color: ThemePalette.text, commandKey: "N"),
ActionEntry(title: "Delete Note", symbol: "trash", color: .systemRed), ActionEntry(title: "Delete Note", symbol: "trash", color: ThemePalette.error),
ActionEntry(title: "Copy as Markdown", symbol: "doc.on.doc", color: SunsetPalette.text), ActionEntry(title: "Copy as Markdown", symbol: "doc.on.doc", color: ThemePalette.text),
ActionEntry(title: "Export as Markdown…", symbol: "square.and.arrow.up", color: SunsetPalette.text) ActionEntry(title: "Export as Markdown…", symbol: "square.and.arrow.up", color: ThemePalette.text)
] ]
view = ActionsPanelView() view = ActionsPanelView()
view.wantsLayer = true view.wantsLayer = true
@@ -106,13 +106,13 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat
content.layer?.borderWidth = 0.5 content.layer?.borderWidth = 0.5
background.setAccessibilityElement(false) background.setAccessibilityElement(false)
content.addSubview(background) content.addSubview(background)
applicationLabel.textColor = SunsetPalette.secondaryText applicationLabel.textColor = ThemePalette.secondaryText
applicationLabel.lineBreakMode = .byTruncatingTail applicationLabel.lineBreakMode = .byTruncatingTail
applicationLabel.font = .systemFont(ofSize: 12, weight: .medium) applicationLabel.font = .systemFont(ofSize: 12, weight: .medium)
search.font = .systemFont(ofSize: LauncherRowView.textSize) search.font = .systemFont(ofSize: LauncherRowView.textSize)
search.textColor = SunsetPalette.text search.textColor = ThemePalette.text
search.placeholderAttributedString = NSAttributedString(string: "Search for actions…", attributes: [ search.placeholderAttributedString = NSAttributedString(string: "Search for actions…", attributes: [
.foregroundColor: SunsetPalette.secondaryText, .font: NSFont.systemFont(ofSize: LauncherRowView.textSize) .foregroundColor: ThemePalette.secondaryText, .font: NSFont.systemFont(ofSize: LauncherRowView.textSize)
]) ])
search.isBordered = false search.isBordered = false
search.drawsBackground = false search.drawsBackground = false
@@ -126,7 +126,7 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat
search.setAccessibilityLabel("Search application actions") search.setAccessibilityLabel("Search application actions")
search.closeActions = { [weak self] in self?.close() } search.closeActions = { [weak self] in self?.close() }
search.commandKey = { [weak self] key in self?.handleCommandKey(key) ?? false } search.commandKey = { [weak self] key in self?.handleCommandKey(key) ?? false }
empty.textColor = SunsetPalette.secondaryText empty.textColor = ThemePalette.secondaryText
empty.font = .systemFont(ofSize: 14) empty.font = .systemFont(ofSize: 14)
empty.isHidden = true empty.isHidden = true
content.translatesAutoresizingMaskIntoConstraints = false content.translatesAutoresizingMaskIntoConstraints = false
@@ -173,7 +173,7 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat
search.bottomAnchor.constraint(equalTo: content.bottomAnchor, constant: -8), search.bottomAnchor.constraint(equalTo: content.bottomAnchor, constant: -8),
search.heightAnchor.constraint(equalToConstant: 24) search.heightAnchor.constraint(equalToConstant: 24)
]) ])
content.layer?.borderColor = SunsetPalette.secondaryText.withAlphaComponent(0.22).cgColor content.layer?.borderColor = ThemePalette.secondaryText.withAlphaComponent(0.22).cgColor
if choices != nil { if choices != nil {
height.isActive = false height.isActive = false
let table = NSTableView() let table = NSTableView()
@@ -290,7 +290,7 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat
let favorite = entries[1] let favorite = entries[1]
let favoriteTitle = isFavorite ? "Remove Favorite" : "Favorite" let favoriteTitle = isFavorite ? "Remove Favorite" : "Favorite"
favorite.button.attributedTitle = NSAttributedString(string: favoriteTitle, attributes: [ favorite.button.attributedTitle = NSAttributedString(string: favoriteTitle, attributes: [
.foregroundColor: SunsetPalette.text, .font: NSFont.systemFont(ofSize: LauncherRowView.textSize, weight: .medium) .foregroundColor: ThemePalette.text, .font: NSFont.systemFont(ofSize: LauncherRowView.textSize, weight: .medium)
]) ])
favorite.button.setAccessibilityLabel(favoriteTitle) favorite.button.setAccessibilityLabel(favoriteTitle)
favorite.icon.image = NSImage(systemSymbolName: isFavorite ? "star.fill" : "star", accessibilityDescription: nil) favorite.icon.image = NSImage(systemSymbolName: isFavorite ? "star.fill" : "star", accessibilityDescription: nil)
@@ -299,7 +299,7 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat
} }
search.placeholderAttributedString = NSAttributedString( search.placeholderAttributedString = NSAttributedString(
string: choices != nil ? "Search for \(selectionName)…" : belowAnchor ? "Search for providers…" : "Search for actions…", string: choices != nil ? "Search for \(selectionName)…" : belowAnchor ? "Search for providers…" : "Search for actions…",
attributes: [.foregroundColor: SunsetPalette.secondaryText, attributes: [.foregroundColor: ThemePalette.secondaryText,
.font: NSFont.systemFont(ofSize: LauncherRowView.textSize)]) .font: NSFont.systemFont(ofSize: LauncherRowView.textSize)])
search.stringValue = "" search.stringValue = ""
controlTextDidChange(Notification(name: NSControl.textDidChangeNotification)) controlTextDidChange(Notification(name: NSControl.textDidChangeNotification))
@@ -332,7 +332,7 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat
coveredPill?.animator().alphaValue = 0 coveredPill?.animator().alphaValue = 0
} }
window.makeFirstResponder(search) window.makeFirstResponder(search)
(search.currentEditor() as? NSTextView)?.insertionPointColor = SunsetPalette.accent (search.currentEditor() as? NSTextView)?.insertionPointColor = ThemePalette.accent
mouseMonitor = NSEvent.addLocalMonitorForEvents(matching: [.leftMouseDown, .rightMouseDown]) { [weak self, weak pill] event in mouseMonitor = NSEvent.addLocalMonitorForEvents(matching: [.leftMouseDown, .rightMouseDown]) { [weak self, weak pill] event in
MainActor.assumeIsolated { MainActor.assumeIsolated {
guard let self, self.isShown, event.window === self.view.window else { return } guard let self, self.isShown, event.window === self.view.window else { return }
@@ -595,7 +595,7 @@ private struct ActionEntry {
@MainActor @MainActor
private final class SelectionCell: NSView { private final class SelectionCell: NSView {
let entry = ActionEntry(title: "", symbol: "checkmark", color: SunsetPalette.text) let entry = ActionEntry(title: "", symbol: "checkmark", color: ThemePalette.text)
init() { init() {
super.init(frame: .zero) super.init(frame: .zero)
+127
View File
@@ -0,0 +1,127 @@
import AppKit
@MainActor
struct ColorScheme {
var background: NSColor
var text: NSColor
var secondaryText: NSColor
var accent: NSColor
var glow: NSColor
var keyBackground: NSColor
var error: NSColor
static let sunset = ColorScheme(
background: NSColor(srgbRed: 0.12, green: 0.09, blue: 0.08, alpha: 1),
text: NSColor(srgbRed: 1.00, green: 0.89, blue: 0.71, alpha: 1),
secondaryText: NSColor(srgbRed: 0.85, green: 0.78, blue: 0.65, alpha: 1),
accent: NSColor(srgbRed: 0.95, green: 0.58, blue: 0.23, alpha: 1),
glow: NSColor(srgbRed: 0.76, green: 0.27, blue: 0.18, alpha: 1),
keyBackground: NSColor(srgbRed: 0.26, green: 0.22, blue: 0.19, alpha: 1),
error: .systemRed
)
static var configurationDirectory: URL {
let base: URL
if let xdg = ProcessInfo.processInfo.environment["XDG_CONFIG_HOME"], xdg.hasPrefix("/") {
base = URL(fileURLWithPath: xdg, isDirectory: true)
} else {
base = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".config", isDirectory: true)
}
return base.appendingPathComponent("conjure", isDirectory: true)
}
static func load(from directory: URL) throws -> ColorScheme {
let configURL = directory.appendingPathComponent("config.json")
guard let config = try readObject(at: configURL, missingAllowed: true) else { return sunset }
try validateKeys(config, allowed: ["theme", "colors"], at: configURL, prefix: "")
let theme: String
if let value = config["theme"] {
guard let name = value as? String, ["sunset", "tint"].contains(name) else {
throw ColorSchemeError(path: configURL.path, reason: "theme must be \"sunset\" or \"tint\".")
}
theme = name
} else {
theme = "sunset"
}
var result = sunset
if theme == "tint" {
let themeURL = directory.appendingPathComponent("theme.json")
if let generated = try readObject(at: themeURL, missingAllowed: false) {
try validateKeys(generated, allowed: ["colors"], at: themeURL, prefix: "")
try result.applyColors(from: generated, at: themeURL)
}
}
try result.applyColors(from: config, at: configURL)
return result
}
private static let colorFields: [String: WritableKeyPath<ColorScheme, NSColor>] = [
"background": \.background,
"text": \.text,
"secondaryText": \.secondaryText,
"accent": \.accent,
"glow": \.glow,
"keyBackground": \.keyBackground,
"error": \.error,
]
private mutating func applyColors(from object: [String: Any], at url: URL) throws {
guard let value = object["colors"] else { return }
guard let colors = value as? [String: Any] else {
throw ColorSchemeError(path: url.path, reason: "colors must be an object.")
}
try Self.validateKeys(colors, allowed: Set(Self.colorFields.keys), at: url, prefix: "colors.")
for (name, value) in colors {
guard let hex = value as? String, hex.utf8.count == 7, hex.first == "#",
hex.utf8.dropFirst().allSatisfy({ (48...57).contains($0) || (65...70).contains($0) || (97...102).contains($0) }),
let rgb = UInt32(hex.dropFirst(), radix: 16) else {
throw ColorSchemeError(path: url.path, reason: "colors.\(name) must be #RRGGBB ASCII hex (no alpha).")
}
if let field = Self.colorFields[name] {
self[keyPath: field] = NSColor(
srgbRed: CGFloat((rgb >> 16) & 0xff) / 255,
green: CGFloat((rgb >> 8) & 0xff) / 255,
blue: CGFloat(rgb & 0xff) / 255,
alpha: 1
)
}
}
}
private static func validateKeys(_ object: [String: Any], allowed: Set<String>, at url: URL, prefix: String) throws {
if let key = object.keys.sorted().first(where: { !allowed.contains($0) }) {
throw ColorSchemeError(path: url.path, reason: "Unknown key \"\(prefix)\(key)\".")
}
}
private static func readObject(at url: URL, missingAllowed: Bool) throws -> [String: Any]? {
let data: Data
do {
data = try Data(contentsOf: url)
} catch {
let cocoa = error as NSError
if missingAllowed, cocoa.domain == NSCocoaErrorDomain, cocoa.code == NSFileReadNoSuchFileError {
return nil
}
throw ColorSchemeError(path: url.path, reason: error.localizedDescription)
}
let value: Any
do {
value = try JSONSerialization.jsonObject(with: data, options: [.fragmentsAllowed])
} catch {
throw ColorSchemeError(path: url.path, reason: "Invalid JSON: \(error.localizedDescription)")
}
guard let object = value as? [String: Any] else {
throw ColorSchemeError(path: url.path, reason: "Expected a JSON object.")
}
return object
}
}
private struct ColorSchemeError: LocalizedError {
let path: String
let reason: String
var errorDescription: String? { "\(path): \(reason)" }
}
+7 -8
View File
@@ -108,7 +108,7 @@ private final class ApplicationDelegate: NSObject, NSApplicationDelegate {
controller.retryUninstallRecovery = { [uninstaller] in await uninstaller.recover() } controller.retryUninstallRecovery = { [uninstaller] in await uninstaller.recover() }
return controller return controller
} }
private var startupShortcutError: String? private var startupError = ThemePalette.configurationError
private var startupRecoveryMessage: String? private var startupRecoveryMessage: String?
private var isLoginLaunch: Bool { private var isLoginLaunch: Bool {
@@ -137,24 +137,23 @@ private final class ApplicationDelegate: NSObject, NSApplicationDelegate {
launcher.present() launcher.present()
} }
let shortcutErrors = shortcuts.start() let shortcutErrors = shortcuts.start()
if !shortcutErrors.isEmpty { startupError = ([startupError].compactMap { $0 } + shortcutErrors).joined(separator: "\n")
startupShortcutError = shortcutErrors.joined(separator: "\n") if startupError?.isEmpty == true { startupError = nil }
if !isLoginLaunch { showSettings(nil) } if startupError != nil && !loginLaunch { showSettings(nil) }
}
} }
func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows: Bool) -> Bool { func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows: Bool) -> Bool {
guard !isLoginLaunch else { return false } guard !isLoginLaunch else { return false }
launcher.present() launcher.present()
if startupShortcutError != nil || startupRecoveryMessage != nil { if startupError != nil || startupRecoveryMessage != nil {
showSettings(nil) showSettings(nil)
} }
return false return false
} }
@objc func showSettings(_ sender: Any?) { @objc func showSettings(_ sender: Any?) {
settings.present(error: startupShortcutError) settings.present(error: startupError)
startupShortcutError = nil startupError = nil
startupRecoveryMessage = nil startupRecoveryMessage = nil
} }
+8 -8
View File
@@ -38,7 +38,7 @@ final class EmojiPickerController: NSViewController, NSCollectionViewDataSource,
category.action = #selector(categoryChanged) category.action = #selector(categoryChanged)
category.font = .systemFont(ofSize: 12) category.font = .systemFont(ofSize: 12)
category.isBordered = false category.isBordered = false
category.contentTintColor = SunsetPalette.secondaryText category.contentTintColor = ThemePalette.secondaryText
category.setContentCompressionResistancePriority(.required, for: .horizontal) category.setContentCompressionResistancePriority(.required, for: .horizontal)
layout.itemSize = NSSize(width: 80, height: 80) layout.itemSize = NSSize(width: 80, height: 80)
layout.minimumInteritemSpacing = 8 layout.minimumInteritemSpacing = 8
@@ -59,9 +59,9 @@ final class EmojiPickerController: NSViewController, NSCollectionViewDataSource,
scroll.scrollerStyle = .overlay scroll.scrollerStyle = .overlay
scroll.drawsBackground = false scroll.drawsBackground = false
scroll.contentView.drawsBackground = false scroll.contentView.drawsBackground = false
empty.textColor = SunsetPalette.secondaryText empty.textColor = ThemePalette.secondaryText
status.font = .systemFont(ofSize: 12) status.font = .systemFont(ofSize: 12)
status.textColor = SunsetPalette.secondaryText status.textColor = ThemePalette.secondaryText
status.lineBreakMode = .byTruncatingTail status.lineBreakMode = .byTruncatingTail
status.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) status.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
copyButton.target = self copyButton.target = self
@@ -323,10 +323,10 @@ private final class EmojiSectionHeader: NSView {
func setTitle(_ title: String, count: Int) { func setTitle(_ title: String, count: Int) {
let text = NSMutableAttributedString(string: title, attributes: [ let text = NSMutableAttributedString(string: title, attributes: [
.font: NSFont.systemFont(ofSize: 13), .foregroundColor: SunsetPalette.secondaryText, .font: NSFont.systemFont(ofSize: 13), .foregroundColor: ThemePalette.secondaryText,
]) ])
text.append(NSAttributedString(string: " \(count)", attributes: [ text.append(NSAttributedString(string: " \(count)", attributes: [
.font: NSFont.systemFont(ofSize: 12), .foregroundColor: SunsetPalette.secondaryText.withAlphaComponent(0.55), .font: NSFont.systemFont(ofSize: 12), .foregroundColor: ThemePalette.secondaryText.withAlphaComponent(0.55),
])) ]))
label.attributedStringValue = text label.attributedStringValue = text
} }
@@ -348,7 +348,7 @@ private final class EmojiItem: NSCollectionViewItem {
} }
func highlight(_ selected: Bool) { func highlight(_ selected: Bool) {
button.layer?.backgroundColor = (selected ? SunsetPalette.selectionFill : SunsetPalette.text.withAlphaComponent(0.045)).cgColor button.layer?.backgroundColor = (selected ? ThemePalette.selectionFill : ThemePalette.text.withAlphaComponent(0.045)).cgColor
button.setAccessibilitySelected(selected) button.setAccessibilitySelected(selected)
button.selected = selected button.selected = selected
} }
@@ -378,7 +378,7 @@ private final class EmojiButton: NSButton {
private func updateBorder() { private func updateBorder() {
layer?.borderWidth = selected || hovered ? 0.5 : 0 layer?.borderWidth = selected || hovered ? 0.5 : 0
layer?.borderColor = (selected ? SunsetPalette.accent.withAlphaComponent(0.8) layer?.borderColor = (selected ? ThemePalette.accent.withAlphaComponent(0.8)
: SunsetPalette.secondaryText.withAlphaComponent(0.35)).cgColor : ThemePalette.secondaryText.withAlphaComponent(0.35)).cgColor
} }
} }
@@ -58,8 +58,8 @@ 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 = SunsetPalette.accent.withAlphaComponent(0.4).cgColor private static let muted = ThemePalette.accent.withAlphaComponent(0.4).cgColor
private static let aligned = SunsetPalette.accent.withAlphaComponent(0.95).cgColor private static let 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
override func hitTest(_ point: NSPoint) -> NSView? { nil } override func hitTest(_ point: NSPoint) -> NSView? { nil }
+23 -13
View File
@@ -1,34 +1,44 @@
import AppKit import AppKit
@MainActor @MainActor
enum SunsetPalette { enum ThemePalette {
static let background = NSColor(srgbRed: 0.12, green: 0.09, blue: 0.08, alpha: 0.55) private static let loaded: (scheme: ColorScheme, error: String?) = {
static let text = NSColor(srgbRed: 1.00, green: 0.89, blue: 0.71, alpha: 1) do {
static let secondaryText = NSColor(srgbRed: 0.85, green: 0.78, blue: 0.65, alpha: 1) return (try ColorScheme.load(from: ColorScheme.configurationDirectory), nil)
static let accent = NSColor(srgbRed: 0.95, green: 0.58, blue: 0.23, alpha: 1) } catch {
return (.sunset, "Color configuration: \(error.localizedDescription)\nUsing the built-in sunset palette. Fix the file and restart Conjure.")
}
}()
static let configurationError = loaded.error
static let background = loaded.scheme.background.withAlphaComponent(0.55)
static let text = loaded.scheme.text
static let secondaryText = loaded.scheme.secondaryText
static let accent = loaded.scheme.accent
static let selectionFill = accent.withAlphaComponent(0.12) static let selectionFill = accent.withAlphaComponent(0.12)
static let selectionBorder = accent.withAlphaComponent(0.30) static let selectionBorder = accent.withAlphaComponent(0.30)
static let ember = NSColor(srgbRed: 0.76, green: 0.27, blue: 0.18, alpha: 1) static let glow = loaded.scheme.glow
static let keyBackground = loaded.scheme.keyBackground.withAlphaComponent(0.5)
static let error = loaded.scheme.error
} }
@MainActor @MainActor
final class LauncherBackground: NSView { final class LauncherBackground: NSView {
weak var canvas: NSView? weak var canvas: NSView?
private let amber = NSGradient(colors: [ private let amber = NSGradient(colors: [
SunsetPalette.accent.withAlphaComponent(0.18), ThemePalette.accent.withAlphaComponent(0.18),
SunsetPalette.accent.withAlphaComponent(0.065), ThemePalette.accent.withAlphaComponent(0.065),
SunsetPalette.accent.withAlphaComponent(0), ThemePalette.accent.withAlphaComponent(0),
])! ])!
private let ember = NSGradient(colors: [ private let ember = NSGradient(colors: [
SunsetPalette.ember.withAlphaComponent(0.26), ThemePalette.glow.withAlphaComponent(0.26),
SunsetPalette.ember.withAlphaComponent(0.10), ThemePalette.glow.withAlphaComponent(0.10),
SunsetPalette.ember.withAlphaComponent(0), ThemePalette.glow.withAlphaComponent(0),
])! ])!
override func hitTest(_ point: NSPoint) -> NSView? { nil } override func hitTest(_ point: NSPoint) -> NSView? { nil }
override func draw(_ dirtyRect: NSRect) { override func draw(_ dirtyRect: NSRect) {
SunsetPalette.background.setFill() ThemePalette.background.setFill()
dirtyRect.fill() dirtyRect.fill()
let canvasBounds = canvas.map { convert($0.bounds, from: $0) } ?? bounds let canvasBounds = canvas.map { convert($0.bounds, from: $0) } ?? bounds
let upperGlow = NSPoint(x: canvasBounds.minX + canvasBounds.width * 0.58, let upperGlow = NSPoint(x: canvasBounds.minX + canvasBounds.width * 0.58,
+18 -18
View File
@@ -169,7 +169,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
if let notes { notes.focusEditor() } if let notes { notes.focusEditor() }
else { window?.makeFirstResponder(searchField) } else { window?.makeFirstResponder(searchField) }
(window as? LauncherWindow)?.sampleNumberHints() (window as? LauncherWindow)?.sampleNumberHints()
(searchField.currentEditor() as? NSTextView)?.insertionPointColor = SunsetPalette.accent (searchField.currentEditor() as? NSTextView)?.insertionPointColor = ThemePalette.accent
refreshApplications() refreshApplications()
} }
@@ -276,9 +276,9 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
searchField.font = searchFont searchField.font = searchFont
searchField.placeholderAttributedString = NSAttributedString( searchField.placeholderAttributedString = NSAttributedString(
string: "Search apps, calculate, or convert…", string: "Search apps, calculate, or convert…",
attributes: [.foregroundColor: SunsetPalette.secondaryText, .font: searchFont] attributes: [.foregroundColor: ThemePalette.secondaryText, .font: searchFont]
) )
searchField.textColor = SunsetPalette.text searchField.textColor = ThemePalette.text
searchField.isBordered = false searchField.isBordered = false
searchField.drawsBackground = false searchField.drawsBackground = false
searchField.focusRingType = .none searchField.focusRingType = .none
@@ -292,11 +292,11 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
searchField.setAccessibilityLabel("Search applications, calculate, or convert units and currencies") searchField.setAccessibilityLabel("Search applications, calculate, or convert units and currencies")
searchSymbol.image = NSImage(systemSymbolName: "sparkles", accessibilityDescription: nil) searchSymbol.image = NSImage(systemSymbolName: "sparkles", accessibilityDescription: nil)
searchSymbol.contentTintColor = SunsetPalette.accent searchSymbol.contentTintColor = ThemePalette.accent
commandBack.image = IconStyle.symbol("chevron.left", for: searchFont) commandBack.image = IconStyle.symbol("chevron.left", for: searchFont)
commandBack.imageScaling = .scaleProportionallyDown commandBack.imageScaling = .scaleProportionallyDown
commandBack.isBordered = false commandBack.isBordered = false
commandBack.contentTintColor = SunsetPalette.secondaryText commandBack.contentTintColor = ThemePalette.secondaryText
commandBack.setAccessibilityLabel("Back to applications") commandBack.setAccessibilityLabel("Back to applications")
commandBack.toolTip = "Back to applications" commandBack.toolTip = "Back to applications"
commandBack.isHidden = true commandBack.isHidden = true
@@ -311,7 +311,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
header.spacing = 12 header.spacing = 12
sectionLabel.font = .systemFont(ofSize: 12, weight: .semibold) sectionLabel.font = .systemFont(ofSize: 12, weight: .semibold)
sectionLabel.textColor = SunsetPalette.secondaryText sectionLabel.textColor = ThemePalette.secondaryText
let column = NSTableColumn(identifier: NSUserInterfaceItemIdentifier("application")) let column = NSTableColumn(identifier: NSUserInterfaceItemIdentifier("application"))
column.resizingMask = .autoresizingMask column.resizingMask = .autoresizingMask
@@ -350,9 +350,9 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
scrollView.contentInsets = NSEdgeInsets(top: LauncherScrollView.fadeHeight, left: 0, bottom: 16, right: 0) scrollView.contentInsets = NSEdgeInsets(top: LauncherScrollView.fadeHeight, left: 0, bottom: 16, right: 0)
statusLabel.font = .systemFont(ofSize: 12) statusLabel.font = .systemFont(ofSize: 12)
statusLabel.textColor = SunsetPalette.secondaryText statusLabel.textColor = ThemePalette.secondaryText
statusLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) statusLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
emptyLabel.textColor = SunsetPalette.secondaryText emptyLabel.textColor = ThemePalette.secondaryText
emptyLabel.alignment = .center emptyLabel.alignment = .center
openButton.target = self openButton.target = self
@@ -395,7 +395,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
footer.heightAnchor.constraint(equalToConstant: 36), footer.heightAnchor.constraint(equalToConstant: 36),
]) ])
window.effectiveAppearance.performAsCurrentDrawingAppearance { window.effectiveAppearance.performAsCurrentDrawingAppearance {
let border = SunsetPalette.secondaryText.withAlphaComponent(0.22).cgColor let border = ThemePalette.secondaryText.withAlphaComponent(0.22).cgColor
content.layer?.borderColor = border content.layer?.borderColor = border
} }
window.initialFirstResponder = searchField window.initialFirstResponder = searchField
@@ -569,7 +569,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
cell.kindLabel.stringValue = isCurrencyCalculation ? "Daily rates" cell.kindLabel.stringValue = isCurrencyCalculation ? "Daily rates"
: calculation.copyValue == nil ? "Error" : "Calculator" : calculation.copyValue == nil ? "Error" : "Calculator"
cell.imageView?.image = NSImage(systemSymbolName: "equal", accessibilityDescription: nil) cell.imageView?.image = NSImage(systemSymbolName: "equal", accessibilityDescription: nil)
cell.imageView?.contentTintColor = SunsetPalette.accent cell.imageView?.contentTintColor = ThemePalette.accent
cell.toolTip = calculation.detail cell.toolTip = calculation.detail
} else if results[row].command != nil { } else if results[row].command != nil {
let entry = results[row] let entry = results[row]
@@ -1016,7 +1016,7 @@ final class ApplicationCell: NSTableCellView {
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: [ static let applicationKind = NSAttributedString(string: "Application", attributes: [
.font: NSFont.systemFont(ofSize: LauncherRowView.textSize), .foregroundColor: SunsetPalette.secondaryText .font: NSFont.systemFont(ofSize: LauncherRowView.textSize), .foregroundColor: ThemePalette.secondaryText
]) ])
static let commandKind = NSAttributedString(string: "Command", attributes: applicationKind.attributes(at: 0, effectiveRange: nil)) static let commandKind = NSAttributedString(string: "Command", attributes: applicationKind.attributes(at: 0, effectiveRange: nil))
static let favoriteKind = favorited(applicationKind) static let favoriteKind = favorited(applicationKind)
@@ -1028,7 +1028,7 @@ final class ApplicationCell: NSTableCellView {
attachment.image = NSImage(systemSymbolName: "star.fill", accessibilityDescription: "Favorite")? attachment.image = NSImage(systemSymbolName: "star.fill", accessibilityDescription: "Favorite")?
.withSymbolConfiguration(IconStyle.symbolConfiguration(for: font) .withSymbolConfiguration(IconStyle.symbolConfiguration(for: font)
.applying(.init(scale: .small)) .applying(.init(scale: .small))
.applying(.init(paletteColors: [SunsetPalette.secondaryText]))) .applying(.init(paletteColors: [ThemePalette.secondaryText])))
let size = attachment.image?.size ?? .zero let size = attachment.image?.size ?? .zero
attachment.bounds = NSRect(x: 0, y: (font.capHeight - size.height) / 2, attachment.bounds = NSRect(x: 0, y: (font.capHeight - size.height) / 2,
width: size.width, height: size.height) width: size.width, height: size.height)
@@ -1041,7 +1041,7 @@ 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: NSColor(srgbRed: 0.26, green: 0.22, blue: 0.19, alpha: 0.5), "1", backgroundColor: ThemePalette.keyBackground,
fontSize: 10, keySize: 20 fontSize: 10, keySize: 20
) )
private let kindFade = CAGradientLayer() private let kindFade = CAGradientLayer()
@@ -1054,10 +1054,10 @@ final class ApplicationCell: NSTableCellView {
let icon = IconView(font: font) let icon = IconView(font: font)
let label = NSTextField(labelWithString: "") let label = NSTextField(labelWithString: "")
label.font = font label.font = font
label.textColor = SunsetPalette.text label.textColor = ThemePalette.text
label.lineBreakMode = .byTruncatingTail label.lineBreakMode = .byTruncatingTail
locationLabel.font = .systemFont(ofSize: 12) locationLabel.font = .systemFont(ofSize: 12)
locationLabel.textColor = SunsetPalette.secondaryText locationLabel.textColor = ThemePalette.secondaryText
locationLabel.lineBreakMode = .byTruncatingMiddle locationLabel.lineBreakMode = .byTruncatingMiddle
locationLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) locationLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
locationLabel.isHidden = true locationLabel.isHidden = true
@@ -1066,7 +1066,7 @@ final class ApplicationCell: NSTableCellView {
title.alignment = .centerY title.alignment = .centerY
title.spacing = 8 title.spacing = 8
kindLabel.font = .systemFont(ofSize: font.pointSize) kindLabel.font = .systemFont(ofSize: font.pointSize)
kindLabel.textColor = SunsetPalette.secondaryText kindLabel.textColor = ThemePalette.secondaryText
kindLabel.setContentCompressionResistancePriority(.required, for: .horizontal) kindLabel.setContentCompressionResistancePriority(.required, for: .horizontal)
kindLabel.setAccessibilityElement(false) kindLabel.setAccessibilityElement(false)
kindLabel.wantsLayer = true kindLabel.wantsLayer = true
@@ -1416,10 +1416,10 @@ final class LauncherRowView: NSTableRowView {
selectionBounds.origin.y += topSpacing selectionBounds.origin.y += topSpacing
selectionBounds.size.height -= topSpacing selectionBounds.size.height -= topSpacing
let selection = NSBezierPath(roundedRect: selectionBounds.insetBy(dx: 0.75, dy: 0.75), xRadius: 10, yRadius: 10) let selection = NSBezierPath(roundedRect: selectionBounds.insetBy(dx: 0.75, dy: 0.75), xRadius: 10, yRadius: 10)
SunsetPalette.selectionFill.setFill() ThemePalette.selectionFill.setFill()
selection.fill() selection.fill()
let increasedContrast = NSWorkspace.shared.accessibilityDisplayShouldIncreaseContrast let increasedContrast = NSWorkspace.shared.accessibilityDisplayShouldIncreaseContrast
(increasedContrast ? SunsetPalette.accent : SunsetPalette.selectionBorder).setStroke() (increasedContrast ? ThemePalette.accent : ThemePalette.selectionBorder).setStroke()
selection.lineWidth = increasedContrast ? 1.5 : 0.5 selection.lineWidth = increasedContrast ? 1.5 : 0.5
selection.stroke() selection.stroke()
} }
+10 -10
View File
@@ -59,7 +59,7 @@ final class NotesController: NSViewController, NSTextViewDelegate {
actionsButton.action = #selector(showActions) actionsButton.action = #selector(showActions)
let controls = ActionPill(primaryButton: browseButton, actionsButton: actionsButton, primaryCommand: "p") let controls = ActionPill(primaryButton: browseButton, actionsButton: actionsButton, primaryCommand: "p")
noteTitle.font = .systemFont(ofSize: 13) noteTitle.font = .systemFont(ofSize: 13)
noteTitle.textColor = SunsetPalette.secondaryText noteTitle.textColor = ThemePalette.secondaryText
noteTitle.alignment = .center noteTitle.alignment = .center
noteTitle.lineBreakMode = .byTruncatingTail noteTitle.lineBreakMode = .byTruncatingTail
noteTitle.setAccessibilityLabel("Note title") noteTitle.setAccessibilityLabel("Note title")
@@ -68,7 +68,7 @@ final class NotesController: NSViewController, NSTextViewDelegate {
vimButton.action = #selector(toggleVim) vimButton.action = #selector(toggleVim)
vimButton.bezelStyle = .accessoryBarAction vimButton.bezelStyle = .accessoryBarAction
vimButton.font = .monospacedSystemFont(ofSize: 10, weight: .medium) vimButton.font = .monospacedSystemFont(ofSize: 10, weight: .medium)
vimButton.contentTintColor = SunsetPalette.secondaryText vimButton.contentTintColor = ThemePalette.secondaryText
vimButton.setAccessibilityLabel("Toggle Vim editing") vimButton.setAccessibilityLabel("Toggle Vim editing")
vimButton.toolTip = "Toggle Vim editing. Escape: Normal mode, then close; i: Insert; v: Visual." vimButton.toolTip = "Toggle Vim editing. Escape: Normal mode, then close; i: Insert; v: Visual."
editor.vimModeChanged = { [weak self] in self?.updateVimIndicator() } editor.vimModeChanged = { [weak self] in self?.updateVimIndicator() }
@@ -130,9 +130,9 @@ final class NotesController: NSViewController, NSTextViewDelegate {
editor.importsGraphics = true editor.importsGraphics = true
editor.allowsUndo = true editor.allowsUndo = true
editor.drawsBackground = false editor.drawsBackground = false
editor.textColor = SunsetPalette.text editor.textColor = ThemePalette.text
editor.insertionPointColor = SunsetPalette.accent editor.insertionPointColor = ThemePalette.accent
editor.selectedTextAttributes = [.backgroundColor: SunsetPalette.selectionFill] editor.selectedTextAttributes = [.backgroundColor: ThemePalette.selectionFill]
editor.font = NotesAppearance.font editor.font = NotesAppearance.font
editor.defaultParagraphStyle = NotesAppearance.paragraph editor.defaultParagraphStyle = NotesAppearance.paragraph
editor.isAutomaticQuoteSubstitutionEnabled = false editor.isAutomaticQuoteSubstitutionEnabled = false
@@ -155,7 +155,7 @@ final class NotesController: NSViewController, NSTextViewDelegate {
scroll.contentInsets = NSEdgeInsets(top: scroll.edgeFadeHeight, left: 0, scroll.contentInsets = NSEdgeInsets(top: scroll.edgeFadeHeight, left: 0,
bottom: scroll.edgeFadeHeight, right: 0) bottom: scroll.edgeFadeHeight, right: 0)
status.font = .systemFont(ofSize: 12) status.font = .systemFont(ofSize: 12)
status.textColor = SunsetPalette.secondaryText status.textColor = ThemePalette.secondaryText
status.setAccessibilityLabel("Notes save status") status.setAccessibilityLabel("Notes save status")
status.lineBreakMode = .byTruncatingTail status.lineBreakMode = .byTruncatingTail
status.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) status.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
@@ -220,7 +220,7 @@ final class NotesController: NSViewController, NSTextViewDelegate {
button.imagePosition = .imageOnly button.imagePosition = .imageOnly
button.bezelStyle = .accessoryBarAction button.bezelStyle = .accessoryBarAction
button.showsBorderOnlyWhileMouseInside = true button.showsBorderOnlyWhileMouseInside = true
button.contentTintColor = SunsetPalette.secondaryText button.contentTintColor = ThemePalette.secondaryText
button.toolTip = label button.toolTip = label
button.setAccessibilityLabel(label) button.setAccessibilityLabel(label)
NSLayoutConstraint.activate([button.widthAnchor.constraint(equalToConstant: 28), button.heightAnchor.constraint(equalToConstant: 28)]) NSLayoutConstraint.activate([button.widthAnchor.constraint(equalToConstant: 28), button.heightAnchor.constraint(equalToConstant: 28)])
@@ -358,7 +358,7 @@ final class NotesController: NSViewController, NSTextViewDelegate {
status.isHidden = false status.isHidden = false
status.stringValue = "Couldn’t export note: \(error.localizedDescription)" status.stringValue = "Couldn’t export note: \(error.localizedDescription)"
status.toolTip = status.stringValue status.toolTip = status.stringValue
status.textColor = .systemRed status.textColor = ThemePalette.error
} }
@@ -371,7 +371,7 @@ 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 = .systemRed 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
@@ -416,7 +416,7 @@ final class NotesController: NSViewController, NSTextViewDelegate {
status.isHidden = false status.isHidden = false
status.stringValue = "Couldn’t save, load, or delete notes: \(error.localizedDescription)" status.stringValue = "Couldn’t save, load, or delete notes: \(error.localizedDescription)"
status.toolTip = status.stringValue status.toolTip = status.stringValue
status.textColor = .systemRed status.textColor = ThemePalette.error
} }
@objc func browseNotes() { @objc func browseNotes() {
+6 -6
View File
@@ -136,7 +136,7 @@ final class NotesTextView: NSTextView {
private func attributes(at position: Int) -> [NSAttributedString.Key: Any] { private func attributes(at position: Int) -> [NSAttributedString.Key: Any] {
guard let storage = textStorage, storage.length > 0 else { guard let storage = textStorage, storage.length > 0 else {
return [.font: NotesAppearance.font, .foregroundColor: SunsetPalette.text, .paragraphStyle: NotesAppearance.paragraph] return [.font: NotesAppearance.font, .foregroundColor: ThemePalette.text, .paragraphStyle: NotesAppearance.paragraph]
} }
var result = storage.attributes(at: min(max(0, position), storage.length - 1), effectiveRange: nil) var result = storage.attributes(at: min(max(0, position), storage.length - 1), effectiveRange: nil)
result.removeValue(forKey: .attachment) result.removeValue(forKey: .attachment)
@@ -157,7 +157,7 @@ final class NotesTextView: NSTextView {
weight: attrs[NoteAttributes.bold] as? Bool == true ? .bold : heading ? .semibold : .regular) weight: attrs[NoteAttributes.bold] as? Bool == true ? .bold : heading ? .semibold : .regular)
attrs[.font] = attrs[NoteAttributes.italic] as? Bool == true attrs[.font] = attrs[NoteAttributes.italic] as? Bool == true
? NSFontManager.shared.convert(font, toHaveTrait: .italicFontMask) : font ? NSFontManager.shared.convert(font, toHaveTrait: .italicFontMask) : font
attrs[.foregroundColor] = attrs[NoteAttributes.inlineCode] as? Bool == true ? SunsetPalette.accent : SunsetPalette.text attrs[.foregroundColor] = attrs[NoteAttributes.inlineCode] as? Bool == true ? ThemePalette.accent : ThemePalette.text
typingAttributes = attrs typingAttributes = attrs
emptyTypingAttributes = paragraph(at: selectedRange().location).length == 0 ? attrs : nil emptyTypingAttributes = paragraph(at: selectedRange().location).length == 0 ? attrs : nil
} }
@@ -512,8 +512,8 @@ final class NotesTextView: NSTextView {
storage.addAttribute(NoteAttributes.italic, value: italic, range: range) storage.addAttribute(NoteAttributes.italic, value: italic, range: range)
let font = NSFont.monospacedSystemFont(ofSize: size, weight: bold ? .bold : kind == "heading" ? .semibold : .regular) let font = NSFont.monospacedSystemFont(ofSize: size, weight: bold ? .bold : kind == "heading" ? .semibold : .regular)
storage.addAttribute(.font, value: italic ? NSFontManager.shared.convert(font, toHaveTrait: .italicFontMask) : font, range: range) storage.addAttribute(.font, value: italic ? NSFontManager.shared.convert(font, toHaveTrait: .italicFontMask) : font, range: range)
storage.addAttribute(.foregroundColor, value: run[NoteAttributes.inlineCode] as? Bool == true || run[.link] != nil ? SunsetPalette.accent : SunsetPalette.text, range: range) storage.addAttribute(.foregroundColor, value: run[NoteAttributes.inlineCode] as? Bool == true || run[.link] != nil ? ThemePalette.accent : ThemePalette.text, range: range)
if run[NoteAttributes.inlineCode] as? Bool == true || kind == "code" { storage.addAttribute(.backgroundColor, value: SunsetPalette.selectionFill, range: range) } if run[NoteAttributes.inlineCode] as? Bool == true || kind == "code" { storage.addAttribute(.backgroundColor, value: ThemePalette.selectionFill, range: range) }
} }
index = NSMaxRange(row) index = NSMaxRange(row)
} }
@@ -557,7 +557,7 @@ final class NotesTextView: NSTextView {
let line = layoutManager.lineFragmentRect(forGlyphAt: glyph, effectiveRange: nil) let line = layoutManager.lineFragmentRect(forGlyphAt: glyph, effectiveRange: nil)
let baseline = line.minY + layoutManager.location(forGlyphAt: glyph).y + textContainerOrigin.y let baseline = line.minY + layoutManager.location(forGlyphAt: glyph).y + textContainerOrigin.y
let label = item.kind == "ordered" ? "\(item.number)." : "•" let label = item.kind == "ordered" ? "\(item.number)." : "•"
let attrs: [NSAttributedString.Key: Any] = [.font: NotesAppearance.font, .foregroundColor: SunsetPalette.accent] let attrs: [NSAttributedString.Key: Any] = [.font: NotesAppearance.font, .foregroundColor: ThemePalette.accent]
let width = (label as NSString).size(withAttributes: attrs).width let width = (label as NSString).size(withAttributes: attrs).width
let right = textContainerOrigin.x + CGFloat(item.depth) * 20 + markerColumn - 4 let right = textContainerOrigin.x + CGFloat(item.depth) * 20 + markerColumn - 4
let x = item.kind == "ordered" ? right - width : right - (markerColumn - 4 + width) / 2 let x = item.kind == "ordered" ? right - width : right - (markerColumn - 4 + width) / 2
@@ -572,7 +572,7 @@ final class NotesTextView: NSTextView {
while checkboxes.count > tasks.count { checkboxes.removeLast().removeFromSuperview() } while checkboxes.count > tasks.count { checkboxes.removeLast().removeFromSuperview() }
while checkboxes.count < tasks.count { while checkboxes.count < tasks.count {
let button = NSButton(checkboxWithTitle: "", target: self, action: #selector(toggleTask(_:))) let button = NSButton(checkboxWithTitle: "", target: self, action: #selector(toggleTask(_:)))
button.controlSize = .small; button.contentTintColor = SunsetPalette.accent; button.sizeToFit() button.controlSize = .small; button.contentTintColor = ThemePalette.accent; button.sizeToFit()
checkboxes.append(button); addSubview(button) checkboxes.append(button); addSubview(button)
} }
for (button, task) in zip(checkboxes, tasks) { for (button, task) in zip(checkboxes, tasks) {
+12 -12
View File
@@ -110,8 +110,8 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate {
instructions.textColor = .secondaryLabelColor instructions.textColor = .secondaryLabelColor
statusLabel.textColor = .secondaryLabelColor statusLabel.textColor = .secondaryLabelColor
statusLabel.setAccessibilityLabel("Global shortcut registration status") statusLabel.setAccessibilityLabel("Global shortcut registration status")
errorLabel.textColor = .systemRed errorLabel.textColor = ThemePalette.error
errorLabel.setAccessibilityLabel("Shortcut feedback") errorLabel.setAccessibilityLabel("Settings feedback")
let spotlight = NSTextField(wrappingLabelWithString: let spotlight = NSTextField(wrappingLabelWithString:
"To use ⌘Space, first free it in System Settings → Keyboard → Keyboard Shortcuts → Spotlight: turn off or change “Show Spotlight search.” Check Input Sources for other reserved shortcuts. Conjure never changes these settings." "To use ⌘Space, first free it in System Settings → Keyboard → Keyboard Shortcuts → Spotlight: turn off or change “Show Spotlight search.” Check Input Sources for other reserved shortcuts. Conjure never changes these settings."
@@ -128,7 +128,7 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate {
loginSettings.action = #selector(openLoginSettings) loginSettings.action = #selector(openLoginSettings)
loginSettings.bezelStyle = .rounded loginSettings.bezelStyle = .rounded
loginStatus.setAccessibilityLabel("Launch at login status") loginStatus.setAccessibilityLabel("Launch at login status")
recoveryLabel.textColor = SunsetPalette.secondaryText recoveryLabel.textColor = ThemePalette.secondaryText
recoveryLabel.isHidden = true recoveryLabel.isHidden = true
recoveryLabel.setAccessibilityLabel("Uninstall recovery status") recoveryLabel.setAccessibilityLabel("Uninstall recovery status")
recoveryButton.isHidden = true recoveryButton.isHidden = true
@@ -137,19 +137,19 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate {
recoveryButton.action = #selector(showUninstallRecovery) recoveryButton.action = #selector(showUninstallRecovery)
heading.font = .systemFont(ofSize: 20, weight: .semibold) heading.font = .systemFont(ofSize: 20, weight: .semibold)
heading.textColor = SunsetPalette.text heading.textColor = ThemePalette.text
for label in [instructions, statusLabel, spotlight, loginStatus] { for label in [instructions, statusLabel, spotlight, loginStatus] {
label.textColor = SunsetPalette.secondaryText label.textColor = ThemePalette.secondaryText
} }
for button in [recorder, commandSpaceButton, clearButton, keyboardSettings, loginToggle, loginSettings, recoveryButton] { for button in [recorder, commandSpaceButton, clearButton, keyboardSettings, loginToggle, loginSettings, recoveryButton] {
button.contentTintColor = SunsetPalette.accent button.contentTintColor = ThemePalette.accent
} }
let loginHeading = NSTextField(labelWithString: "Startup") let loginHeading = NSTextField(labelWithString: "Startup")
loginHeading.font = .systemFont(ofSize: 20, weight: .semibold) loginHeading.font = .systemFont(ofSize: 20, weight: .semibold)
loginHeading.textColor = SunsetPalette.text loginHeading.textColor = ThemePalette.text
let translationHeading = NSTextField(labelWithString: "Conjure Translate") let translationHeading = NSTextField(labelWithString: "Conjure Translate")
translationHeading.font = .systemFont(ofSize: 20, weight: .semibold) translationHeading.font = .systemFont(ofSize: 20, weight: .semibold)
translationHeading.textColor = SunsetPalette.text translationHeading.textColor = ThemePalette.text
let stack = NSStackView(views: [ let stack = NSStackView(views: [
heading, destinationSelector, controls, instructions, statusLabel, errorLabel, spotlight, keyboardSettings, heading, destinationSelector, controls, instructions, statusLabel, errorLabel, spotlight, keyboardSettings,
@@ -206,7 +206,7 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate {
recoveryLabel.stringValue = report.needsRecovery recoveryLabel.stringValue = report.needsRecovery
? "An interrupted uninstall needs attention. Files have been retained for recovery." ? "An interrupted uninstall needs attention. Files have been retained for recovery."
: "Interrupted uninstall recovery completed." : "Interrupted uninstall recovery completed."
recoveryLabel.textColor = report.needsRecovery ? .systemRed : SunsetPalette.secondaryText recoveryLabel.textColor = report.needsRecovery ? ThemePalette.error : ThemePalette.secondaryText
recoveryButton.title = report.needsRecovery ? "Retry uninstall recovery…" : "View recovery details…" recoveryButton.title = report.needsRecovery ? "Retry uninstall recovery…" : "View recovery details…"
} }
@@ -229,7 +229,7 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate {
private func refreshLogin() { private func refreshLogin() {
let status = SMAppService.mainApp.status let status = SMAppService.mainApp.status
loginStatus.textColor = SunsetPalette.secondaryText loginStatus.textColor = ThemePalette.secondaryText
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 {
@@ -243,7 +243,7 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate {
loginStatus.stringValue = "Install Conjure in Applications before enabling launch at login." loginStatus.stringValue = "Install Conjure in Applications before enabling launch at login."
@unknown default: @unknown default:
loginStatus.stringValue = "Launch at login status is unavailable." loginStatus.stringValue = "Launch at login status is unavailable."
loginStatus.textColor = .systemRed loginStatus.textColor = ThemePalette.error
} }
} }
@@ -259,7 +259,7 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate {
} catch { } catch {
refreshLogin() refreshLogin()
loginStatus.stringValue = "Could not change launch at login: \(error.localizedDescription)" loginStatus.stringValue = "Could not change launch at login: \(error.localizedDescription)"
loginStatus.textColor = .systemRed loginStatus.textColor = ThemePalette.error
} }
} }
+2 -2
View File
@@ -15,12 +15,12 @@ final class ShortcutIndicator: NSStackView {
for key in keys { for key in keys {
let badge = ShortcutKeyBadge(labelWithString: key) let badge = ShortcutKeyBadge(labelWithString: key)
badge.font = .systemFont(ofSize: fontSize, weight: .medium) badge.font = .systemFont(ofSize: fontSize, weight: .medium)
badge.textColor = SunsetPalette.secondaryText badge.textColor = ThemePalette.secondaryText
badge.alignment = .center badge.alignment = .center
badge.wantsLayer = true badge.wantsLayer = true
badge.layer?.cornerRadius = 5 badge.layer?.cornerRadius = 5
badge.layer?.borderWidth = 1 badge.layer?.borderWidth = 1
badge.layer?.borderColor = SunsetPalette.secondaryText.withAlphaComponent(0.35).cgColor badge.layer?.borderColor = ThemePalette.secondaryText.withAlphaComponent(0.35).cgColor
badge.layer?.backgroundColor = backgroundColor?.cgColor badge.layer?.backgroundColor = backgroundColor?.cgColor
badge.setAccessibilityElement(false) badge.setAccessibilityElement(false)
badge.translatesAutoresizingMaskIntoConstraints = false badge.translatesAutoresizingMaskIntoConstraints = false
+10 -10
View File
@@ -85,11 +85,11 @@ final class TranslationController: NSViewController {
providerButton.toolTip = "Choose translation provider (Command-P)" providerButton.toolTip = "Choose translation provider (Command-P)"
providerButton.isBordered = false providerButton.isBordered = false
providerButton.font = .systemFont(ofSize: 12, weight: .medium) providerButton.font = .systemFont(ofSize: 12, weight: .medium)
providerButton.contentTintColor = SunsetPalette.text providerButton.contentTintColor = ThemePalette.text
let chevron = NSButton(image: NSImage(systemSymbolName: "chevron.down", accessibilityDescription: nil)!, let chevron = NSButton(image: NSImage(systemSymbolName: "chevron.down", accessibilityDescription: nil)!,
target: self, action: #selector(showProviders)) target: self, action: #selector(showProviders))
chevron.isBordered = false chevron.isBordered = false
chevron.contentTintColor = SunsetPalette.secondaryText chevron.contentTintColor = ThemePalette.secondaryText
chevron.setAccessibilityElement(false) chevron.setAccessibilityElement(false)
stylePill(providerControl) stylePill(providerControl)
for control in [providerButton, chevron] { for control in [providerButton, chevron] {
@@ -109,7 +109,7 @@ final class TranslationController: NSViewController {
languageButton.action = #selector(showActions) languageButton.action = #selector(showActions)
languageButton.isBordered = false languageButton.isBordered = false
languageButton.font = .systemFont(ofSize: 12, weight: .medium) languageButton.font = .systemFont(ofSize: 12, weight: .medium)
languageButton.contentTintColor = SunsetPalette.text languageButton.contentTintColor = ThemePalette.text
languageButton.toolTip = "Change or swap translation languages (Command-K)" languageButton.toolTip = "Change or swap translation languages (Command-K)"
languageButton.setAccessibilityLabel("Translation languages") languageButton.setAccessibilityLabel("Translation languages")
actionsButton.target = self actionsButton.target = self
@@ -140,7 +140,7 @@ final class TranslationController: NSViewController {
output.isEditable = false output.isEditable = false
output.isSelectable = true output.isSelectable = true
output.drawsBackground = false output.drawsBackground = false
output.textColor = SunsetPalette.text output.textColor = ThemePalette.text
output.font = .systemFont(ofSize: 20) output.font = .systemFont(ofSize: 20)
output.textContainerInset = .zero output.textContainerInset = .zero
output.textContainer?.lineFragmentPadding = 0 output.textContainer?.lineFragmentPadding = 0
@@ -156,7 +156,7 @@ final class TranslationController: NSViewController {
status.setAccessibilityLabel("Translation status and errors") status.setAccessibilityLabel("Translation status and errors")
status.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) status.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
sourcePreview.font = .systemFont(ofSize: 14) sourcePreview.font = .systemFont(ofSize: 14)
sourcePreview.textColor = SunsetPalette.secondaryText sourcePreview.textColor = ThemePalette.secondaryText
sourcePreview.maximumNumberOfLines = 3 sourcePreview.maximumNumberOfLines = 3
sourcePreview.lineBreakMode = .byTruncatingTail sourcePreview.lineBreakMode = .byTruncatingTail
sourcePreview.setAccessibilityLabel("Original text to translate") sourcePreview.setAccessibilityLabel("Original text to translate")
@@ -192,8 +192,8 @@ final class TranslationController: NSViewController {
pill.wantsLayer = true pill.wantsLayer = true
pill.layer?.cornerRadius = 18 pill.layer?.cornerRadius = 18
pill.layer?.borderWidth = 0.5 pill.layer?.borderWidth = 0.5
pill.layer?.borderColor = SunsetPalette.accent.withAlphaComponent(0.35).cgColor pill.layer?.borderColor = ThemePalette.accent.withAlphaComponent(0.35).cgColor
pill.layer?.backgroundColor = SunsetPalette.background.withAlphaComponent(0.25).cgColor pill.layer?.backgroundColor = ThemePalette.background.withAlphaComponent(0.25).cgColor
} }
@objc func showProviders() { @objc func showProviders() {
@@ -308,7 +308,7 @@ final class TranslationController: NSViewController {
private func setStatus(_ message: String, isError: Bool = false) { private func setStatus(_ message: String, isError: Bool = false) {
status.stringValue = message status.stringValue = message
status.textColor = isError ? .systemRed : SunsetPalette.secondaryText status.textColor = isError ? ThemePalette.error : ThemePalette.secondaryText
} }
@objc func performPrimaryAction() { @objc func performPrimaryAction() {
@@ -376,11 +376,11 @@ final class TranslationController: NSViewController {
guard !Task.isCancelled, let self else { return } guard !Task.isCancelled, let self else { return }
let rendered = NSMutableAttributedString(string: translation, attributes: [ let rendered = NSMutableAttributedString(string: translation, attributes: [
.font: NSFont.systemFont(ofSize: 20), .font: NSFont.systemFont(ofSize: 20),
.foregroundColor: SunsetPalette.text, .foregroundColor: ThemePalette.text,
]) ])
if !myMemory, let separator = translation.range(of: "\n\n") { if !myMemory, let separator = translation.range(of: "\n\n") {
rendered.addAttributes([ rendered.addAttributes([
.foregroundColor: SunsetPalette.secondaryText, .foregroundColor: ThemePalette.secondaryText,
.font: NSFont.systemFont(ofSize: 14), .font: NSFont.systemFont(ofSize: 14),
], range: NSRange(separator.upperBound..<translation.endIndex, in: translation)) ], range: NSRange(separator.upperBound..<translation.endIndex, in: translation))
} }
@@ -21,7 +21,7 @@ final class TranslationSettingsController: NSViewController {
let forget = NSButton(title: "Forget Key", target: self, action: #selector(forgetKey)) let forget = NSButton(title: "Forget Key", target: self, action: #selector(forgetKey))
for button in [save, forget] { for button in [save, forget] {
button.bezelStyle = .rounded button.bezelStyle = .rounded
button.contentTintColor = SunsetPalette.accent button.contentTintColor = ThemePalette.accent
} }
let controls = NSStackView(views: [keyField, save, forget]) let controls = NSStackView(views: [keyField, save, forget])
controls.orientation = .horizontal controls.orientation = .horizontal
@@ -37,7 +37,7 @@ final class TranslationSettingsController: NSViewController {
"OpenAI · gpt-6-luna · Text and optional situation/context are sent to OpenAI only when you explicitly translate, not when advancing to context. Saving a key does not send your text. Your API key is stored in Keychain." "OpenAI · gpt-6-luna · Text and optional situation/context are sent to OpenAI only when you explicitly translate, not when advancing to context. Saving a key does not send your text. Your API key is stored in Keychain."
) )
for label in [status, myMemory, openAI] { for label in [status, myMemory, openAI] {
label.textColor = SunsetPalette.secondaryText label.textColor = ThemePalette.secondaryText
} }
let stack = NSStackView(views: [controls, status, myMemory, openAI]) let stack = NSStackView(views: [controls, status, myMemory, openAI])
stack.orientation = .vertical stack.orientation = .vertical
@@ -85,6 +85,6 @@ final class TranslationSettingsController: NSViewController {
private func setStatus(_ message: String, isError: Bool = false) { private func setStatus(_ message: String, isError: Bool = false) {
status.stringValue = message status.stringValue = message
status.textColor = isError ? .systemRed : SunsetPalette.secondaryText status.textColor = isError ? ThemePalette.error : ThemePalette.secondaryText
} }
} }
+17 -17
View File
@@ -41,7 +41,7 @@ final class UninstallReviewController: NSViewController, NSSearchFieldDelegate,
discoveryState = .loading discoveryState = .loading
updateStatus() updateStatus()
window.makeFirstResponder(search) window.makeFirstResponder(search)
(search.currentEditor() as? NSTextView)?.insertionPointColor = SunsetPalette.accent (search.currentEditor() as? NSTextView)?.insertionPointColor = ThemePalette.accent
scanTask = Task { [weak self, application] in scanTask = Task { [weak self, application] in
do { do {
let found = try await Task.detached(priority: .userInitiated) { let found = try await Task.detached(priority: .userInitiated) {
@@ -62,13 +62,13 @@ final class UninstallReviewController: NSViewController, NSSearchFieldDelegate,
view = content view = content
let context = NSTextField(wrappingLabelWithString: "Uninstall \(application.name)") let context = NSTextField(wrappingLabelWithString: "Uninstall \(application.name)")
context.font = .systemFont(ofSize: 14, weight: .medium) context.font = .systemFont(ofSize: 14, weight: .medium)
context.textColor = SunsetPalette.text context.textColor = ThemePalette.text
let guidance = NSTextField(wrappingLabelWithString: "Return toggles file inclusion · Command-Return opens confirmation") let guidance = NSTextField(wrappingLabelWithString: "Return toggles file inclusion · Command-Return opens confirmation")
guidance.font = .systemFont(ofSize: 12) guidance.font = .systemFont(ofSize: 12)
guidance.textColor = SunsetPalette.secondaryText guidance.textColor = ThemePalette.secondaryText
search.placeholderAttributedString = NSAttributedString( search.placeholderAttributedString = NSAttributedString(
string: "Filter files and folders by name or path…", string: "Filter files and folders by name or path…",
attributes: [.foregroundColor: SunsetPalette.secondaryText, .font: NSFont.systemFont(ofSize: 20)] attributes: [.foregroundColor: ThemePalette.secondaryText, .font: NSFont.systemFont(ofSize: 20)]
) )
if let cell = search.cell as? NSSearchFieldCell { if let cell = search.cell as? NSSearchFieldCell {
cell.searchButtonCell = nil cell.searchButtonCell = nil
@@ -79,12 +79,12 @@ final class UninstallReviewController: NSViewController, NSSearchFieldDelegate,
search.setAccessibilityLabel("Filter uninstall files for \(application.name)") search.setAccessibilityLabel("Filter uninstall files for \(application.name)")
search.setAccessibilityHelp(guidance.stringValue) search.setAccessibilityHelp(guidance.stringValue)
search.font = .systemFont(ofSize: 20) search.font = .systemFont(ofSize: 20)
search.textColor = SunsetPalette.text search.textColor = ThemePalette.text
search.isBordered = false search.isBordered = false
search.drawsBackground = false search.drawsBackground = false
search.focusRingType = .none search.focusRingType = .none
status.font = .systemFont(ofSize: 12) status.font = .systemFont(ofSize: 12)
status.textColor = SunsetPalette.secondaryText status.textColor = ThemePalette.secondaryText
status.setAccessibilityLabel("Uninstall selection and progress") status.setAccessibilityLabel("Uninstall selection and progress")
back.target = self back.target = self
back.action = #selector(goBack) back.action = #selector(goBack)
@@ -92,7 +92,7 @@ final class UninstallReviewController: NSViewController, NSSearchFieldDelegate,
back.image = IconStyle.symbol("chevron.left", for: search.font!) back.image = IconStyle.symbol("chevron.left", for: search.font!)
back.imageScaling = .scaleProportionallyDown back.imageScaling = .scaleProportionallyDown
back.isBordered = false back.isBordered = false
back.contentTintColor = SunsetPalette.secondaryText back.contentTintColor = ThemePalette.secondaryText
back.setAccessibilityLabel("Back to applications") back.setAccessibilityLabel("Back to applications")
let header = NSStackView(views: [back, search]) let header = NSStackView(views: [back, search])
header.orientation = .horizontal header.orientation = .horizontal
@@ -100,7 +100,7 @@ final class UninstallReviewController: NSViewController, NSSearchFieldDelegate,
header.spacing = 12 header.spacing = 12
remove.target = self remove.target = self
remove.action = #selector(confirmUninstall) remove.action = #selector(confirmUninstall)
remove.contentTintColor = .systemRed remove.contentTintColor = ThemePalette.error
remove.isBordered = false remove.isBordered = false
remove.font = .systemFont(ofSize: 12, weight: .medium) remove.font = .systemFont(ofSize: 12, weight: .medium)
remove.keyEquivalent = "\r" remove.keyEquivalent = "\r"
@@ -128,8 +128,8 @@ final class UninstallReviewController: NSViewController, NSSearchFieldDelegate,
pill.wantsLayer = true pill.wantsLayer = true
pill.layer?.cornerRadius = 18 pill.layer?.cornerRadius = 18
pill.layer?.borderWidth = 0.5 pill.layer?.borderWidth = 0.5
pill.layer?.borderColor = SunsetPalette.accent.withAlphaComponent(0.35).cgColor pill.layer?.borderColor = ThemePalette.accent.withAlphaComponent(0.35).cgColor
pill.layer?.backgroundColor = SunsetPalette.background.withAlphaComponent(0.25).cgColor pill.layer?.backgroundColor = ThemePalette.background.withAlphaComponent(0.25).cgColor
let shortcut = ShortcutIndicator("⌘", "↵") let shortcut = ShortcutIndicator("⌘", "↵")
for child in [remove, shortcut] { for child in [remove, shortcut] {
child.translatesAutoresizingMaskIntoConstraints = false child.translatesAutoresizingMaskIntoConstraints = false
@@ -204,14 +204,14 @@ final class UninstallReviewController: NSViewController, NSSearchFieldDelegate,
} }
private func updateStatus() { private func updateStatus() {
status.textColor = SunsetPalette.secondaryText status.textColor = ThemePalette.secondaryText
remove.isEnabled = false remove.isEnabled = false
switch discoveryState { switch discoveryState {
case .loading: case .loading:
status.stringValue = "Finding associated files…" status.stringValue = "Finding associated files…"
case .failed(let message): case .failed(let message):
status.stringValue = message status.stringValue = message
status.textColor = .systemRed status.textColor = ThemePalette.error
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 +260,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 ? SunsetPalette.text : SunsetPalette.secondaryText cell.name.textColor = candidate.isSelectable ? ThemePalette.text : ThemePalette.secondaryText
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) } ?? "—"
@@ -318,7 +318,7 @@ final class UninstallReviewController: NSViewController, NSSearchFieldDelegate,
return return
} }
do { do {
status.textColor = SunsetPalette.secondaryText status.textColor = ThemePalette.secondaryText
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.") }
@@ -368,7 +368,7 @@ final class UninstallReviewController: NSViewController, NSSearchFieldDelegate,
alert.addButton(withTitle: "OK") alert.addButton(withTitle: "OK")
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 = SunsetPalette.secondaryText status.textColor = ThemePalette.secondaryText
status.stringValue = "Recovering pending uninstall…" status.stringValue = "Recovering pending uninstall…"
let report = await uninstaller.recover() let report = await uninstaller.recover()
changed?() changed?()
@@ -432,7 +432,7 @@ private final class UninstallFileCell: NSTableCellView {
super.init(frame: .zero) super.init(frame: .zero)
name.font = font name.font = font
detail.font = .systemFont(ofSize: 11) detail.font = .systemFont(ofSize: 11)
detail.textColor = SunsetPalette.secondaryText detail.textColor = ThemePalette.secondaryText
for label in [name, detail] { for label in [name, detail] {
label.lineBreakMode = .byTruncatingMiddle label.lineBreakMode = .byTruncatingMiddle
label.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) label.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
@@ -441,7 +441,7 @@ private final class UninstallFileCell: NSTableCellView {
labels.orientation = .vertical labels.orientation = .vertical
labels.alignment = .leading labels.alignment = .leading
labels.spacing = 3 labels.spacing = 3
size.textColor = SunsetPalette.secondaryText size.textColor = ThemePalette.secondaryText
size.font = .systemFont(ofSize: 12) size.font = .systemFont(ofSize: 12)
size.setContentCompressionResistancePriority(.required, for: .horizontal) size.setContentCompressionResistancePriority(.required, for: .horizontal)
let row = NSStackView(views: [toggle, labels, NSView(), size, fileIcon]) let row = NSStackView(views: [toggle, labels, NSView(), size, fileIcon])
+1 -1
View File
@@ -1,4 +1,4 @@
- [ ] feature: configurable color scheme - [x] feature: configurable color scheme
- general design should stay consistent - general design should stay consistent
- only colors should be configurable; not the way the colors are used/presented (e.g. frosted glass effect with color blobs behind shouldn't change) - only colors should be configurable; not the way the colors are used/presented (e.g. frosted glass effect with color blobs behind shouldn't change)
- this should play nicely together with the theme control tool in `/Users/valdemar/Developer/Personal/Tools/tint`. - this should play nicely together with the theme control tool in `/Users/valdemar/Developer/Personal/Tools/tint`.
@@ -0,0 +1,135 @@
import AppKit
import Testing
@testable import Conjure
@Test @MainActor
func missingColorConfigurationUsesSunsetWithoutReadingGeneratedTheme() throws {
let directory = try colorConfigurationDirectory()
defer { try? FileManager.default.removeItem(at: directory) }
try Data("not JSON".utf8).write(to: directory.appendingPathComponent("theme.json"))
let loaded = try ColorScheme.load(from: directory)
expectSameColors(loaded, ColorScheme.sunset)
try Data("{}".utf8).write(to: directory.appendingPathComponent("config.json"))
expectSameColors(try ColorScheme.load(from: directory), ColorScheme.sunset)
try Data(#"{"theme":"sunset"}"#.utf8).write(to: directory.appendingPathComponent("config.json"))
expectSameColors(try ColorScheme.load(from: directory), ColorScheme.sunset)
}
@Test @MainActor
func partialManualColorsOverrideSunsetWithoutChangingOtherFields() throws {
let directory = try colorConfigurationDirectory()
defer { try? FileManager.default.removeItem(at: directory) }
try Data(##"{"colors":{"accent":"#aB1234","error":"#123456"}}"##.utf8)
.write(to: directory.appendingPathComponent("config.json"))
var expected = ColorScheme.sunset
expected.accent = NSColor(srgbRed: 171.0 / 255, green: 18.0 / 255, blue: 52.0 / 255, alpha: 1)
expected.error = NSColor(srgbRed: 18.0 / 255, green: 52.0 / 255, blue: 86.0 / 255, alpha: 1)
expectSameColors(try ColorScheme.load(from: directory), expected)
}
@Test @MainActor
func tintColorsLayerBetweenDefaultsAndManualOverrides() throws {
let directory = try colorConfigurationDirectory()
defer { try? FileManager.default.removeItem(at: directory) }
try Data(##"{"theme":"tint","colors":{"accent":"#ffffff","text":"#000000"}}"##.utf8)
.write(to: directory.appendingPathComponent("config.json"))
try Data(##"{"colors":{"accent":"#ff0000","background":"#123456","glow":"#00ff00"}}"##.utf8)
.write(to: directory.appendingPathComponent("theme.json"))
var expected = ColorScheme.sunset
expected.accent = NSColor(srgbRed: 1, green: 1, blue: 1, alpha: 1)
expected.text = NSColor(srgbRed: 0, green: 0, blue: 0, alpha: 1)
expected.background = NSColor(srgbRed: 18.0 / 255, green: 52.0 / 255, blue: 86.0 / 255, alpha: 1)
expected.glow = NSColor(srgbRed: 0, green: 1, blue: 0, alpha: 1)
expectSameColors(try ColorScheme.load(from: directory), expected)
}
@Test @MainActor
func malformedColorConfigurationReportsItsPathAndReason() throws {
let directory = try colorConfigurationDirectory()
defer { try? FileManager.default.removeItem(at: directory) }
let config = directory.appendingPathComponent("config.json")
let invalid: [(String, String)] = [
(##"{"colors":{"accent":"#12345678"}}"##, "colors.accent"),
(##"{"colors":{"accent":"#fff"}}"##, "colors.accent"),
(##"{"colors":{"accent":"#12gg56"}}"##, "colors.accent"),
(##"{"colors":{"accent":"#123456"}}"##, "colors.accent"),
(#"{"colors":{"accent":"123456"}}"#, "colors.accent"),
(#"{"colors":{"accent":" #123456"}}"#, "colors.accent"),
(#"{"colors":{"accent":42}}"#, "colors.accent"),
(#"{"colors":{"accent":null}}"#, "colors.accent"),
(##"{"colors":{"accnet":"#123456"}}"##, "colors.accnet"),
(#"{"opacity":0.5}"#, "opacity"),
(#"{"colors":[]}"#, "colors must be an object"),
(#"{"colors":null}"#, "colors must be an object"),
(#"{"theme":"unknown"}"#, "theme"),
(#"{"theme":true}"#, "theme"),
(#"{"theme":null}"#, "theme"),
("{", "Invalid JSON"),
("[]", "JSON object"),
("null", "JSON object"),
]
for (json, reason) in invalid {
try Data(json.utf8).write(to: config)
expectColorSchemeFailure(in: directory, path: config, reason: reason)
}
}
@Test @MainActor
func selectedTintRequiresAnExistingValidGeneratedTheme() throws {
let directory = try colorConfigurationDirectory()
defer { try? FileManager.default.removeItem(at: directory) }
let theme = directory.appendingPathComponent("theme.json")
try Data(##"{"theme":"tint","colors":{"accent":"#ffffff"}}"##.utf8)
.write(to: directory.appendingPathComponent("config.json"))
expectColorSchemeFailure(in: directory, path: theme)
let invalid: [(String, String)] = [
("not JSON", "Invalid JSON"),
("[]", "JSON object"),
(#"{"theme":"sunset"}"#, "theme"),
(##"{"colors":{"accent":"#12345678"}}"##, "colors.accent"),
(##"{"colors":{"glwo":"#123456"}}"##, "colors.glwo"),
(#"{"colors":false}"#, "colors must be an object"),
]
for (json, reason) in invalid {
try Data(json.utf8).write(to: theme)
expectColorSchemeFailure(in: directory, path: theme, reason: reason)
}
try Data(#"{"colors":{}}"#.utf8).write(to: theme)
var expected = ColorScheme.sunset
expected.accent = NSColor(srgbRed: 1, green: 1, blue: 1, alpha: 1)
expectSameColors(try ColorScheme.load(from: directory), expected)
}
private func colorConfigurationDirectory() throws -> URL {
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
return directory
}
@MainActor private func expectSameColors(_ actual: ColorScheme, _ expected: ColorScheme) {
#expect(actual.background == expected.background)
#expect(actual.text == expected.text)
#expect(actual.secondaryText == expected.secondaryText)
#expect(actual.accent == expected.accent)
#expect(actual.glow == expected.glow)
#expect(actual.keyBackground == expected.keyBackground)
#expect(actual.error == expected.error)
}
@MainActor private func expectColorSchemeFailure(in directory: URL, path: URL, reason: String? = nil) {
do {
_ = try ColorScheme.load(from: directory)
Issue.record("Expected invalid color configuration to throw: \(path.path)")
} catch {
#expect(error is any LocalizedError)
#expect(error.localizedDescription.contains(path.path))
if let reason { #expect(error.localizedDescription.contains(reason)) }
}
}
+3 -3
View File
@@ -280,8 +280,8 @@ func inlineCodeContainerIsLimitedToItsMarkAndDisappearsWhenToggledOff() throws {
let storage = try #require(editor.textStorage) let storage = try #require(editor.textStorage)
let body = (editor.string as NSString).range(of: "some *literal* code 🦊") let body = (editor.string as NSString).range(of: "some *literal* code 🦊")
for index in body.location..<NSMaxRange(body) { for index in body.location..<NSMaxRange(body) {
#expect(storage.attribute(.foregroundColor, at: index, effectiveRange: nil) as? NSColor == SunsetPalette.accent) #expect(storage.attribute(.foregroundColor, at: index, effectiveRange: nil) as? NSColor == ThemePalette.accent)
#expect(storage.attribute(.backgroundColor, at: index, effectiveRange: nil) as? NSColor == SunsetPalette.selectionFill) #expect(storage.attribute(.backgroundColor, at: index, effectiveRange: nil) as? NSColor == ThemePalette.selectionFill)
} }
for token in ["Before", "after", "not inline"] { for token in ["Before", "after", "not inline"] {
let index = (editor.string as NSString).range(of: token).location let index = (editor.string as NSString).range(of: token).location
@@ -295,5 +295,5 @@ func inlineCodeContainerIsLimitedToItsMarkAndDisappearsWhenToggledOff() throws {
#expect(editor.string == before) #expect(editor.string == before)
#expect(editor.documentContents.attribute(NoteAttributes.inlineCode, at: body.location, effectiveRange: nil) as? Bool != true) #expect(editor.documentContents.attribute(NoteAttributes.inlineCode, at: body.location, effectiveRange: nil) as? Bool != true)
#expect(storage.attribute(.backgroundColor, at: body.location, effectiveRange: nil) == nil) #expect(storage.attribute(.backgroundColor, at: body.location, effectiveRange: nil) == nil)
#expect(storage.attribute(.foregroundColor, at: body.location, effectiveRange: nil) as? NSColor == SunsetPalette.text) #expect(storage.attribute(.foregroundColor, at: body.location, effectiveRange: nil) as? NSColor == ThemePalette.text)
} }
@@ -36,7 +36,7 @@ func uninstallFilteringPreservesDiscoveryStateAndCheckedFiles() throws {
controller.applyDiscoveryResult(.failure(ScanFailure())) controller.applyDiscoveryResult(.failure(ScanFailure()))
let failure = status.stringValue let failure = status.stringValue
#expect(failure.contains(ScanFailure().localizedDescription)) #expect(failure.contains(ScanFailure().localizedDescription))
#expect(status.textColor == .systemRed) #expect(status.textColor == ThemePalette.error)
filterBy("different") filterBy("different")
#expect(status.stringValue == failure) #expect(status.stringValue == failure)
#expect(!remove.isEnabled) #expect(!remove.isEnabled)
@@ -46,7 +46,7 @@ func uninstallFilteringPreservesDiscoveryStateAndCheckedFiles() throws {
controller.applyDiscoveryResult(.success([candidate])) controller.applyDiscoveryResult(.success([candidate]))
filterBy("") filterBy("")
let counts = status.stringValue let counts = status.stringValue
#expect(status.textColor == SunsetPalette.secondaryText) #expect(status.textColor == ThemePalette.secondaryText)
#expect(remove.isEnabled) #expect(remove.isEnabled)
#expect(table.numberOfRows == 1) #expect(table.numberOfRows == 1)
filterBy("no-match") filterBy("no-match")
@@ -72,7 +72,7 @@ func shortcutModifierGuidanceDoesNotAppearAsAnError() throws {
$0.action == NSSelectorFromString("beginRecording") $0.action == NSSelectorFromString("beginRecording")
}) })
let error = try #require(views.compactMap { $0 as? NSTextField }.first { let error = try #require(views.compactMap { $0 as? NSTextField }.first {
$0.accessibilityLabel() == "Shortcut feedback" $0.accessibilityLabel() == "Settings feedback"
}) })
let status = try #require(views.compactMap { $0 as? NSTextField }.first { let status = try #require(views.compactMap { $0 as? NSTextField }.first {
$0.accessibilityLabel() == "Global shortcut registration status" $0.accessibilityLabel() == "Global shortcut registration status"
@@ -85,5 +85,5 @@ func shortcutModifierGuidanceDoesNotAppearAsAnError() throws {
context: nil, characters: "", charactersIgnoringModifiers: "", isARepeat: false, keyCode: 55)) context: nil, characters: "", charactersIgnoringModifiers: "", isARepeat: false, keyCode: 55))
recorder.flagsChanged(with: modifier) recorder.flagsChanged(with: modifier)
#expect(error.stringValue.isEmpty) #expect(error.stringValue.isEmpty)
#expect(status.textColor == SunsetPalette.secondaryText) #expect(status.textColor == ThemePalette.secondaryText)
} }
@@ -269,7 +269,7 @@ func translationExecutionRecoversFromCredentialAndRequestFailures() async throws
tool.translate() // No stored key. tool.translate() // No stored key.
#expect(requests.inputs.isEmpty) #expect(requests.inputs.isEmpty)
#expect(button.isEnabled) #expect(button.isEnabled)
#expect(status.textColor == .systemRed) #expect(status.textColor == ThemePalette.error)
#expect(!openSettings.isHidden) #expect(!openSettings.isHidden)
field.stringValue = "typed-key" field.stringValue = "typed-key"
tool.translate() // Typing in Settings must not implicitly save on Translate. tool.translate() // Typing in Settings must not implicitly save on Translate.
@@ -296,7 +296,7 @@ func translationExecutionRecoversFromCredentialAndRequestFailures() async throws
#expect(requests.inputs[0].key == "typed-key") #expect(requests.inputs[0].key == "typed-key")
requests.pending[0].resume(throwing: CredentialFailure()) requests.pending[0].resume(throwing: CredentialFailure())
try await waitForTranslation { button.isEnabled } try await waitForTranslation { button.isEnabled }
#expect(status.textColor == .systemRed) #expect(status.textColor == ThemePalette.error)
keys.denied = true keys.denied = true
forget.performClick(nil) forget.performClick(nil)
#expect(keys.value == "typed-key") #expect(keys.value == "typed-key")
@@ -315,7 +315,7 @@ func translationExecutionRecoversFromCredentialAndRequestFailures() async throws
try await waitForTranslation { requests.pending.count == 3 } try await waitForTranslation { requests.pending.count == 3 }
requests.pending[2].resume(returning: "成功") requests.pending[2].resume(returning: "成功")
try await waitForTranslation { output.string == "成功" } try await waitForTranslation { output.string == "成功" }
#expect(status.textColor != .systemRed) #expect(status.textColor != ThemePalette.error)
} }
@Test @MainActor @Test @MainActor