diff --git a/Package.resolved b/Package.resolved index d9159cb..69c9e5b 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "ca20de906df51b7d817aeab261f0e858854d41b957c0169d04e413adab8a05bf", + "originHash" : "068211154e3a6007735a5b19402de83e9c3091c99e38c7be2bc2e85bc3259877", "pins" : [ { "identity" : "highlighterswift", @@ -27,6 +27,15 @@ "revision" : "ea79e83c8744d2b50b0dc2d5bbd1e857e1253bf9", "version" : "0.6.0" } + }, + { + "identity" : "tomldecoder", + "kind" : "remoteSourceControl", + "location" : "https://github.com/dduan/TOMLDecoder", + "state" : { + "revision" : "a2bbd2796fe3064e107de18cb56031052c4fa899", + "version" : "0.4.5" + } } ], "version" : 3 diff --git a/Package.swift b/Package.swift index 1b13d17..b429e85 100644 --- a/Package.swift +++ b/Package.swift @@ -8,6 +8,7 @@ let package = Package( dependencies: [ .package(url: "https://github.com/smittytone/HighlighterSwift", from: "3.1.0"), .package(url: "https://github.com/swiftlang/swift-markdown", exact: "0.6.0"), + .package(url: "https://github.com/dduan/TOMLDecoder", exact: "0.4.5"), ], targets: [ .target(name: "ConjureKit", dependencies: [ @@ -15,6 +16,7 @@ let package = Package( ], resources: [.process("Resources")]), .executableTarget(name: "Conjure", dependencies: [ "ConjureKit", .product(name: "Highlighter", package: "HighlighterSwift"), + .product(name: "TOMLDecoder", package: "TOMLDecoder"), ]), .testTarget(name: "ConjureKitTests", dependencies: ["ConjureKit", "Conjure"]), ] diff --git a/README.md b/README.md index 071b46b..7f1a03d 100644 --- a/README.md +++ b/README.md @@ -134,20 +134,18 @@ The picker works offline with 1,870 emoji from [gemoji v4.1.0](https://github.co ## Color scheme -Create `$XDG_CONFIG_HOME/conjure/config.json`, or `~/.config/conjure/config.json` when `XDG_CONFIG_HOME` is unset, empty, or relative. No configuration is required: Conjure keeps its built-in sunset colors by default. +Create `$XDG_CONFIG_HOME/conjure/config.toml`, or `~/.config/conjure/config.toml` when `XDG_CONFIG_HOME` is unset, empty, or relative. No configuration is required: Conjure keeps its built-in sunset colors by default. The editable configuration uses TOML, including comments and standard TOML quoting. -```json -{ - "colors": { - "background": "#171b24", - "text": "#e0e6f0", - "secondaryText": "#a0adc0", - "accent": "#82aaff", - "glow": "#c792ea", - "keyBackground": "#303848", - "error": "#ff5370" - } -} +```toml +# Override only the colors you want to change. +[colors] +background = "#171b24" +text = "#e0e6f0" +secondaryText = "#a0adc0" +accent = "#82aaff" +glow = "#c792ea" +keyBackground = "#303848" +error = "#ff5370" ``` Every color is optional and must be an opaque `#RRGGBB` value. `accent` colors selections, borders, carets, and the primary background glows; `glow` colors the secondary background glow. `keyBackground` colors application-number shortcut badges, and `error` colors errors and destructive actions. Overrides apply throughout the launcher, tools, popups, and Settings wherever those shared colors are used. @@ -156,20 +154,18 @@ Only colors change. Frosted glass, glow positions and sizes, opacity, selection ### Follow tint -The sibling [tint](../tint) tool generates `conjure/theme.json` in the same configuration directory when it applies a theme. Rebuild/install the updated tint tool with `make install` in its repository, then apply your chosen pair, for example `tint swamp`. Opt in from Conjure's `config.json`: +The sibling [tint](../tint) tool generates `conjure/theme.json` in the same configuration directory when it applies a theme. This machine-generated palette stays JSON and is not intended for editing. Rebuild/install the updated tint tool with `make install` in its repository, then apply your chosen pair, for example `tint swamp`. Opt in from Conjure's `config.toml`: -```json -{ - "theme": "tint", - "colors": { - "accent": "#82aaff" - } -} +```toml +theme = "tint" + +[colors] +accent = "#82aaff" # Optional personal override ``` -Precedence is personal `colors` → tint-generated colors → built-in sunset colors. Omit `colors` to follow tint without overrides. Omit `theme`, or set it to `"sunset"`, to ignore the generated palette. tint never overwrites `config.json`; edit that file, not the generated `theme.json`. Both processes must use the same configuration directory. +Precedence is personal `[colors]` → tint-generated colors → built-in sunset colors. Omit `[colors]` to follow tint without overrides. Omit `theme`, or set it to `"sunset"` before any table, to ignore the generated palette. tint never overwrites `config.toml`; edit that file, not the generated `theme.json`. Both processes must use the same configuration directory. -Conjure reads colors once at launch. Quit and reopen it after editing configuration or applying a tint theme, including automatic light/dark switches. Invalid JSON, unknown keys, unsupported themes, invalid colors, or a missing selected tint palette cause a full fallback to sunset and a file-specific error in Settings. Normal launches open Settings to show the error; login launches leave it available for the next time Settings opens. +Conjure reads colors once at launch. Quit and reopen it after editing configuration or applying a tint theme, including automatic light/dark switches. Invalid TOML, invalid generated JSON, unknown keys, unsupported themes, invalid colors, or a missing selected tint palette cause a full fallback to sunset and a file-specific error in Settings. Normal launches open Settings to show the error; login launches leave it available for the next time Settings opens. ## System theme diff --git a/Sources/Conjure/ColorScheme.swift b/Sources/Conjure/ColorScheme.swift index 203e5f9..05c04ba 100644 --- a/Sources/Conjure/ColorScheme.swift +++ b/Sources/Conjure/ColorScheme.swift @@ -1,4 +1,5 @@ import AppKit +import TOMLDecoder @MainActor struct ColorScheme { @@ -31,7 +32,7 @@ struct ColorScheme { } static func load(from directory: URL) throws -> ColorScheme { - let configURL = directory.appendingPathComponent("config.json") + 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 @@ -69,7 +70,7 @@ struct ColorScheme { 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.") + 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 { @@ -107,10 +108,13 @@ struct ColorScheme { 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 { - throw ColorSchemeError(path: url.path, reason: "Invalid JSON: \(error.localizedDescription)") + 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.") diff --git a/Tests/ConjureKitTests/ColorSchemeTests.swift b/Tests/ConjureKitTests/ColorSchemeTests.swift index e646fbe..d6ebe81 100644 --- a/Tests/ConjureKitTests/ColorSchemeTests.swift +++ b/Tests/ConjureKitTests/ColorSchemeTests.swift @@ -11,10 +11,10 @@ func missingColorConfigurationUsesSunsetWithoutReadingGeneratedTheme() throws { let loaded = try ColorScheme.load(from: directory) expectSameColors(loaded, ColorScheme.sunset) - try Data("{}".utf8).write(to: directory.appendingPathComponent("config.json")) + try Data("# Comments-only configuration\n".utf8).write(to: directory.appendingPathComponent("config.toml")) expectSameColors(try ColorScheme.load(from: directory), ColorScheme.sunset) - try Data(#"{"theme":"sunset"}"#.utf8).write(to: directory.appendingPathComponent("config.json")) + try Data("theme = 'sunset'\n".utf8).write(to: directory.appendingPathComponent("config.toml")) expectSameColors(try ColorScheme.load(from: directory), ColorScheme.sunset) } @@ -22,8 +22,12 @@ func missingColorConfigurationUsesSunsetWithoutReadingGeneratedTheme() throws { func partialManualColorsOverrideSunsetWithoutChangingOtherFields() throws { let directory = try colorConfigurationDirectory() defer { try? FileManager.default.removeItem(at: directory) } - try Data(##"{"colors":{"accent":"#aB1234","error":"#123456"}}"##.utf8) - .write(to: directory.appendingPathComponent("config.json")) + try Data(""" + # Personal color overrides + [colors] + accent = '#aB1234' # Keep the hash inside the quoted color + error = "#123456" + """.utf8).write(to: directory.appendingPathComponent("config.toml")) var expected = ColorScheme.sunset expected.accent = NSColor(srgbRed: 171.0 / 255, green: 18.0 / 255, blue: 52.0 / 255, alpha: 1) @@ -35,8 +39,12 @@ func partialManualColorsOverrideSunsetWithoutChangingOtherFields() throws { func tintColorsLayerBetweenDefaultsAndManualOverrides() throws { let directory = try colorConfigurationDirectory() defer { try? FileManager.default.removeItem(at: directory) } - try Data(##"{"theme":"tint","colors":{"accent":"#ffffff","text":"#000000"}}"##.utf8) - .write(to: directory.appendingPathComponent("config.json")) + try Data(""" + theme = "tint" + [colors] + accent = "#ffffff" + text = "#000000" + """.utf8).write(to: directory.appendingPathComponent("config.toml")) try Data(##"{"colors":{"accent":"#ff0000","background":"#123456","glow":"#00ff00"}}"##.utf8) .write(to: directory.appendingPathComponent("theme.json")) @@ -52,29 +60,28 @@ func tintColorsLayerBetweenDefaultsAndManualOverrides() throws { func malformedColorConfigurationReportsItsPathAndReason() throws { let directory = try colorConfigurationDirectory() defer { try? FileManager.default.removeItem(at: directory) } - let config = directory.appendingPathComponent("config.json") - let invalid: [(String, String)] = [ - (##"{"colors":{"accent":"#12345678"}}"##, "colors.accent"), - (##"{"colors":{"accent":"#fff"}}"##, "colors.accent"), - (##"{"colors":{"accent":"#12gg56"}}"##, "colors.accent"), - (##"{"colors":{"accent":"#123456"}}"##, "colors.accent"), - (#"{"colors":{"accent":"123456"}}"#, "colors.accent"), - (#"{"colors":{"accent":" #123456"}}"#, "colors.accent"), - (#"{"colors":{"accent":42}}"#, "colors.accent"), - (#"{"colors":{"accent":null}}"#, "colors.accent"), - (##"{"colors":{"accnet":"#123456"}}"##, "colors.accnet"), - (#"{"opacity":0.5}"#, "opacity"), - (#"{"colors":[]}"#, "colors must be an object"), - (#"{"colors":null}"#, "colors must be an object"), - (#"{"theme":"unknown"}"#, "theme"), - (#"{"theme":true}"#, "theme"), - (#"{"theme":null}"#, "theme"), - ("{", "Invalid JSON"), - ("[]", "JSON object"), - ("null", "JSON object"), + let config = directory.appendingPathComponent("config.toml") + let invalid: [(String, String?)] = [ + ("colors.accent = '#12345678'", "colors.accent"), + ("colors.accent = '#fff'", "colors.accent"), + ("colors.accent = '#12gg56'", "colors.accent"), + ("colors.accent = '#123456'", "colors.accent"), + ("colors.accent = '123456'", "colors.accent"), + ("colors.accent = ' #123456'", "colors.accent"), + ("colors.accent = 42", "colors.accent"), + ("colors.accnet = '#123456'", "colors.accnet"), + ("opacity = 0.5", "opacity"), + ("colors = []", "colors"), + ("colors = false", "colors"), + ("theme = 'unknown'", "theme"), + ("theme = true", "theme"), + ("[colors", nil), + ("colors.accent = '#123456'\ncolors.accent = '#abcdef'", nil), + ("[colors]\n[colors]", nil), + ("theme = 'sunset'\ntheme = 'tint'", nil), ] - for (json, reason) in invalid { - try Data(json.utf8).write(to: config) + for (toml, reason) in invalid { + try Data(toml.utf8).write(to: config) expectColorSchemeFailure(in: directory, path: config, reason: reason) } } @@ -84,17 +91,17 @@ func selectedTintRequiresAnExistingValidGeneratedTheme() throws { let directory = try colorConfigurationDirectory() defer { try? FileManager.default.removeItem(at: directory) } let theme = directory.appendingPathComponent("theme.json") - try Data(##"{"theme":"tint","colors":{"accent":"#ffffff"}}"##.utf8) - .write(to: directory.appendingPathComponent("config.json")) + try Data("theme = 'tint'\ncolors.accent = '#ffffff'\n".utf8) + .write(to: directory.appendingPathComponent("config.toml")) expectColorSchemeFailure(in: directory, path: theme) - let invalid: [(String, String)] = [ - ("not JSON", "Invalid JSON"), - ("[]", "JSON object"), + let invalid: [(String, String?)] = [ + ("not JSON", nil), + ("[]", nil), (#"{"theme":"sunset"}"#, "theme"), (##"{"colors":{"accent":"#12345678"}}"##, "colors.accent"), (##"{"colors":{"glwo":"#123456"}}"##, "colors.glwo"), - (#"{"colors":false}"#, "colors must be an object"), + (#"{"colors":false}"#, "colors"), ] for (json, reason) in invalid { try Data(json.utf8).write(to: theme)