diff --git a/README.md b/README.md index 8ccb3aa..071b46b 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/Sources/Conjure/ActionPill.swift b/Sources/Conjure/ActionPill.swift index 738a714..4da954c 100644 --- a/Sources/Conjure/ActionPill.swift +++ b/Sources/Conjure/ActionPill.swift @@ -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 diff --git a/Sources/Conjure/ApplicationActionsController.swift b/Sources/Conjure/ApplicationActionsController.swift index 19a01d0..ed15f6c 100644 --- a/Sources/Conjure/ApplicationActionsController.swift +++ b/Sources/Conjure/ApplicationActionsController.swift @@ -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) diff --git a/Sources/Conjure/ColorScheme.swift b/Sources/Conjure/ColorScheme.swift new file mode 100644 index 0000000..203e5f9 --- /dev/null +++ b/Sources/Conjure/ColorScheme.swift @@ -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] = [ + "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, 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)" } +} diff --git a/Sources/Conjure/ConjureApp.swift b/Sources/Conjure/ConjureApp.swift index 7c964c1..aec6fd9 100644 --- a/Sources/Conjure/ConjureApp.swift +++ b/Sources/Conjure/ConjureApp.swift @@ -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 } diff --git a/Sources/Conjure/EmojiPickerController.swift b/Sources/Conjure/EmojiPickerController.swift index 9bc32dd..9d4a1f8 100644 --- a/Sources/Conjure/EmojiPickerController.swift +++ b/Sources/Conjure/EmojiPickerController.swift @@ -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 } } diff --git a/Sources/Conjure/LauncherAlignmentGuides.swift b/Sources/Conjure/LauncherAlignmentGuides.swift index 1d58436..b6fb692 100644 --- a/Sources/Conjure/LauncherAlignmentGuides.swift +++ b/Sources/Conjure/LauncherAlignmentGuides.swift @@ -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 } diff --git a/Sources/Conjure/LauncherBackground.swift b/Sources/Conjure/LauncherBackground.swift index e34c6af..c06a18f 100644 --- a/Sources/Conjure/LauncherBackground.swift +++ b/Sources/Conjure/LauncherBackground.swift @@ -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, diff --git a/Sources/Conjure/LauncherWindowController.swift b/Sources/Conjure/LauncherWindowController.swift index f7be2ed..cd0aa2b 100644 --- a/Sources/Conjure/LauncherWindowController.swift +++ b/Sources/Conjure/LauncherWindowController.swift @@ -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() } diff --git a/Sources/Conjure/NotesController.swift b/Sources/Conjure/NotesController.swift index c1911ea..5cf93d9 100644 --- a/Sources/Conjure/NotesController.swift +++ b/Sources/Conjure/NotesController.swift @@ -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() { diff --git a/Sources/Conjure/NotesTextView.swift b/Sources/Conjure/NotesTextView.swift index 9b504bb..6490c4b 100644 --- a/Sources/Conjure/NotesTextView.swift +++ b/Sources/Conjure/NotesTextView.swift @@ -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) { diff --git a/Sources/Conjure/SettingsWindowController.swift b/Sources/Conjure/SettingsWindowController.swift index 802dbfc..748f876 100644 --- a/Sources/Conjure/SettingsWindowController.swift +++ b/Sources/Conjure/SettingsWindowController.swift @@ -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 } } diff --git a/Sources/Conjure/ShortcutIndicator.swift b/Sources/Conjure/ShortcutIndicator.swift index 3084378..01437f8 100644 --- a/Sources/Conjure/ShortcutIndicator.swift +++ b/Sources/Conjure/ShortcutIndicator.swift @@ -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 diff --git a/Sources/Conjure/TranslationController.swift b/Sources/Conjure/TranslationController.swift index 071a954..cde97ad 100644 --- a/Sources/Conjure/TranslationController.swift +++ b/Sources/Conjure/TranslationController.swift @@ -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.. 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)) } + } +} diff --git a/Tests/ConjureKitTests/NotesMarkupTests.swift b/Tests/ConjureKitTests/NotesMarkupTests.swift index debab91..1aaa924 100644 --- a/Tests/ConjureKitTests/NotesMarkupTests.swift +++ b/Tests/ConjureKitTests/NotesMarkupTests.swift @@ -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..