back up project

This commit is contained in:
hl-valdemar
2026-09-18 00:21:11 +02:00
commit ffe8b986ce
10 changed files with 1322 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
state.toml
+74
View File
@@ -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' \
'<?xml version="1.0" encoding="UTF-8"?>' \
'<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">' \
'<plist version="1.0">' \
'<dict>' \
' <key>Label</key>' \
' <string>$(LAUNCH_AGENT_LABEL)</string>' \
' <key>ProgramArguments</key>' \
' <array>' \
" <string>$$dark_notify</string>" \
' <string>-c</string>' \
' <string>$(BIN_DIR)/tint</string>' \
' </array>' \
' <key>RunAtLoad</key>' \
' <true/>' \
' <key>KeepAlive</key>' \
' <true/>' \
' <key>StandardOutPath</key>' \
' <string>$(DARK_NOTIFY_LOG)</string>' \
' <key>StandardErrorPath</key>' \
' <string>$(DARK_NOTIFY_LOG)</string>' \
'</dict>' \
'</plist>' > "$(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
+179
View File
@@ -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.
+259
View File
@@ -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)
}
+19
View File
@@ -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
"""
+5
View File
@@ -0,0 +1,5 @@
module tint
go 1.26
require github.com/BurntSushi/toml v1.6.0
+2
View File
@@ -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=
+248
View File
@@ -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 <theme|pair> Add a theme or pair to favorites
tint unfavorite <theme|pair> Remove a theme or pair from favorites
tint <pair> Switch to a saved pair
tint pair <name> dark:<theme> light:<theme>
Save a named theme pair
tint light|dark Apply the saved light or dark theme
tint light:<theme> Set the light theme
tint dark:<theme> Set the dark theme
tint light:<theme> dark:<theme> Set both themes
`)
printCurrent(writer, statePath)
}
+243
View File
@@ -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)
}
+292
View File
@@ -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
}