fix: render light palettes with matching appearance
This commit is contained in:
@@ -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) }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user