feat: configure launcher color scheme

This commit is contained in:
2026-10-22 15:36:44 +02:00
parent 1ec4c0d582
commit be761b4704
21 changed files with 460 additions and 150 deletions
+127
View File
@@ -0,0 +1,127 @@
import AppKit
@MainActor
struct ColorScheme {
var background: NSColor
var text: NSColor
var secondaryText: NSColor
var accent: NSColor
var glow: NSColor
var keyBackground: NSColor
var error: NSColor
static let sunset = ColorScheme(
background: NSColor(srgbRed: 0.12, green: 0.09, blue: 0.08, alpha: 1),
text: NSColor(srgbRed: 1.00, green: 0.89, blue: 0.71, alpha: 1),
secondaryText: NSColor(srgbRed: 0.85, green: 0.78, blue: 0.65, alpha: 1),
accent: NSColor(srgbRed: 0.95, green: 0.58, blue: 0.23, alpha: 1),
glow: NSColor(srgbRed: 0.76, green: 0.27, blue: 0.18, alpha: 1),
keyBackground: NSColor(srgbRed: 0.26, green: 0.22, blue: 0.19, alpha: 1),
error: .systemRed
)
static var configurationDirectory: URL {
let base: URL
if let xdg = ProcessInfo.processInfo.environment["XDG_CONFIG_HOME"], xdg.hasPrefix("/") {
base = URL(fileURLWithPath: xdg, isDirectory: true)
} else {
base = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".config", isDirectory: true)
}
return base.appendingPathComponent("conjure", isDirectory: true)
}
static func load(from directory: URL) throws -> ColorScheme {
let configURL = directory.appendingPathComponent("config.json")
guard let config = try readObject(at: configURL, missingAllowed: true) else { return sunset }
try validateKeys(config, allowed: ["theme", "colors"], at: configURL, prefix: "")
let theme: String
if let value = config["theme"] {
guard let name = value as? String, ["sunset", "tint"].contains(name) else {
throw ColorSchemeError(path: configURL.path, reason: "theme must be \"sunset\" or \"tint\".")
}
theme = name
} else {
theme = "sunset"
}
var result = sunset
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 result.applyColors(from: generated, at: themeURL)
}
}
try result.applyColors(from: config, at: configURL)
return result
}
private static let colorFields: [String: WritableKeyPath<ColorScheme, NSColor>] = [
"background": \.background,
"text": \.text,
"secondaryText": \.secondaryText,
"accent": \.accent,
"glow": \.glow,
"keyBackground": \.keyBackground,
"error": \.error,
]
private mutating func applyColors(from object: [String: Any], at url: URL) throws {
guard let value = object["colors"] else { return }
guard let colors = value as? [String: Any] else {
throw ColorSchemeError(path: url.path, reason: "colors must be an object.")
}
try Self.validateKeys(colors, allowed: Set(Self.colorFields.keys), at: url, prefix: "colors.")
for (name, value) in colors {
guard let hex = value as? String, hex.utf8.count == 7, hex.first == "#",
hex.utf8.dropFirst().allSatisfy({ (48...57).contains($0) || (65...70).contains($0) || (97...102).contains($0) }),
let rgb = UInt32(hex.dropFirst(), radix: 16) else {
throw ColorSchemeError(path: url.path, reason: "colors.\(name) must be #RRGGBB ASCII hex (no alpha).")
}
if let field = Self.colorFields[name] {
self[keyPath: field] = NSColor(
srgbRed: CGFloat((rgb >> 16) & 0xff) / 255,
green: CGFloat((rgb >> 8) & 0xff) / 255,
blue: CGFloat(rgb & 0xff) / 255,
alpha: 1
)
}
}
}
private static func validateKeys(_ object: [String: Any], allowed: Set<String>, at url: URL, prefix: String) throws {
if let key = object.keys.sorted().first(where: { !allowed.contains($0) }) {
throw ColorSchemeError(path: url.path, reason: "Unknown key \"\(prefix)\(key)\".")
}
}
private static func readObject(at url: URL, missingAllowed: Bool) throws -> [String: Any]? {
let data: Data
do {
data = try Data(contentsOf: url)
} catch {
let cocoa = error as NSError
if missingAllowed, cocoa.domain == NSCocoaErrorDomain, cocoa.code == NSFileReadNoSuchFileError {
return nil
}
throw ColorSchemeError(path: url.path, reason: error.localizedDescription)
}
let value: Any
do {
value = try JSONSerialization.jsonObject(with: data, options: [.fragmentsAllowed])
} catch {
throw ColorSchemeError(path: url.path, reason: "Invalid JSON: \(error.localizedDescription)")
}
guard let object = value as? [String: Any] else {
throw ColorSchemeError(path: url.path, reason: "Expected a JSON object.")
}
return object
}
}
private struct ColorSchemeError: LocalizedError {
let path: String
let reason: String
var errorDescription: String? { "\(path): \(reason)" }
}