Skip to content
21 changes: 18 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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.
Expand Down
79 changes: 78 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -215,6 +216,82 @@ their rows. Cobra's template gives a namespace a `Usage: <cmd> [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
Expand Down
15 changes: 9 additions & 6 deletions doc.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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:
Expand Down
52 changes: 52 additions & 0 deletions tuitheme/default.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
12 changes: 12 additions & 0 deletions tuitheme/reload_other.go
Original file line number Diff line number Diff line change
@@ -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) {}
22 changes: 22 additions & 0 deletions tuitheme/reload_unix.go
Original file line number Diff line number Diff line change
@@ -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)
}
26 changes: 26 additions & 0 deletions tuitheme/reload_unix_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
Loading
Loading