160 lines
6.6 KiB
Swift
160 lines
6.6 KiB
Swift
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<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 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<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
|
|
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)" }
|
|
}
|