Files
conjure/Sources/Conjure/LauncherBackground.swift

208 lines
9.8 KiB
Swift

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()
}
}