commit ffe8b986cef4c1079351762981952eaa90818e72 Author: hl-valdemar Date: Fri Sep 18 00:21:11 2026 +0200 back up project 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 +}