import AppKit import TOMLDecoder @MainActor struct ColorScheme { var background: NSColor var text: NSColor var secondaryText: NSColor var accent: NSColor 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), 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.toml") 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: ["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) } } try result.applyColors(from: config, at: configURL) return result } private static let colorFields: [String: WritableKeyPath] = [ "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 contain named color values.") } 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, 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 let isTOML = url.pathExtension == "toml" do { if isTOML { return try Dictionary(TOMLTable(source: data)) } value = try JSONSerialization.jsonObject(with: data, options: [.fragmentsAllowed]) } catch { let detail = isTOML ? String(describing: error) : error.localizedDescription throw ColorSchemeError(path: url.path, reason: "Invalid \(isTOML ? "TOML" : "JSON"): \(detail)") } 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)" } }