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 let keyBackground: NSColor init(scheme: ColorScheme, error: String? = nil, usesFallback: Bool = false) { self.scheme = scheme self.error = error self.usesFallback = usesFallback 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 = { do { return State(scheme: try ColorScheme.load(from: ColorScheme.configurationDirectory)) } catch { return State(scheme: .sunset, error: "Color configuration: \(error.localizedDescription)\nUsing the built-in sunset palette. Fix the file to apply changes.", usesFallback: true) } }() private static var observation: ColorSchemeObservation? 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.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 } static func startObserving() { guard observation == nil else { return } let directory = ColorScheme.configurationDirectory observation = ColorSchemeObservation(directory: directory) { let previousError = configurationError reload(from: directory) if let diagnostic = configurationError, diagnostic != previousError { NSLog("%@", diagnostic) } } reload(from: directory) } @discardableResult static func reload(from directory: URL = ColorScheme.configurationDirectory) -> Bool { do { let scheme = try ColorScheme.load(from: directory) let previous = loaded.scheme 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 || scheme.variant != previous.variant else { loaded.error = nil loaded.usesFallback = false return false } loaded = State(scheme: scheme) NotificationCenter.default.post(name: didChangeNotification, object: nil) return true } catch { let recovery = loaded.usesFallback ? "Using the built-in sunset palette." : "Keeping the last valid palette." loaded.error = "Color configuration: \(error.localizedDescription)\n\(recovery) Fix the file to apply changes." return false } } } @MainActor final class LauncherBackground: NSView { weak var canvas: NSView? private var amber = NSGradient(colors: [ 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.glowStrength), ThemePalette.glow.withAlphaComponent(0.10 * ThemePalette.glowStrength), ThemePalette.glow.withAlphaComponent(0), ])! override init(frame frameRect: NSRect) { super.init(frame: frameRect) observePalette() } required init?(coder: NSCoder) { super.init(coder: coder) observePalette() } private func observePalette() { NotificationCenter.default.addObserver(self, selector: #selector(paletteChanged), name: ThemePalette.didChangeNotification, object: nil) } @objc private func paletteChanged() { amber = NSGradient(colors: [ 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.glowStrength), ThemePalette.glow.withAlphaComponent(0.10 * ThemePalette.glowStrength), ThemePalette.glow.withAlphaComponent(0), ])! needsDisplay = true } override func hitTest(_ point: NSPoint) -> NSView? { nil } override func draw(_ dirtyRect: NSRect) { ThemePalette.background.setFill() dirtyRect.fill() let canvasBounds = canvas.map { convert($0.bounds, from: $0) } ?? bounds let upperGlow = NSPoint(x: canvasBounds.minX + canvasBounds.width * 0.58, y: canvasBounds.minY + canvasBounds.height * 0.94) ember.draw(fromCenter: upperGlow, radius: 0, toCenter: upperGlow, radius: canvasBounds.width * 0.40, options: []) let lowerGlow = NSPoint(x: canvasBounds.minX + canvasBounds.width * 0.88, y: canvasBounds.minY + canvasBounds.height * 0.10) amber.draw(fromCenter: lowerGlow, radius: 0, toCenter: lowerGlow, radius: canvasBounds.width * 0.55, options: []) let leftGlow = NSPoint(x: canvasBounds.minX + canvasBounds.width * 0.08, y: canvasBounds.minY + canvasBounds.height * 0.65) NSGraphicsContext.saveGraphicsState() NSGraphicsContext.current?.cgContext.setAlpha(0.45) amber.draw(fromCenter: leftGlow, radius: 0, toCenter: leftGlow, radius: canvasBounds.width * 0.32, options: []) NSGraphicsContext.restoreGraphicsState() } }