feat: configure launcher color scheme
This commit is contained in:
@@ -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)" }
|
||||
}
|
||||
Reference in New Issue
Block a user