back up project
This commit is contained in:
@@ -0,0 +1 @@
|
|||||||
|
state.toml
|
||||||
@@ -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
|
||||||
@@ -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.
|
||||||
@@ -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
@@ -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
|
||||||
|
"""
|
||||||
@@ -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=
|
||||||
@@ -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
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user