feat: configure launcher color scheme
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)" }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,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)) }
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user