diff --git a/Makefile b/Makefile index 4a94da9..9239b50 100644 --- a/Makefile +++ b/Makefile @@ -32,6 +32,7 @@ install: fi XDG_CONFIG_HOME="$(XDG_CONFIG_HOME)" tinty sync @dark_notify="$$(command -v dark-notify)"; \ + tinty_dir="$$(dirname "$$(command -v tinty)")"; \ printf '%s\n' \ '' \ '' \ @@ -45,6 +46,15 @@ install: ' -c' \ ' $(BIN_DIR)/tint' \ ' ' \ + ' EnvironmentVariables' \ + ' ' \ + ' HOME' \ + ' $(HOME)' \ + ' PATH' \ + " $$tinty_dir:/usr/bin:/bin:/usr/sbin:/sbin" \ + ' XDG_CONFIG_HOME' \ + ' $(XDG_CONFIG_HOME)' \ + ' ' \ ' RunAtLoad' \ ' ' \ ' KeepAlive' \ diff --git a/README.md b/README.md index 8ac9704..b524886 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # tint -A small wrapper around [Tinty](https://github.com/tinted-theming/tinty) that manages light/dark theme pairs and applies the selected theme to Ghostty, Zed, Herdr, and OMP. +A small wrapper around [Tinty](https://github.com/tinted-theming/tinty) that manages light/dark theme pairs and applies the selected theme to Ghostty, Zed, Herdr, OMP, and Conjure. ## Requirements @@ -21,7 +21,7 @@ brew install tinty go brew install cormacrelf/tap/dark-notify ``` -Ghostty, Zed, Herdr, and OMP are optional consumers. Install only the applications you use. +Ghostty, Zed, Herdr, OMP, and Conjure are optional consumers. Install only the applications you use. ## Install @@ -119,6 +119,18 @@ The wrapper writes: Configure OMP to use `tinty-current` where required. +### Conjure + +The `apply-apps` flow writes `$XDG_CONFIG_HOME/conjure/theme.json`, falling back to `~/.config/conjure/theme.json` when `XDG_CONFIG_HOME` is unset, empty, or relative. The generated JSON contains seven opaque colors: `background`, `text`, `secondaryText`, `accent`, `glow`, `keyBackground`, and `error`, using Tinty semantic UI colors with Base16/Base24 or tinted8 palette fallbacks. + +Opt in by adding this to `conjure/config.json` in the same configuration directory: + +```json +{"theme":"tint"} +``` + +The wrapper never changes `config.json`. Optional entries in its `colors` object override generated colors. Conjure reads configuration only at launch: restart Conjure after applying a theme or editing its configuration. The wrapper does not signal, reload, restart, or notify Conjure. + ## Usage Show the current pair and available commands: diff --git a/apps.go b/apps.go index 83fe382..ccb1e82 100644 --- a/apps.go +++ b/apps.go @@ -40,6 +40,9 @@ func applyApps(zedSource string) error { if err := writeHerdrTheme(colors, ui, syntax); err != nil { return err } + if err := writeConjureTheme(colors, ui); err != nil { + return err + } return writeZedTheme(zedSource) } @@ -186,6 +189,29 @@ func semanticTheme(colors, ui, syntax map[string]string) (map[string]string, map return omp, herdr } +func writeConjureTheme(colors, ui map[string]string) error { + mapping := map[string]string{ + "background": pick(ui, "global.background.normal", colors, "base00"), + "text": pick(ui, "global.foreground.normal", colors, "base05"), + "secondaryText": pick(ui, "global.foreground.dark", colors, "base04"), + "accent": pick(ui, "accent.normal", colors, "base0D"), + "glow": pick(ui, "status.error", colors, "base08"), + "keyBackground": pick(ui, "chrome.background.normal", colors, "base01"), + "error": pick(ui, "status.error", colors, "base08"), + } + validColor := regexp.MustCompile(`^#[0-9a-fA-F]{6}$`) + for key, value := range mapping { + if !validColor.MatchString(value) { + return fmt.Errorf("invalid Conjure color %s: %q", key, value) + } + } + configHome := os.Getenv("XDG_CONFIG_HOME") + if !filepath.IsAbs(configHome) { + configHome = filepath.Join(homeDir(), ".config") + } + return writeJSON(filepath.Join(configHome, "conjure", "theme.json"), map[string]any{"colors": mapping}) +} + func writeOMPTheme(colors, ui, syntax map[string]string) error { semantic, _ := semanticTheme(colors, ui, syntax) theme := map[string]any{ diff --git a/main_test.go b/main_test.go index 2a5778a..07dcdcf 100644 --- a/main_test.go +++ b/main_test.go @@ -167,6 +167,7 @@ case "$1" in esac `) t.Setenv("HOME", tmp) + t.Setenv("XDG_CONFIG_HOME", filepath.Join(tmp, "xdg")) t.Setenv("PATH", bin+":"+os.Getenv("PATH")) t.Setenv("TINTY_TEST_SCHEME", scheme.ID) t.Setenv("TINTY_TEST_LIST", listPath) @@ -180,6 +181,14 @@ esac if err := os.WriteFile(zed, []byte(`{"name":"old","themes":[{"name":"old"}]}`), 0o644); err != nil { t.Fatal(err) } + conjureConfig := filepath.Join(tmp, "xdg", "conjure", "config.json") + if err := os.MkdirAll(filepath.Dir(conjureConfig), 0o755); err != nil { + t.Fatal(err) + } + const userConfig = `{"theme":"tint","colors":{"accent":"#abcdef"}}` + if err := os.WriteFile(conjureConfig, []byte(userConfig), 0o644); err != nil { + t.Fatal(err) + } if err := applyApps(zed); err != nil { t.Fatal(err) @@ -201,6 +210,27 @@ esac if !strings.Contains(zedOutput, `"name": "Tinty Current"`) { t.Fatalf("Zed theme was not renamed:\n%s", zedOutput) } + var conjure struct { + Colors map[string]string `json:"colors"` + } + if err := json.Unmarshal([]byte(readFile(t, filepath.Join(filepath.Dir(conjureConfig), "theme.json"))), &conjure); err != nil { + t.Fatal(err) + } + expected := map[string]string{ + "background": "#010101", "text": "#fefefe", "secondaryText": "#444444", + "accent": "#123456", "glow": "#aa0000", "keyBackground": "#111111", "error": "#aa0000", + } + if len(conjure.Colors) != len(expected) { + t.Fatalf("wrong Conjure roles: %#v", conjure.Colors) + } + for key, value := range expected { + if conjure.Colors[key] != value { + t.Errorf("Conjure %s = %q, want %q", key, conjure.Colors[key], value) + } + } + if readFile(t, conjureConfig) != userConfig { + t.Fatal("Conjure user configuration changed") + } } func TestBase16SemanticFallback(t *testing.T) { @@ -226,6 +256,65 @@ func TestBase16SemanticFallback(t *testing.T) { } } +func TestConjureThemeFallbacks(t *testing.T) { + for _, system := range []string{"base16", "base24"} { + for _, xdg := range []string{"", "relative", "absolute"} { + t.Run(system+"/"+xdg, func(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + configHome := filepath.Join(home, ".config") + value := xdg + if xdg == "absolute" { + value = filepath.Join(home, "xdg") + configHome = value + } + t.Setenv("XDG_CONFIG_HOME", value) + palette := make(map[string]tintyColor) + for i := range 24 { + palette[fmt.Sprintf("base%02X", i)] = tintyColor{Hex: fmt.Sprintf("#%06x", i)} + } + colors, err := semanticColors(tintyScheme{System: system, Palette: palette}) + if err != nil { + t.Fatal(err) + } + if err := writeConjureTheme(colors, nil); err != nil { + t.Fatal(err) + } + var theme struct { + Colors map[string]string `json:"colors"` + } + if err := json.Unmarshal([]byte(readFile(t, filepath.Join(configHome, "conjure", "theme.json"))), &theme); err != nil { + t.Fatal(err) + } + for key, base := range map[string]string{ + "background": "base00", "text": "base05", "secondaryText": "base04", + "accent": "base0D", "glow": "base08", "keyBackground": "base01", "error": "base08", + } { + if theme.Colors[key] != colors[base] { + t.Errorf("%s = %q, want %q", key, theme.Colors[key], colors[base]) + } + } + }) + } + } +} + +func TestConjureThemeRejectsInvalidColors(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + t.Setenv("XDG_CONFIG_HOME", "") + for _, value := range []string{"", "#12345", "#12345678", "#gggggg", "#123456"} { + t.Run(value, func(t *testing.T) { + colors := map[string]string{ + "base00": "#000000", "base01": "#111111", "base04": "#444444", + "base05": "#555555", "base08": "#880000", "base0D": value, + } + if err := writeConjureTheme(colors, nil); err == nil { + t.Fatalf("accepted invalid accent %q", value) + } + }) + } +} + func writeExecutable(t *testing.T, path, content string) { t.Helper() if err := os.WriteFile(path, []byte(content), 0o755); err != nil {