From ffe8b986cef4c1079351762981952eaa90818e72 Mon Sep 17 00:00:00 2001 From: hl-valdemar Date: Fri, 18 Sep 2026 00:21:11 +0200 Subject: [PATCH] back up project --- .gitignore | 1 + Makefile | 74 +++++++++++++ README.md | 179 +++++++++++++++++++++++++++++++ apps.go | 259 +++++++++++++++++++++++++++++++++++++++++++++ config.toml | 19 ++++ go.mod | 5 + go.sum | 2 + main.go | 248 +++++++++++++++++++++++++++++++++++++++++++ main_test.go | 243 ++++++++++++++++++++++++++++++++++++++++++ state.go | 292 +++++++++++++++++++++++++++++++++++++++++++++++++++ 10 files changed, 1322 insertions(+) create mode 100644 .gitignore create mode 100644 Makefile create mode 100644 README.md create mode 100644 apps.go create mode 100644 config.toml create mode 100644 go.mod create mode 100644 go.sum create mode 100644 main.go create mode 100644 main_test.go create mode 100644 state.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e46e54a --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +state.toml diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..4a94da9 --- /dev/null +++ b/Makefile @@ -0,0 +1,74 @@ +XDG_CONFIG_HOME ?= $(HOME)/.config +CONFIG_DIR := $(XDG_CONFIG_HOME)/tinted-theming/tinty +BIN_DIR := $(HOME)/.local/bin +HERDR_DIR := $(HOME)/.config/herdr +HERDR_CONFIG := $(HERDR_DIR)/config.toml +HERDR_MARKER := $(HERDR_DIR)/.tint-created-config +LAUNCH_AGENT_DIR := $(HOME)/Library/LaunchAgents +LAUNCH_AGENT_LABEL := dev.tinty.dark-notify +LAUNCH_AGENT := $(LAUNCH_AGENT_DIR)/$(LAUNCH_AGENT_LABEL).plist +DARK_NOTIFY_LOG := $(HOME)/Library/Logs/dark-notify.log +CONFIG_MARKER := $(CONFIG_DIR)/.tint-installed-config +STATE_MARKER := $(CONFIG_DIR)/.tint-installed-state + +.PHONY: install uninstall +install: + @command -v tinty >/dev/null || { echo "tinty is required" >&2; exit 1; } + @command -v dark-notify >/dev/null || { echo "dark-notify is required" >&2; exit 1; } + mkdir -p "$(BIN_DIR)" "$(CONFIG_DIR)" "$(HERDR_DIR)" "$(LAUNCH_AGENT_DIR)" "$(dir $(DARK_NOTIFY_LOG))" + go build -o "$(BIN_DIR)/tint" . + @if [ "$(CURDIR)" != "$(CONFIG_DIR)" ]; then \ + if [ -e "$(CONFIG_MARKER)" ] || [ ! -e "$(CONFIG_DIR)/config.toml" ]; then \ + cp config.toml "$(CONFIG_DIR)/config.toml"; \ + touch "$(CONFIG_MARKER)"; \ + fi; \ + if [ ! -e "$(CONFIG_DIR)/state.toml" ]; then \ + cp state.toml "$(CONFIG_DIR)/state.toml"; \ + touch "$(STATE_MARKER)"; \ + fi; \ + fi + @if [ ! -e "$(HERDR_CONFIG)" ]; then \ + touch "$(HERDR_CONFIG)" "$(HERDR_MARKER)"; \ + fi + XDG_CONFIG_HOME="$(XDG_CONFIG_HOME)" tinty sync + @dark_notify="$$(command -v dark-notify)"; \ + printf '%s\n' \ + '' \ + '' \ + '' \ + '' \ + ' Label' \ + ' $(LAUNCH_AGENT_LABEL)' \ + ' ProgramArguments' \ + ' ' \ + " $$dark_notify" \ + ' -c' \ + ' $(BIN_DIR)/tint' \ + ' ' \ + ' RunAtLoad' \ + ' ' \ + ' KeepAlive' \ + ' ' \ + ' StandardOutPath' \ + ' $(DARK_NOTIFY_LOG)' \ + ' StandardErrorPath' \ + ' $(DARK_NOTIFY_LOG)' \ + '' \ + '' > "$(LAUNCH_AGENT)" + plutil -lint "$(LAUNCH_AGENT)" + @launchctl bootout "gui/$$(id -u)/$(LAUNCH_AGENT_LABEL)" 2>/dev/null || true + launchctl bootstrap "gui/$$(id -u)" "$(LAUNCH_AGENT)" + +uninstall: + @launchctl bootout "gui/$$(id -u)/$(LAUNCH_AGENT_LABEL)" 2>/dev/null || true + rm -f "$(BIN_DIR)/tint" "$(LAUNCH_AGENT)" "$(DARK_NOTIFY_LOG)" + @if [ -e "$(STATE_MARKER)" ]; then \ + rm -f "$(CONFIG_DIR)/state.toml" "$(STATE_MARKER)"; \ + fi + @if [ -e "$(CONFIG_MARKER)" ]; then \ + rm -f "$(CONFIG_DIR)/config.toml" "$(CONFIG_MARKER)"; \ + fi + @if [ -e "$(HERDR_MARKER)" ]; then \ + rm -f "$(HERDR_CONFIG)" "$(HERDR_MARKER)"; \ + fi + @rmdir "$(CONFIG_DIR)" "$(dir $(CONFIG_DIR))" "$(HERDR_DIR)" "$(BIN_DIR)" "$(LAUNCH_AGENT_DIR)" "$(dir $(DARK_NOTIFY_LOG))" "$(XDG_CONFIG_HOME)" "$(HOME)/.config" "$(HOME)/.local" 2>/dev/null || true diff --git a/README.md b/README.md new file mode 100644 index 0000000..dbdb3d6 --- /dev/null +++ b/README.md @@ -0,0 +1,179 @@ +# 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. + +## Requirements + +This setup targets macOS and requires: + +- [Homebrew](https://brew.sh/) +- Go 1.26 or newer +- Tinty 0.34 or newer +- [`dark-notify`](https://github.com/cormacrelf/dark-notify) for automatic switching when macOS changes appearance +- `make` from the Xcode Command Line Tools + +Install the required tools: + +```sh +xcode-select --install +brew tap tinted-theming/tinted +brew install tinty go +brew install cormacrelf/tap/dark-notify +``` + +Ghostty, Zed, Herdr, and OMP are optional consumers. Install only the applications you use. + +## Install + +From this directory, run: + +```sh +make install +``` + +The installer: + +- Builds `~/.local/bin/tint`. +- Installs `config.toml` under `$XDG_CONFIG_HOME/tinted-theming/tinty`, or `~/.config/tinted-theming/tinty` when `XDG_CONFIG_HOME` is unset. +- Copies `state.toml` only when no state file already exists. +- Creates `~/.config/herdr/config.toml` if needed. +- Runs `tinty sync` to download the configured theme repositories. +- Creates and loads a `dark-notify` LaunchAgent for automatic macOS appearance switching. + +Add the binary directory to your shell path: + +```sh +echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.zshrc +source ~/.zshrc +``` + +Verify the installation: + +```sh +which tint +tinty --version +tint +``` + +## Automatic light/dark switching + +`make install` creates and loads `~/Library/LaunchAgents/dev.tinty.dark-notify.plist`. The agent starts at login, restarts if it exits, and invokes `tint light` or `tint dark` whenever macOS changes appearance. + +Confirm that the agent is running: + +```sh +launchctl print "gui/$(id -u)/dev.tinty.dark-notify" +``` + +Inspect its output if an application does not update: + +```sh +tail -f ~/Library/Logs/dark-notify.log +``` + +## Application setup + +### Ghostty + +The Tinty hook writes: + +```text +~/.config/ghostty/themes/tinted-theming +``` + +Select it in the Ghostty configuration: + +```text +theme = tinted-theming +``` + +The hook sends `SIGUSR2` to reload Ghostty after applying a theme. + +### Zed + +The wrapper writes: + +```text +~/.config/zed/themes/tinty-current.json +``` + +After applying the first theme, select `Tinty Current` in Zed. + +### Herdr + +The wrapper maintains a generated `[theme.custom]` block in: + +```text +~/.config/herdr/config.toml +``` + +When the `herdr` executable is available, the wrapper also runs `herdr server reload-config`. + +### OMP + +The wrapper writes: + +```text +~/.omp/agent/themes/tinty-current.json +``` + +Configure OMP to use `tinty-current` where required. + +## Usage + +Show the current pair and available commands: + +```sh +tint +``` + +Select a named pair: + +```sh +tint swamp +``` + +Apply one side of the current pair: + +```sh +tint light +tint dark +``` + +Set both sides: + +```sh +tint light:base16-swamp-light dark:base16-swamp-dark +``` + +Save and favorite a named pair: + +```sh +tint pair swamp dark:base16-swamp-dark light:base16-swamp-light +tint favorite swamp +``` + +Force appearance selection for scripts or troubleshooting: + +```sh +TINTY_PAIR_APPEARANCE=light tint swamp +TINTY_PAIR_APPEARANCE=dark tint swamp +``` + +## Update + +After changing or updating the wrapper source: + +```sh +make install +``` + +Existing `state.toml` content is preserved. + +## Uninstall + +```sh +make uninstall +``` + +This unloads and removes the LaunchAgent, log, and `tint` binary. It also removes configuration, state, and the empty Herdr configuration only when they were originally created by `make install`; pre-existing user files are preserved. Tinty itself and its shared data remain installed. diff --git a/apps.go b/apps.go new file mode 100644 index 0000000..83fe382 --- /dev/null +++ b/apps.go @@ -0,0 +1,259 @@ +package main + +import ( + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "regexp" + "strings" +) + +type tintyColor struct { + Hex string `json:"hex_str"` +} + +type tintyScheme struct { + ID string `json:"id"` + System string `json:"system"` + Variant string `json:"variant"` + Palette map[string]tintyColor `json:"palette"` + UI map[string]tintyColor `json:"ui"` + Syntax map[string]tintyColor `json:"syntax"` +} + +func applyApps(zedSource string) error { + scheme, err := currentScheme() + if err != nil { + return err + } + colors, err := semanticColors(scheme) + if err != nil { + return err + } + ui := colorValues(scheme.UI) + syntax := colorValues(scheme.Syntax) + if err := writeOMPTheme(colors, ui, syntax); err != nil { + return err + } + if err := writeHerdrTheme(colors, ui, syntax); err != nil { + return err + } + return writeZedTheme(zedSource) +} + +func currentScheme() (tintyScheme, error) { + current, err := exec.Command("tinty", "current").Output() + if err != nil { + return tintyScheme{}, err + } + output, err := exec.Command("tinty", "list", "--json").Output() + if err != nil { + return tintyScheme{}, err + } + var schemes []tintyScheme + if err := json.Unmarshal(output, &schemes); err != nil { + return tintyScheme{}, err + } + id := strings.TrimSpace(string(current)) + for _, scheme := range schemes { + if scheme.ID == id { + return scheme, nil + } + } + return tintyScheme{}, fmt.Errorf("scheme not found: %s", id) +} + +func colorValues(values map[string]tintyColor) map[string]string { + result := make(map[string]string, len(values)) + for name, value := range values { + result[name] = value.Hex + } + return result +} + +func semanticColors(scheme tintyScheme) (map[string]string, error) { + colors := colorValues(scheme.Palette) + if colors["base00"] != "" { + return colors, nil + } + if scheme.System != "tinted8" { + return nil, fmt.Errorf("unsupported scheme system: %s", scheme.System) + } + neutrals := map[string]string{ + "base00": "black-normal", "base01": "black-bright", + "base02": "gray-dim", "base03": "gray-normal", + "base04": "gray-bright", "base05": "white-normal", + "base06": "white-bright", "base07": "white-bright", + } + if scheme.Variant == "light" { + neutrals = map[string]string{ + "base00": "white-normal", "base01": "white-dim", + "base02": "gray-bright", "base03": "gray-normal", + "base04": "gray-dim", "base05": "black-normal", + "base06": "black-dim", "base07": "black-dim", + } + } + aliases := map[string]string{ + "base08": "red-normal", "base09": "orange-normal", + "base0A": "yellow-normal", "base0B": "green-normal", + "base0C": "cyan-normal", "base0D": "blue-normal", + "base0E": "magenta-normal", "base0F": "brown-normal", + } + for base, tinted8 := range neutrals { + aliases[base] = tinted8 + } + for base, tinted8 := range aliases { + value := colors[tinted8] + if value == "" { + return nil, fmt.Errorf("scheme %s lacks %s", scheme.ID, tinted8) + } + colors[base] = value + } + return colors, nil +} + +func pick(values map[string]string, key string, colors map[string]string, fallback string) string { + if value := values[key]; value != "" { + return value + } + return colors[fallback] +} + +func semanticTheme(colors, ui, syntax map[string]string) (map[string]string, map[string]string) { + u := func(key, fallback string) string { return pick(ui, key, colors, fallback) } + s := func(key, fallback string) string { return pick(syntax, key, colors, fallback) } + accent := u("accent.normal", "base0D") + foreground := u("global.foreground.normal", "base05") + muted := u("global.foreground.dark", "base04") + panel := u("chrome.background.normal", "base01") + info := u("status.info", "base0C") + success := u("status.success", "base0B") + warning := u("status.warning", "base0A") + failure := u("status.error", "base08") + omp := map[string]string{ + "accent": accent, "border": u("border.normal", "base03"), + "borderAccent": accent, "borderMuted": u("indent-guide.background", "base02"), + "success": success, "error": failure, "warning": warning, + "muted": muted, "dim": u("whitespace.foreground", "base03"), + "text": foreground, "thinkingText": muted, + "selectedBg": u("selection.background", "base02"), + "userMessageBg": panel, "customMessageBg": panel, + "toolPendingBg": u("highlight.line.background", "base01"), + "toolSuccessBg": u("highlight.line.background", "base01"), + "toolErrorBg": u("highlight.line.background", "base01"), + "statusLineBg": u("chrome.background.dark", "base01"), + "userMessageText": foreground, "customMessageText": foreground, + "customMessageLabel": accent, "toolTitle": u("chrome.foreground.normal", "base05"), + "toolOutput": muted, "mdHeading": s("markup.heading", "base0D"), + "mdLink": s("markup.link", "base0C"), "mdLinkUrl": u("link.normal.foreground", "base04"), + "mdCode": s("markup.raw", "base0B"), "mdCodeBlock": s("text", "base05"), + "mdCodeBlockBorder": u("border.normal", "base02"), + "mdQuote": s("markup.quote", "base04"), "mdQuoteBorder": u("border.normal", "base03"), + "mdHr": u("indent-guide.background", "base02"), "mdListBullet": s("markup.list", "base0D"), + "toolDiffAdded": s("markup.inserted", "base0B"), + "toolDiffRemoved": s("markup.deleted", "base08"), + "toolDiffContext": s("markup.changed", "base04"), + "syntaxComment": s("comment", "base03"), "syntaxKeyword": s("keyword", "base0E"), + "syntaxFunction": s("entity.name.function", "base0D"), + "syntaxVariable": s("variable", "base05"), "syntaxString": s("string", "base0B"), + "syntaxNumber": s("constant.numeric", "base09"), "syntaxType": s("entity.name.type", "base0C"), + "syntaxOperator": s("keyword.operator", "base05"), "syntaxPunctuation": s("punctuation", "base05"), + "thinkingOff": u("whitespace.foreground", "base03"), "thinkingMinimal": muted, + "thinkingLow": info, "thinkingMedium": accent, "thinkingHigh": s("keyword", "base0E"), + "thinkingXhigh": warning, "thinkingMax": failure, + "bashMode": s("string", "base0C"), "pythonMode": s("keyword", "base0A"), + "statusLineSep": u("border.normal", "base03"), "statusLineModel": accent, + "statusLinePath": u("link.normal.foreground", "base0D"), + "statusLineGitClean": success, "statusLineGitDirty": warning, + "statusLineContext": info, "statusLineSpend": accent, "statusLineStaged": success, + "statusLineDirty": warning, "statusLineUntracked": info, + "statusLineOutput": foreground, "statusLineCost": warning, "statusLineSubagents": accent, + } + herdr := map[string]string{ + "accent": accent, "panel_bg": u("global.background.normal", "base00"), + "sidebar_bg": u("chrome.background.dark", "base00"), + "active_row_bg": u("highlight.line.background", "base01"), + "selection_bg": u("selection.background", "base02"), + "surface0": panel, "surface1": u("highlight.line.background", "base02"), + "surface_dim": u("global.background.dark", "base00"), + "overlay0": u("border.normal", "base03"), "overlay1": muted, + "text": foreground, "subtext0": muted, "mauve": s("keyword", "base0E"), + "green": success, "yellow": warning, "red": failure, "blue": info, + "teal": accent, "peach": s("constant", "base09"), + } + return omp, herdr +} + +func writeOMPTheme(colors, ui, syntax map[string]string) error { + semantic, _ := semanticTheme(colors, ui, syntax) + theme := map[string]any{ + "name": "tinty-current", + "vars": colors, + "colors": semantic, + "export": map[string]string{ + "pageBg": pick(ui, "global.background.normal", colors, "base00"), + "cardBg": pick(ui, "chrome.background.normal", colors, "base01"), + "infoBg": pick(ui, "highlight.line.background", colors, "base02"), + }, + } + return writeJSON(filepath.Join(homeDir(), ".omp", "agent", "themes", "tinty-current.json"), theme) +} + +func writeHerdrTheme(colors, ui, syntax map[string]string) error { + _, mapping := semanticTheme(colors, ui, syntax) + path := filepath.Join(homeDir(), ".config", "herdr", "config.toml") + data, err := os.ReadFile(path) + if err != nil { + return err + } + keys := []string{"accent", "panel_bg", "sidebar_bg", "active_row_bg", "selection_bg", "surface0", "surface1", "surface_dim", "overlay0", "overlay1", "text", "subtext0", "mauve", "green", "yellow", "red", "blue", "teal", "peach"} + var block strings.Builder + block.WriteString("# tint generated theme start\n[theme.custom]\n") + for _, key := range keys { + fmt.Fprintf(&block, "%s = %q\n", key, mapping[key]) + } + block.WriteString("# tint generated theme end\n") + pattern := regexp.MustCompile(`(?s)\n?` + regexp.QuoteMeta("# tint generated theme start") + `.*?` + regexp.QuoteMeta("# tint generated theme end") + `\n?`) + clean := strings.TrimSpace(pattern.ReplaceAllString(string(data), "\n")) + if err := writeAtomic(path, []byte(clean+"\n\n"+block.String()), 0o644); err != nil { + return err + } + if _, err := exec.LookPath("herdr"); err == nil { + _ = exec.Command("herdr", "server", "reload-config").Run() + } + return nil +} + +func writeZedTheme(source string) error { + data, err := os.ReadFile(source) + if err != nil { + return err + } + var theme map[string]any + if err := json.Unmarshal(data, &theme); err != nil { + return err + } + theme["name"] = "Tinty Current" + variants, ok := theme["themes"].([]any) + if !ok { + return fmt.Errorf("invalid Zed theme: %s", source) + } + for _, value := range variants { + variant, ok := value.(map[string]any) + if !ok { + return fmt.Errorf("invalid Zed theme: %s", source) + } + variant["name"] = "Tinty Current" + } + return writeJSON(filepath.Join(homeDir(), ".config", "zed", "themes", "tinty-current.json"), theme) +} + +func writeJSON(path string, value any) error { + data, err := json.MarshalIndent(value, "", " ") + if err != nil { + return err + } + return writeAtomic(path, append(data, '\n'), 0o644) +} diff --git a/config.toml b/config.toml new file mode 100644 index 0000000..a007b66 --- /dev/null +++ b/config.toml @@ -0,0 +1,19 @@ +shell = "sh -c '{}'" + +[[items]] +name = "tinted-zed" +path = "https://github.com/tinted-theming/tinted-zed" +themes-dir = "themes" +supported-systems = ["base16", "base24", "tinted8"] +hook = "$HOME/.local/bin/tint apply-apps \"$TINTY_THEME_FILE_PATH\"" + +[[items]] +name = "tinted-terminal" +path = "https://github.com/tinted-theming/tinted-terminal" +themes-dir = "themes/ghostty" +supported-systems = ["base16", "base24", "tinted8"] +hook = """ +mkdir -p ~/.config/ghostty/themes +cp -f "$TINTY_THEME_FILE_PATH" ~/.config/ghostty/themes/tinted-theming +killall -SIGUSR2 ghostty 2>/dev/null || true +""" diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..0943703 --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module tint + +go 1.26 + +require github.com/BurntSushi/toml v1.6.0 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..f74b269 --- /dev/null +++ b/go.sum @@ -0,0 +1,2 @@ +github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= +github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= diff --git a/main.go b/main.go new file mode 100644 index 0000000..c2d3721 --- /dev/null +++ b/main.go @@ -0,0 +1,248 @@ +package main + +import ( + "errors" + "fmt" + "io" + "os" + "os/exec" + "regexp" + "strings" +) + +var pairNamePattern = regexp.MustCompile(`^[A-Za-z0-9._-]+$`) + +type usageError struct{} + +func (usageError) Error() string { return "invalid arguments" } + +func main() { + if err := runCLI(os.Args[1:], os.Stdout, os.Stderr); err != nil { + var usage usageError + if errors.As(err, &usage) { + printHelp(os.Stderr, defaultStatePath()) + os.Exit(2) + } + fmt.Fprintf(os.Stderr, "tint: %v\n", err) + os.Exit(1) + } +} + +func runCLI(args []string, stdout, stderr io.Writer) error { + statePath := defaultStatePath() + if len(args) == 2 && args[0] == "apply-apps" { + return applyApps(args[1]) + } + if len(args) == 0 { + printHelp(stdout, statePath) + return nil + } + if len(args) == 1 { + switch args[0] { + case "-h", "--help": + printHelp(stdout, statePath) + return nil + case "config": + data, err := os.ReadFile(statePath) + if err != nil { + return err + } + _, err = stdout.Write(data) + return err + case "light", "dark": + value, err := loadState(statePath) + if errors.Is(err, os.ErrNotExist) { + return nil + } + if err != nil { + return err + } + if err := validatePair(value.Current, stderr); err != nil { + return err + } + return applySelected(value.Current, args[0], stdout, stderr) + } + if mode, theme, ok := parseThemeArg(args[0]); ok { + value, err := loadState(statePath) + if err != nil { + return usageError{} + } + if mode == "light" { + value.Current.Light = theme + } else { + value.Current.Dark = theme + } + return updateAndApply(statePath, value, "", stdout, stderr) + } + value, err := loadState(statePath) + if err != nil { + return err + } + named, _, found := value.Catalog.findPair(args[0]) + if !found { + return fmt.Errorf("unknown pair: %s", args[0]) + } + value.Current = currentPair{Light: named.Light, Dark: named.Dark} + return updateAndApply(statePath, value, "", stdout, stderr) + } + if len(args) == 2 { + if args[0] == "favorite" || args[0] == "unfavorite" { + value, err := loadState(statePath) + if err != nil { + return err + } + _, _, isPair := value.Catalog.findPair(args[1]) + if !isPair && args[0] == "favorite" { + if err := validateTheme(args[1], stderr); err != nil { + return err + } + } + if args[0] == "favorite" { + value.Catalog.favorite(args[1]) + } else { + value.Catalog.unfavorite(args[1]) + } + return saveState(statePath, value) + } + firstMode, firstTheme, firstOK := parseThemeArg(args[0]) + secondMode, secondTheme, secondOK := parseThemeArg(args[1]) + if !firstOK || !secondOK || firstMode == secondMode { + return usageError{} + } + value, err := loadState(statePath) + if errors.Is(err, os.ErrNotExist) { + value = state{} + } else if err != nil { + return err + } + if firstMode == "light" { + value.Current.Light, value.Current.Dark = firstTheme, secondTheme + } else { + value.Current.Dark, value.Current.Light = firstTheme, secondTheme + } + return updateAndApply(statePath, value, "", stdout, stderr) + } + if len(args) == 4 && args[0] == "pair" { + if !pairNamePattern.MatchString(args[1]) || reservedPairName(args[1]) { + return fmt.Errorf("invalid pair name: %s", args[1]) + } + darkMode, dark, darkOK := parseThemeArg(args[2]) + lightMode, light, lightOK := parseThemeArg(args[3]) + if !darkOK || !lightOK || darkMode != "dark" || lightMode != "light" { + return usageError{} + } + if err := validatePair(currentPair{Light: light, Dark: dark}, stderr); err != nil { + return err + } + value, err := loadState(statePath) + if err != nil { + return err + } + value.Catalog.configurePair(themePair{Name: args[1], Dark: dark, Light: light}) + return saveState(statePath, value) + } + return usageError{} +} + +func parseThemeArg(arg string) (string, string, bool) { + mode, theme, ok := strings.Cut(arg, ":") + if !ok || theme == "" || (mode != "light" && mode != "dark") { + return "", "", false + } + return mode, theme, true +} + +func reservedPairName(name string) bool { + switch name { + case "config", "light", "dark", "pair", "favorite", "unfavorite", "help", "-h", "--help", "apply-apps": + return true + default: + return false + } +} + +func updateAndApply(path string, value state, requested string, stdout, stderr io.Writer) error { + if err := validatePair(value.Current, stderr); err != nil { + return err + } + value.Catalog.record(value.Current) + if err := saveState(path, value); err != nil { + return err + } + var err error + if requested == "" { + requested, err = currentAppearance() + if err != nil { + return err + } + } + return applySelected(value.Current, requested, stdout, stderr) +} + +func validatePair(pair currentPair, stderr io.Writer) error { + if pair.Light == "" || pair.Dark == "" { + return usageError{} + } + if err := validateTheme(pair.Light, stderr); err != nil { + return err + } + return validateTheme(pair.Dark, stderr) +} + +func validateTheme(theme string, stderr io.Writer) error { + cmd := exec.Command("tinty", "info", theme) + cmd.Stdout = io.Discard + cmd.Stderr = stderr + return cmd.Run() +} + +func currentAppearance() (string, error) { + if appearance := os.Getenv("TINTY_PAIR_APPEARANCE"); appearance != "" { + if appearance != "light" && appearance != "dark" { + return "", fmt.Errorf("TINTY_PAIR_APPEARANCE must be light or dark") + } + return appearance, nil + } + output, err := exec.Command("defaults", "read", "-g", "AppleInterfaceStyle").Output() + if err == nil && strings.TrimSpace(string(output)) == "Dark" { + return "dark", nil + } + return "light", nil +} + +func applySelected(pair currentPair, appearance string, stdout, stderr io.Writer) error { + theme := pair.Light + if appearance == "dark" { + theme = pair.Dark + } + cmd := exec.Command("tinty", "apply", theme) + cmd.Stdout = stdout + cmd.Stderr = stderr + return cmd.Run() +} + +func printCurrent(writer io.Writer, path string) { + value, err := loadState(path) + if err != nil { + value.Current = currentPair{Light: "-", Dark: "-"} + } + fmt.Fprintf(writer, "[current]\nlight:%s\ndark:%s\n\n", value.Current.Light, value.Current.Dark) +} + +func printHelp(writer io.Writer, statePath string) { + io.WriteString(writer, `Usage: + tint Show this help + tint config Print the configuration + tint favorite Add a theme or pair to favorites + tint unfavorite Remove a theme or pair from favorites + tint Switch to a saved pair + tint pair dark: light: + Save a named theme pair + tint light|dark Apply the saved light or dark theme + tint light: Set the light theme + tint dark: Set the dark theme + tint light: dark: Set both themes + +`) + printCurrent(writer, statePath) +} diff --git a/main_test.go b/main_test.go new file mode 100644 index 0000000..2a5778a --- /dev/null +++ b/main_test.go @@ -0,0 +1,243 @@ +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestStateTransitions(t *testing.T) { + value := state{ + Current: currentPair{Light: "old-light", Dark: "old-dark"}, + Catalog: catalog{ + FavoritePairs: []themePair{{Name: "swamp", Dark: "swamp-dark", Light: "swamp-light"}}, + FavoriteThemes: []string{"favorite"}, + OtherPairs: []themePair{{Name: "nord", Dark: "nord-dark", Light: "nord-light"}}, + OtherThemes: []string{"swamp-dark", "nord-light", "other"}, + }, + } + value.Catalog.normalize() + if len(value.Catalog.OtherThemes) != 1 || value.Catalog.OtherThemes[0] != "other" { + t.Fatalf("pair themes leaked into other themes: %#v", value.Catalog.OtherThemes) + } + if !value.Catalog.favorite("nord") { + t.Fatal("named pair was treated as a theme") + } + value.Catalog.configurePair(themePair{Name: "nord", Dark: "new-dark", Light: "new-light"}) + pair, favorite, found := value.Catalog.findPair("nord") + if !found || !favorite || pair.Dark != "new-dark" { + t.Fatalf("reconfigured pair lost favorite state: %#v", value.Catalog) + } + if !value.Catalog.unfavorite("nord") { + t.Fatal("favorite pair was treated as a theme") + } + + path := filepath.Join(t.TempDir(), "state.toml") + if err := saveState(path, value); err != nil { + t.Fatal(err) + } + roundTrip, err := loadState(path) + if err != nil { + t.Fatal(err) + } + if roundTrip.Current != value.Current { + t.Fatalf("current pair changed after round trip: %#v", roundTrip.Current) + } + if _, favorite, found := roundTrip.Catalog.findPair("nord"); !found || favorite { + t.Fatalf("pair did not remain in other pairs: %#v", roundTrip.Catalog) + } + config := readFile(t, path) + if strings.Contains(config, "\n ") { + t.Fatalf("configuration is unnecessarily indented:\n%s", config) + } + order := []string{"[current]", "[[favorites.pairs]]", "[favorites]", "[[others.pairs]]", "[others]"} + last := -1 + for _, section := range order { + index := strings.Index(config, section) + if index <= last { + t.Fatalf("configuration section order is wrong:\n%s", config) + } + last = index + } + if strings.Contains(config, "current =") || strings.Contains(config, "[[favorite_themes]]") || strings.Contains(config, "[[other_themes]]") { + t.Fatalf("state entries used the wrong TOML shape:\n%s", config) + } +} + +func TestCLIUpdatesAndAppliesPair(t *testing.T) { + tmp := t.TempDir() + bin := filepath.Join(tmp, "bin") + if err := os.Mkdir(bin, 0o755); err != nil { + t.Fatal(err) + } + log := filepath.Join(tmp, "applied") + writeExecutable(t, filepath.Join(bin, "tinty"), `#!/bin/sh +case "$1" in + info) [ "$2" != missing ] ;; + apply) printf '%s\n' "$2" >> "$TINTY_LOG" ;; + *) exit 2 ;; +esac +`) + statePath := filepath.Join(tmp, "state.toml") + t.Setenv("PATH", bin+":"+os.Getenv("PATH")) + t.Setenv("TINTY_LOG", log) + t.Setenv("TINT_STATE_FILE", statePath) + t.Setenv("TINTY_PAIR_APPEARANCE", "dark") + if err := saveState(statePath, state{Current: currentPair{Light: "old-light", Dark: "old-dark"}}); err != nil { + t.Fatal(err) + } + + var stdout, stderr bytes.Buffer + if err := runCLI([]string{"dark:new-dark"}, &stdout, &stderr); err != nil { + t.Fatalf("update failed: %v: %s", err, stderr.String()) + } + value, err := loadState(statePath) + if err != nil || value.Current.Dark != "new-dark" || value.Current.Light != "old-light" { + t.Fatalf("wrong current pair: %#v, %v", value.Current, err) + } + if got := strings.TrimSpace(readFile(t, log)); got != "new-dark" { + t.Fatalf("wrong applied theme: %q", got) + } + + if err := runCLI([]string{"pair", "nord", "dark:nord-dark", "light:nord-light"}, &stdout, &stderr); err != nil { + t.Fatal(err) + } + if err := runCLI([]string{"favorite", "nord"}, &stdout, &stderr); err != nil { + t.Fatal(err) + } + if err := runCLI([]string{"nord"}, &stdout, &stderr); err != nil { + t.Fatal(err) + } + value, err = loadState(statePath) + if err != nil || value.Current.Dark != "nord-dark" || value.Current.Light != "nord-light" { + t.Fatalf("named pair not applied: %#v, %v", value.Current, err) + } + if _, favorite, found := value.Catalog.findPair("nord"); !found || !favorite { + t.Fatalf("pair not favorited: %#v", value.Catalog) + } + stdout.Reset() + if err := runCLI([]string{"config"}, &stdout, &stderr); err != nil { + t.Fatal(err) + } + if stdout.String() != readFile(t, statePath) { + t.Fatal("tint config did not print the state file verbatim") + } +} + +func TestApplyAppsUsesTinted8Semantics(t *testing.T) { + tmp := t.TempDir() + bin := filepath.Join(tmp, "bin") + if err := os.Mkdir(bin, 0o755); err != nil { + t.Fatal(err) + } + palette := map[string]tintyColor{ + "black-normal": {Hex: "#000000"}, "black-bright": {Hex: "#111111"}, + "gray-dim": {Hex: "#222222"}, "gray-normal": {Hex: "#333333"}, "gray-bright": {Hex: "#444444"}, + "white-normal": {Hex: "#eeeeee"}, "white-bright": {Hex: "#ffffff"}, + "red-normal": {Hex: "#aa0000"}, "orange-normal": {Hex: "#bb5500"}, + "yellow-normal": {Hex: "#aaaa00"}, "green-normal": {Hex: "#00aa00"}, + "cyan-normal": {Hex: "#00aaaa"}, "blue-normal": {Hex: "#0000aa"}, + "magenta-normal": {Hex: "#aa00aa"}, "brown-normal": {Hex: "#663300"}, + } + scheme := tintyScheme{ + ID: "tinted8-test", System: "tinted8", Variant: "dark", Palette: palette, + UI: map[string]tintyColor{ + "accent.normal": {Hex: "#123456"}, "global.background.normal": {Hex: "#010101"}, + "global.foreground.normal": {Hex: "#fefefe"}, + }, + Syntax: map[string]tintyColor{"entity.name.function": {Hex: "#654321"}}, + } + listPath := filepath.Join(tmp, "schemes.json") + data, err := json.Marshal([]tintyScheme{scheme}) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(listPath, data, 0o644); err != nil { + t.Fatal(err) + } + writeExecutable(t, filepath.Join(bin, "tinty"), `#!/bin/sh +case "$1" in + current) printf '%s\n' "$TINTY_TEST_SCHEME" ;; + list) cat "$TINTY_TEST_LIST" ;; + *) exit 2 ;; +esac +`) + t.Setenv("HOME", tmp) + t.Setenv("PATH", bin+":"+os.Getenv("PATH")) + t.Setenv("TINTY_TEST_SCHEME", scheme.ID) + t.Setenv("TINTY_TEST_LIST", listPath) + if err := os.MkdirAll(filepath.Join(tmp, ".config", "herdr"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(tmp, ".config", "herdr", "config.toml"), []byte("onboarding = false\n"), 0o644); err != nil { + t.Fatal(err) + } + zed := filepath.Join(tmp, "zed.json") + if err := os.WriteFile(zed, []byte(`{"name":"old","themes":[{"name":"old"}]}`), 0o644); err != nil { + t.Fatal(err) + } + + if err := applyApps(zed); err != nil { + t.Fatal(err) + } + var omp struct { + Colors map[string]string `json:"colors"` + } + if err := json.Unmarshal([]byte(readFile(t, filepath.Join(tmp, ".omp", "agent", "themes", "tinty-current.json"))), &omp); err != nil { + t.Fatal(err) + } + if omp.Colors["accent"] != "#123456" || omp.Colors["syntaxFunction"] != "#654321" { + t.Fatalf("semantic colors ignored: %#v", omp.Colors) + } + herdr := readFile(t, filepath.Join(tmp, ".config", "herdr", "config.toml")) + if !strings.Contains(herdr, `accent = "#123456"`) { + t.Fatalf("semantic Herdr accent missing:\n%s", herdr) + } + zedOutput := readFile(t, filepath.Join(tmp, ".config", "zed", "themes", "tinty-current.json")) + if !strings.Contains(zedOutput, `"name": "Tinty Current"`) { + t.Fatalf("Zed theme was not renamed:\n%s", zedOutput) + } +} + +func TestBase16SemanticFallback(t *testing.T) { + palette := make(map[string]tintyColor) + for i, value := range []string{ + "#000000", "#111111", "#222222", "#333333", + "#444444", "#555555", "#666666", "#777777", + "#880000", "#990000", "#aaaa00", "#00bb00", + "#00cccc", "#0000dd", "#ee00ee", "#ff00ff", + } { + palette[fmt.Sprintf("base%02X", i)] = tintyColor{Hex: value} + } + colors, err := semanticColors(tintyScheme{System: "base16", Palette: palette}) + if err != nil { + t.Fatal(err) + } + omp, herdr := semanticTheme(colors, nil, nil) + if omp["accent"] != colors["base0D"] || omp["syntaxFunction"] != colors["base0D"] { + t.Fatalf("Base16 OMP fallback changed: %#v", omp) + } + if herdr["panel_bg"] != colors["base00"] || herdr["text"] != colors["base05"] { + t.Fatalf("Base16 Herdr fallback changed: %#v", herdr) + } +} + +func writeExecutable(t *testing.T, path, content string) { + t.Helper() + if err := os.WriteFile(path, []byte(content), 0o755); err != nil { + t.Fatal(err) + } +} + +func readFile(t *testing.T, path string) string { + t.Helper() + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + return string(data) +} diff --git a/state.go b/state.go new file mode 100644 index 0000000..7fb1856 --- /dev/null +++ b/state.go @@ -0,0 +1,292 @@ +package main + +import ( + "fmt" + "os" + "path/filepath" + "slices" + "sort" + "strconv" + "strings" + + "github.com/BurntSushi/toml" +) + +type themePair struct { + Name string `toml:"name"` + Dark string `toml:"dark"` + Light string `toml:"light"` +} +type storedCatalog struct { + Pairs []themePair `toml:"pairs"` + Themes []string `toml:"themes"` +} + +type currentPair struct { + Light string `toml:"light"` + Dark string `toml:"dark"` +} + +type catalog struct { + FavoritePairs []themePair + FavoriteThemes []string + OtherPairs []themePair + OtherThemes []string +} + +type state struct { + Current currentPair + Catalog catalog +} + +type storedState struct { + Current currentPair `toml:"current"` + Favorites storedCatalog `toml:"favorites"` + Others storedCatalog `toml:"others"` +} + +func defaultStatePath() string { + if path := os.Getenv("TINT_STATE_FILE"); path != "" { + return path + } + config := os.Getenv("XDG_CONFIG_HOME") + if config == "" { + config = filepath.Join(homeDir(), ".config") + } + return filepath.Join(config, "tinted-theming", "tinty", "state.toml") +} + +func loadState(path string) (state, error) { + var stored storedState + metadata, err := toml.DecodeFile(path, &stored) + if err != nil { + return state{}, err + } + if unknown := metadata.Undecoded(); len(unknown) != 0 { + return state{}, fmt.Errorf("unknown state keys: %s", unknownKeys(unknown)) + } + value := state{ + Current: stored.Current, + Catalog: catalog{ + FavoritePairs: stored.Favorites.Pairs, + FavoriteThemes: stored.Favorites.Themes, + OtherPairs: stored.Others.Pairs, + OtherThemes: stored.Others.Themes, + }, + } + value.Catalog.normalize() + return value, nil +} + +func saveState(path string, value state) error { + return writeAtomic(path, []byte(formatState(value)), 0o644) +} + +func formatState(value state) string { + value.Catalog.normalize() + var output strings.Builder + fmt.Fprintf(&output, "[current]\nlight = %s\ndark = %s\n\n", + strconv.Quote(value.Current.Light), strconv.Quote(value.Current.Dark)) + writePairs := func(name string, pairs []themePair) { + for _, pair := range pairs { + fmt.Fprintf(&output, "[[%s]]\nname = %s\ndark = %s\nlight = %s\n\n", + name, strconv.Quote(pair.Name), strconv.Quote(pair.Dark), strconv.Quote(pair.Light)) + } + } + writeThemes := func(name string, themes []string) { + fmt.Fprintf(&output, "[%s]\nthemes = [\n", name) + for _, theme := range themes { + fmt.Fprintf(&output, "%s,\n", strconv.Quote(theme)) + } + output.WriteString("]\n\n") + } + writePairs("favorites.pairs", value.Catalog.FavoritePairs) + writeThemes("favorites", value.Catalog.FavoriteThemes) + writePairs("others.pairs", value.Catalog.OtherPairs) + writeThemes("others", value.Catalog.OtherThemes) + return strings.TrimRight(output.String(), "\n") + "\n" +} + +func unknownKeys(keys []toml.Key) string { + values := make([]string, len(keys)) + for i, key := range keys { + values[i] = key.String() + } + return strings.Join(values, ", ") +} + +func (c *catalog) normalize() { + c.FavoritePairs = uniquePairs(c.FavoritePairs) + favoritePairNames := make(map[string]bool, len(c.FavoritePairs)) + for _, pair := range c.FavoritePairs { + favoritePairNames[pair.Name] = true + } + otherPairs := uniquePairs(c.OtherPairs) + c.OtherPairs = c.OtherPairs[:0] + for _, pair := range otherPairs { + if !favoritePairNames[pair.Name] { + c.OtherPairs = append(c.OtherPairs, pair) + } + } + + c.FavoriteThemes = uniqueStrings(c.FavoriteThemes) + blocked := make(map[string]bool) + for _, theme := range c.FavoriteThemes { + blocked[theme] = true + } + for _, pair := range append(slices.Clone(c.FavoritePairs), c.OtherPairs...) { + blocked[pair.Dark] = true + blocked[pair.Light] = true + } + otherThemes := uniqueStrings(c.OtherThemes) + c.OtherThemes = c.OtherThemes[:0] + for _, theme := range otherThemes { + if !blocked[theme] { + c.OtherThemes = append(c.OtherThemes, theme) + } + } +} + +func uniquePairs(pairs []themePair) []themePair { + byName := make(map[string]themePair, len(pairs)) + for _, pair := range pairs { + byName[pair.Name] = pair + } + result := make([]themePair, 0, len(byName)) + for _, pair := range byName { + result = append(result, pair) + } + sort.Slice(result, func(i, j int) bool { return result[i].Name < result[j].Name }) + return result +} + +func uniqueStrings(values []string) []string { + seen := make(map[string]bool, len(values)) + result := make([]string, 0, len(values)) + for _, value := range values { + if value != "" && !seen[value] { + seen[value] = true + result = append(result, value) + } + } + sort.Strings(result) + return result +} + +func (c *catalog) findPair(name string) (themePair, bool, bool) { + for _, pair := range c.FavoritePairs { + if pair.Name == name { + return pair, true, true + } + } + for _, pair := range c.OtherPairs { + if pair.Name == name { + return pair, false, true + } + } + return themePair{}, false, false +} + +func (c *catalog) configurePair(pair themePair) { + _, favorite, exists := c.findPair(pair.Name) + if exists { + for _, old := range append(slices.Clone(c.FavoritePairs), c.OtherPairs...) { + if old.Name == pair.Name { + c.OtherThemes = append(c.OtherThemes, old.Dark, old.Light) + } + } + } + c.FavoritePairs = removePair(c.FavoritePairs, pair.Name) + c.OtherPairs = removePair(c.OtherPairs, pair.Name) + if favorite { + c.FavoritePairs = append(c.FavoritePairs, pair) + } else { + c.OtherPairs = append(c.OtherPairs, pair) + } + c.normalize() +} + +func (c *catalog) favorite(item string) bool { + pair, _, found := c.findPair(item) + if found { + c.OtherPairs = removePair(c.OtherPairs, item) + c.FavoritePairs = append(c.FavoritePairs, pair) + c.normalize() + return true + } + c.OtherThemes = removeString(c.OtherThemes, item) + c.FavoriteThemes = append(c.FavoriteThemes, item) + c.normalize() + return false +} + +func (c *catalog) unfavorite(item string) bool { + pair, _, found := c.findPair(item) + if found { + c.FavoritePairs = removePair(c.FavoritePairs, item) + c.OtherPairs = append(c.OtherPairs, pair) + c.normalize() + return true + } + c.FavoriteThemes = removeString(c.FavoriteThemes, item) + c.OtherThemes = append(c.OtherThemes, item) + c.normalize() + return false +} + +func (c *catalog) record(pair currentPair) { + c.OtherThemes = append(c.OtherThemes, pair.Light, pair.Dark) + c.normalize() +} + +func removePair(pairs []themePair, name string) []themePair { + result := pairs[:0] + for _, pair := range pairs { + if pair.Name != name { + result = append(result, pair) + } + } + return result +} + +func removeString(values []string, target string) []string { + result := values[:0] + for _, value := range values { + if value != target { + result = append(result, value) + } + } + return result +} + +func writeAtomic(path string, data []byte, mode os.FileMode) error { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + file, err := os.CreateTemp(filepath.Dir(path), ".tint-*") + if err != nil { + return err + } + tmp := file.Name() + defer os.Remove(tmp) + if _, err := file.Write(data); err != nil { + file.Close() + return err + } + if err := file.Chmod(mode); err != nil { + file.Close() + return err + } + if err := file.Close(); err != nil { + return err + } + return os.Rename(tmp, path) +} + +func homeDir() string { + if home := os.Getenv("HOME"); home != "" { + return home + } + home, _ := os.UserHomeDir() + return home +}