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.
## 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
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.
+3 -3
View File
@@ -7,12 +7,12 @@ final class ActionPill: NSView {
wantsLayer = true
layer?.cornerRadius = 18
layer?.borderWidth = 0.5
layer?.borderColor = SunsetPalette.accent.withAlphaComponent(0.35).cgColor
layer?.backgroundColor = SunsetPalette.background.withAlphaComponent(0.25).cgColor
layer?.borderColor = ThemePalette.accent.withAlphaComponent(0.35).cgColor
layer?.backgroundColor = ThemePalette.background.withAlphaComponent(0.25).cgColor
for button in [primaryButton, actionsButton] {
button.isBordered = false
button.font = .systemFont(ofSize: 12, weight: .medium)
button.contentTintColor = SunsetPalette.text
button.contentTintColor = ThemePalette.text
}
primaryButton.keyEquivalent = primaryCommand ?? "\r"
primaryButton.keyEquivalentModifierMask = primaryCommand == nil ? [] : .command
@@ -74,23 +74,23 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat
selectionScroll = nil
selectionViewportHeight = nil
entries = [
ActionEntry(title: "Open", symbol: "arrow.up.forward.app", color: SunsetPalette.text),
ActionEntry(title: "Favorite", symbol: "star", color: SunsetPalette.text),
ActionEntry(title: "Show in Finder", symbol: "finder", color: SunsetPalette.text),
ActionEntry(title: "Reset ranking", symbol: "arrow.counterclockwise", color: SunsetPalette.text),
ActionEntry(title: "Quit application", symbol: "stop.circle", color: SunsetPalette.text),
ActionEntry(title: "Uninstall application", symbol: "trash", color: .systemRed),
ActionEntry(title: "Copy Emoji", symbol: "doc.on.doc", color: SunsetPalette.text),
ActionEntry(title: "Change source language…", symbol: "character.bubble", color: SunsetPalette.text),
ActionEntry(title: "Change target language…", symbol: "character.bubble", color: SunsetPalette.text),
ActionEntry(title: "Swap languages", symbol: "arrow.left.arrow.right", color: SunsetPalette.text),
ActionEntry(title: "MyMemory (no key)", symbol: "checkmark", color: SunsetPalette.text),
ActionEntry(title: "OpenAI · gpt-6-luna", symbol: "checkmark", color: SunsetPalette.text),
ActionEntry(title: "Browse Notes", symbol: "list.bullet", color: SunsetPalette.text, commandKey: "P"),
ActionEntry(title: "New Note", symbol: "square.and.pencil", color: SunsetPalette.text, commandKey: "N"),
ActionEntry(title: "Delete Note", symbol: "trash", color: .systemRed),
ActionEntry(title: "Copy as Markdown", symbol: "doc.on.doc", color: SunsetPalette.text),
ActionEntry(title: "Export as Markdown…", symbol: "square.and.arrow.up", color: SunsetPalette.text)
ActionEntry(title: "Open", symbol: "arrow.up.forward.app", color: ThemePalette.text),
ActionEntry(title: "Favorite", symbol: "star", color: ThemePalette.text),
ActionEntry(title: "Show in Finder", symbol: "finder", color: ThemePalette.text),
ActionEntry(title: "Reset ranking", symbol: "arrow.counterclockwise", color: ThemePalette.text),
ActionEntry(title: "Quit application", symbol: "stop.circle", color: ThemePalette.text),
ActionEntry(title: "Uninstall application", symbol: "trash", color: ThemePalette.error),
ActionEntry(title: "Copy Emoji", symbol: "doc.on.doc", color: ThemePalette.text),
ActionEntry(title: "Change source language…", symbol: "character.bubble", color: ThemePalette.text),
ActionEntry(title: "Change target language…", symbol: "character.bubble", color: ThemePalette.text),
ActionEntry(title: "Swap languages", symbol: "arrow.left.arrow.right", color: ThemePalette.text),
ActionEntry(title: "MyMemory (no key)", symbol: "checkmark", color: ThemePalette.text),
ActionEntry(title: "OpenAI · gpt-6-luna", symbol: "checkmark", color: ThemePalette.text),
ActionEntry(title: "Browse Notes", symbol: "list.bullet", color: ThemePalette.text, commandKey: "P"),
ActionEntry(title: "New Note", symbol: "square.and.pencil", color: ThemePalette.text, commandKey: "N"),
ActionEntry(title: "Delete Note", symbol: "trash", color: ThemePalette.error),
ActionEntry(title: "Copy as Markdown", symbol: "doc.on.doc", color: ThemePalette.text),
ActionEntry(title: "Export as Markdown…", symbol: "square.and.arrow.up", color: ThemePalette.text)
]
view = ActionsPanelView()
view.wantsLayer = true
@@ -106,13 +106,13 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat
content.layer?.borderWidth = 0.5
background.setAccessibilityElement(false)
content.addSubview(background)
applicationLabel.textColor = SunsetPalette.secondaryText
applicationLabel.textColor = ThemePalette.secondaryText
applicationLabel.lineBreakMode = .byTruncatingTail
applicationLabel.font = .systemFont(ofSize: 12, weight: .medium)
search.font = .systemFont(ofSize: LauncherRowView.textSize)
search.textColor = SunsetPalette.text
search.textColor = ThemePalette.text
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.drawsBackground = false
@@ -126,7 +126,7 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat
search.setAccessibilityLabel("Search application actions")
search.closeActions = { [weak self] in self?.close() }
search.commandKey = { [weak self] key in self?.handleCommandKey(key) ?? false }
empty.textColor = SunsetPalette.secondaryText
empty.textColor = ThemePalette.secondaryText
empty.font = .systemFont(ofSize: 14)
empty.isHidden = true
content.translatesAutoresizingMaskIntoConstraints = false
@@ -173,7 +173,7 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat
search.bottomAnchor.constraint(equalTo: content.bottomAnchor, constant: -8),
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 {
height.isActive = false
let table = NSTableView()
@@ -290,7 +290,7 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat
let favorite = entries[1]
let favoriteTitle = isFavorite ? "Remove Favorite" : "Favorite"
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.icon.image = NSImage(systemSymbolName: isFavorite ? "star.fill" : "star", accessibilityDescription: nil)
@@ -299,7 +299,7 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat
}
search.placeholderAttributedString = NSAttributedString(
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)])
search.stringValue = ""
controlTextDidChange(Notification(name: NSControl.textDidChangeNotification))
@@ -332,7 +332,7 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat
coveredPill?.animator().alphaValue = 0
}
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
MainActor.assumeIsolated {
guard let self, self.isShown, event.window === self.view.window else { return }
@@ -595,7 +595,7 @@ private struct ActionEntry {
@MainActor
private final class SelectionCell: NSView {
let entry = ActionEntry(title: "", symbol: "checkmark", color: SunsetPalette.text)
let entry = ActionEntry(title: "", symbol: "checkmark", color: ThemePalette.text)
init() {
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() }
return controller
}
private var startupShortcutError: String?
private var startupError = ThemePalette.configurationError
private var startupRecoveryMessage: String?
private var isLoginLaunch: Bool {
@@ -137,24 +137,23 @@ private final class ApplicationDelegate: NSObject, NSApplicationDelegate {
launcher.present()
}
let shortcutErrors = shortcuts.start()
if !shortcutErrors.isEmpty {
startupShortcutError = shortcutErrors.joined(separator: "\n")
if !isLoginLaunch { showSettings(nil) }
}
startupError = ([startupError].compactMap { $0 } + shortcutErrors).joined(separator: "\n")
if startupError?.isEmpty == true { startupError = nil }
if startupError != nil && !loginLaunch { showSettings(nil) }
}
func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows: Bool) -> Bool {
guard !isLoginLaunch else { return false }
launcher.present()
if startupShortcutError != nil || startupRecoveryMessage != nil {
if startupError != nil || startupRecoveryMessage != nil {
showSettings(nil)
}
return false
}
@objc func showSettings(_ sender: Any?) {
settings.present(error: startupShortcutError)
startupShortcutError = nil
settings.present(error: startupError)
startupError = nil
startupRecoveryMessage = nil
}
+8 -8
View File
@@ -38,7 +38,7 @@ final class EmojiPickerController: NSViewController, NSCollectionViewDataSource,
category.action = #selector(categoryChanged)
category.font = .systemFont(ofSize: 12)
category.isBordered = false
category.contentTintColor = SunsetPalette.secondaryText
category.contentTintColor = ThemePalette.secondaryText
category.setContentCompressionResistancePriority(.required, for: .horizontal)
layout.itemSize = NSSize(width: 80, height: 80)
layout.minimumInteritemSpacing = 8
@@ -59,9 +59,9 @@ final class EmojiPickerController: NSViewController, NSCollectionViewDataSource,
scroll.scrollerStyle = .overlay
scroll.drawsBackground = false
scroll.contentView.drawsBackground = false
empty.textColor = SunsetPalette.secondaryText
empty.textColor = ThemePalette.secondaryText
status.font = .systemFont(ofSize: 12)
status.textColor = SunsetPalette.secondaryText
status.textColor = ThemePalette.secondaryText
status.lineBreakMode = .byTruncatingTail
status.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
copyButton.target = self
@@ -323,10 +323,10 @@ private final class EmojiSectionHeader: NSView {
func setTitle(_ title: String, count: Int) {
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: [
.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
}
@@ -348,7 +348,7 @@ private final class EmojiItem: NSCollectionViewItem {
}
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.selected = selected
}
@@ -378,7 +378,7 @@ private final class EmojiButton: NSButton {
private func updateBorder() {
layer?.borderWidth = selected || hovered ? 0.5 : 0
layer?.borderColor = (selected ? SunsetPalette.accent.withAlphaComponent(0.8)
: SunsetPalette.secondaryText.withAlphaComponent(0.35)).cgColor
layer?.borderColor = (selected ? ThemePalette.accent.withAlphaComponent(0.8)
: ThemePalette.secondaryText.withAlphaComponent(0.35)).cgColor
}
}
@@ -58,8 +58,8 @@ private final class AlignmentGuideView: NSView {
var snapsHorizontally = false
var snapsVertically = false
private static let dash: [CGFloat] = [1, 5]
private static let muted = SunsetPalette.accent.withAlphaComponent(0.4).cgColor
private static let aligned = SunsetPalette.accent.withAlphaComponent(0.95).cgColor
private static let muted = ThemePalette.accent.withAlphaComponent(0.4).cgColor
private static let aligned = ThemePalette.accent.withAlphaComponent(0.95).cgColor
private static let shadow = NSColor.black.withAlphaComponent(0.6).cgColor
override func hitTest(_ point: NSPoint) -> NSView? { nil }
+23 -13
View File
@@ -1,34 +1,44 @@
import AppKit
@MainActor
enum SunsetPalette {
static let background = NSColor(srgbRed: 0.12, green: 0.09, blue: 0.08, alpha: 0.55)
static let text = NSColor(srgbRed: 1.00, green: 0.89, blue: 0.71, alpha: 1)
static let secondaryText = NSColor(srgbRed: 0.85, green: 0.78, blue: 0.65, alpha: 1)
static let accent = NSColor(srgbRed: 0.95, green: 0.58, blue: 0.23, alpha: 1)
enum ThemePalette {
private static let loaded: (scheme: ColorScheme, error: String?) = {
do {
return (try ColorScheme.load(from: ColorScheme.configurationDirectory), nil)
} 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 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
final class LauncherBackground: NSView {
weak var canvas: NSView?
private let amber = NSGradient(colors: [
SunsetPalette.accent.withAlphaComponent(0.18),
SunsetPalette.accent.withAlphaComponent(0.065),
SunsetPalette.accent.withAlphaComponent(0),
ThemePalette.accent.withAlphaComponent(0.18),
ThemePalette.accent.withAlphaComponent(0.065),
ThemePalette.accent.withAlphaComponent(0),
])!
private let ember = NSGradient(colors: [
SunsetPalette.ember.withAlphaComponent(0.26),
SunsetPalette.ember.withAlphaComponent(0.10),
SunsetPalette.ember.withAlphaComponent(0),
ThemePalette.glow.withAlphaComponent(0.26),
ThemePalette.glow.withAlphaComponent(0.10),
ThemePalette.glow.withAlphaComponent(0),
])!
override func hitTest(_ point: NSPoint) -> NSView? { nil }
override func draw(_ dirtyRect: NSRect) {
SunsetPalette.background.setFill()
ThemePalette.background.setFill()
dirtyRect.fill()
let canvasBounds = canvas.map { convert($0.bounds, from: $0) } ?? bounds
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() }
else { window?.makeFirstResponder(searchField) }
(window as? LauncherWindow)?.sampleNumberHints()
(searchField.currentEditor() as? NSTextView)?.insertionPointColor = SunsetPalette.accent
(searchField.currentEditor() as? NSTextView)?.insertionPointColor = ThemePalette.accent
refreshApplications()
}
@@ -276,9 +276,9 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
searchField.font = searchFont
searchField.placeholderAttributedString = NSAttributedString(
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.drawsBackground = false
searchField.focusRingType = .none
@@ -292,11 +292,11 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
searchField.setAccessibilityLabel("Search applications, calculate, or convert units and currencies")
searchSymbol.image = NSImage(systemSymbolName: "sparkles", accessibilityDescription: nil)
searchSymbol.contentTintColor = SunsetPalette.accent
searchSymbol.contentTintColor = ThemePalette.accent
commandBack.image = IconStyle.symbol("chevron.left", for: searchFont)
commandBack.imageScaling = .scaleProportionallyDown
commandBack.isBordered = false
commandBack.contentTintColor = SunsetPalette.secondaryText
commandBack.contentTintColor = ThemePalette.secondaryText
commandBack.setAccessibilityLabel("Back to applications")
commandBack.toolTip = "Back to applications"
commandBack.isHidden = true
@@ -311,7 +311,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
header.spacing = 12
sectionLabel.font = .systemFont(ofSize: 12, weight: .semibold)
sectionLabel.textColor = SunsetPalette.secondaryText
sectionLabel.textColor = ThemePalette.secondaryText
let column = NSTableColumn(identifier: NSUserInterfaceItemIdentifier("application"))
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)
statusLabel.font = .systemFont(ofSize: 12)
statusLabel.textColor = SunsetPalette.secondaryText
statusLabel.textColor = ThemePalette.secondaryText
statusLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
emptyLabel.textColor = SunsetPalette.secondaryText
emptyLabel.textColor = ThemePalette.secondaryText
emptyLabel.alignment = .center
openButton.target = self
@@ -395,7 +395,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
footer.heightAnchor.constraint(equalToConstant: 36),
])
window.effectiveAppearance.performAsCurrentDrawingAppearance {
let border = SunsetPalette.secondaryText.withAlphaComponent(0.22).cgColor
let border = ThemePalette.secondaryText.withAlphaComponent(0.22).cgColor
content.layer?.borderColor = border
}
window.initialFirstResponder = searchField
@@ -569,7 +569,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
cell.kindLabel.stringValue = isCurrencyCalculation ? "Daily rates"
: calculation.copyValue == nil ? "Error" : "Calculator"
cell.imageView?.image = NSImage(systemSymbolName: "equal", accessibilityDescription: nil)
cell.imageView?.contentTintColor = SunsetPalette.accent
cell.imageView?.contentTintColor = ThemePalette.accent
cell.toolTip = calculation.detail
} else if results[row].command != nil {
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 timing = CAMediaTimingFunction(name: .easeOut)
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 favoriteKind = favorited(applicationKind)
@@ -1028,7 +1028,7 @@ final class ApplicationCell: NSTableCellView {
attachment.image = NSImage(systemSymbolName: "star.fill", accessibilityDescription: "Favorite")?
.withSymbolConfiguration(IconStyle.symbolConfiguration(for: font)
.applying(.init(scale: .small))
.applying(.init(paletteColors: [SunsetPalette.secondaryText])))
.applying(.init(paletteColors: [ThemePalette.secondaryText])))
let size = attachment.image?.size ?? .zero
attachment.bounds = NSRect(x: 0, y: (font.capHeight - size.height) / 2,
width: size.width, height: size.height)
@@ -1041,7 +1041,7 @@ final class ApplicationCell: NSTableCellView {
let locationLabel = NSTextField(labelWithString: "")
let kindLabel = NSTextField(labelWithString: "Application")
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
)
private let kindFade = CAGradientLayer()
@@ -1054,10 +1054,10 @@ final class ApplicationCell: NSTableCellView {
let icon = IconView(font: font)
let label = NSTextField(labelWithString: "")
label.font = font
label.textColor = SunsetPalette.text
label.textColor = ThemePalette.text
label.lineBreakMode = .byTruncatingTail
locationLabel.font = .systemFont(ofSize: 12)
locationLabel.textColor = SunsetPalette.secondaryText
locationLabel.textColor = ThemePalette.secondaryText
locationLabel.lineBreakMode = .byTruncatingMiddle
locationLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
locationLabel.isHidden = true
@@ -1066,7 +1066,7 @@ final class ApplicationCell: NSTableCellView {
title.alignment = .centerY
title.spacing = 8
kindLabel.font = .systemFont(ofSize: font.pointSize)
kindLabel.textColor = SunsetPalette.secondaryText
kindLabel.textColor = ThemePalette.secondaryText
kindLabel.setContentCompressionResistancePriority(.required, for: .horizontal)
kindLabel.setAccessibilityElement(false)
kindLabel.wantsLayer = true
@@ -1416,10 +1416,10 @@ final class LauncherRowView: NSTableRowView {
selectionBounds.origin.y += topSpacing
selectionBounds.size.height -= topSpacing
let selection = NSBezierPath(roundedRect: selectionBounds.insetBy(dx: 0.75, dy: 0.75), xRadius: 10, yRadius: 10)
SunsetPalette.selectionFill.setFill()
ThemePalette.selectionFill.setFill()
selection.fill()
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.stroke()
}
+10 -10
View File
@@ -59,7 +59,7 @@ final class NotesController: NSViewController, NSTextViewDelegate {
actionsButton.action = #selector(showActions)
let controls = ActionPill(primaryButton: browseButton, actionsButton: actionsButton, primaryCommand: "p")
noteTitle.font = .systemFont(ofSize: 13)
noteTitle.textColor = SunsetPalette.secondaryText
noteTitle.textColor = ThemePalette.secondaryText
noteTitle.alignment = .center
noteTitle.lineBreakMode = .byTruncatingTail
noteTitle.setAccessibilityLabel("Note title")
@@ -68,7 +68,7 @@ final class NotesController: NSViewController, NSTextViewDelegate {
vimButton.action = #selector(toggleVim)
vimButton.bezelStyle = .accessoryBarAction
vimButton.font = .monospacedSystemFont(ofSize: 10, weight: .medium)
vimButton.contentTintColor = SunsetPalette.secondaryText
vimButton.contentTintColor = ThemePalette.secondaryText
vimButton.setAccessibilityLabel("Toggle Vim editing")
vimButton.toolTip = "Toggle Vim editing. Escape: Normal mode, then close; i: Insert; v: Visual."
editor.vimModeChanged = { [weak self] in self?.updateVimIndicator() }
@@ -130,9 +130,9 @@ final class NotesController: NSViewController, NSTextViewDelegate {
editor.importsGraphics = true
editor.allowsUndo = true
editor.drawsBackground = false
editor.textColor = SunsetPalette.text
editor.insertionPointColor = SunsetPalette.accent
editor.selectedTextAttributes = [.backgroundColor: SunsetPalette.selectionFill]
editor.textColor = ThemePalette.text
editor.insertionPointColor = ThemePalette.accent
editor.selectedTextAttributes = [.backgroundColor: ThemePalette.selectionFill]
editor.font = NotesAppearance.font
editor.defaultParagraphStyle = NotesAppearance.paragraph
editor.isAutomaticQuoteSubstitutionEnabled = false
@@ -155,7 +155,7 @@ final class NotesController: NSViewController, NSTextViewDelegate {
scroll.contentInsets = NSEdgeInsets(top: scroll.edgeFadeHeight, left: 0,
bottom: scroll.edgeFadeHeight, right: 0)
status.font = .systemFont(ofSize: 12)
status.textColor = SunsetPalette.secondaryText
status.textColor = ThemePalette.secondaryText
status.setAccessibilityLabel("Notes save status")
status.lineBreakMode = .byTruncatingTail
status.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
@@ -220,7 +220,7 @@ final class NotesController: NSViewController, NSTextViewDelegate {
button.imagePosition = .imageOnly
button.bezelStyle = .accessoryBarAction
button.showsBorderOnlyWhileMouseInside = true
button.contentTintColor = SunsetPalette.secondaryText
button.contentTintColor = ThemePalette.secondaryText
button.toolTip = label
button.setAccessibilityLabel(label)
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.stringValue = "Couldn’t export note: \(error.localizedDescription)"
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.addButton(withTitle: "Cancel").keyEquivalent = "\u{1b}"
let delete = alert.addButton(withTitle: "Delete Note")
delete.contentTintColor = .systemRed
delete.contentTintColor = ThemePalette.error
delete.keyEquivalent = "\r"
alert.window.initialFirstResponder = delete
alert.window.defaultButtonCell = delete.cell as? NSButtonCell
@@ -416,7 +416,7 @@ final class NotesController: NSViewController, NSTextViewDelegate {
status.isHidden = false
status.stringValue = "Couldn’t save, load, or delete notes: \(error.localizedDescription)"
status.toolTip = status.stringValue
status.textColor = .systemRed
status.textColor = ThemePalette.error
}
@objc func browseNotes() {
+6 -6
View File
@@ -136,7 +136,7 @@ final class NotesTextView: NSTextView {
private func attributes(at position: Int) -> [NSAttributedString.Key: Any] {
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)
result.removeValue(forKey: .attachment)
@@ -157,7 +157,7 @@ final class NotesTextView: NSTextView {
weight: attrs[NoteAttributes.bold] as? Bool == true ? .bold : heading ? .semibold : .regular)
attrs[.font] = attrs[NoteAttributes.italic] as? Bool == true
? 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
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)
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(.foregroundColor, value: run[NoteAttributes.inlineCode] as? Bool == true || run[.link] != nil ? SunsetPalette.accent : SunsetPalette.text, range: range)
if run[NoteAttributes.inlineCode] as? Bool == true || kind == "code" { storage.addAttribute(.backgroundColor, value: SunsetPalette.selectionFill, 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: ThemePalette.selectionFill, range: range) }
}
index = NSMaxRange(row)
}
@@ -557,7 +557,7 @@ final class NotesTextView: NSTextView {
let line = layoutManager.lineFragmentRect(forGlyphAt: glyph, effectiveRange: nil)
let baseline = line.minY + layoutManager.location(forGlyphAt: glyph).y + textContainerOrigin.y
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 right = textContainerOrigin.x + CGFloat(item.depth) * 20 + markerColumn - 4
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 {
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)
}
for (button, task) in zip(checkboxes, tasks) {
+12 -12
View File
@@ -110,8 +110,8 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate {
instructions.textColor = .secondaryLabelColor
statusLabel.textColor = .secondaryLabelColor
statusLabel.setAccessibilityLabel("Global shortcut registration status")
errorLabel.textColor = .systemRed
errorLabel.setAccessibilityLabel("Shortcut feedback")
errorLabel.textColor = ThemePalette.error
errorLabel.setAccessibilityLabel("Settings feedback")
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."
@@ -128,7 +128,7 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate {
loginSettings.action = #selector(openLoginSettings)
loginSettings.bezelStyle = .rounded
loginStatus.setAccessibilityLabel("Launch at login status")
recoveryLabel.textColor = SunsetPalette.secondaryText
recoveryLabel.textColor = ThemePalette.secondaryText
recoveryLabel.isHidden = true
recoveryLabel.setAccessibilityLabel("Uninstall recovery status")
recoveryButton.isHidden = true
@@ -137,19 +137,19 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate {
recoveryButton.action = #selector(showUninstallRecovery)
heading.font = .systemFont(ofSize: 20, weight: .semibold)
heading.textColor = SunsetPalette.text
heading.textColor = ThemePalette.text
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] {
button.contentTintColor = SunsetPalette.accent
button.contentTintColor = ThemePalette.accent
}
let loginHeading = NSTextField(labelWithString: "Startup")
loginHeading.font = .systemFont(ofSize: 20, weight: .semibold)
loginHeading.textColor = SunsetPalette.text
loginHeading.textColor = ThemePalette.text
let translationHeading = NSTextField(labelWithString: "Conjure Translate")
translationHeading.font = .systemFont(ofSize: 20, weight: .semibold)
translationHeading.textColor = SunsetPalette.text
translationHeading.textColor = ThemePalette.text
let stack = NSStackView(views: [
heading, destinationSelector, controls, instructions, statusLabel, errorLabel, spotlight, keyboardSettings,
@@ -206,7 +206,7 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate {
recoveryLabel.stringValue = report.needsRecovery
? "An interrupted uninstall needs attention. Files have been retained for recovery."
: "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…"
}
@@ -229,7 +229,7 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate {
private func refreshLogin() {
let status = SMAppService.mainApp.status
loginStatus.textColor = SunsetPalette.secondaryText
loginStatus.textColor = ThemePalette.secondaryText
loginToggle.state = (status == .enabled || status == .requiresApproval) ? .on : .off
loginSettings.isHidden = status != .requiresApproval
switch status {
@@ -243,7 +243,7 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate {
loginStatus.stringValue = "Install Conjure in Applications before enabling launch at login."
@unknown default:
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 {
refreshLogin()
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 {
let badge = ShortcutKeyBadge(labelWithString: key)
badge.font = .systemFont(ofSize: fontSize, weight: .medium)
badge.textColor = SunsetPalette.secondaryText
badge.textColor = ThemePalette.secondaryText
badge.alignment = .center
badge.wantsLayer = true
badge.layer?.cornerRadius = 5
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.setAccessibilityElement(false)
badge.translatesAutoresizingMaskIntoConstraints = false
+10 -10
View File
@@ -85,11 +85,11 @@ final class TranslationController: NSViewController {
providerButton.toolTip = "Choose translation provider (Command-P)"
providerButton.isBordered = false
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)!,
target: self, action: #selector(showProviders))
chevron.isBordered = false
chevron.contentTintColor = SunsetPalette.secondaryText
chevron.contentTintColor = ThemePalette.secondaryText
chevron.setAccessibilityElement(false)
stylePill(providerControl)
for control in [providerButton, chevron] {
@@ -109,7 +109,7 @@ final class TranslationController: NSViewController {
languageButton.action = #selector(showActions)
languageButton.isBordered = false
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.setAccessibilityLabel("Translation languages")
actionsButton.target = self
@@ -140,7 +140,7 @@ final class TranslationController: NSViewController {
output.isEditable = false
output.isSelectable = true
output.drawsBackground = false
output.textColor = SunsetPalette.text
output.textColor = ThemePalette.text
output.font = .systemFont(ofSize: 20)
output.textContainerInset = .zero
output.textContainer?.lineFragmentPadding = 0
@@ -156,7 +156,7 @@ final class TranslationController: NSViewController {
status.setAccessibilityLabel("Translation status and errors")
status.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
sourcePreview.font = .systemFont(ofSize: 14)
sourcePreview.textColor = SunsetPalette.secondaryText
sourcePreview.textColor = ThemePalette.secondaryText
sourcePreview.maximumNumberOfLines = 3
sourcePreview.lineBreakMode = .byTruncatingTail
sourcePreview.setAccessibilityLabel("Original text to translate")
@@ -192,8 +192,8 @@ final class TranslationController: NSViewController {
pill.wantsLayer = true
pill.layer?.cornerRadius = 18
pill.layer?.borderWidth = 0.5
pill.layer?.borderColor = SunsetPalette.accent.withAlphaComponent(0.35).cgColor
pill.layer?.backgroundColor = SunsetPalette.background.withAlphaComponent(0.25).cgColor
pill.layer?.borderColor = ThemePalette.accent.withAlphaComponent(0.35).cgColor
pill.layer?.backgroundColor = ThemePalette.background.withAlphaComponent(0.25).cgColor
}
@objc func showProviders() {
@@ -308,7 +308,7 @@ final class TranslationController: NSViewController {
private func setStatus(_ message: String, isError: Bool = false) {
status.stringValue = message
status.textColor = isError ? .systemRed : SunsetPalette.secondaryText
status.textColor = isError ? ThemePalette.error : ThemePalette.secondaryText
}
@objc func performPrimaryAction() {
@@ -376,11 +376,11 @@ final class TranslationController: NSViewController {
guard !Task.isCancelled, let self else { return }
let rendered = NSMutableAttributedString(string: translation, attributes: [
.font: NSFont.systemFont(ofSize: 20),
.foregroundColor: SunsetPalette.text,
.foregroundColor: ThemePalette.text,
])
if !myMemory, let separator = translation.range(of: "\n\n") {
rendered.addAttributes([
.foregroundColor: SunsetPalette.secondaryText,
.foregroundColor: ThemePalette.secondaryText,
.font: NSFont.systemFont(ofSize: 14),
], 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))
for button in [save, forget] {
button.bezelStyle = .rounded
button.contentTintColor = SunsetPalette.accent
button.contentTintColor = ThemePalette.accent
}
let controls = NSStackView(views: [keyField, save, forget])
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."
)
for label in [status, myMemory, openAI] {
label.textColor = SunsetPalette.secondaryText
label.textColor = ThemePalette.secondaryText
}
let stack = NSStackView(views: [controls, status, myMemory, openAI])
stack.orientation = .vertical
@@ -85,6 +85,6 @@ final class TranslationSettingsController: NSViewController {
private func setStatus(_ message: String, isError: Bool = false) {
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
updateStatus()
window.makeFirstResponder(search)
(search.currentEditor() as? NSTextView)?.insertionPointColor = SunsetPalette.accent
(search.currentEditor() as? NSTextView)?.insertionPointColor = ThemePalette.accent
scanTask = Task { [weak self, application] in
do {
let found = try await Task.detached(priority: .userInitiated) {
@@ -62,13 +62,13 @@ final class UninstallReviewController: NSViewController, NSSearchFieldDelegate,
view = content
let context = NSTextField(wrappingLabelWithString: "Uninstall \(application.name)")
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")
guidance.font = .systemFont(ofSize: 12)
guidance.textColor = SunsetPalette.secondaryText
guidance.textColor = ThemePalette.secondaryText
search.placeholderAttributedString = NSAttributedString(
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 {
cell.searchButtonCell = nil
@@ -79,12 +79,12 @@ final class UninstallReviewController: NSViewController, NSSearchFieldDelegate,
search.setAccessibilityLabel("Filter uninstall files for \(application.name)")
search.setAccessibilityHelp(guidance.stringValue)
search.font = .systemFont(ofSize: 20)
search.textColor = SunsetPalette.text
search.textColor = ThemePalette.text
search.isBordered = false
search.drawsBackground = false
search.focusRingType = .none
status.font = .systemFont(ofSize: 12)
status.textColor = SunsetPalette.secondaryText
status.textColor = ThemePalette.secondaryText
status.setAccessibilityLabel("Uninstall selection and progress")
back.target = self
back.action = #selector(goBack)
@@ -92,7 +92,7 @@ final class UninstallReviewController: NSViewController, NSSearchFieldDelegate,
back.image = IconStyle.symbol("chevron.left", for: search.font!)
back.imageScaling = .scaleProportionallyDown
back.isBordered = false
back.contentTintColor = SunsetPalette.secondaryText
back.contentTintColor = ThemePalette.secondaryText
back.setAccessibilityLabel("Back to applications")
let header = NSStackView(views: [back, search])
header.orientation = .horizontal
@@ -100,7 +100,7 @@ final class UninstallReviewController: NSViewController, NSSearchFieldDelegate,
header.spacing = 12
remove.target = self
remove.action = #selector(confirmUninstall)
remove.contentTintColor = .systemRed
remove.contentTintColor = ThemePalette.error
remove.isBordered = false
remove.font = .systemFont(ofSize: 12, weight: .medium)
remove.keyEquivalent = "\r"
@@ -128,8 +128,8 @@ final class UninstallReviewController: NSViewController, NSSearchFieldDelegate,
pill.wantsLayer = true
pill.layer?.cornerRadius = 18
pill.layer?.borderWidth = 0.5
pill.layer?.borderColor = SunsetPalette.accent.withAlphaComponent(0.35).cgColor
pill.layer?.backgroundColor = SunsetPalette.background.withAlphaComponent(0.25).cgColor
pill.layer?.borderColor = ThemePalette.accent.withAlphaComponent(0.35).cgColor
pill.layer?.backgroundColor = ThemePalette.background.withAlphaComponent(0.25).cgColor
let shortcut = ShortcutIndicator("⌘", "↵")
for child in [remove, shortcut] {
child.translatesAutoresizingMaskIntoConstraints = false
@@ -204,14 +204,14 @@ final class UninstallReviewController: NSViewController, NSSearchFieldDelegate,
}
private func updateStatus() {
status.textColor = SunsetPalette.secondaryText
status.textColor = ThemePalette.secondaryText
remove.isEnabled = false
switch discoveryState {
case .loading:
status.stringValue = "Finding associated files…"
case .failed(let message):
status.stringValue = message
status.textColor = .systemRed
status.textColor = ThemePalette.error
case .ready:
let chosen = candidates.filter { selected.contains($0.url) }
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.setAccessibilityLabel("Select \(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
cell.detail.stringValue = candidate.reason.map { "\(path) · \($0)" } ?? path
cell.size.stringValue = candidate.size.map { ByteCountFormatter.string(fromByteCount: $0, countStyle: .file) } ?? "—"
@@ -318,7 +318,7 @@ final class UninstallReviewController: NSViewController, NSSearchFieldDelegate,
return
}
do {
status.textColor = SunsetPalette.secondaryText
status.textColor = ThemePalette.secondaryText
status.stringValue = "Waiting for application to quit…"
for app in running where !app.isTerminated {
guard app.terminate() else { throw ReviewError("\(app.localizedName ?? application.name) could not quit. No files were moved.") }
@@ -368,7 +368,7 @@ final class UninstallReviewController: NSViewController, NSSearchFieldDelegate,
alert.addButton(withTitle: "OK")
if canRecover { alert.addButton(withTitle: "Retry recovery") }
guard await alert.beginSheetModal(for: window) == .alertSecondButtonReturn, canRecover else { return }
status.textColor = SunsetPalette.secondaryText
status.textColor = ThemePalette.secondaryText
status.stringValue = "Recovering pending uninstall…"
let report = await uninstaller.recover()
changed?()
@@ -432,7 +432,7 @@ private final class UninstallFileCell: NSTableCellView {
super.init(frame: .zero)
name.font = font
detail.font = .systemFont(ofSize: 11)
detail.textColor = SunsetPalette.secondaryText
detail.textColor = ThemePalette.secondaryText
for label in [name, detail] {
label.lineBreakMode = .byTruncatingMiddle
label.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
@@ -441,7 +441,7 @@ private final class UninstallFileCell: NSTableCellView {
labels.orientation = .vertical
labels.alignment = .leading
labels.spacing = 3
size.textColor = SunsetPalette.secondaryText
size.textColor = ThemePalette.secondaryText
size.font = .systemFont(ofSize: 12)
size.setContentCompressionResistancePriority(.required, for: .horizontal)
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
- 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`.
@@ -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 body = (editor.string as NSString).range(of: "some *literal* code 🦊")
for index in body.location..<NSMaxRange(body) {
#expect(storage.attribute(.foregroundColor, at: index, effectiveRange: nil) as? NSColor == SunsetPalette.accent)
#expect(storage.attribute(.backgroundColor, at: index, effectiveRange: nil) as? NSColor == SunsetPalette.selectionFill)
#expect(storage.attribute(.foregroundColor, at: index, effectiveRange: nil) as? NSColor == ThemePalette.accent)
#expect(storage.attribute(.backgroundColor, at: index, effectiveRange: nil) as? NSColor == ThemePalette.selectionFill)
}
for token in ["Before", "after", "not inline"] {
let index = (editor.string as NSString).range(of: token).location
@@ -295,5 +295,5 @@ func inlineCodeContainerIsLimitedToItsMarkAndDisappearsWhenToggledOff() throws {
#expect(editor.string == before)
#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(.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()))
let failure = status.stringValue
#expect(failure.contains(ScanFailure().localizedDescription))
#expect(status.textColor == .systemRed)
#expect(status.textColor == ThemePalette.error)
filterBy("different")
#expect(status.stringValue == failure)
#expect(!remove.isEnabled)
@@ -46,7 +46,7 @@ func uninstallFilteringPreservesDiscoveryStateAndCheckedFiles() throws {
controller.applyDiscoveryResult(.success([candidate]))
filterBy("")
let counts = status.stringValue
#expect(status.textColor == SunsetPalette.secondaryText)
#expect(status.textColor == ThemePalette.secondaryText)
#expect(remove.isEnabled)
#expect(table.numberOfRows == 1)
filterBy("no-match")
@@ -72,7 +72,7 @@ func shortcutModifierGuidanceDoesNotAppearAsAnError() throws {
$0.action == NSSelectorFromString("beginRecording")
})
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 {
$0.accessibilityLabel() == "Global shortcut registration status"
@@ -85,5 +85,5 @@ func shortcutModifierGuidanceDoesNotAppearAsAnError() throws {
context: nil, characters: "", charactersIgnoringModifiers: "", isARepeat: false, keyCode: 55))
recorder.flagsChanged(with: modifier)
#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.
#expect(requests.inputs.isEmpty)
#expect(button.isEnabled)
#expect(status.textColor == .systemRed)
#expect(status.textColor == ThemePalette.error)
#expect(!openSettings.isHidden)
field.stringValue = "typed-key"
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")
requests.pending[0].resume(throwing: CredentialFailure())
try await waitForTranslation { button.isEnabled }
#expect(status.textColor == .systemRed)
#expect(status.textColor == ThemePalette.error)
keys.denied = true
forget.performClick(nil)
#expect(keys.value == "typed-key")
@@ -315,7 +315,7 @@ func translationExecutionRecoversFromCredentialAndRequestFailures() async throws
try await waitForTranslation { requests.pending.count == 3 }
requests.pending[2].resume(returning: "成功")
try await waitForTranslation { output.string == "成功" }
#expect(status.textColor != .systemRed)
#expect(status.textColor != ThemePalette.error)
}
@Test @MainActor