diff --git a/CLAUDE.md b/CLAUDE.md index dacdb75..a89e9da 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,22 @@ 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, 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 547a144..0401064 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,82 @@ 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, 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 +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`. + +- **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, 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 + 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. +- **`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..de442ad 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,10 +25,12 @@ // // 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 -// module graph lists cobra while its build compiles none of it. +// 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 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: diff --git a/tuitheme/default.json b/tuitheme/default.json new file mode 100644 index 0000000..6176c13 --- /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": "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..7c6a679 --- /dev/null +++ b/tuitheme/reload_unix_test.go @@ -0,0 +1,26 @@ +//go:build unix + +package tuitheme + +import ( + "os" + "syscall" + "testing" + "time" +) + +// A theme generator signals every reader with SIGUSR2 after it installs a theme. +func TestSIGUSR2AsksForTheFileAgain(t *testing.T) { + reloads := make(chan os.Signal, 1) + Notify(reloads) + defer Stop(reloads) + + 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") + } +} diff --git a/tuitheme/schema/v1.json b/tuitheme/schema/v1.json new file mode 100644 index 0000000..9846fb1 --- /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, usually as a foreground color, so it still reads on the selected row.", + "$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/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) + } + } +} diff --git a/tuitheme/style.go b/tuitheme/style.go new file mode 100644 index 0000000..51f3fb5 --- /dev/null +++ b/tuitheme/style.go @@ -0,0 +1,111 @@ +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. +func parseColor(s string) (Color, error) { + 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..256a9d6 --- /dev/null +++ b/tuitheme/theme.go @@ -0,0 +1,235 @@ +// 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. +// +// 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. Load always returns a theme to draw. On an +// error it is the built-in default, and a program reloading keeps its own: +// +// 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) +// 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 + +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{} +} + +// 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 "", 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"), nil +} + +// 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 Default(), fmt.Errorf("tuitheme: %w", err) + } + roles, err := parse(data) + if err != nil { + return Default(), 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, +// 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"` +} + +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 +} + +// 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) + 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 := optionalColor(obj.Fg) + if err != nil { + return Style{}, fmt.Errorf("fg: %w", err) + } + bg, err := optionalColor(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 +} + +// 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 { + 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..84fffdb --- /dev/null +++ b/tuitheme/theme_test.go @@ -0,0 +1,69 @@ +package tuitheme + +import ( + "os" + "path/filepath" + "strings" + "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) + } + } +} + +// 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) + } + } +}