feat: use TOML for editable color configuration
This commit is contained in:
+10
-1
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"originHash" : "ca20de906df51b7d817aeab261f0e858854d41b957c0169d04e413adab8a05bf",
|
"originHash" : "068211154e3a6007735a5b19402de83e9c3091c99e38c7be2bc2e85bc3259877",
|
||||||
"pins" : [
|
"pins" : [
|
||||||
{
|
{
|
||||||
"identity" : "highlighterswift",
|
"identity" : "highlighterswift",
|
||||||
@@ -27,6 +27,15 @@
|
|||||||
"revision" : "ea79e83c8744d2b50b0dc2d5bbd1e857e1253bf9",
|
"revision" : "ea79e83c8744d2b50b0dc2d5bbd1e857e1253bf9",
|
||||||
"version" : "0.6.0"
|
"version" : "0.6.0"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "tomldecoder",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/dduan/TOMLDecoder",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "a2bbd2796fe3064e107de18cb56031052c4fa899",
|
||||||
|
"version" : "0.4.5"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"version" : 3
|
"version" : 3
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ let package = Package(
|
|||||||
dependencies: [
|
dependencies: [
|
||||||
.package(url: "https://github.com/smittytone/HighlighterSwift", from: "3.1.0"),
|
.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/swiftlang/swift-markdown", exact: "0.6.0"),
|
||||||
|
.package(url: "https://github.com/dduan/TOMLDecoder", exact: "0.4.5"),
|
||||||
],
|
],
|
||||||
targets: [
|
targets: [
|
||||||
.target(name: "ConjureKit", dependencies: [
|
.target(name: "ConjureKit", dependencies: [
|
||||||
@@ -15,6 +16,7 @@ let package = Package(
|
|||||||
], resources: [.process("Resources")]),
|
], resources: [.process("Resources")]),
|
||||||
.executableTarget(name: "Conjure", dependencies: [
|
.executableTarget(name: "Conjure", dependencies: [
|
||||||
"ConjureKit", .product(name: "Highlighter", package: "HighlighterSwift"),
|
"ConjureKit", .product(name: "Highlighter", package: "HighlighterSwift"),
|
||||||
|
.product(name: "TOMLDecoder", package: "TOMLDecoder"),
|
||||||
]),
|
]),
|
||||||
.testTarget(name: "ConjureKitTests", dependencies: ["ConjureKit", "Conjure"]),
|
.testTarget(name: "ConjureKitTests", dependencies: ["ConjureKit", "Conjure"]),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -134,20 +134,18 @@ The picker works offline with 1,870 emoji from [gemoji v4.1.0](https://github.co
|
|||||||
|
|
||||||
## Color scheme
|
## 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
|
```toml
|
||||||
{
|
# Override only the colors you want to change.
|
||||||
"colors": {
|
[colors]
|
||||||
"background": "#171b24",
|
background = "#171b24"
|
||||||
"text": "#e0e6f0",
|
text = "#e0e6f0"
|
||||||
"secondaryText": "#a0adc0",
|
secondaryText = "#a0adc0"
|
||||||
"accent": "#82aaff",
|
accent = "#82aaff"
|
||||||
"glow": "#c792ea",
|
glow = "#c792ea"
|
||||||
"keyBackground": "#303848",
|
keyBackground = "#303848"
|
||||||
"error": "#ff5370"
|
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.
|
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
|
### 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
|
```toml
|
||||||
{
|
theme = "tint"
|
||||||
"theme": "tint",
|
|
||||||
"colors": {
|
[colors]
|
||||||
"accent": "#82aaff"
|
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
|
## System theme
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import AppKit
|
import AppKit
|
||||||
|
import TOMLDecoder
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
struct ColorScheme {
|
struct ColorScheme {
|
||||||
@@ -31,7 +32,7 @@ struct ColorScheme {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static func load(from directory: URL) throws -> 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 }
|
guard let config = try readObject(at: configURL, missingAllowed: true) else { return sunset }
|
||||||
try validateKeys(config, allowed: ["theme", "colors"], at: configURL, prefix: "")
|
try validateKeys(config, allowed: ["theme", "colors"], at: configURL, prefix: "")
|
||||||
let theme: String
|
let theme: String
|
||||||
@@ -69,7 +70,7 @@ struct ColorScheme {
|
|||||||
private mutating func applyColors(from object: [String: Any], at url: URL) throws {
|
private mutating func applyColors(from object: [String: Any], at url: URL) throws {
|
||||||
guard let value = object["colors"] else { return }
|
guard let value = object["colors"] else { return }
|
||||||
guard let colors = value as? [String: Any] else {
|
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.")
|
try Self.validateKeys(colors, allowed: Set(Self.colorFields.keys), at: url, prefix: "colors.")
|
||||||
for (name, value) in colors {
|
for (name, value) in colors {
|
||||||
@@ -107,10 +108,13 @@ struct ColorScheme {
|
|||||||
throw ColorSchemeError(path: url.path, reason: error.localizedDescription)
|
throw ColorSchemeError(path: url.path, reason: error.localizedDescription)
|
||||||
}
|
}
|
||||||
let value: Any
|
let value: Any
|
||||||
|
let isTOML = url.pathExtension == "toml"
|
||||||
do {
|
do {
|
||||||
|
if isTOML { return try Dictionary(TOMLTable(source: data)) }
|
||||||
value = try JSONSerialization.jsonObject(with: data, options: [.fragmentsAllowed])
|
value = try JSONSerialization.jsonObject(with: data, options: [.fragmentsAllowed])
|
||||||
} catch {
|
} 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 {
|
guard let object = value as? [String: Any] else {
|
||||||
throw ColorSchemeError(path: url.path, reason: "Expected a JSON object.")
|
throw ColorSchemeError(path: url.path, reason: "Expected a JSON object.")
|
||||||
|
|||||||
@@ -11,10 +11,10 @@ func missingColorConfigurationUsesSunsetWithoutReadingGeneratedTheme() throws {
|
|||||||
let loaded = try ColorScheme.load(from: directory)
|
let loaded = try ColorScheme.load(from: directory)
|
||||||
expectSameColors(loaded, ColorScheme.sunset)
|
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)
|
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)
|
expectSameColors(try ColorScheme.load(from: directory), ColorScheme.sunset)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -22,8 +22,12 @@ func missingColorConfigurationUsesSunsetWithoutReadingGeneratedTheme() throws {
|
|||||||
func partialManualColorsOverrideSunsetWithoutChangingOtherFields() throws {
|
func partialManualColorsOverrideSunsetWithoutChangingOtherFields() throws {
|
||||||
let directory = try colorConfigurationDirectory()
|
let directory = try colorConfigurationDirectory()
|
||||||
defer { try? FileManager.default.removeItem(at: directory) }
|
defer { try? FileManager.default.removeItem(at: directory) }
|
||||||
try Data(##"{"colors":{"accent":"#aB1234","error":"#123456"}}"##.utf8)
|
try Data("""
|
||||||
.write(to: directory.appendingPathComponent("config.json"))
|
# 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
|
var expected = ColorScheme.sunset
|
||||||
expected.accent = NSColor(srgbRed: 171.0 / 255, green: 18.0 / 255, blue: 52.0 / 255, alpha: 1)
|
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 {
|
func tintColorsLayerBetweenDefaultsAndManualOverrides() throws {
|
||||||
let directory = try colorConfigurationDirectory()
|
let directory = try colorConfigurationDirectory()
|
||||||
defer { try? FileManager.default.removeItem(at: directory) }
|
defer { try? FileManager.default.removeItem(at: directory) }
|
||||||
try Data(##"{"theme":"tint","colors":{"accent":"#ffffff","text":"#000000"}}"##.utf8)
|
try Data("""
|
||||||
.write(to: directory.appendingPathComponent("config.json"))
|
theme = "tint"
|
||||||
|
[colors]
|
||||||
|
accent = "#ffffff"
|
||||||
|
text = "#000000"
|
||||||
|
""".utf8).write(to: directory.appendingPathComponent("config.toml"))
|
||||||
try Data(##"{"colors":{"accent":"#ff0000","background":"#123456","glow":"#00ff00"}}"##.utf8)
|
try Data(##"{"colors":{"accent":"#ff0000","background":"#123456","glow":"#00ff00"}}"##.utf8)
|
||||||
.write(to: directory.appendingPathComponent("theme.json"))
|
.write(to: directory.appendingPathComponent("theme.json"))
|
||||||
|
|
||||||
@@ -52,29 +60,28 @@ func tintColorsLayerBetweenDefaultsAndManualOverrides() throws {
|
|||||||
func malformedColorConfigurationReportsItsPathAndReason() throws {
|
func malformedColorConfigurationReportsItsPathAndReason() throws {
|
||||||
let directory = try colorConfigurationDirectory()
|
let directory = try colorConfigurationDirectory()
|
||||||
defer { try? FileManager.default.removeItem(at: directory) }
|
defer { try? FileManager.default.removeItem(at: directory) }
|
||||||
let config = directory.appendingPathComponent("config.json")
|
let config = directory.appendingPathComponent("config.toml")
|
||||||
let invalid: [(String, String)] = [
|
let invalid: [(String, String?)] = [
|
||||||
(##"{"colors":{"accent":"#12345678"}}"##, "colors.accent"),
|
("colors.accent = '#12345678'", "colors.accent"),
|
||||||
(##"{"colors":{"accent":"#fff"}}"##, "colors.accent"),
|
("colors.accent = '#fff'", "colors.accent"),
|
||||||
(##"{"colors":{"accent":"#12gg56"}}"##, "colors.accent"),
|
("colors.accent = '#12gg56'", "colors.accent"),
|
||||||
(##"{"colors":{"accent":"#123456"}}"##, "colors.accent"),
|
("colors.accent = '#123456'", "colors.accent"),
|
||||||
(#"{"colors":{"accent":"123456"}}"#, "colors.accent"),
|
("colors.accent = '123456'", "colors.accent"),
|
||||||
(#"{"colors":{"accent":" #123456"}}"#, "colors.accent"),
|
("colors.accent = ' #123456'", "colors.accent"),
|
||||||
(#"{"colors":{"accent":42}}"#, "colors.accent"),
|
("colors.accent = 42", "colors.accent"),
|
||||||
(#"{"colors":{"accent":null}}"#, "colors.accent"),
|
("colors.accnet = '#123456'", "colors.accnet"),
|
||||||
(##"{"colors":{"accnet":"#123456"}}"##, "colors.accnet"),
|
("opacity = 0.5", "opacity"),
|
||||||
(#"{"opacity":0.5}"#, "opacity"),
|
("colors = []", "colors"),
|
||||||
(#"{"colors":[]}"#, "colors must be an object"),
|
("colors = false", "colors"),
|
||||||
(#"{"colors":null}"#, "colors must be an object"),
|
("theme = 'unknown'", "theme"),
|
||||||
(#"{"theme":"unknown"}"#, "theme"),
|
("theme = true", "theme"),
|
||||||
(#"{"theme":true}"#, "theme"),
|
("[colors", nil),
|
||||||
(#"{"theme":null}"#, "theme"),
|
("colors.accent = '#123456'\ncolors.accent = '#abcdef'", nil),
|
||||||
("{", "Invalid JSON"),
|
("[colors]\n[colors]", nil),
|
||||||
("[]", "JSON object"),
|
("theme = 'sunset'\ntheme = 'tint'", nil),
|
||||||
("null", "JSON object"),
|
|
||||||
]
|
]
|
||||||
for (json, reason) in invalid {
|
for (toml, reason) in invalid {
|
||||||
try Data(json.utf8).write(to: config)
|
try Data(toml.utf8).write(to: config)
|
||||||
expectColorSchemeFailure(in: directory, path: config, reason: reason)
|
expectColorSchemeFailure(in: directory, path: config, reason: reason)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -84,17 +91,17 @@ func selectedTintRequiresAnExistingValidGeneratedTheme() throws {
|
|||||||
let directory = try colorConfigurationDirectory()
|
let directory = try colorConfigurationDirectory()
|
||||||
defer { try? FileManager.default.removeItem(at: directory) }
|
defer { try? FileManager.default.removeItem(at: directory) }
|
||||||
let theme = directory.appendingPathComponent("theme.json")
|
let theme = directory.appendingPathComponent("theme.json")
|
||||||
try Data(##"{"theme":"tint","colors":{"accent":"#ffffff"}}"##.utf8)
|
try Data("theme = 'tint'\ncolors.accent = '#ffffff'\n".utf8)
|
||||||
.write(to: directory.appendingPathComponent("config.json"))
|
.write(to: directory.appendingPathComponent("config.toml"))
|
||||||
expectColorSchemeFailure(in: directory, path: theme)
|
expectColorSchemeFailure(in: directory, path: theme)
|
||||||
|
|
||||||
let invalid: [(String, String)] = [
|
let invalid: [(String, String?)] = [
|
||||||
("not JSON", "Invalid JSON"),
|
("not JSON", nil),
|
||||||
("[]", "JSON object"),
|
("[]", nil),
|
||||||
(#"{"theme":"sunset"}"#, "theme"),
|
(#"{"theme":"sunset"}"#, "theme"),
|
||||||
(##"{"colors":{"accent":"#12345678"}}"##, "colors.accent"),
|
(##"{"colors":{"accent":"#12345678"}}"##, "colors.accent"),
|
||||||
(##"{"colors":{"glwo":"#123456"}}"##, "colors.glwo"),
|
(##"{"colors":{"glwo":"#123456"}}"##, "colors.glwo"),
|
||||||
(#"{"colors":false}"#, "colors must be an object"),
|
(#"{"colors":false}"#, "colors"),
|
||||||
]
|
]
|
||||||
for (json, reason) in invalid {
|
for (json, reason) in invalid {
|
||||||
try Data(json.utf8).write(to: theme)
|
try Data(json.utf8).write(to: theme)
|
||||||
|
|||||||
Reference in New Issue
Block a user