From 68766b8025202475bf494d5dce5f5fe60e3f87d0 Mon Sep 17 00:00:00 2001 From: Chris Birch Date: Sun, 8 Nov 2026 00:58:11 -0500 Subject: [PATCH 1/7] feat(tuitheme): read the TUI theme file every terminal UI draws from The 16 terminal colors hold no shade near the background, so a TUI drawn in them can only mark a selected row by reversing it into a slab. A theme generator can write the theme's own selection, panel and accent colors to one file per theme, and every program on this module reads that file. tuitheme.Load reads it: groups ui, markdown and syntax, each value a #rrggbb color, a palette slot "0" to "15", or a style object. A missing key falls back along its dots, then to an embedded default written in palette slots, so a program with no file draws in the terminal's own colors. Theme.Code gives a role as one SGR sequence that gotermstyle.Palette.Paint takes. Notify relays SIGUSR2, which a generator sends after it moves current.json, so a running program loads it again. tuitheme/schema/v1.json documents every key and is the $schema URL a theme file names. Agent-Name: claude-code-unattended Agent-Session: 4773265d-bd77-424c-b9db-836de857b797 Agent-Run: 01a1b9b4-580a-78e4-993a-0b0cbf56760f Worklog: 2555.1 --- CLAUDE.md | 16 ++- README.md | 61 ++++++++++- doc.go | 12 ++- tuitheme/default.json | 52 ++++++++++ tuitheme/reload_other.go | 12 +++ tuitheme/reload_unix.go | 22 ++++ tuitheme/reload_unix_test.go | 35 +++++++ tuitheme/schema/v1.json | 139 +++++++++++++++++++++++++ tuitheme/style.go | 114 ++++++++++++++++++++ tuitheme/theme.go | 196 +++++++++++++++++++++++++++++++++++ tuitheme/theme_test.go | 48 +++++++++ 11 files changed, 698 insertions(+), 9 deletions(-) create mode 100644 tuitheme/default.json create mode 100644 tuitheme/reload_other.go create mode 100644 tuitheme/reload_unix.go create mode 100644 tuitheme/reload_unix_test.go create mode 100644 tuitheme/schema/v1.json create mode 100644 tuitheme/style.go create mode 100644 tuitheme/theme.go create mode 100644 tuitheme/theme_test.go diff --git a/CLAUDE.md b/CLAUDE.md index dacdb75..e3b4bd0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -15,7 +15,8 @@ not as a blank slate. lives in its own package so a consumer importing only the core inherits nothing. `cobrahelp/` is that frontend. It is the one package that imports cobra and pflag, and the only requirements in `go.mod` are its. A tool - importing the root package compiles neither. + importing the root package compiles neither. `tuitheme/` imports the standard + library alone as well. The root package exists so a Go screen looks the same as its Python and bash neighbors, and everything it needs to do that is a string. The bytes differ, @@ -41,8 +42,17 @@ not as a blank slate. "Relationship to the other two" says where each lives. Changing one without the other two is the failure this library exists to prevent — the point is that three screens on one `PATH` look like one product. -- **No dim, ever.** It is unreadable on many terminal themes. Use normal text, - bold, or a color. +- **No dim, ever, in anything this module chooses.** It is unreadable on many + terminal themes. Use normal text, bold, or a color. The one dim it draws is + one a user's theme file asks for, because that file's author sees the colors + it dims. `tuitheme/default.json` never sets it. +- **The theme file format only grows.** Programs released long ago read files + written today, so a reader ignores a group or key it does not know, and a key + added later is optional. A change an older reader cannot read, such as a new + value form, is a new schema file and URL beside `tuitheme/schema/v1.json`, + never an edit to it. A key added to the schema gets its value in + `tuitheme/default.json` in the same change, unless it falls back along its + dots to one there. - **Color resolves once, and only where it will be rendered.** `NO_COLOR` outranks `FORCE_COLOR`: the first is the user's preference, the second only overrides the terminal detection. diff --git a/README.md b/README.md index 547a144..b8b7f16 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,8 @@ A Go library that styles a command-line tool's output. It holds a fixed palette, a boxed section header, a help-screen grammar, and the rule for when -any of it reaches the terminal. Its purpose is a Go tool whose help screen +any of it reaches the terminal. Its `tuitheme` package reads the theme file a +terminal UI draws its colors from. Its purpose is a Go tool whose help screen looks the same as its Python and bash neighbors on `PATH`. Those neighbors are styled by pytermstyle and `formatting.sh`. The section on the other two says where each lives. @@ -215,6 +216,64 @@ their rows. Cobra's template gives a namespace a `Usage: [command]` block, which a first-time reader takes for the answer, and puts the examples above the commands. +## `tuitheme` — every terminal UI reads its colors from one theme file + +```go +import "github.com/datapointchris/gotermstyle/tuitheme" + +path := tuitheme.DefaultPath() +theme, err := tuitheme.Load(path) // a missing file is the built-in default +fmt.Println(pal.Paint(theme.Code("ui.selection"), row)) // pal is a gotermstyle.Palette + +reloads := make(chan os.Signal, 1) +tuitheme.Notify(reloads) // SIGUSR2 asks for the file again +``` + +The 16 terminal colors hold no shade near the background, so a selected row +drawn in them is a reversed slab. A theme file gives a program the theme's +own selection, panel and accent colors. A theme generator writes one file per +theme, and every program on this package reads the same one. + +The file is JSON in three groups. `ui` holds the interface's roles, +`markdown` holds Neovim's `@markup` captures, and `syntax` holds tree-sitter +captures. A value is `"#rrggbb"`, a palette slot from `"0"` to `"15"`, or a +style object of `fg`, `bg`, `bold`, `italic`, `underline`, `strikethrough` and +`dim`: + +```json +{ + "$schema": "https://raw.githubusercontent.com/datapointchris/gotermstyle/main/tuitheme/schema/v1.json", + "ui": { "accent": "#4eb6fe", "selection": { "fg": "#c5c8c6", "bg": "#373b41" } }, + "markdown": { "heading": { "fg": "#4eb6fe", "bold": true } }, + "syntax": { "keyword": "#b294bb", "keyword.return": { "fg": "#b294bb", "bold": true } } +} +``` + +[`tuitheme/schema/v1.json`](tuitheme/schema/v1.json) describes every key. An +editor validates and completes a file against it through `$schema`. A change an +older reader could not read gets a new schema URL, so the file has no version +field. + +- **Every key is optional.** A missing key falls back along its dots, so + `markdown.heading.2` takes `markdown.heading`. Then it takes the built-in + default, [`tuitheme/default.json`](tuitheme/default.json). +- **The built-in default is written in palette slots.** A program with no theme + file draws in the terminal's own 16 colors. +- **A group or key the reader does not know is ignored**, so a newer file never + breaks an older program. A value of neither form fails the load, and the + error names its key. +- **`DefaultPath` is `$XDG_CONFIG_HOME/tui-theme/current.json`**, or + `~/.config/tui-theme/current.json`. A program may load another path. +- **`Code` is one SGR sequence opening with a reset**, as the house palette's + codes do. An exact color is 24-bit, which most terminals and tmux with its + `RGB` feature render. +- **`dim` is drawn when a file asks for it.** The built-in default never does. + +`Notify` relays SIGUSR2 on unix and nothing elsewhere. The program loads the +file again when it arrives, which re-resolves a `current.json` symlink. A Go +program that never called `Notify` catches SIGUSR2 and ignores it. So a +generator can signal every process of a reader's name, CLI runs included. + ## Why this exists — cobra renders help with no notion of style A Go tool on cobra prints its help monochrome. A Python tool on Typer and Rich diff --git a/doc.go b/doc.go index b2e201d..ef6f421 100644 --- a/doc.go +++ b/doc.go @@ -11,8 +11,9 @@ // # Scope // // The palette, the section header, the help grammar, and the rules about when -// any of it is emitted at all. Not domain logic, not configuration, not -// anything a single tool could own instead. +// any of it is emitted at all. Beside them, tuitheme reads the colors every +// terminal UI on this module shares. Not domain logic, not a tool's own +// configuration, not anything a single tool could own instead. // // The membership test: it belongs here only if two tools would otherwise write // it identically *and* writing it differently would be visible on screen. @@ -24,9 +25,10 @@ // // None in this package, and that is a constraint rather than a coincidence. // Terminal detection is os.Stat plus os.ModeCharDevice, not golang.org/x/term, -// so a tool importing this package builds no other module. The one subpackage, -// cobrahelp, is the cobra frontend, kept apart so cobra reaches only a tool -// that imports it. The module's go.mod names cobra for it, so an importer's +// so a tool importing this package builds no other module. The subpackage +// cobrahelp is the cobra frontend, kept apart so cobra reaches only a tool +// that imports it. The subpackage tuitheme reads the theme file a terminal UI +// draws from, and imports only the standard library too. The module's go.mod names cobra for it, so an importer's // module graph lists cobra while its build compiles none of it. // // The go line in this module's go.mod does become the importer's floor, and go diff --git a/tuitheme/default.json b/tuitheme/default.json new file mode 100644 index 0000000..a01de8e --- /dev/null +++ b/tuitheme/default.json @@ -0,0 +1,52 @@ +{ + "$schema": "https://raw.githubusercontent.com/datapointchris/gotermstyle/main/tuitheme/schema/v1.json", + "ui": { + "text": {}, + "muted": "8", + "accent": "11", + "selection": { "fg": "0", "bg": "7" }, + "match": { "fg": "0", "bg": "11" }, + "border": "8", + "panel": {}, + "error": "9", + "warning": "11", + "info": "12", + "success": "10" + }, + "markdown": { + "strong": { "bold": true }, + "italic": { "italic": true }, + "strikethrough": { "strikethrough": true }, + "underline": { "underline": true }, + "heading": { "bold": true }, + "heading.1": { "fg": "12", "bold": true }, + "heading.2": { "fg": "12", "bold": true }, + "heading.3": { "fg": "14", "bold": true }, + "quote": {}, + "math": "11", + "link": { "fg": "12", "underline": true }, + "link.label": { "fg": "12", "underline": true }, + "link.url": "8", + "raw": "11", + "raw.block": {}, + "list": {}, + "list.checked": "10", + "list.unchecked": "10" + }, + "syntax": { + "comment": { "fg": "8", "italic": true }, + "string": "10", + "number": "11", + "constant": "11", + "parameter": {}, + "function": "12", + "function.builtin": "14", + "keyword": "13", + "type": "14", + "operator": {}, + "punctuation": {}, + "attribute": "14", + "variable": {}, + "tag": "12" + } +} diff --git a/tuitheme/reload_other.go b/tuitheme/reload_other.go new file mode 100644 index 0000000..6f400c6 --- /dev/null +++ b/tuitheme/reload_other.go @@ -0,0 +1,12 @@ +//go:build !unix + +package tuitheme + +import "os" + +// Notify relays nothing here, because this platform has no SIGUSR2. A program +// still builds, and rereads its theme when it next loads it. +func Notify(chan<- os.Signal) {} + +// Stop does nothing here, because Notify relays nothing. +func Stop(chan<- os.Signal) {} diff --git a/tuitheme/reload_unix.go b/tuitheme/reload_unix.go new file mode 100644 index 0000000..276e633 --- /dev/null +++ b/tuitheme/reload_unix.go @@ -0,0 +1,22 @@ +//go:build unix + +package tuitheme + +import ( + "os" + "os/signal" + "syscall" +) + +// Notify relays each request to reread the theme file to c. The request is +// SIGUSR2, which a theme generator sends every running reader after it moves +// current.json. A Go program that never calls Notify catches SIGUSR2 and +// ignores it, so the signal is safe to send any Go program by name. +func Notify(c chan<- os.Signal) { + signal.Notify(c, syscall.SIGUSR2) +} + +// Stop ends Notify's relaying to c. +func Stop(c chan<- os.Signal) { + signal.Stop(c) +} diff --git a/tuitheme/reload_unix_test.go b/tuitheme/reload_unix_test.go new file mode 100644 index 0000000..8cddf6a --- /dev/null +++ b/tuitheme/reload_unix_test.go @@ -0,0 +1,35 @@ +//go:build unix + +package tuitheme + +import ( + "os" + "path/filepath" + "syscall" + "testing" + "time" +) + +func TestSIGUSR2AsksForTheFileAgain(t *testing.T) { + path := filepath.Join(t.TempDir(), "current.json") + reloads := make(chan os.Signal, 1) + Notify(reloads) + defer Stop(reloads) + + writeTheme(t, path, `{"ui": {"selection": {"bg": "#2d4f67"}}}`) + if err := syscall.Kill(os.Getpid(), syscall.SIGUSR2); err != nil { + t.Fatal(err) + } + select { + case <-reloads: + case <-time.After(5 * time.Second): + t.Fatal("no reload relayed within 5s of SIGUSR2") + } + theme, err := Load(path) + if err != nil { + t.Fatal(err) + } + if got, want := theme.Code("ui.selection"), "\033[0;48;2;45;79;103m"; got != want { + t.Errorf("ui.selection after reload = %q, want %q", got, want) + } +} diff --git a/tuitheme/schema/v1.json b/tuitheme/schema/v1.json new file mode 100644 index 0000000..b4f3cde --- /dev/null +++ b/tuitheme/schema/v1.json @@ -0,0 +1,139 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://raw.githubusercontent.com/datapointchris/gotermstyle/main/tuitheme/schema/v1.json", + "title": "TUI theme", + "description": "The colors and styles a terminal program draws its interface, markdown and code in, keyed by role and tree-sitter capture rather than by any one program's widgets. Every group and key is optional. A missing key falls back along its dots (heading.2 to heading), then to the reader's built-in default, which is written in the terminal's own 16 colors. A reader ignores a group or key it does not know. A change an older reader cannot read gets a new schema URL rather than a version field.", + "type": "object", + "properties": { + "$schema": { + "description": "This schema's URL, so an editor validates and completes the file with no setup.", + "type": "string" + }, + "ui": { + "description": "The interface's roles: text, selection, borders and the colors that carry a state.", + "type": "object", + "properties": { + "text": { + "description": "Ordinary text. An empty style object is the terminal's own foreground.", + "$ref": "#/definitions/value" + }, + "muted": { + "description": "Text that recedes: a hint, a timestamp, a label beside its value.", + "$ref": "#/definitions/value" + }, + "accent": { + "description": "What marks the current place, such as the current tab, in the theme's UI accent.", + "$ref": "#/definitions/value" + }, + "selection": { + "description": "The row or item the cursor is on. Give it a bg, so the whole row reads as selected.", + "$ref": "#/definitions/value" + }, + "match": { + "description": "Text a search or filter matched.", + "$ref": "#/definitions/value" + }, + "border": { + "description": "Rules, box edges and the separators between panes.", + "$ref": "#/definitions/value" + }, + "panel": { + "description": "A pane set apart from the main area. Its bg is the pane's background.", + "$ref": "#/definitions/value" + }, + "error": { + "description": "Something failed.", + "$ref": "#/definitions/value" + }, + "warning": { + "description": "Something needs attention but has not failed.", + "$ref": "#/definitions/value" + }, + "info": { + "description": "A neutral notice.", + "$ref": "#/definitions/value" + }, + "success": { + "description": "Something finished well.", + "$ref": "#/definitions/value" + } + }, + "additionalProperties": false + }, + "markdown": { + "description": "Markdown elements, named as Neovim's @markup captures without the prefix (:h treesitter-highlight-groups), so a note reads in a terminal program the way it reads in the editor.", + "type": "object", + "properties": { + "strong": { "description": "**Strong** text.", "$ref": "#/definitions/value" }, + "italic": { "description": "*Emphasized* text.", "$ref": "#/definitions/value" }, + "strikethrough": { "description": "~~Struck~~ text.", "$ref": "#/definitions/value" }, + "underline": { "description": "Underlined text.", "$ref": "#/definitions/value" }, + "heading": { "description": "Every heading level a heading.N key leaves out.", "$ref": "#/definitions/value" }, + "heading.1": { "description": "A level 1 heading.", "$ref": "#/definitions/value" }, + "heading.2": { "description": "A level 2 heading.", "$ref": "#/definitions/value" }, + "heading.3": { "description": "A level 3 heading.", "$ref": "#/definitions/value" }, + "heading.4": { "description": "A level 4 heading.", "$ref": "#/definitions/value" }, + "heading.5": { "description": "A level 5 heading.", "$ref": "#/definitions/value" }, + "heading.6": { "description": "A level 6 heading.", "$ref": "#/definitions/value" }, + "quote": { "description": "A block quote's text.", "$ref": "#/definitions/value" }, + "math": { "description": "Inline and block math.", "$ref": "#/definitions/value" }, + "link": { "description": "A link, where link.label and link.url leave a part out.", "$ref": "#/definitions/value" }, + "link.label": { "description": "A link's words.", "$ref": "#/definitions/value" }, + "link.url": { "description": "A link's address.", "$ref": "#/definitions/value" }, + "raw": { "description": "`Inline code`.", "$ref": "#/definitions/value" }, + "raw.block": { "description": "A code block's text where no syntax highlighting applies.", "$ref": "#/definitions/value" }, + "list": { "description": "A list's bullets and numbers.", "$ref": "#/definitions/value" }, + "list.checked": { "description": "A checked task box.", "$ref": "#/definitions/value" }, + "list.unchecked": { "description": "An unchecked task box.", "$ref": "#/definitions/value" } + }, + "additionalProperties": false + }, + "syntax": { + "description": "Code, keyed by tree-sitter capture without the @. Any dotted capture may be added, such as keyword.return or function.builtin, and one left out falls back along its dots to the base role.", + "type": "object", + "properties": { + "comment": { "description": "Comments.", "$ref": "#/definitions/value" }, + "string": { "description": "String literals.", "$ref": "#/definitions/value" }, + "number": { "description": "Numeric literals.", "$ref": "#/definitions/value" }, + "constant": { "description": "Constants and built-in values such as true and nil.", "$ref": "#/definitions/value" }, + "parameter": { "description": "A function's parameters.", "$ref": "#/definitions/value" }, + "function": { "description": "Function names, at definition and at a call.", "$ref": "#/definitions/value" }, + "keyword": { "description": "Keywords.", "$ref": "#/definitions/value" }, + "type": { "description": "Type names.", "$ref": "#/definitions/value" }, + "operator": { "description": "Operators.", "$ref": "#/definitions/value" }, + "punctuation": { "description": "Brackets, delimiters and separators.", "$ref": "#/definitions/value" }, + "attribute": { "description": "Attributes, annotations and decorators.", "$ref": "#/definitions/value" }, + "variable": { "description": "Variable names.", "$ref": "#/definitions/value" }, + "tag": { "description": "Markup tags, such as HTML elements.", "$ref": "#/definitions/value" } + }, + "propertyNames": { "pattern": "^[a-z][a-z0-9_-]*(\\.[a-z0-9_-]+)*$" }, + "additionalProperties": { "$ref": "#/definitions/value" } + } + }, + "additionalProperties": false, + "definitions": { + "color": { + "description": "#rrggbb for an exact color, or \"0\" to \"15\" for a slot of the terminal's own palette.", + "type": "string", + "pattern": "^(#[0-9a-fA-F]{6}|[0-9]|1[0-5])$" + }, + "style": { + "description": "A color and its attributes. A color left out is the terminal's own.", + "type": "object", + "properties": { + "fg": { "description": "The text's color.", "$ref": "#/definitions/color" }, + "bg": { "description": "The background's color.", "$ref": "#/definitions/color" }, + "bold": { "description": "Bold text.", "type": "boolean" }, + "italic": { "description": "Italic text.", "type": "boolean" }, + "underline": { "description": "Underlined text.", "type": "boolean" }, + "strikethrough": { "description": "Text struck through.", "type": "boolean" }, + "dim": { "description": "Faint text. Its contrast is whatever the terminal makes it.", "type": "boolean" } + }, + "additionalProperties": false + }, + "value": { + "description": "A color, which is the text's color alone, or a style object.", + "oneOf": [{ "$ref": "#/definitions/color" }, { "$ref": "#/definitions/style" }] + } + } +} diff --git a/tuitheme/style.go b/tuitheme/style.go new file mode 100644 index 0000000..5e6e03d --- /dev/null +++ b/tuitheme/style.go @@ -0,0 +1,114 @@ +package tuitheme + +import ( + "fmt" + "strconv" + "strings" +) + +type colorKind uint8 + +const ( + unset colorKind = iota + slot + rgb +) + +// Color is one of a style's two colors: unset, which is the terminal's own, a +// slot of the terminal's 16, or an exact RGB value. +type Color struct { + kind colorKind + slot uint8 + r, g, b uint8 +} + +// parseColor reads the file's two color forms, and the empty string as unset. +func parseColor(s string) (Color, error) { + if s == "" { + return Color{}, nil + } + if strings.HasPrefix(s, "#") { + v, err := strconv.ParseUint(s[1:], 16, 32) + if len(s) != 7 || err != nil { + return Color{}, fmt.Errorf("%q is neither #rrggbb nor a slot from 0 to 15", s) + } + return Color{kind: rgb, r: uint8(v >> 16), g: uint8(v >> 8), b: uint8(v)}, nil + } + n, err := strconv.Atoi(s) + if err != nil || n < 0 || n > 15 || strconv.Itoa(n) != s { + return Color{}, fmt.Errorf("%q is neither #rrggbb nor a slot from 0 to 15", s) + } + return Color{kind: slot, slot: uint8(n)}, nil +} + +// String is the color as the file writes it: "#rrggbb", a slot from "0" to +// "15", or "" when unset. +func (c Color) String() string { + switch c.kind { + case slot: + return strconv.Itoa(int(c.slot)) + case rgb: + return fmt.Sprintf("#%02x%02x%02x", c.r, c.g, c.b) + } + return "" +} + +// Slot is the terminal palette slot this color names, if it names one. +func (c Color) Slot() (int, bool) { + return int(c.slot), c.kind == slot +} + +// RGB is this color's exact value, if it has one. +func (c Color) RGB() (r, g, b uint8, ok bool) { + return c.r, c.g, c.b, c.kind == rgb +} + +// sgr is the color's SGR parameters, where base is 30 for a foreground and 40 +// for a background. +func (c Color) sgr(base int) string { + switch c.kind { + case slot: + if c.slot < 8 { + return strconv.Itoa(base + int(c.slot)) + } + return strconv.Itoa(base + 60 + int(c.slot) - 8) + case rgb: + return fmt.Sprintf("%d;2;%d;%d;%d", base+8, c.r, c.g, c.b) + } + return "" +} + +// Style is what one role draws in. +type Style struct { + Fg, Bg Color + Bold bool + Italic bool + Underline bool + Strikethrough bool + Dim bool +} + +// Code is the style as one SGR sequence, for gotermstyle.Palette.Paint. It +// opens with a reset, as the house palette's codes do, so it replaces whatever +// came before. A style that sets nothing is "", which Paint leaves unpainted. +func (s Style) Code() string { + var params []string + for _, attr := range []struct { + on bool + code string + }{{s.Bold, "1"}, {s.Dim, "2"}, {s.Italic, "3"}, {s.Underline, "4"}, {s.Strikethrough, "9"}} { + if attr.on { + params = append(params, attr.code) + } + } + if fg := s.Fg.sgr(30); fg != "" { + params = append(params, fg) + } + if bg := s.Bg.sgr(40); bg != "" { + params = append(params, bg) + } + if len(params) == 0 { + return "" + } + return "\033[0;" + strings.Join(params, ";") + "m" +} diff --git a/tuitheme/theme.go b/tuitheme/theme.go new file mode 100644 index 0000000..d31343a --- /dev/null +++ b/tuitheme/theme.go @@ -0,0 +1,196 @@ +// Package tuitheme reads the TUI theme file: the colors and styles a terminal +// program draws its interface, markdown and code in, keyed by role. A theme +// generator writes one per theme, and every program on this package reads the +// same file, so one theme change recolors them all. +// +// The format is JSON with three groups, ui, markdown and syntax. A value is +// "#rrggbb", a terminal palette slot from "0" to "15", or a style object of fg, +// bg, bold, italic, underline, strikethrough and dim. The schema published +// beside this package documents every key: +// +// https://raw.githubusercontent.com/datapointchris/gotermstyle/main/tuitheme/schema/v1.json +// +// A role is a group and a key, such as "ui.selection" or "syntax.keyword.return". +// A key the file leaves out falls back along its dots, then to the built-in +// default. The default is written in palette slots, so a program with no theme +// file draws in the terminal's own 16 colors. +// +// Load a theme once, paint each role through a gotermstyle.Palette, and load it +// again when Notify relays a reload: +// +// path := tuitheme.DefaultPath() +// theme, err := tuitheme.Load(path) +// fmt.Println(pal.Paint(theme.Code("ui.selection"), row)) +// +// reloads := make(chan os.Signal, 1) +// tuitheme.Notify(reloads) +// <-reloads // then Load(path) again +// +// A Theme never changes once loaded, so it is safe to share between goroutines. +package tuitheme + +import ( + _ "embed" + "encoding/json" + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "strings" +) + +//go:embed default.json +var defaultJSON []byte + +// builtIn is the default every lookup ends at. +var builtIn = mustParse(defaultJSON) + +// groups are the format's groups. Any other is ignored, so a newer generator +// never breaks an older reader. +var groups = []string{"ui", "markdown", "syntax"} + +// Theme is a theme file's roles over the built-in default. +type Theme struct { + roles map[string]Style +} + +// Default is the built-in theme, in the terminal's 16 colors. +func Default() *Theme { + return &Theme{} +} + +// DefaultPath is where a theme generator installs the current theme: +// $XDG_CONFIG_HOME/tui-theme/current.json, or ~/.config/tui-theme/current.json. +func DefaultPath() string { + dir := os.Getenv("XDG_CONFIG_HOME") + if !filepath.IsAbs(dir) { + home, err := os.UserHomeDir() + if err != nil { + return "" + } + dir = filepath.Join(home, ".config") + } + return filepath.Join(dir, "tui-theme", "current.json") +} + +// Load reads the theme file at path. A file that does not exist is the +// built-in default, with no error, because a terminal with no theme file is the +// ordinary case. A file that cannot be read or holds a value of neither form is +// an error, and the caller keeps what it was drawing. +func Load(path string) (*Theme, error) { + data, err := os.ReadFile(path) + if errors.Is(err, fs.ErrNotExist) { + return Default(), nil + } + if err != nil { + return nil, fmt.Errorf("tuitheme: %w", err) + } + roles, err := parse(data) + if err != nil { + return nil, fmt.Errorf("tuitheme: %s: %w", path, err) + } + return &Theme{roles: roles}, nil +} + +// Style is what role draws in: the file's value for it or the nearest key along +// its dots, else the built-in default's, else a style that sets nothing. +func (t *Theme) Style(role string) Style { + if s, ok := lookup(t.roles, role); ok { + return s + } + s, _ := lookup(builtIn, role) + return s +} + +// Code is role's style as an SGR sequence, for gotermstyle.Palette.Paint. +func (t *Theme) Code(role string) string { + return t.Style(role).Code() +} + +// lookup tries role, then role with its last dotted part dropped, stopping at +// the group, so markdown.heading.2 tries markdown.heading but never markdown. +func lookup(roles map[string]Style, role string) (Style, bool) { + group, key, _ := strings.Cut(role, ".") + for key != "" { + if s, ok := roles[group+"."+key]; ok { + return s, true + } + cut := strings.LastIndex(key, ".") + if cut < 0 { + break + } + key = key[:cut] + } + return Style{}, false +} + +// styleObject is a value's object form. Fields it does not name are ignored. +type styleObject struct { + Fg string `json:"fg"` + Bg string `json:"bg"` + Bold bool `json:"bold"` + Italic bool `json:"italic"` + Underline bool `json:"underline"` + Strikethrough bool `json:"strikethrough"` + Dim bool `json:"dim"` +} + +func parse(data []byte) (map[string]Style, error) { + var file map[string]json.RawMessage + if err := json.Unmarshal(data, &file); err != nil { + return nil, err + } + roles := map[string]Style{} + for _, group := range groups { + raw, ok := file[group] + if !ok { + continue + } + var keys map[string]json.RawMessage + if json.Unmarshal(raw, &keys) != nil { + return nil, fmt.Errorf("%s: %s is not an object of roles", group, raw) + } + for key, value := range keys { + s, err := parseValue(value) + if err != nil { + return nil, fmt.Errorf("%s.%s: %w", group, key, err) + } + roles[group+"."+key] = s + } + } + return roles, nil +} + +func parseValue(raw json.RawMessage) (Style, error) { + var color string + if json.Unmarshal(raw, &color) == nil { + fg, err := parseColor(color) + return Style{Fg: fg}, err + } + var obj styleObject + if err := json.Unmarshal(raw, &obj); err != nil { + return Style{}, fmt.Errorf("%s is neither a color nor a style object", raw) + } + fg, err := parseColor(obj.Fg) + if err != nil { + return Style{}, fmt.Errorf("fg: %w", err) + } + bg, err := parseColor(obj.Bg) + if err != nil { + return Style{}, fmt.Errorf("bg: %w", err) + } + return Style{ + Fg: fg, Bg: bg, + Bold: obj.Bold, Italic: obj.Italic, Underline: obj.Underline, + Strikethrough: obj.Strikethrough, Dim: obj.Dim, + }, nil +} + +func mustParse(data []byte) map[string]Style { + roles, err := parse(data) + if err != nil { + panic("tuitheme: default.json: " + err.Error()) + } + return roles +} diff --git a/tuitheme/theme_test.go b/tuitheme/theme_test.go new file mode 100644 index 0000000..196355b --- /dev/null +++ b/tuitheme/theme_test.go @@ -0,0 +1,48 @@ +package tuitheme + +import ( + "os" + "path/filepath" + "testing" +) + +func writeTheme(t *testing.T, path, content string) { + t.Helper() + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatal(err) + } +} + +func TestRoleFallsBackAlongItsDotsThenToTheTerminalsSixteenColors(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "current.json") + writeTheme(t, path, `{ + "ui": {"selection": {"fg": "#c5c8c6", "bg": "#373b41"}}, + "markdown": {"heading": {"fg": "#4eb6fe", "bold": true}}, + "panes": {"left": "#ffffff"} + }`) + themed, err := Load(path) + if err != nil { + t.Fatal(err) + } + unthemed, err := Load(filepath.Join(dir, "absent.json")) + if err != nil { + t.Fatal(err) + } + + for _, test := range []struct { + theme *Theme + role, want string + }{ + {themed, "ui.selection", "\033[0;38;2;197;200;198;48;2;55;59;65m"}, + {themed, "markdown.heading.2", "\033[0;1;38;2;78;182;254m"}, + {themed, "ui.muted", "\033[0;90m"}, + {unthemed, "ui.selection", "\033[0;30;47m"}, + {unthemed, "markdown.heading.4", "\033[0;1m"}, + {unthemed, "ui.text", ""}, + } { + if got := test.theme.Code(test.role); got != test.want { + t.Errorf("%s = %q, want %q", test.role, got, test.want) + } + } +} From 8eef34cde8a0b89eee974bfac135fe6764fa8214 Mon Sep 17 00:00:00 2001 From: Chris Birch Date: Sun, 8 Nov 2026 01:07:52 -0500 Subject: [PATCH 2/7] fix(tuitheme): draw the default match as a foreground, as a theme file does A theme generator writes ui.match as one color, which is a foreground. The built-in default drew it as slot 0 on slot 11, so a program read a match as a background with no theme file and a foreground with one. A foreground match also stays readable on the selected row, as fzf and Telescope draw theirs. Agent-Name: claude-code-unattended Agent-Session: 4773265d-bd77-424c-b9db-836de857b797 Agent-Run: 01a1b9b4-580a-78e4-993a-0b0cbf56760f Worklog: 2555.1 --- tuitheme/default.json | 2 +- tuitheme/schema/v1.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tuitheme/default.json b/tuitheme/default.json index a01de8e..6176c13 100644 --- a/tuitheme/default.json +++ b/tuitheme/default.json @@ -5,7 +5,7 @@ "muted": "8", "accent": "11", "selection": { "fg": "0", "bg": "7" }, - "match": { "fg": "0", "bg": "11" }, + "match": "11", "border": "8", "panel": {}, "error": "9", diff --git a/tuitheme/schema/v1.json b/tuitheme/schema/v1.json index b4f3cde..9846fb1 100644 --- a/tuitheme/schema/v1.json +++ b/tuitheme/schema/v1.json @@ -30,7 +30,7 @@ "$ref": "#/definitions/value" }, "match": { - "description": "Text a search or filter matched.", + "description": "Text a search or filter matched, usually as a foreground color, so it still reads on the selected row.", "$ref": "#/definitions/value" }, "border": { From fff61b75e7cbdd2885afcad1ec71b8f7952ebd48 Mon Sep 17 00:00:00 2001 From: Chris Birch Date: Sun, 8 Nov 2026 01:43:48 -0500 Subject: [PATCH 3/7] docs: say go.mod names cobra for cobrahelp, then describe tuitheme The tuitheme sentence sat between cobrahelp and the go.mod sentence, so "names cobra for it" read as naming cobra for tuitheme, which imports the standard library alone. Agent-Name: claude-code-unattended Agent-Session: 4773265d-bd77-424c-b9db-836de857b797 Agent-Run: 01a1b9cf-f0a1-76ce-a000-cdf27a041ee5 Worklog: 2555.1 --- doc.go | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/doc.go b/doc.go index ef6f421..de442ad 100644 --- a/doc.go +++ b/doc.go @@ -27,9 +27,10 @@ // Terminal detection is os.Stat plus os.ModeCharDevice, not golang.org/x/term, // so a tool importing this package builds no other module. The subpackage // cobrahelp is the cobra frontend, kept apart so cobra reaches only a tool -// that imports it. The subpackage tuitheme reads the theme file a terminal UI -// draws from, and imports only the standard library too. The module's go.mod names cobra for it, so an importer's -// module graph lists cobra while its build compiles none of it. +// that imports it. The module's go.mod names cobra for cobrahelp, so an +// importer's module graph lists cobra while its build compiles none of it. +// The subpackage tuitheme reads the theme file a terminal UI draws from, and +// imports only the standard library. // // The go line in this module's go.mod does become the importer's floor, and go // get raises a lower one to match. This prints it: From 213f78cd67a18193a00049cb82bcd0ac3956feb4 Mon Sep 17 00:00:00 2001 From: Chris Birch Date: Sun, 8 Nov 2026 01:43:54 -0500 Subject: [PATCH 4/7] test(tuitheme): keep only the SIGUSR2 relay in the reload test Its second half loaded a file written before the signal, so it passed whether or not a reload worked, and the fallback table already pins the same rgb background. The relay half stays: changing Notify to SIGHUP fails it, and a theme generator signals every reader with SIGUSR2. Agent-Name: claude-code-unattended Agent-Session: 4773265d-bd77-424c-b9db-836de857b797 Agent-Run: 01a1b9cf-f0a1-76ce-a000-cdf27a041ee5 Worklog: 2555.1 --- tuitheme/reload_unix_test.go | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/tuitheme/reload_unix_test.go b/tuitheme/reload_unix_test.go index 8cddf6a..7c6a679 100644 --- a/tuitheme/reload_unix_test.go +++ b/tuitheme/reload_unix_test.go @@ -4,19 +4,17 @@ package tuitheme import ( "os" - "path/filepath" "syscall" "testing" "time" ) +// A theme generator signals every reader with SIGUSR2 after it installs a theme. func TestSIGUSR2AsksForTheFileAgain(t *testing.T) { - path := filepath.Join(t.TempDir(), "current.json") reloads := make(chan os.Signal, 1) Notify(reloads) defer Stop(reloads) - writeTheme(t, path, `{"ui": {"selection": {"bg": "#2d4f67"}}}`) if err := syscall.Kill(os.Getpid(), syscall.SIGUSR2); err != nil { t.Fatal(err) } @@ -25,11 +23,4 @@ func TestSIGUSR2AsksForTheFileAgain(t *testing.T) { case <-time.After(5 * time.Second): t.Fatal("no reload relayed within 5s of SIGUSR2") } - theme, err := Load(path) - if err != nil { - t.Fatal(err) - } - if got, want := theme.Code("ui.selection"), "\033[0;48;2;45;79;103m"; got != want { - t.Errorf("ui.selection after reload = %q, want %q", got, want) - } } From 2985c020d46b9b1722b13aef539f488fef23550b Mon Sep 17 00:00:00 2001 From: Chris Birch Date: Sun, 8 Nov 2026 01:44:14 -0500 Subject: [PATCH 5/7] fix(tuitheme): refuse a null or empty color, and return a theme on every error A role written as null or "" loaded as a style that sets nothing. It hid the role's fallback and the built-in default, so a generator with no color for the selection left the selected row unmarked and reported success. Both now fail the load and name the key, as the schema does. An fg or bg the object leaves out is still unset. Load returned nil beside an error, and the documented startup flow then dereferenced it. It now returns the built-in default beside every error, so a program starting up draws the terminal's 16 colors and reports the error, while a program reloading keeps the theme it has. An empty path is an error rather than a missing file. Agent-Name: claude-code-unattended Agent-Session: 4773265d-bd77-424c-b9db-836de857b797 Agent-Run: 01a1b9cf-f0a1-76ce-a000-cdf27a041ee5 Worklog: 2555.1 --- tuitheme/style.go | 5 +---- tuitheme/theme.go | 49 ++++++++++++++++++++++++++++-------------- tuitheme/theme_test.go | 21 ++++++++++++++++++ 3 files changed, 55 insertions(+), 20 deletions(-) diff --git a/tuitheme/style.go b/tuitheme/style.go index 5e6e03d..51f3fb5 100644 --- a/tuitheme/style.go +++ b/tuitheme/style.go @@ -22,11 +22,8 @@ type Color struct { r, g, b uint8 } -// parseColor reads the file's two color forms, and the empty string as unset. +// parseColor reads the file's two color forms. func parseColor(s string) (Color, error) { - if s == "" { - return Color{}, nil - } if strings.HasPrefix(s, "#") { v, err := strconv.ParseUint(s[1:], 16, 32) if len(s) != 7 || err != nil { diff --git a/tuitheme/theme.go b/tuitheme/theme.go index d31343a..c8d5281 100644 --- a/tuitheme/theme.go +++ b/tuitheme/theme.go @@ -74,21 +74,24 @@ func DefaultPath() string { return filepath.Join(dir, "tui-theme", "current.json") } -// Load reads the theme file at path. A file that does not exist is the -// built-in default, with no error, because a terminal with no theme file is the -// ordinary case. A file that cannot be read or holds a value of neither form is -// an error, and the caller keeps what it was drawing. +// Load reads the theme file at path, and always returns a theme to draw. A file +// that does not exist is the built-in default with no error, because a terminal +// with no theme file is the ordinary case. On any other error the theme is the +// built-in default too, and a program reloading keeps the theme it has. func Load(path string) (*Theme, error) { + if path == "" { + return Default(), errors.New("tuitheme: no theme file named") + } data, err := os.ReadFile(path) if errors.Is(err, fs.ErrNotExist) { return Default(), nil } if err != nil { - return nil, fmt.Errorf("tuitheme: %w", err) + return Default(), fmt.Errorf("tuitheme: %w", err) } roles, err := parse(data) if err != nil { - return nil, fmt.Errorf("tuitheme: %s: %w", path, err) + return Default(), fmt.Errorf("tuitheme: %s: %w", path, err) } return &Theme{roles: roles}, nil } @@ -125,15 +128,16 @@ func lookup(roles map[string]Style, role string) (Style, bool) { return Style{}, false } -// styleObject is a value's object form. Fields it does not name are ignored. +// styleObject is a value's object form. Fields it does not name are ignored, +// and an fg or bg it leaves out is nil. type styleObject struct { - Fg string `json:"fg"` - Bg string `json:"bg"` - Bold bool `json:"bold"` - Italic bool `json:"italic"` - Underline bool `json:"underline"` - Strikethrough bool `json:"strikethrough"` - Dim bool `json:"dim"` + Fg *string `json:"fg"` + Bg *string `json:"bg"` + Bold bool `json:"bold"` + Italic bool `json:"italic"` + Underline bool `json:"underline"` + Strikethrough bool `json:"strikethrough"` + Dim bool `json:"dim"` } func parse(data []byte) (map[string]Style, error) { @@ -162,7 +166,12 @@ func parse(data []byte) (map[string]Style, error) { return roles, nil } +// parseValue names a null as the file writes it, rather than as the "" it +// decodes to. func parseValue(raw json.RawMessage) (Style, error) { + if string(raw) == "null" { + return Style{}, errors.New("null is neither a color nor a style object") + } var color string if json.Unmarshal(raw, &color) == nil { fg, err := parseColor(color) @@ -172,11 +181,11 @@ func parseValue(raw json.RawMessage) (Style, error) { if err := json.Unmarshal(raw, &obj); err != nil { return Style{}, fmt.Errorf("%s is neither a color nor a style object", raw) } - fg, err := parseColor(obj.Fg) + fg, err := optionalColor(obj.Fg) if err != nil { return Style{}, fmt.Errorf("fg: %w", err) } - bg, err := parseColor(obj.Bg) + bg, err := optionalColor(obj.Bg) if err != nil { return Style{}, fmt.Errorf("bg: %w", err) } @@ -187,6 +196,14 @@ func parseValue(raw json.RawMessage) (Style, error) { }, nil } +// optionalColor reads an fg or bg, which is unset when the object leaves it out. +func optionalColor(s *string) (Color, error) { + if s == nil { + return Color{}, nil + } + return parseColor(*s) +} + func mustParse(data []byte) map[string]Style { roles, err := parse(data) if err != nil { diff --git a/tuitheme/theme_test.go b/tuitheme/theme_test.go index 196355b..84fffdb 100644 --- a/tuitheme/theme_test.go +++ b/tuitheme/theme_test.go @@ -3,6 +3,7 @@ package tuitheme import ( "os" "path/filepath" + "strings" "testing" ) @@ -46,3 +47,23 @@ func TestRoleFallsBackAlongItsDotsThenToTheTerminalsSixteenColors(t *testing.T) } } } + +// A generator with no color for a role writes null or "" for it. The load fails +// naming the key, and the theme it returns still draws the default selection. +func TestAGeneratorsNullOrEmptyColorFailsTheLoadAndLeavesTheDefaultToDraw(t *testing.T) { + path := filepath.Join(t.TempDir(), "current.json") + for _, test := range []struct{ file, key string }{ + {`{"ui": {"selection": null}}`, "ui.selection"}, + {`{"ui": {"accent": ""}}`, "ui.accent"}, + {`{"ui": {"selection": {"fg": "", "bg": "#373b41"}}}`, "ui.selection: fg"}, + } { + writeTheme(t, path, test.file) + theme, err := Load(path) + if err == nil || !strings.Contains(err.Error(), test.key) { + t.Errorf("%s: err = %v, want one naming %s", test.file, err, test.key) + } + if got, want := theme.Code("ui.selection"), "\033[0;30;47m"; got != want { + t.Errorf("%s: ui.selection = %q, want the default %q", test.file, got, want) + } + } +} From 81e571ae5d2dd046b75499b523cd228c8742357e Mon Sep 17 00:00:00 2001 From: Chris Birch Date: Sun, 8 Nov 2026 01:44:35 -0500 Subject: [PATCH 6/7] feat(tuitheme): TUI_THEME_FILE points every reader at another theme file DefaultPath compiled in one location and offered no way to name another, so a theme under test or a second theme location would have grown a flag or variable of its own in each reader. TUI_THEME_FILE now wins over the XDG path in every reader at once. DefaultPath also returned "" when it could not resolve a home directory, which Load read as a missing file and drew the default with no error. It now returns (string, error) and names what it could not resolve. The README and package examples check both errors and show a reload that keeps the theme on screen when the new file fails to load. Agent-Name: claude-code-unattended Agent-Session: 4773265d-bd77-424c-b9db-836de857b797 Agent-Run: 01a1b9cf-f0a1-76ce-a000-cdf27a041ee5 Worklog: 2555.1 --- README.md | 30 +++++++++++++++++++++++------- tuitheme/theme.go | 38 ++++++++++++++++++++++++++++++-------- 2 files changed, 53 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index b8b7f16..549fd20 100644 --- a/README.md +++ b/README.md @@ -221,12 +221,23 @@ above the commands. ```go import "github.com/datapointchris/gotermstyle/tuitheme" -path := tuitheme.DefaultPath() -theme, err := tuitheme.Load(path) // a missing file is the built-in default +path, err := tuitheme.DefaultPath() +if err != nil { + return err +} +theme, err := tuitheme.Load(path) // a missing file is the built-in default, with no error +if err != nil { + log.Print(err) // theme is the built-in default, so the screen still draws +} fmt.Println(pal.Paint(theme.Code("ui.selection"), row)) // pal is a gotermstyle.Palette reloads := make(chan os.Signal, 1) tuitheme.Notify(reloads) // SIGUSR2 asks for the file again +for range reloads { + if next, err := tuitheme.Load(path); err == nil { + theme = next // on an error, keep drawing the theme on screen + } +} ``` The 16 terminal colors hold no shade near the background, so a selected row @@ -259,11 +270,16 @@ field. default, [`tuitheme/default.json`](tuitheme/default.json). - **The built-in default is written in palette slots.** A program with no theme file draws in the terminal's own 16 colors. -- **A group or key the reader does not know is ignored**, so a newer file never - breaks an older program. A value of neither form fails the load, and the - error names its key. -- **`DefaultPath` is `$XDG_CONFIG_HOME/tui-theme/current.json`**, or - `~/.config/tui-theme/current.json`. A program may load another path. +- **A group, key or style field the reader does not know is ignored**, so a + newer file never breaks an older program. A value of neither form fails the + load, and the error names its key. `null` and `""` are neither form. +- **`DefaultPath` is `$TUI_THEME_FILE` when set**, else + `$XDG_CONFIG_HOME/tui-theme/current.json`, else + `~/.config/tui-theme/current.json`. `TUI_THEME_FILE` points every program at + another file at once, such as a theme under test. +- **`Load` always returns a theme to draw.** On an error it is the built-in + default, so a program starting up draws the terminal's 16 colors and reports + the error. A program reloading keeps the theme it has. - **`Code` is one SGR sequence opening with a reset**, as the house palette's codes do. An exact color is 24-bit, which most terminals and tmux with its `RGB` feature render. diff --git a/tuitheme/theme.go b/tuitheme/theme.go index c8d5281..256a9d6 100644 --- a/tuitheme/theme.go +++ b/tuitheme/theme.go @@ -15,16 +15,30 @@ // default. The default is written in palette slots, so a program with no theme // file draws in the terminal's own 16 colors. // +// Every program finds the same file through DefaultPath. TUI_THEME_FILE points +// them all at another one, such as a theme under test. +// // Load a theme once, paint each role through a gotermstyle.Palette, and load it -// again when Notify relays a reload: +// again when Notify relays a reload. Load always returns a theme to draw. On an +// error it is the built-in default, and a program reloading keeps its own: // -// path := tuitheme.DefaultPath() +// path, err := tuitheme.DefaultPath() +// if err != nil { +// return err +// } // theme, err := tuitheme.Load(path) +// if err != nil { +// log.Print(err) // theme is the built-in default +// } // fmt.Println(pal.Paint(theme.Code("ui.selection"), row)) // // reloads := make(chan os.Signal, 1) // tuitheme.Notify(reloads) -// <-reloads // then Load(path) again +// for range reloads { +// if next, err := tuitheme.Load(path); err == nil { +// theme = next +// } +// } // // A Theme never changes once loaded, so it is safe to share between goroutines. package tuitheme @@ -60,18 +74,26 @@ func Default() *Theme { return &Theme{} } -// DefaultPath is where a theme generator installs the current theme: -// $XDG_CONFIG_HOME/tui-theme/current.json, or ~/.config/tui-theme/current.json. -func DefaultPath() string { +// PathEnv names the variable that points every program at another theme file. +const PathEnv = "TUI_THEME_FILE" + +// DefaultPath is the theme file a program loads: $TUI_THEME_FILE when set, else +// $XDG_CONFIG_HOME/tui-theme/current.json, else ~/.config/tui-theme/current.json. +// A theme generator installs the current theme at the XDG path. A relative +// XDG_CONFIG_HOME is ignored, as the XDG spec asks. +func DefaultPath() (string, error) { + if path := os.Getenv(PathEnv); path != "" { + return path, nil + } dir := os.Getenv("XDG_CONFIG_HOME") if !filepath.IsAbs(dir) { home, err := os.UserHomeDir() if err != nil { - return "" + return "", fmt.Errorf("tuitheme: no theme file: %s is unset and %w", PathEnv, err) } dir = filepath.Join(home, ".config") } - return filepath.Join(dir, "tui-theme", "current.json") + return filepath.Join(dir, "tui-theme", "current.json"), nil } // Load reads the theme file at path, and always returns a theme to draw. A file From c2ffec6910ef4c0c6a20a1a82b8aa96a4b19a2f4 Mon Sep 17 00:00:00 2001 From: Chris Birch Date: Sun, 8 Nov 2026 01:44:48 -0500 Subject: [PATCH 7/7] test(tuitheme): hold the schema, the default and the reader to one vocabulary The groups, roles, style fields and color grammar live in the schema, default.json, theme.go and style.go, and only a CLAUDE.md sentence kept them level. A new test reads schema/v1.json and fails when a group the schema declares is not read, a role it declares has no default along its dots, default.json sets a key the schema refuses, the style fields differ, or parseColor and the color pattern disagree on a probe. The format's growth rule now keeps readers working, not only editors. A reader fails the whole load on a value it cannot read, so a new value form goes in a new key or style field. A change that cannot be added that way is a new file beside current.json, which a generator writes alongside the old one. Agent-Name: claude-code-unattended Agent-Session: 4773265d-bd77-424c-b9db-836de857b797 Agent-Run: 01a1b9cf-f0a1-76ce-a000-cdf27a041ee5 Worklog: 2555.1 --- CLAUDE.md | 17 +++++--- README.md | 8 ++-- tuitheme/schema_test.go | 92 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 108 insertions(+), 9 deletions(-) create mode 100644 tuitheme/schema_test.go diff --git a/CLAUDE.md b/CLAUDE.md index e3b4bd0..a89e9da 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -47,12 +47,17 @@ not as a blank slate. one a user's theme file asks for, because that file's author sees the colors it dims. `tuitheme/default.json` never sets it. - **The theme file format only grows.** Programs released long ago read files - written today, so a reader ignores a group or key it does not know, and a key - added later is optional. A change an older reader cannot read, such as a new - value form, is a new schema file and URL beside `tuitheme/schema/v1.json`, - never an edit to it. A key added to the schema gets its value in - `tuitheme/default.json` in the same change, unless it falls back along its - dots to one there. + written today, so a reader ignores a group, key or style field it does not + know, and a key added later is optional. An older reader fails the whole load + on a value it cannot read, so a new value form goes in a new key or style + field, never in place of an old one. A change that cannot be added that way + is a new file beside `current.json`, with a schema beside + `tuitheme/schema/v1.json`, and a generator writes both. A key added to the + schema gets its value in `tuitheme/default.json` in the same change, unless it + falls back along its dots to one there, and `tuitheme/schema_test.go` fails + until it does. datapointchris/theme's generator types the `ui` and `markdown` + keys out again, and its suite compares them with the published schema, so a + new key lands there too. - **Color resolves once, and only where it will be rendered.** `NO_COLOR` outranks `FORCE_COLOR`: the first is the user's preference, the second only overrides the terminal detection. diff --git a/README.md b/README.md index 549fd20..0401064 100644 --- a/README.md +++ b/README.md @@ -261,9 +261,7 @@ style object of `fg`, `bg`, `bold`, `italic`, `underline`, `strikethrough` and ``` [`tuitheme/schema/v1.json`](tuitheme/schema/v1.json) describes every key. An -editor validates and completes a file against it through `$schema`. A change an -older reader could not read gets a new schema URL, so the file has no version -field. +editor validates and completes a file against it through `$schema`. - **Every key is optional.** A missing key falls back along its dots, so `markdown.heading.2` takes `markdown.heading`. Then it takes the built-in @@ -273,6 +271,10 @@ field. - **A group, key or style field the reader does not know is ignored**, so a newer file never breaks an older program. A value of neither form fails the load, and the error names its key. `null` and `""` are neither form. +- **The format only grows.** A new value form arrives as a new key or style + field, never in place of an old one. A change no older program could read is + a new file beside `current.json`, with a schema of its own. A generator writes + both, so a program built for v1 keeps its colors. - **`DefaultPath` is `$TUI_THEME_FILE` when set**, else `$XDG_CONFIG_HOME/tui-theme/current.json`, else `~/.config/tui-theme/current.json`. `TUI_THEME_FILE` points every program at diff --git a/tuitheme/schema_test.go b/tuitheme/schema_test.go new file mode 100644 index 0000000..b6760fc --- /dev/null +++ b/tuitheme/schema_test.go @@ -0,0 +1,92 @@ +package tuitheme + +import ( + "encoding/json" + "maps" + "os" + "reflect" + "regexp" + "slices" + "strconv" + "strings" + "testing" +) + +type schemaGroup struct { + Properties map[string]json.RawMessage `json:"properties"` + PropertyNames struct { + Pattern string `json:"pattern"` + } `json:"propertyNames"` +} + +type schemaFile struct { + Properties map[string]schemaGroup `json:"properties"` + Definitions struct { + Color struct { + Pattern string `json:"pattern"` + } `json:"color"` + Style struct { + Properties map[string]json.RawMessage `json:"properties"` + } `json:"style"` + } `json:"definitions"` +} + +// A role added to the schema, such as a ui.cursor, needs a value in default.json +// along its dots, and a value form the schema allows needs a reader that takes it. +func TestTheSchemaAndTheReaderAgreeOnEveryGroupRoleAndColor(t *testing.T) { + data, err := os.ReadFile("schema/v1.json") + if err != nil { + t.Fatal(err) + } + var schema schemaFile + if err := json.Unmarshal(data, &schema); err != nil { + t.Fatal(err) + } + + schemaGroups := slices.Sorted(maps.Keys(schema.Properties)) + schemaGroups = slices.DeleteFunc(schemaGroups, func(g string) bool { return g == "$schema" }) + if got := slices.Sorted(slices.Values(groups)); !slices.Equal(got, schemaGroups) { + t.Errorf("the reader reads groups %v, the schema declares %v", got, schemaGroups) + } + + for _, group := range schemaGroups { + for key := range schema.Properties[group].Properties { + if _, ok := lookup(builtIn, group+"."+key); !ok { + t.Errorf("%s.%s has no value in default.json, not even along its dots", group, key) + } + } + } + + for role := range builtIn { + group, key, _ := strings.Cut(role, ".") + declared := schema.Properties[group] + if _, ok := declared.Properties[key]; ok { + continue + } + if p := declared.PropertyNames.Pattern; p != "" && regexp.MustCompile(p).MatchString(key) { + continue + } + t.Errorf("default.json sets %s, which the schema does not allow", role) + } + + var fields []string + for _, f := range reflect.VisibleFields(reflect.TypeFor[styleObject]()) { + fields = append(fields, f.Tag.Get("json")) + } + slices.Sort(fields) + if want := slices.Sorted(maps.Keys(schema.Definitions.Style.Properties)); !slices.Equal(fields, want) { + t.Errorf("the reader reads style fields %v, the schema declares %v", fields, want) + } + + color := regexp.MustCompile(schema.Definitions.Color.Pattern) + probes := []string{"#2d4f67", "#ABCDEF", "#fff", "#2d4f6", "#2d4f677", "#gggggg", "2d4f67", "16", "07", "-1", "+5", "", "red"} + for n := range 16 { + probes = append(probes, strconv.Itoa(n)) + } + for _, s := range probes { + _, err := parseColor(s) + if accepted, allowed := err == nil, color.MatchString(s); accepted != allowed { + t.Errorf("parseColor(%q) accepts it: %v; the schema allows it: %v", s, accepted, allowed) + } + } +}