fix: render light palettes with matching appearance
This commit is contained in:
@@ -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.
|
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
|
### 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.
|
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.
|
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
|
## 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.
|
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.
|
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.
|
||||||
|
|
||||||
|
|||||||
@@ -55,11 +55,11 @@ final class ThemeObservation {
|
|||||||
nonisolated(unsafe) private let token: NSObjectProtocol
|
nonisolated(unsafe) private let token: NSObjectProtocol
|
||||||
|
|
||||||
init(_ apply: @escaping @MainActor () -> Void) {
|
init(_ apply: @escaping @MainActor () -> Void) {
|
||||||
apply()
|
ThemePalette.appearance.performAsCurrentDrawingAppearance(apply)
|
||||||
token = NotificationCenter.default.addObserver(
|
token = NotificationCenter.default.addObserver(
|
||||||
forName: ThemePalette.didChangeNotification, object: nil, queue: .main
|
forName: ThemePalette.didChangeNotification, object: nil, queue: .main
|
||||||
) { _ in
|
) { _ in
|
||||||
MainActor.assumeIsolated { apply() }
|
MainActor.assumeIsolated { ThemePalette.appearance.performAsCurrentDrawingAppearance(apply) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -94,6 +94,7 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat
|
|||||||
ActionEntry(title: "Export as Markdown…", symbol: "square.and.arrow.up")
|
ActionEntry(title: "Export as Markdown…", symbol: "square.and.arrow.up")
|
||||||
]
|
]
|
||||||
view = ActionsPanelView()
|
view = ActionsPanelView()
|
||||||
|
view.appearance = ThemePalette.appearance
|
||||||
view.wantsLayer = true
|
view.wantsLayer = true
|
||||||
view.layer?.shadowColor = NSColor.black.cgColor
|
view.layer?.shadowColor = NSColor.black.cgColor
|
||||||
view.layer?.shadowOpacity = 0.35
|
view.layer?.shadowOpacity = 0.35
|
||||||
@@ -215,6 +216,7 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat
|
|||||||
updateSelection()
|
updateSelection()
|
||||||
themeObservation = ThemeObservation { [weak self, weak content] in
|
themeObservation = ThemeObservation { [weak self, weak content] in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
|
self.view.appearance = ThemePalette.appearance
|
||||||
self.search.applyTheme()
|
self.search.applyTheme()
|
||||||
self.applicationLabel.textColor = ThemePalette.secondaryText
|
self.applicationLabel.textColor = ThemePalette.secondaryText
|
||||||
self.empty.textColor = ThemePalette.secondaryText
|
self.empty.textColor = ThemePalette.secondaryText
|
||||||
|
|||||||
@@ -10,6 +10,28 @@ struct ColorScheme {
|
|||||||
var glow: NSColor
|
var glow: NSColor
|
||||||
var keyBackground: NSColor
|
var keyBackground: NSColor
|
||||||
var error: 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(
|
static let sunset = ColorScheme(
|
||||||
background: NSColor(srgbRed: 0.12, green: 0.09, blue: 0.08, alpha: 1),
|
background: NSColor(srgbRed: 0.12, green: 0.09, blue: 0.08, alpha: 1),
|
||||||
@@ -49,7 +71,13 @@ struct ColorScheme {
|
|||||||
if theme == "tint" {
|
if theme == "tint" {
|
||||||
let themeURL = directory.appendingPathComponent("theme.json")
|
let themeURL = directory.appendingPathComponent("theme.json")
|
||||||
if let generated = try readObject(at: themeURL, missingAllowed: false) {
|
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)
|
try result.applyColors(from: generated, at: themeURL)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ final class LauncherAlignmentGuides {
|
|||||||
private let view = AlignmentGuideView()
|
private let view = AlignmentGuideView()
|
||||||
private lazy var overlay: NSPanel = {
|
private lazy var overlay: NSPanel = {
|
||||||
let panel = NSPanel(contentRect: .zero, styleMask: [.borderless, .nonactivatingPanel], backing: .buffered, defer: false)
|
let panel = NSPanel(contentRect: .zero, styleMask: [.borderless, .nonactivatingPanel], backing: .buffered, defer: false)
|
||||||
|
panel.appearance = ThemePalette.appearance
|
||||||
panel.backgroundColor = .clear
|
panel.backgroundColor = .clear
|
||||||
panel.isOpaque = false
|
panel.isOpaque = false
|
||||||
panel.hasShadow = false
|
panel.hasShadow = false
|
||||||
@@ -20,6 +21,7 @@ final class LauncherAlignmentGuides {
|
|||||||
|
|
||||||
func begin(for window: NSWindow) {
|
func begin(for window: NSWindow) {
|
||||||
guard !dragging, window.screen != nil else { return }
|
guard !dragging, window.screen != nil else { return }
|
||||||
|
overlay.appearance = ThemePalette.appearance
|
||||||
dragging = true
|
dragging = true
|
||||||
update(for: window)
|
update(for: window)
|
||||||
overlay.level = window.level
|
overlay.level = window.level
|
||||||
@@ -66,6 +68,7 @@ private final class AlignmentGuideView: NSView {
|
|||||||
override init(frame frameRect: NSRect) {
|
override init(frame frameRect: NSRect) {
|
||||||
super.init(frame: frameRect)
|
super.init(frame: frameRect)
|
||||||
themeObservation = ThemeObservation { [weak self] in
|
themeObservation = ThemeObservation { [weak self] in
|
||||||
|
self?.window?.appearance = ThemePalette.appearance
|
||||||
self?.muted = ThemePalette.accent.withAlphaComponent(0.4).cgColor
|
self?.muted = ThemePalette.accent.withAlphaComponent(0.4).cgColor
|
||||||
self?.aligned = ThemePalette.accent.withAlphaComponent(0.95).cgColor
|
self?.aligned = ThemePalette.accent.withAlphaComponent(0.95).cgColor
|
||||||
self?.needsDisplay = true
|
self?.needsDisplay = true
|
||||||
|
|||||||
@@ -2,10 +2,15 @@ import AppKit
|
|||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
enum ThemePalette {
|
enum ThemePalette {
|
||||||
|
@MainActor
|
||||||
struct State {
|
struct State {
|
||||||
let scheme: ColorScheme
|
let scheme: ColorScheme
|
||||||
var error: String?
|
var error: String?
|
||||||
var usesFallback: Bool
|
var usesFallback: Bool
|
||||||
|
let isLight: Bool
|
||||||
|
let appearance: NSAppearance
|
||||||
|
let secondaryText: NSColor
|
||||||
|
let glowStrength: CGFloat
|
||||||
let background: NSColor
|
let background: NSColor
|
||||||
let selectionFill: NSColor
|
let selectionFill: NSColor
|
||||||
let selectionBorder: NSColor
|
let selectionBorder: NSColor
|
||||||
@@ -15,11 +20,58 @@ enum ThemePalette {
|
|||||||
self.scheme = scheme
|
self.scheme = scheme
|
||||||
self.error = error
|
self.error = error
|
||||||
self.usesFallback = usesFallback
|
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)
|
selectionFill = scheme.accent.withAlphaComponent(0.12)
|
||||||
selectionBorder = scheme.accent.withAlphaComponent(0.30)
|
selectionBorder = scheme.accent.withAlphaComponent(0.30)
|
||||||
keyBackground = scheme.keyBackground.withAlphaComponent(0.5)
|
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 = {
|
static var loaded: State = {
|
||||||
@@ -35,13 +87,16 @@ enum ThemePalette {
|
|||||||
|
|
||||||
static let didChangeNotification = Notification.Name("ConjureThemePaletteDidChange")
|
static let didChangeNotification = Notification.Name("ConjureThemePaletteDidChange")
|
||||||
static var configurationError: String? { loaded.error }
|
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 background: NSColor { loaded.background }
|
||||||
static var text: NSColor { loaded.scheme.text }
|
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 accent: NSColor { loaded.scheme.accent }
|
||||||
static var selectionFill: NSColor { loaded.selectionFill }
|
static var selectionFill: NSColor { loaded.selectionFill }
|
||||||
static var selectionBorder: NSColor { loaded.selectionBorder }
|
static var selectionBorder: NSColor { loaded.selectionBorder }
|
||||||
static var glow: NSColor { loaded.scheme.glow }
|
static var glow: NSColor { loaded.scheme.glow }
|
||||||
|
static var glowStrength: CGFloat { loaded.glowStrength }
|
||||||
static var keyBackground: NSColor { loaded.keyBackground }
|
static var keyBackground: NSColor { loaded.keyBackground }
|
||||||
static var error: NSColor { loaded.scheme.error }
|
static var error: NSColor { loaded.scheme.error }
|
||||||
|
|
||||||
@@ -66,7 +121,7 @@ enum ThemePalette {
|
|||||||
guard scheme.background != previous.background || scheme.text != previous.text
|
guard scheme.background != previous.background || scheme.text != previous.text
|
||||||
|| scheme.secondaryText != previous.secondaryText || scheme.accent != previous.accent
|
|| scheme.secondaryText != previous.secondaryText || scheme.accent != previous.accent
|
||||||
|| scheme.glow != previous.glow || scheme.keyBackground != previous.keyBackground
|
|| 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.error = nil
|
||||||
loaded.usesFallback = false
|
loaded.usesFallback = false
|
||||||
return false
|
return false
|
||||||
@@ -88,13 +143,13 @@ enum ThemePalette {
|
|||||||
final class LauncherBackground: NSView {
|
final class LauncherBackground: NSView {
|
||||||
weak var canvas: NSView?
|
weak var canvas: NSView?
|
||||||
private var amber = NSGradient(colors: [
|
private var amber = NSGradient(colors: [
|
||||||
ThemePalette.accent.withAlphaComponent(0.18),
|
ThemePalette.accent.withAlphaComponent(0.18 * ThemePalette.glowStrength),
|
||||||
ThemePalette.accent.withAlphaComponent(0.065),
|
ThemePalette.accent.withAlphaComponent(0.065 * ThemePalette.glowStrength),
|
||||||
ThemePalette.accent.withAlphaComponent(0),
|
ThemePalette.accent.withAlphaComponent(0),
|
||||||
])!
|
])!
|
||||||
private var ember = NSGradient(colors: [
|
private var ember = NSGradient(colors: [
|
||||||
ThemePalette.glow.withAlphaComponent(0.26),
|
ThemePalette.glow.withAlphaComponent(0.26 * ThemePalette.glowStrength),
|
||||||
ThemePalette.glow.withAlphaComponent(0.10),
|
ThemePalette.glow.withAlphaComponent(0.10 * ThemePalette.glowStrength),
|
||||||
ThemePalette.glow.withAlphaComponent(0),
|
ThemePalette.glow.withAlphaComponent(0),
|
||||||
])!
|
])!
|
||||||
|
|
||||||
@@ -115,13 +170,13 @@ final class LauncherBackground: NSView {
|
|||||||
|
|
||||||
@objc private func paletteChanged() {
|
@objc private func paletteChanged() {
|
||||||
amber = NSGradient(colors: [
|
amber = NSGradient(colors: [
|
||||||
ThemePalette.accent.withAlphaComponent(0.18),
|
ThemePalette.accent.withAlphaComponent(0.18 * ThemePalette.glowStrength),
|
||||||
ThemePalette.accent.withAlphaComponent(0.065),
|
ThemePalette.accent.withAlphaComponent(0.065 * ThemePalette.glowStrength),
|
||||||
ThemePalette.accent.withAlphaComponent(0),
|
ThemePalette.accent.withAlphaComponent(0),
|
||||||
])!
|
])!
|
||||||
ember = NSGradient(colors: [
|
ember = NSGradient(colors: [
|
||||||
ThemePalette.glow.withAlphaComponent(0.26),
|
ThemePalette.glow.withAlphaComponent(0.26 * ThemePalette.glowStrength),
|
||||||
ThemePalette.glow.withAlphaComponent(0.10),
|
ThemePalette.glow.withAlphaComponent(0.10 * ThemePalette.glowStrength),
|
||||||
ThemePalette.glow.withAlphaComponent(0),
|
ThemePalette.glow.withAlphaComponent(0),
|
||||||
])!
|
])!
|
||||||
needsDisplay = true
|
needsDisplay = true
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
|
|||||||
window.titleVisibility = .hidden
|
window.titleVisibility = .hidden
|
||||||
window.titlebarAppearsTransparent = true
|
window.titlebarAppearsTransparent = true
|
||||||
window.animationBehavior = .none
|
window.animationBehavior = .none
|
||||||
window.appearance = NSAppearance(named: .darkAqua)
|
window.appearance = ThemePalette.appearance
|
||||||
window.backgroundColor = .clear
|
window.backgroundColor = .clear
|
||||||
window.isOpaque = false
|
window.isOpaque = false
|
||||||
window.hasShadow = true
|
window.hasShadow = true
|
||||||
@@ -403,6 +403,8 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
|
|||||||
launcherViews = [header, sectionLabel, scrollView, emptyLabel, footer]
|
launcherViews = [header, sectionLabel, scrollView, emptyLabel, footer]
|
||||||
themeObservation = ThemeObservation { [weak self, weak content] in
|
themeObservation = ThemeObservation { [weak self, weak content] in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
|
self.window?.appearance = ThemePalette.appearance
|
||||||
|
self.window?.attachedSheet?.appearance = ThemePalette.appearance
|
||||||
self.searchField.applyTheme()
|
self.searchField.applyTheme()
|
||||||
(self.searchField.currentEditor() as? NSTextView)?.insertionPointColor = ThemePalette.accent
|
(self.searchField.currentEditor() as? NSTextView)?.insertionPointColor = ThemePalette.accent
|
||||||
self.searchSymbol.contentTintColor = ThemePalette.accent
|
self.searchSymbol.contentTintColor = ThemePalette.accent
|
||||||
@@ -767,6 +769,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
|
|||||||
dismiss()
|
dismiss()
|
||||||
} catch {
|
} catch {
|
||||||
let alert = NSAlert(error: error)
|
let alert = NSAlert(error: error)
|
||||||
|
alert.window.appearance = ThemePalette.appearance
|
||||||
alert.messageText = "Couldn’t quit \(application.name)"
|
alert.messageText = "Couldn’t quit \(application.name)"
|
||||||
if let window { alert.beginSheetModal(for: window) }
|
if let window { alert.beginSheetModal(for: window) }
|
||||||
}
|
}
|
||||||
@@ -788,6 +791,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
|
|||||||
dismiss(restoringFocus: false)
|
dismiss(restoringFocus: false)
|
||||||
} catch {
|
} catch {
|
||||||
let alert = NSAlert(error: error)
|
let alert = NSAlert(error: error)
|
||||||
|
alert.window.appearance = ThemePalette.appearance
|
||||||
alert.messageText = "Couldn’t open \(application.name)"
|
alert.messageText = "Couldn’t open \(application.name)"
|
||||||
if let window {
|
if let window {
|
||||||
await alert.beginSheetModal(for: window)
|
await alert.beginSheetModal(for: window)
|
||||||
@@ -858,6 +862,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
|
|||||||
if dismissOnSuccess { dismiss() }
|
if dismissOnSuccess { dismiss() }
|
||||||
} catch {
|
} catch {
|
||||||
let alert = NSAlert(error: error)
|
let alert = NSAlert(error: error)
|
||||||
|
alert.window.appearance = ThemePalette.appearance
|
||||||
alert.messageText = "Couldn’t run \(Application(command: command).name)"
|
alert.messageText = "Couldn’t run \(Application(command: command).name)"
|
||||||
if let window, window.isVisible {
|
if let window, window.isVisible {
|
||||||
await alert.beginSheetModal(for: window)
|
await alert.beginSheetModal(for: window)
|
||||||
@@ -939,7 +944,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
|
|||||||
windowSize: previousFrame.size, visibleFrame: window.screen?.visibleFrame ?? previousFrame)
|
windowSize: previousFrame.size, visibleFrame: window.screen?.visibleFrame ?? previousFrame)
|
||||||
window.styleMask = [.borderless]
|
window.styleMask = [.borderless]
|
||||||
window.minSize = .zero
|
window.minSize = .zero
|
||||||
window.appearance = NSAppearance(named: .darkAqua)
|
window.appearance = ThemePalette.appearance
|
||||||
window.setFrame(frame, display: true)
|
window.setFrame(frame, display: true)
|
||||||
if let material = content as? NSVisualEffectView { roundMaterialCorners(material, radius: 24) }
|
if let material = content as? NSVisualEffectView { roundMaterialCorners(material, radius: 24) }
|
||||||
}
|
}
|
||||||
@@ -980,7 +985,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
|
|||||||
if command == .notes {
|
if command == .notes {
|
||||||
let savedSize = defaults.string(forKey: Self.notesSizeKey).map(NSSizeFromString)
|
let savedSize = defaults.string(forKey: Self.notesSizeKey).map(NSSizeFromString)
|
||||||
?? NSSize(width: 570, height: 404)
|
?? NSSize(width: 570, height: 404)
|
||||||
window?.appearance = nil
|
window?.appearance = ThemePalette.appearance
|
||||||
window?.styleMask = [.titled, .closable, .resizable, .fullSizeContentView]
|
window?.styleMask = [.titled, .closable, .resizable, .fullSizeContentView]
|
||||||
window?.minSize = NSSize(width: 570, height: 260)
|
window?.minSize = NSSize(width: 570, height: 260)
|
||||||
window?.standardWindowButton(.miniaturizeButton)?.isHidden = true
|
window?.standardWindowButton(.miniaturizeButton)?.isHidden = true
|
||||||
|
|||||||
@@ -345,6 +345,7 @@ final class NotesController: NSViewController, NSTextViewDelegate {
|
|||||||
do {
|
do {
|
||||||
let export = try NoteMarkdownExport(contents: editor.documentContents)
|
let export = try NoteMarkdownExport(contents: editor.documentContents)
|
||||||
let panel = NSSavePanel()
|
let panel = NSSavePanel()
|
||||||
|
panel.appearance = ThemePalette.appearance
|
||||||
panel.title = "Export as Markdown"
|
panel.title = "Export as Markdown"
|
||||||
panel.prompt = "Export"
|
panel.prompt = "Export"
|
||||||
panel.canCreateDirectories = true
|
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 }
|
guard let window = view.window, window.attachedSheet == nil, let id = selectedID else { return }
|
||||||
actions.close(animated: false)
|
actions.close(animated: false)
|
||||||
let alert = NSAlert()
|
let alert = NSAlert()
|
||||||
|
alert.window.appearance = ThemePalette.appearance
|
||||||
alert.alertStyle = .warning
|
alert.alertStyle = .warning
|
||||||
alert.messageText = "Delete “\(noteTitle.stringValue)”?"
|
alert.messageText = "Delete “\(noteTitle.stringValue)”?"
|
||||||
alert.informativeText = "This permanently deletes the note and its embedded images. This cannot be undone."
|
alert.informativeText = "This permanently deletes the note and its embedded images. This cannot be undone."
|
||||||
|
|||||||
@@ -516,11 +516,17 @@ final class NotesTextView: NSTextView {
|
|||||||
super.didChangeText()
|
super.didChangeText()
|
||||||
}
|
}
|
||||||
|
|
||||||
private static let highlighter: Highlighter = {
|
private static let syntaxHighlighter = Highlighter()!
|
||||||
let value = Highlighter()!
|
private static var highlightedAsLight: Bool?
|
||||||
value.setTheme("atom-one-dark", withFont: "Menlo-Regular", ofSize: 15)
|
private static var highlighter: Highlighter {
|
||||||
return value
|
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.
|
// ponytail: style all paragraphs; dirty-paragraph styling if large notes make editing slow.
|
||||||
func applyDocumentStyle() {
|
func applyDocumentStyle() {
|
||||||
guard !styling, let storage = textStorage else { return }
|
guard !styling, let storage = textStorage else { return }
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate {
|
|||||||
defer: false
|
defer: false
|
||||||
)
|
)
|
||||||
window.title = "Conjure Settings"
|
window.title = "Conjure Settings"
|
||||||
window.appearance = NSAppearance(named: .darkAqua)
|
window.appearance = ThemePalette.appearance
|
||||||
window.titlebarAppearsTransparent = true
|
window.titlebarAppearsTransparent = true
|
||||||
window.backgroundColor = .clear
|
window.backgroundColor = .clear
|
||||||
window.isOpaque = false
|
window.isOpaque = false
|
||||||
@@ -201,6 +201,8 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate {
|
|||||||
}
|
}
|
||||||
themeObservation = ThemeObservation { [weak self] in
|
themeObservation = ThemeObservation { [weak self] in
|
||||||
guard let self else { return }
|
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 [heading, loginHeading, translationHeading] { label.textColor = ThemePalette.text }
|
||||||
for label in [instructions, self.statusLabel, spotlight] { label.textColor = ThemePalette.secondaryText }
|
for label in [instructions, self.statusLabel, spotlight] { label.textColor = ThemePalette.secondaryText }
|
||||||
self.errorLabel.textColor = ThemePalette.error
|
self.errorLabel.textColor = ThemePalette.error
|
||||||
@@ -234,6 +236,7 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate {
|
|||||||
setUninstallRecoveryReport(result)
|
setUninstallRecoveryReport(result)
|
||||||
} else { result = report }
|
} else { result = report }
|
||||||
let alert = NSAlert()
|
let alert = NSAlert()
|
||||||
|
alert.window.appearance = ThemePalette.appearance
|
||||||
alert.messageText = result.needsRecovery ? "Recovery needs attention" : "Recovery complete"
|
alert.messageText = result.needsRecovery ? "Recovery needs attention" : "Recovery complete"
|
||||||
alert.informativeText = result.messages.isEmpty ? "No pending files need recovery." : result.messages.joined(separator: "\n")
|
alert.informativeText = result.messages.isEmpty ? "No pending files need recovery." : result.messages.joined(separator: "\n")
|
||||||
await alert.beginSheetModal(for: window)
|
await alert.beginSheetModal(for: window)
|
||||||
|
|||||||
@@ -321,6 +321,7 @@ final class UninstallReviewController: NSViewController, NSSearchFieldDelegate,
|
|||||||
guard !urls.isEmpty else { return }
|
guard !urls.isEmpty else { return }
|
||||||
let running = runningApplications()
|
let running = runningApplications()
|
||||||
let alert = NSAlert()
|
let alert = NSAlert()
|
||||||
|
alert.window.appearance = ThemePalette.appearance
|
||||||
alert.alertStyle = .warning
|
alert.alertStyle = .warning
|
||||||
alert.messageText = "Uninstall \(application.name)?"
|
alert.messageText = "Uninstall \(application.name)?"
|
||||||
alert.informativeText = "Move \(urls.count) selected files and folders to Trash."
|
alert.informativeText = "Move \(urls.count) selected files and folders to Trash."
|
||||||
@@ -380,6 +381,7 @@ final class UninstallReviewController: NSViewController, NSSearchFieldDelegate,
|
|||||||
var canRecover = allowRecovery
|
var canRecover = allowRecovery
|
||||||
while true {
|
while true {
|
||||||
let alert = NSAlert()
|
let alert = NSAlert()
|
||||||
|
alert.window.appearance = ThemePalette.appearance
|
||||||
alert.messageText = title
|
alert.messageText = title
|
||||||
alert.informativeText = message
|
alert.informativeText = message
|
||||||
alert.addButton(withTitle: "OK")
|
alert.addButton(withTitle: "OK")
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ func malformedColorConfigurationReportsItsPathAndReason() throws {
|
|||||||
("colors.accent = 42", "colors.accent"),
|
("colors.accent = 42", "colors.accent"),
|
||||||
("colors.accnet = '#123456'", "colors.accnet"),
|
("colors.accnet = '#123456'", "colors.accnet"),
|
||||||
("opacity = 0.5", "opacity"),
|
("opacity = 0.5", "opacity"),
|
||||||
|
("variant = 'light'", "variant"),
|
||||||
("colors = []", "colors"),
|
("colors = []", "colors"),
|
||||||
("colors = false", "colors"),
|
("colors = false", "colors"),
|
||||||
("theme = 'unknown'", "theme"),
|
("theme = 'unknown'", "theme"),
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
@@ -28,7 +28,7 @@ func livePaletteReloadsGeneratedColorsKeepsOverridesAndRetainsLastValidPalette()
|
|||||||
}
|
}
|
||||||
defer { NotificationCenter.default.removeObserver(observer) }
|
defer { NotificationCenter.default.removeObserver(observer) }
|
||||||
try Data("theme = 'tint'\ncolors.accent = '#ffffff'\n".utf8).write(to: config)
|
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)
|
.write(to: theme)
|
||||||
#expect(ThemePalette.reload(from: directory))
|
#expect(ThemePalette.reload(from: directory))
|
||||||
let firstBackground = ThemePalette.background
|
let firstBackground = ThemePalette.background
|
||||||
|
|||||||
@@ -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(.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: 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(.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(.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.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")
|
#expect(editor.textStorage?.attribute(NoteAttributes.language, at: code.location + 1, effectiveRange: nil) as? String == "swift")
|
||||||
|
|||||||
Reference in New Issue
Block a user