diff --git a/README.md b/README.md index c3b7049..71cc21e 100644 --- a/README.md +++ b/README.md @@ -150,7 +150,7 @@ 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. +Only colors are configurable. Frosted glass, glow positions and sizes, selection treatment, typography, and layout keep the same design. Native controls and Notes syntax highlighting follow the palette's light/dark appearance. Light palettes use a denser translucent base, softer glows, and contrast-corrected secondary text so placeholders and muted labels remain readable; the dark rendering is unchanged. Application and emoji icons are not recolored. ### Follow tint @@ -165,6 +165,8 @@ accent = "#82aaff" # Optional personal override Precedence is personal `[colors]` → tint-generated colors → built-in sunset colors. Omit `[colors]` to follow tint without overrides. Omit `theme`, or set it to `"sunset"` before any table, to ignore the generated palette. tint never overwrites `config.toml`; edit that file, not the generated `theme.json`. Both processes must use the same configuration directory. +The generated palette includes tint's `light`/`dark` variant, which selects the matching native appearance for the launcher, Notes, Settings, popups, and sheets. For manually configured colors or a palette without variant metadata, Conjure infers appearance by comparing background and foreground luminance. There is no separate appearance, opacity, or glow-strength setting. Low-contrast secondary colors are adjusted only in the light-mode presentation; source palette files are not rewritten. + Conjure watches `config.toml` and the generated `theme.json` while running. Applying a tint theme, including automatic system light/dark switches, updates existing windows and tools without restarting or clearing searches, notes, selections, or translation results. Personal configuration edits also apply live. In-place saves and atomic file replacements are supported, including configuration directories created after launch. Invalid configuration at startup falls back to sunset and reports a file-specific error in Settings; invalid live edits keep the last valid palette and log the diagnostic, then recover automatically when the file is fixed. ## System theme @@ -179,7 +181,7 @@ Commands explicitly declare `Application.Command.presentsView`. The shared dispa Search for **Conjure Notes**, identified by its centered 📔 icon, or assign it a global shortcut in Settings. Notes starts with a compact 570 × 404 layout inspired by Raycast Notes, with rounded corners and a centered muted note title, while using Conjure’s shared warm gradient background, cream text, and amber list markers and caret. Drag a window edge or corner to resize it; Notes remembers its size across reopening and app restarts, independently of other launcher views. The minimum size is 570 × 260, and restored dimensions fit the current screen’s visible area. The title bar offers Close; the bottom-right action pill offers **Browse Notes** and **Actions**, with **New Note** in Actions and on Command-N. Browse Notes (Command-P) opens Conjure’s styled, scrollable searchable picker upwards from the footer, shared with translation language selection—not a native menu. Fuzzy-search note titles, then select with Up/Down, Control-J/K, and Return or by clicking a note. A checkmark marks the current note, which is selected and scrolled into view on opening. Reopening Notes restores the last selection. Each note’s first nonempty line becomes its title; duplicate titles remain independently selectable. -Notes uses a standard macOS window with system-inherited appearance and only the native Close button visible; minimize and zoom are hidden. The editor uses 15-point monospaced body text, including bold, italic, and code, while retaining Conjure’s warm palette. Heading sizes decrease from H1 through H6 (26, 23, 21, 19, 17, and 15 points); inline emphasis retains the heading’s size, and code-block contents remain literal. Closing still saves first and keeps the window open if saving fails. Other launcher views remain borderless with their existing dark appearance. +Notes uses a standard macOS window matching Conjure's palette appearance and only the native Close button visible; minimize and zoom are hidden. The editor uses 15-point monospaced body text, including bold, italic, and code, with light/dark syntax highlighting following the selected palette. Heading sizes decrease from H1 through H6 (26, 23, 21, 19, 17, and 15 points); inline emphasis retains the heading's size, and code-block contents remain literal. Closing still saves first and keeps the window open if saving fails. Other launcher views remain borderless and use the same palette appearance. Actions (Command-K) opens the same searchable popup style used throughout Conjure, with **Browse Notes** (Command-P), **New Note** (Command-N), and **Delete Note**. Delete Note asks for confirmation before permanently deleting the currently selected note and its embedded images. Delete is focused by default: Return confirms and Escape cancels; deletion cannot be undone. After deletion, Notes selects a remaining note or creates a fresh blank note if none remain. Save or deletion failures preserve the current note and editor buffer. Command-N creates a note while the editor or either popup is focused. Command-P toggles Browse Notes even while its title-bar controls are covered. Escape or clicking outside closes an open popup and returns focus to the editor without leaving Notes; another Escape dismisses the launcher. Control-J/K navigates only while a popup is open, preserving normal editor commands when it is closed. diff --git a/Sources/Conjure/ActionPill.swift b/Sources/Conjure/ActionPill.swift index 53092d2..2d0ecac 100644 --- a/Sources/Conjure/ActionPill.swift +++ b/Sources/Conjure/ActionPill.swift @@ -55,11 +55,11 @@ final class ThemeObservation { nonisolated(unsafe) private let token: NSObjectProtocol init(_ apply: @escaping @MainActor () -> Void) { - apply() + ThemePalette.appearance.performAsCurrentDrawingAppearance(apply) token = NotificationCenter.default.addObserver( forName: ThemePalette.didChangeNotification, object: nil, queue: .main ) { _ in - MainActor.assumeIsolated { apply() } + MainActor.assumeIsolated { ThemePalette.appearance.performAsCurrentDrawingAppearance(apply) } } } diff --git a/Sources/Conjure/ApplicationActionsController.swift b/Sources/Conjure/ApplicationActionsController.swift index 5eefde3..ddd6cb4 100644 --- a/Sources/Conjure/ApplicationActionsController.swift +++ b/Sources/Conjure/ApplicationActionsController.swift @@ -94,6 +94,7 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat ActionEntry(title: "Export as Markdown…", symbol: "square.and.arrow.up") ] view = ActionsPanelView() + view.appearance = ThemePalette.appearance view.wantsLayer = true view.layer?.shadowColor = NSColor.black.cgColor view.layer?.shadowOpacity = 0.35 @@ -215,6 +216,7 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat updateSelection() themeObservation = ThemeObservation { [weak self, weak content] in guard let self else { return } + self.view.appearance = ThemePalette.appearance self.search.applyTheme() self.applicationLabel.textColor = ThemePalette.secondaryText self.empty.textColor = ThemePalette.secondaryText diff --git a/Sources/Conjure/ColorScheme.swift b/Sources/Conjure/ColorScheme.swift index 05c04ba..f0b1790 100644 --- a/Sources/Conjure/ColorScheme.swift +++ b/Sources/Conjure/ColorScheme.swift @@ -10,6 +10,28 @@ struct ColorScheme { var glow: NSColor var keyBackground: NSColor var error: NSColor + enum Variant: String { + case light, dark + } + + var variant: Variant? = nil + + var isLight: Bool { + if let variant { return variant == .light } + return Self.relativeLuminance(background) > Self.relativeLuminance(text) + } + + static func relativeLuminance(_ color: NSColor) -> CGFloat { + let rgb = color.usingColorSpace(.sRGB)! + return relativeLuminance(red: rgb.redComponent, green: rgb.greenComponent, blue: rgb.blueComponent) + } + + static func relativeLuminance(red: CGFloat, green: CGFloat, blue: CGFloat) -> CGFloat { + func linear(_ value: CGFloat) -> CGFloat { + value <= 0.04045 ? value / 12.92 : pow((value + 0.055) / 1.055, 2.4) + } + return 0.2126 * linear(red) + 0.7152 * linear(green) + 0.0722 * linear(blue) + } static let sunset = ColorScheme( background: NSColor(srgbRed: 0.12, green: 0.09, blue: 0.08, alpha: 1), @@ -49,7 +71,13 @@ struct ColorScheme { 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 validateKeys(generated, allowed: ["variant", "colors"], at: themeURL, prefix: "") + if let value = generated["variant"] { + guard let name = value as? String, let variant = Variant(rawValue: name) else { + throw ColorSchemeError(path: themeURL.path, reason: "variant must be \"light\" or \"dark\".") + } + result.variant = variant + } try result.applyColors(from: generated, at: themeURL) } } diff --git a/Sources/Conjure/LauncherAlignmentGuides.swift b/Sources/Conjure/LauncherAlignmentGuides.swift index b4ab24f..e8d94cd 100644 --- a/Sources/Conjure/LauncherAlignmentGuides.swift +++ b/Sources/Conjure/LauncherAlignmentGuides.swift @@ -6,6 +6,7 @@ final class LauncherAlignmentGuides { private let view = AlignmentGuideView() private lazy var overlay: NSPanel = { let panel = NSPanel(contentRect: .zero, styleMask: [.borderless, .nonactivatingPanel], backing: .buffered, defer: false) + panel.appearance = ThemePalette.appearance panel.backgroundColor = .clear panel.isOpaque = false panel.hasShadow = false @@ -20,6 +21,7 @@ final class LauncherAlignmentGuides { func begin(for window: NSWindow) { guard !dragging, window.screen != nil else { return } + overlay.appearance = ThemePalette.appearance dragging = true update(for: window) overlay.level = window.level @@ -66,6 +68,7 @@ private final class AlignmentGuideView: NSView { override init(frame frameRect: NSRect) { super.init(frame: frameRect) themeObservation = ThemeObservation { [weak self] in + self?.window?.appearance = ThemePalette.appearance self?.muted = ThemePalette.accent.withAlphaComponent(0.4).cgColor self?.aligned = ThemePalette.accent.withAlphaComponent(0.95).cgColor self?.needsDisplay = true diff --git a/Sources/Conjure/LauncherBackground.swift b/Sources/Conjure/LauncherBackground.swift index 86f041c..18e12f6 100644 --- a/Sources/Conjure/LauncherBackground.swift +++ b/Sources/Conjure/LauncherBackground.swift @@ -2,10 +2,15 @@ import AppKit @MainActor enum ThemePalette { + @MainActor struct State { let scheme: ColorScheme var error: String? var usesFallback: Bool + let isLight: Bool + let appearance: NSAppearance + let secondaryText: NSColor + let glowStrength: CGFloat let background: NSColor let selectionFill: NSColor let selectionBorder: NSColor @@ -15,11 +20,58 @@ enum ThemePalette { self.scheme = scheme self.error = error self.usesFallback = usesFallback - background = scheme.background.withAlphaComponent(0.55) + let light = scheme.isLight + isLight = light + appearance = NSAppearance(named: light ? .aqua : .darkAqua)! + glowStrength = light ? 0.35 : 1 + background = scheme.background.withAlphaComponent(light ? 0.82 : 0.55) + secondaryText = light ? Self.readableSecondaryText(scheme) : scheme.secondaryText selectionFill = scheme.accent.withAlphaComponent(0.12) selectionBorder = scheme.accent.withAlphaComponent(0.30) keyBackground = scheme.keyBackground.withAlphaComponent(0.5) } + + private static func readableSecondaryText(_ scheme: ColorScheme) -> NSColor { + let background = scheme.background.usingColorSpace(.sRGB)! + let accent = scheme.accent.usingColorSpace(.sRGB)! + let glow = scheme.glow.usingColorSpace(.sRGB)! + // Black behind the translucent fill bounds every desktop. Each gradient may + // darken a channel independently, including the third, dimmed accent glow. + func darkestChannel(_ base: CGFloat, _ accent: CGFloat, _ glow: CGFloat) -> CGFloat { + var value = base * 0.82 + value = min(value, value * (1 - 0.26 * 0.35) + glow * 0.26 * 0.35) + value = min(value, value * (1 - 0.18 * 0.35) + accent * 0.18 * 0.35) + return min(value, value * (1 - 0.18 * 0.35 * 0.45) + accent * 0.18 * 0.35 * 0.45) + } + let surface = ColorScheme.relativeLuminance( + red: darkestChannel(background.redComponent, accent.redComponent, glow.redComponent), + green: darkestChannel(background.greenComponent, accent.greenComponent, glow.greenComponent), + blue: darkestChannel(background.blueComponent, accent.blueComponent, glow.blueComponent)) + // A small margin covers 8-bit surface rounding. + let ceiling = max(0, (surface + 0.05) / 4.55 - 0.05) + guard ColorScheme.relativeLuminance(scheme.secondaryText) > ceiling else { + return scheme.secondaryText + } + let source = scheme.secondaryText.usingColorSpace(.sRGB)! + let target = (ColorScheme.relativeLuminance(scheme.text) <= ceiling ? scheme.text : .black) + .usingColorSpace(.sRGB)! + func component(_ from: CGFloat, _ to: CGFloat, _ fraction: CGFloat) -> CGFloat { + from + (to - from) * fraction + } + var lower: CGFloat = 0 + var upper: CGFloat = 1 + for _ in 0..<24 { + let fraction = (lower + upper) / 2 + let luminance = ColorScheme.relativeLuminance( + red: component(source.redComponent, target.redComponent, fraction), + green: component(source.greenComponent, target.greenComponent, fraction), + blue: component(source.blueComponent, target.blueComponent, fraction)) + if luminance > ceiling { lower = fraction } else { upper = fraction } + } + return NSColor(srgbRed: component(source.redComponent, target.redComponent, upper), + green: component(source.greenComponent, target.greenComponent, upper), + blue: component(source.blueComponent, target.blueComponent, upper), alpha: 1) + } } static var loaded: State = { @@ -35,13 +87,16 @@ enum ThemePalette { static let didChangeNotification = Notification.Name("ConjureThemePaletteDidChange") static var configurationError: String? { loaded.error } + static var isLight: Bool { loaded.isLight } + static var appearance: NSAppearance { loaded.appearance } static var background: NSColor { loaded.background } static var text: NSColor { loaded.scheme.text } - static var secondaryText: NSColor { loaded.scheme.secondaryText } + static var secondaryText: NSColor { loaded.secondaryText } static var accent: NSColor { loaded.scheme.accent } static var selectionFill: NSColor { loaded.selectionFill } static var selectionBorder: NSColor { loaded.selectionBorder } static var glow: NSColor { loaded.scheme.glow } + static var glowStrength: CGFloat { loaded.glowStrength } static var keyBackground: NSColor { loaded.keyBackground } static var error: NSColor { loaded.scheme.error } @@ -66,7 +121,7 @@ enum ThemePalette { guard scheme.background != previous.background || scheme.text != previous.text || scheme.secondaryText != previous.secondaryText || scheme.accent != previous.accent || scheme.glow != previous.glow || scheme.keyBackground != previous.keyBackground - || scheme.error != previous.error else { + || scheme.error != previous.error || scheme.variant != previous.variant else { loaded.error = nil loaded.usesFallback = false return false @@ -88,13 +143,13 @@ enum ThemePalette { final class LauncherBackground: NSView { weak var canvas: NSView? private var amber = NSGradient(colors: [ - ThemePalette.accent.withAlphaComponent(0.18), - ThemePalette.accent.withAlphaComponent(0.065), + ThemePalette.accent.withAlphaComponent(0.18 * ThemePalette.glowStrength), + ThemePalette.accent.withAlphaComponent(0.065 * ThemePalette.glowStrength), ThemePalette.accent.withAlphaComponent(0), ])! private var ember = NSGradient(colors: [ - ThemePalette.glow.withAlphaComponent(0.26), - ThemePalette.glow.withAlphaComponent(0.10), + ThemePalette.glow.withAlphaComponent(0.26 * ThemePalette.glowStrength), + ThemePalette.glow.withAlphaComponent(0.10 * ThemePalette.glowStrength), ThemePalette.glow.withAlphaComponent(0), ])! @@ -115,13 +170,13 @@ final class LauncherBackground: NSView { @objc private func paletteChanged() { amber = NSGradient(colors: [ - ThemePalette.accent.withAlphaComponent(0.18), - ThemePalette.accent.withAlphaComponent(0.065), + ThemePalette.accent.withAlphaComponent(0.18 * ThemePalette.glowStrength), + ThemePalette.accent.withAlphaComponent(0.065 * ThemePalette.glowStrength), ThemePalette.accent.withAlphaComponent(0), ])! ember = NSGradient(colors: [ - ThemePalette.glow.withAlphaComponent(0.26), - ThemePalette.glow.withAlphaComponent(0.10), + ThemePalette.glow.withAlphaComponent(0.26 * ThemePalette.glowStrength), + ThemePalette.glow.withAlphaComponent(0.10 * ThemePalette.glowStrength), ThemePalette.glow.withAlphaComponent(0), ])! needsDisplay = true diff --git a/Sources/Conjure/LauncherWindowController.swift b/Sources/Conjure/LauncherWindowController.swift index 069f03e..6352e10 100644 --- a/Sources/Conjure/LauncherWindowController.swift +++ b/Sources/Conjure/LauncherWindowController.swift @@ -81,7 +81,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe window.titleVisibility = .hidden window.titlebarAppearsTransparent = true window.animationBehavior = .none - window.appearance = NSAppearance(named: .darkAqua) + window.appearance = ThemePalette.appearance window.backgroundColor = .clear window.isOpaque = false window.hasShadow = true @@ -403,6 +403,8 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe launcherViews = [header, sectionLabel, scrollView, emptyLabel, footer] themeObservation = ThemeObservation { [weak self, weak content] in guard let self else { return } + self.window?.appearance = ThemePalette.appearance + self.window?.attachedSheet?.appearance = ThemePalette.appearance self.searchField.applyTheme() (self.searchField.currentEditor() as? NSTextView)?.insertionPointColor = ThemePalette.accent self.searchSymbol.contentTintColor = ThemePalette.accent @@ -767,6 +769,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe dismiss() } catch { let alert = NSAlert(error: error) + alert.window.appearance = ThemePalette.appearance alert.messageText = "Couldn’t quit \(application.name)" if let window { alert.beginSheetModal(for: window) } } @@ -788,6 +791,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe dismiss(restoringFocus: false) } catch { let alert = NSAlert(error: error) + alert.window.appearance = ThemePalette.appearance alert.messageText = "Couldn’t open \(application.name)" if let window { await alert.beginSheetModal(for: window) @@ -858,6 +862,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe if dismissOnSuccess { dismiss() } } catch { let alert = NSAlert(error: error) + alert.window.appearance = ThemePalette.appearance alert.messageText = "Couldn’t run \(Application(command: command).name)" if let window, window.isVisible { await alert.beginSheetModal(for: window) @@ -939,7 +944,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe windowSize: previousFrame.size, visibleFrame: window.screen?.visibleFrame ?? previousFrame) window.styleMask = [.borderless] window.minSize = .zero - window.appearance = NSAppearance(named: .darkAqua) + window.appearance = ThemePalette.appearance window.setFrame(frame, display: true) if let material = content as? NSVisualEffectView { roundMaterialCorners(material, radius: 24) } } @@ -980,7 +985,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe if command == .notes { let savedSize = defaults.string(forKey: Self.notesSizeKey).map(NSSizeFromString) ?? NSSize(width: 570, height: 404) - window?.appearance = nil + window?.appearance = ThemePalette.appearance window?.styleMask = [.titled, .closable, .resizable, .fullSizeContentView] window?.minSize = NSSize(width: 570, height: 260) window?.standardWindowButton(.miniaturizeButton)?.isHidden = true diff --git a/Sources/Conjure/NotesController.swift b/Sources/Conjure/NotesController.swift index 33fa1bd..7fd71c0 100644 --- a/Sources/Conjure/NotesController.swift +++ b/Sources/Conjure/NotesController.swift @@ -345,6 +345,7 @@ final class NotesController: NSViewController, NSTextViewDelegate { do { let export = try NoteMarkdownExport(contents: editor.documentContents) let panel = NSSavePanel() + panel.appearance = ThemePalette.appearance panel.title = "Export as Markdown" panel.prompt = "Export" panel.canCreateDirectories = true @@ -376,6 +377,7 @@ final class NotesController: NSViewController, NSTextViewDelegate { guard let window = view.window, window.attachedSheet == nil, let id = selectedID else { return } actions.close(animated: false) let alert = NSAlert() + alert.window.appearance = ThemePalette.appearance alert.alertStyle = .warning alert.messageText = "Delete “\(noteTitle.stringValue)”?" alert.informativeText = "This permanently deletes the note and its embedded images. This cannot be undone." diff --git a/Sources/Conjure/NotesTextView.swift b/Sources/Conjure/NotesTextView.swift index 434a30a..74e43ab 100644 --- a/Sources/Conjure/NotesTextView.swift +++ b/Sources/Conjure/NotesTextView.swift @@ -516,11 +516,17 @@ final class NotesTextView: NSTextView { super.didChangeText() } - private static let highlighter: Highlighter = { - let value = Highlighter()! - value.setTheme("atom-one-dark", withFont: "Menlo-Regular", ofSize: 15) - return value - }() + private static let syntaxHighlighter = Highlighter()! + private static var highlightedAsLight: Bool? + private static var highlighter: Highlighter { + let isLight = ThemePalette.isLight + if highlightedAsLight != isLight { + syntaxHighlighter.setTheme(isLight ? "atom-one-light" : "atom-one-dark", + withFont: "Menlo-Regular", ofSize: 15) + highlightedAsLight = isLight + } + return syntaxHighlighter + } // ponytail: style all paragraphs; dirty-paragraph styling if large notes make editing slow. func applyDocumentStyle() { guard !styling, let storage = textStorage else { return } diff --git a/Sources/Conjure/SettingsWindowController.swift b/Sources/Conjure/SettingsWindowController.swift index b02cd05..cabe944 100644 --- a/Sources/Conjure/SettingsWindowController.swift +++ b/Sources/Conjure/SettingsWindowController.swift @@ -32,7 +32,7 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate { defer: false ) window.title = "Conjure Settings" - window.appearance = NSAppearance(named: .darkAqua) + window.appearance = ThemePalette.appearance window.titlebarAppearsTransparent = true window.backgroundColor = .clear window.isOpaque = false @@ -201,6 +201,8 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate { } themeObservation = ThemeObservation { [weak self] in guard let self else { return } + self.window?.appearance = ThemePalette.appearance + self.window?.attachedSheet?.appearance = ThemePalette.appearance for label in [heading, loginHeading, translationHeading] { label.textColor = ThemePalette.text } for label in [instructions, self.statusLabel, spotlight] { label.textColor = ThemePalette.secondaryText } self.errorLabel.textColor = ThemePalette.error @@ -234,6 +236,7 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate { setUninstallRecoveryReport(result) } else { result = report } let alert = NSAlert() + alert.window.appearance = ThemePalette.appearance alert.messageText = result.needsRecovery ? "Recovery needs attention" : "Recovery complete" alert.informativeText = result.messages.isEmpty ? "No pending files need recovery." : result.messages.joined(separator: "\n") await alert.beginSheetModal(for: window) diff --git a/Sources/Conjure/UninstallReviewController.swift b/Sources/Conjure/UninstallReviewController.swift index ebb98e5..34c2447 100644 --- a/Sources/Conjure/UninstallReviewController.swift +++ b/Sources/Conjure/UninstallReviewController.swift @@ -321,6 +321,7 @@ final class UninstallReviewController: NSViewController, NSSearchFieldDelegate, guard !urls.isEmpty else { return } let running = runningApplications() let alert = NSAlert() + alert.window.appearance = ThemePalette.appearance alert.alertStyle = .warning alert.messageText = "Uninstall \(application.name)?" alert.informativeText = "Move \(urls.count) selected files and folders to Trash." @@ -380,6 +381,7 @@ final class UninstallReviewController: NSViewController, NSSearchFieldDelegate, var canRecover = allowRecovery while true { let alert = NSAlert() + alert.window.appearance = ThemePalette.appearance alert.messageText = title alert.informativeText = message alert.addButton(withTitle: "OK") diff --git a/Tests/ConjureKitTests/ColorSchemeTests.swift b/Tests/ConjureKitTests/ColorSchemeTests.swift index d6ebe81..1a5edcd 100644 --- a/Tests/ConjureKitTests/ColorSchemeTests.swift +++ b/Tests/ConjureKitTests/ColorSchemeTests.swift @@ -71,6 +71,7 @@ func malformedColorConfigurationReportsItsPathAndReason() throws { ("colors.accent = 42", "colors.accent"), ("colors.accnet = '#123456'", "colors.accnet"), ("opacity = 0.5", "opacity"), + ("variant = 'light'", "variant"), ("colors = []", "colors"), ("colors = false", "colors"), ("theme = 'unknown'", "theme"), diff --git a/Tests/ConjureKitTests/LightAppearanceTests.swift b/Tests/ConjureKitTests/LightAppearanceTests.swift new file mode 100644 index 0000000..b3d5a02 --- /dev/null +++ b/Tests/ConjureKitTests/LightAppearanceTests.swift @@ -0,0 +1,194 @@ +import AppKit +import Testing +@testable import Conjure + +// Conjure roles generated by tint from base16-swamp-light. +private let swampLightTheme = ##"{"variant":"light","colors":{"background":"#f1e3d1","text":"#64513e","secondaryText":"#a0907d","accent":"#bf7979","glow":"#d09700","keyBackground":"#ddcebc","error":"#d09700"}}"## + +@Test @MainActor +func generatedLightMetadataAndManualLuminanceChooseTheMatchingAppearance() throws { + let directory = try lightConfigurationDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let theme = directory.appendingPathComponent("theme.json") + try Data("theme = 'tint'\n".utf8).write(to: directory.appendingPathComponent("config.toml")) + try Data(swampLightTheme.utf8).write(to: theme) + let light = try ColorScheme.load(from: directory) + #expect(light.isLight) + #expect(ThemePalette.State(scheme: light).appearance.name == .aqua) + + // Metadata wins even when manual colors disagree with the generated variant. + try Data("theme = 'tint'\ncolors.background = '#000000'\ncolors.text = '#ffffff'\n".utf8) + .write(to: directory.appendingPathComponent("config.toml")) + #expect(try ColorScheme.load(from: directory).isLight) + + try Data("colors.background = '#f1e3d1'\ncolors.text = '#64513e'\n".utf8) + .write(to: directory.appendingPathComponent("config.toml")) + let manual = try ColorScheme.load(from: directory) + #expect(manual.isLight) + #expect(ThemePalette.State(scheme: manual).appearance.name == .aqua) + + try Data("theme = 'tint'\n".utf8).write(to: directory.appendingPathComponent("config.toml")) + try Data(swampLightTheme.replacingOccurrences(of: "\"variant\":\"light\",", with: "").utf8).write(to: theme) + #expect(try ColorScheme.load(from: directory).isLight) + try FileManager.default.removeItem(at: directory.appendingPathComponent("config.toml")) + #expect(try !ColorScheme.load(from: directory).isLight) +} + +@Test(arguments: ["null", "true", "42", "[]", "{}", "\"Light\"", "\"darkAqua\"", "\"\""]) @MainActor +func invalidGeneratedVariantsReportTheThemePath(_ value: String) throws { + let directory = try lightConfigurationDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + try Data("theme = 'tint'\n".utf8).write(to: directory.appendingPathComponent("config.toml")) + let theme = directory.appendingPathComponent("theme.json") + try Data("{\"variant\":\(value),\"colors\":{}}".utf8).write(to: theme) + do { + _ = try ColorScheme.load(from: directory) + Issue.record("Expected invalid generated variant to be rejected") + } catch { + #expect(error.localizedDescription.contains(theme.path)) + #expect(error.localizedDescription.contains("variant")) + } +} + +@Test @MainActor +func variantOnlyReloadNotifiesConsumersAndChangesRenderedAppearance() throws { + let original = ThemePalette.loaded + let directory = try lightConfigurationDirectory() + defer { + ThemePalette.loaded = original + try? FileManager.default.removeItem(at: directory) + } + try Data("theme = 'tint'\n".utf8).write(to: directory.appendingPathComponent("config.toml")) + let theme = directory.appendingPathComponent("theme.json") + try Data(swampLightTheme.replacingOccurrences(of: "\"light\"", with: "\"dark\"").utf8).write(to: theme) + ThemePalette.reload(from: directory) + let changes = LightPaletteChanges() + let observer = NotificationCenter.default.addObserver(forName: ThemePalette.didChangeNotification, + object: nil, queue: .main) { _ in + MainActor.assumeIsolated { + changes.appearances.append(ThemePalette.appearance.name) + } + } + defer { NotificationCenter.default.removeObserver(observer) } + let before = try renderedBackground(over: .black) + try Data(swampLightTheme.utf8).write(to: theme) + #expect(ThemePalette.reload(from: directory)) + #expect(changes.appearances == [.aqua]) + #expect(!ThemePalette.reload(from: directory)) + #expect(changes.appearances.count == 1) + let after = try renderedBackground(over: .black) + let oldSurface = try #require(before.colorAt(x: 375, y: 240)) + let newSurface = try #require(after.colorAt(x: 375, y: 240)) + #expect(testLuminance(newSurface) > testLuminance(oldSurface)) +} + +@Test @MainActor +func lightSecondaryTextRemainsReadableAcrossRenderedGlowsWithoutChangingSourceColors() throws { + let original = ThemePalette.loaded + let directory = try lightConfigurationDirectory() + defer { + ThemePalette.loaded = original + try? FileManager.default.removeItem(at: directory) + } + try Data("theme = 'tint'\n".utf8).write(to: directory.appendingPathComponent("config.toml")) + try Data(swampLightTheme.utf8).write(to: directory.appendingPathComponent("theme.json")) + let source = try ColorScheme.load(from: directory) + ThemePalette.reload(from: directory) + #expect(ThemePalette.text == source.text) + #expect(ThemePalette.loaded.scheme.secondaryText == source.secondaryText) + #expect(testLuminance(ThemePalette.secondaryText) < testLuminance(source.secondaryText)) + for backing in [NSColor.black, .white] { + let bitmap = try renderedBackground(over: backing) + for y in stride(from: 0, to: bitmap.pixelsHigh, by: 12) { + for x in stride(from: 0, to: bitmap.pixelsWide, by: 12) { + let surface = try #require(bitmap.colorAt(x: x, y: y)) + let foreground = testLuminance(ThemePalette.secondaryText) + #expect((testLuminance(surface) + 0.05) / (foreground + 0.05) >= 4.5) + } + } + } + // An already-readable manual secondary color is not needlessly restyled. + try Data("theme = 'tint'\ncolors.secondaryText = '#302820'\n".utf8) + .write(to: directory.appendingPathComponent("config.toml")) + ThemePalette.reload(from: directory) + #expect(ThemePalette.secondaryText == ThemePalette.loaded.scheme.secondaryText) +} + +@Test @MainActor +func darkBackgroundRenderingAndSemanticColorsRemainUnchanged() throws { + let original = ThemePalette.loaded + defer { ThemePalette.loaded = original } + for scheme in [ColorScheme.sunset, customDarkScheme()] { + ThemePalette.loaded = ThemePalette.State(scheme: scheme) + #expect(ThemePalette.secondaryText == scheme.secondaryText) + #expect(ThemePalette.text == scheme.text) + #expect(ThemePalette.appearance.name == .darkAqua) + for backing in [NSColor.black, .white] { + let actual = try renderedBackground(over: backing) + let legacy = try renderedBackground(over: backing, legacy: scheme) + for y in stride(from: 0, to: actual.pixelsHigh, by: 12) { + for x in stride(from: 0, to: actual.pixelsWide, by: 12) { + #expect(actual.colorAt(x: x, y: y) == legacy.colorAt(x: x, y: y)) + } + } + } + } +} + +@MainActor private final class LightPaletteChanges { + var appearances: [NSAppearance.Name] = [] +} + +private func lightConfigurationDirectory() throws -> URL { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + return directory +} + +@MainActor private func customDarkScheme() -> ColorScheme { + var scheme = ColorScheme.sunset + scheme.background = NSColor(srgbRed: 0.04, green: 0.08, blue: 0.12, alpha: 1) + scheme.accent = .cyan + scheme.glow = .magenta + return scheme +} + +private func testLuminance(_ color: NSColor) -> CGFloat { + let rgb = color.usingColorSpace(.sRGB)! + func linear(_ value: CGFloat) -> CGFloat { + value <= 0.04045 ? value / 12.92 : pow((value + 0.055) / 1.055, 2.4) + } + return 0.2126 * linear(rgb.redComponent) + 0.7152 * linear(rgb.greenComponent) + 0.0722 * linear(rgb.blueComponent) +} + +@MainActor private func renderedBackground(over backing: NSColor, legacy scheme: ColorScheme? = nil) throws -> NSBitmapImageRep { + _ = NSApplication.shared + let bounds = NSRect(x: 0, y: 0, width: 750, height: 480) + let bitmap = try #require(NSBitmapImageRep(bitmapDataPlanes: nil, pixelsWide: 750, pixelsHigh: 480, + bitsPerSample: 8, samplesPerPixel: 4, hasAlpha: true, isPlanar: false, + colorSpaceName: .deviceRGB, bytesPerRow: 0, bitsPerPixel: 0)) + let context = try #require(NSGraphicsContext(bitmapImageRep: bitmap)) + NSGraphicsContext.saveGraphicsState() + defer { NSGraphicsContext.restoreGraphicsState() } + NSGraphicsContext.current = context + backing.setFill() + bounds.fill() + if let scheme { + scheme.background.withAlphaComponent(0.55).setFill() + bounds.fill() + let amber = try #require(NSGradient(colors: [scheme.accent.withAlphaComponent(0.18), + scheme.accent.withAlphaComponent(0.065), scheme.accent.withAlphaComponent(0)])) + let ember = try #require(NSGradient(colors: [scheme.glow.withAlphaComponent(0.26), + scheme.glow.withAlphaComponent(0.10), scheme.glow.withAlphaComponent(0)])) + let upper = NSPoint(x: bounds.width * 0.58, y: bounds.height * 0.94) + ember.draw(fromCenter: upper, radius: 0, toCenter: upper, radius: bounds.width * 0.40, options: []) + let lower = NSPoint(x: bounds.width * 0.88, y: bounds.height * 0.10) + amber.draw(fromCenter: lower, radius: 0, toCenter: lower, radius: bounds.width * 0.55, options: []) + let left = NSPoint(x: bounds.width * 0.08, y: bounds.height * 0.65) + context.cgContext.setAlpha(0.45) + amber.draw(fromCenter: left, radius: 0, toCenter: left, radius: bounds.width * 0.32, options: []) + } else { + LauncherBackground(frame: bounds).draw(bounds) + } + return bitmap +} diff --git a/Tests/ConjureKitTests/LightAppearanceViewsTests.swift b/Tests/ConjureKitTests/LightAppearanceViewsTests.swift new file mode 100644 index 0000000..adbf17f --- /dev/null +++ b/Tests/ConjureKitTests/LightAppearanceViewsTests.swift @@ -0,0 +1,137 @@ +import AppKit +import ConjureKit +import Testing +@testable import Conjure + +@Test @MainActor +func existingWindowsAndNotesFollowPaletteAppearanceWithoutResettingState() throws { + _ = NSApplication.shared + let originalPalette = ThemePalette.loaded + let originalAppearance = NSApp.appearance + defer { + NSApp.appearance = originalAppearance + ThemePalette.loaded = originalPalette + NotificationCenter.default.post(name: ThemePalette.didChangeNotification, object: nil) + } + let suite = "Conjure.LightAppearanceViews.\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suite)) + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(suite) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { + defaults.removePersistentDomain(forName: suite) + try? FileManager.default.removeItem(at: directory) + } + try Data("theme = \"tint\"".utf8).write(to: directory.appendingPathComponent("config.toml")) + func writePalette(light: Bool) throws { + try Data(""" + {"variant":"\(light ? "light" : "dark")","colors":{ + "background":"\(light ? "#ffffff" : "#201714")", + "text":"\(light ? "#112233" : "#ffe3b5")", + "secondaryText":"\(light ? "#667788" : "#d9c7a6")" + }} + """.utf8).write(to: directory.appendingPathComponent("theme.json")) + } + func descendants(_ view: NSView) -> [NSView] { [view] + view.subviews.flatMap(descendants) } + func appearanceName(_ appearance: NSAppearance) -> NSAppearance.Name? { + appearance.bestMatch(from: [.aqua, .darkAqua]) + } + try writePalette(light: false) + ThemePalette.reload(from: directory) + NSApp.appearance = NSAppearance(named: .aqua) + let launcher = LauncherWindowController( + history: LaunchHistory(defaults: defaults, now: { Date(timeIntervalSinceReferenceDate: 12_000) }), + defaults: defaults, notesDirectory: directory.appendingPathComponent("notes")) + let window = try #require(launcher.window) + let content = try #require(window.contentView) + defer { window.orderOut(nil) } + #expect(appearanceName(window.effectiveAppearance) == .darkAqua) + let search = try #require(descendants(content).compactMap { $0 as? NSSearchField }.first) + search.stringValue = "notes" + launcher.controlTextDidChange(Notification(name: NSControl.textDidChangeNotification, object: search)) + _ = launcher.control(search, textView: NSTextView(), doCommandBy: #selector(NSResponder.insertNewline(_:))) + let editor = try #require(descendants(content).compactMap { $0 as? NotesTextView }.first) + let document = NSMutableAttributedString(string: "let answer = 42\nplain") + let code = NSRange(location: 0, length: 16) + document.addAttribute(NoteAttributes.block, value: "code", range: code) + document.addAttribute(NoteAttributes.language, value: "swift", range: code) + editor.setDocumentContents(document) + editor.beginInsertionSession() + editor.insertText("!", replacementRange: NSRange(location: document.length, length: 0)) + editor.endInsertionSession() + let selection = NSRange(location: 1, length: 2) + editor.setSelectedRange(selection) + let text = editor.string + let undoGrouping = editor.undoManager?.groupingLevel + let frame = window.frame + let style = window.styleMask + let settings = SettingsWindowController(shortcuts: GlobalShortcuts(defaults: defaults) { _ in }) + let settingsWindow = try #require(settings.window) + defer { settingsWindow.close() } + let settingsContent = try #require(settingsWindow.contentView) + let destination = try #require(descendants(settingsContent).compactMap { $0 as? NSPopUpButton }.first) + destination.selectItem(at: 2) + let credential = try #require(descendants(settingsContent).compactMap { $0 as? NSSecureTextField }.first) + credential.stringValue = "unsaved-value" + let anchor = try #require(descendants(content).compactMap { $0 as? NSButton }.first { + $0.title == "Actions" && $0.target is NotesController + }) + let notes = try #require(anchor.target as? NotesController) + let popup = ApplicationActionsController() + popup.showLanguages([("en", "English"), ("ja", "Japanese")], selected: "en", source: true, relativeTo: anchor) + defer { popup.close(animated: false) } + let popupSearch = try #require(descendants(popup.view).compactMap { $0 as? NSSearchField }.first) + popupSearch.stringValue = "Eng" + popup.controlTextDidChange(Notification(name: NSControl.textDidChangeNotification, object: popupSearch)) + let table = try #require(descendants(popup.view).compactMap { $0 as? NSTableView }.first) + let rows = table.numberOfRows + let selectedRows = table.selectedRowIndexes + let material = try #require(popup.view.subviews.compactMap { $0 as? NSVisualEffectView }.first) + #expect(appearanceName(editor.effectiveAppearance) == .darkAqua) + #expect(appearanceName(settingsWindow.effectiveAppearance) == .darkAqua) + #expect(appearanceName(material.effectiveAppearance) == .darkAqua) + + // Deliberately oppose the light palette: app/system inheritance is not the contract. + NSApp.appearance = NSAppearance(named: .darkAqua) + for light in [true, false] { + try writePalette(light: light) + #expect(ThemePalette.reload(from: directory)) + let expected: NSAppearance.Name = light ? .aqua : .darkAqua + for appearance in [window.effectiveAppearance, content.effectiveAppearance, editor.effectiveAppearance, + settingsWindow.effectiveAppearance, settingsContent.effectiveAppearance, + popup.view.effectiveAppearance, material.effectiveAppearance] { + #expect(appearanceName(appearance) == expected) + } + #expect(launcher.window === window) + #expect(window.frame == frame) + #expect(window.styleMask == style) + #expect(editor.string == text) + #expect(editor.selectedRange() == selection) + #expect(editor.undoManager?.groupingLevel == undoGrouping) + #expect(editor.undoManager?.canUndo == true) + #expect(editor.textStorage?.attribute(NoteAttributes.block, at: 0, effectiveRange: nil) as? String == "code") + #expect(editor.textStorage?.attribute(NoteAttributes.language, at: 0, effectiveRange: nil) as? String == "swift") + let keyword = try #require((editor.textStorage?.attribute(.foregroundColor, at: 0, effectiveRange: nil) as? NSColor)?.usingColorSpace(.sRGB)) + let rgb: (CGFloat, CGFloat, CGFloat) = light ? (166, 38, 164) : (198, 120, 221) + #expect(abs(keyword.redComponent * 255 - rgb.0) < 1) + #expect(abs(keyword.greenComponent * 255 - rgb.1) < 1) + #expect(abs(keyword.blueComponent * 255 - rgb.2) < 1) + #expect(destination.indexOfSelectedItem == 2) + #expect(credential.stringValue == "unsaved-value") + #expect(popup.isShown) + #expect(popupSearch.stringValue == "Eng") + #expect(table.numberOfRows == rows) + #expect(table.selectedRowIndexes == selectedRows) + #expect(material.layer?.borderColor == ThemePalette.secondaryText.withAlphaComponent(0.22).cgColor) + #expect(material.layer?.borderColor?.alpha == 0.22) + } + popup.close(animated: false) + editor.undoManager?.undo() + #expect(editor.string == document.string) + try writePalette(light: true) + #expect(ThemePalette.reload(from: directory)) + notes.goBack() + #expect(window.styleMask == [.borderless]) + #expect(appearanceName(window.effectiveAppearance) == .aqua) + #expect(appearanceName(content.effectiveAppearance) == .aqua) + #expect(search.stringValue == "notes") +} diff --git a/Tests/ConjureKitTests/LiveColorSchemeTests.swift b/Tests/ConjureKitTests/LiveColorSchemeTests.swift index 96fd21e..bd8750d 100644 --- a/Tests/ConjureKitTests/LiveColorSchemeTests.swift +++ b/Tests/ConjureKitTests/LiveColorSchemeTests.swift @@ -28,7 +28,7 @@ func livePaletteReloadsGeneratedColorsKeepsOverridesAndRetainsLastValidPalette() } defer { NotificationCenter.default.removeObserver(observer) } try Data("theme = 'tint'\ncolors.accent = '#ffffff'\n".utf8).write(to: config) - try Data(##"{"colors":{"background":"#123456","accent":"#ff0000","text":"#000000"}}"##.utf8) + try Data(##"{"variant":"dark","colors":{"background":"#123456","accent":"#ff0000","text":"#000000"}}"##.utf8) .write(to: theme) #expect(ThemePalette.reload(from: directory)) let firstBackground = ThemePalette.background diff --git a/Tests/ConjureKitTests/LiveThemeViewsTests.swift b/Tests/ConjureKitTests/LiveThemeViewsTests.swift index e3e3add..22c5193 100644 --- a/Tests/ConjureKitTests/LiveThemeViewsTests.swift +++ b/Tests/ConjureKitTests/LiveThemeViewsTests.swift @@ -48,7 +48,7 @@ func livePaletteRepaintsNotesWithoutChangingContentSelectionOrUndo() throws { #expect(editor.textStorage?.attribute(.foregroundColor, at: inline.location + 1, effectiveRange: nil) as? NSColor == ThemePalette.accent) #expect(editor.textStorage?.attribute(.backgroundColor, at: inline.location + 1, effectiveRange: nil) as? NSColor == ThemePalette.selectionFill) #expect(editor.textStorage?.attribute(.backgroundColor, at: code.location + 1, effectiveRange: nil) as? NSColor == ThemePalette.selectionFill) - #expect(editor.textStorage?.attribute(.foregroundColor, at: code.location + 1, effectiveRange: nil) as? NSColor == codeColor) + #expect(editor.textStorage?.attribute(.foregroundColor, at: code.location + 1, effectiveRange: nil) as? NSColor != codeColor) #expect(editor.textStorage?.attribute(.font, at: 1, effectiveRange: nil) as? NSFont == boldFont) #expect(editor.textStorage?.attribute(NoteAttributes.inlineCode, at: inline.location + 1, effectiveRange: nil) as? Bool == true) #expect(editor.textStorage?.attribute(NoteAttributes.language, at: code.location + 1, effectiveRange: nil) as? String == "swift")