Files
tinty-wrapper/apps.go
T
2026-10-22 16:58:52 +02:00

286 lines
10 KiB
Go

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
}
if err := writeConjureTheme(colors, ui); 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 writeConjureTheme(colors, ui map[string]string) error {
mapping := map[string]string{
"background": pick(ui, "global.background.normal", colors, "base00"),
"text": pick(ui, "global.foreground.normal", colors, "base05"),
"secondaryText": pick(ui, "global.foreground.dark", colors, "base04"),
"accent": pick(ui, "accent.normal", colors, "base0D"),
"glow": pick(ui, "status.error", colors, "base08"),
"keyBackground": pick(ui, "chrome.background.normal", colors, "base01"),
"error": pick(ui, "status.error", colors, "base08"),
}
validColor := regexp.MustCompile(`^#[0-9a-fA-F]{6}$`)
for key, value := range mapping {
if !validColor.MatchString(value) {
return fmt.Errorf("invalid Conjure color %s: %q", key, value)
}
}
configHome := os.Getenv("XDG_CONFIG_HOME")
if !filepath.IsAbs(configHome) {
configHome = filepath.Join(homeDir(), ".config")
}
return writeJSON(filepath.Join(configHome, "conjure", "theme.json"), map[string]any{"colors": mapping})
}
func writeOMPTheme(colors, ui, syntax map[string]string) error {
semantic, _ := semanticTheme(colors, ui, syntax)
theme := map[string]any{
"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)
}