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 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.
It renders and does nothing else. The caller keeps its command framework and
its execution. The root package is called from inside the caller's help
function; cobrahelp.Install replaces that function instead. Real layout
belongs to lipgloss, which has
tables, lists, layers and wide-rune width. None of those are here.
Something belongs in this package only if two tools would otherwise write it identically and a difference would show on screen. Domain logic and configuration stay in the tool. So does anything one tool could own alone.
The root package imports nothing outside the standard library, by its own
choice. cobrahelp, the cobra frontend, is the one package that imports cobra
and pflag, so a tool importing only the root compiles neither.
go get github.com/datapointchris/gotermstyle@latestReleases are git tags on the 0.x line. Under semantic versioning, a 0.x minor release may change the API.
The module's go line becomes the floor of every module that imports it.
go get raises a lower go line to match. This prints the floor:
go list -m -f '{{.GoVersion}}' github.com/datapointchris/gotermstyle@latestSave this as main.go in an empty directory:
package main
import (
"os"
"github.com/datapointchris/gotermstyle"
)
func main() {
s := gotermstyle.New(os.Stdout)
s.HelpHeader("hello", "Greet people from the terminal.")
s.HelpUsage("hello <verb>")
s.HelpSection("Commands")
s.HelpRow("hello greet", "<name>", "Greet one person")
s.HelpRow("hello list", "", "List everyone greeted")
s.HelpEnd()
}Then, from that directory:
go mod init example.com/hello # a module to import it into
go mod tidy # resolves the import and records its checksums in go.sum
go run . # cyan rules and names, because stdout is a terminal
go run . | cat # the same screen uncolored, because a pipe is not a terminal
NO_COLOR=1 go run . # uncolored, because the user's preference outranks everything
FORCE_COLOR=1 go run . | cat -v # the escape codes made visible, proving they reached the bytesColor is emitted only where something will render it. New resolves that once,
against the writer it is given. FORCE_COLOR overrides the terminal detection
for a caller who knows it is wrong. A CI log that renders escapes is one case.
A pager run with -R is another. NO_COLOR still wins, because it is the
user's preference and FORCE_COLOR only corrects the detection.
A writer that is not an *os.File, such as a bytes.Buffer, never gets color
from New. A test that asserts on the codes builds its own palette:
gotermstyle.NewWith(&buf, gotermstyle.NewPalette(true)).
go doc -all github.com/datapointchris/gotermstyle prints the whole API. Run
it inside a module that requires the package.
In a cobra tool, the screen is drawn from the function given to SetHelpFunc,
against cmd.OutOrStdout():
root.SetHelpFunc(func(cmd *cobra.Command, _ []string) {
s := gotermstyle.New(cmd.OutOrStdout())
s.HelpHeader("lab", "Small experiments worth revisiting.")
s.HelpUsage("lab <verb>")
s.HelpSection("Commands")
s.HelpRow("lab list", "", "List every lab")
s.HelpRow("lab show", "<id>", "Show one lab")
s.HelpRow("", "", "An id comes from list") // continues the row above
s.HelpEnd()
})HelpRow buffers rather than writing. The next HelpSection, HelpText or
HelpEnd flushes the buffer. The flush sizes the description column from the
longest name and arguments in the section. A row with an empty name continues
the row above it, with its description aligned and no color.
HelpText prints prose between rows. It flushes first, so pending rows land
above the prose rather than under it. HelpEnd closes the screen and resets
it for the next one.
The Usage: label belongs to the library, so every screen labels it the same.
Extra usage lines align under the first. Header draws a boxed header over a
command's own output, outside a help screen.
A section's underline takes its color from the heading's name first and its
position second. The headings most tools share keep one color everywhere,
because a color is learnable across tools only if it never shifts. Commands
is cyan, Options is magenta, and Examples is yellow. fixedSections in
color.go holds the full mapping. A heading the tool invents rotates through
green, blue and white by position, so neighbors differ. Rows under Examples
render in the command color, because an example is a command you would type.
p := gotermstyle.PaletteFor(os.Stdout) // decide once, against the stream
fmt.Println(p.Cyan(name), p.Command(path)) // paint with the decisionRe-deciding at each call site is how one screen ends up half colored. Resolve
the palette once and pass it around. Inside a help screen, s.Palette() is the
screen's own.
The gate runs in a fixed order. NO_COLOR turns color off. FORCE_COLOR turns
it on. TERM=dumb turns it off. Otherwise color is on only when the writer is
an *os.File on a character device. That check is os.Stat plus
os.ModeCharDevice, so it needs no golang.org/x/term.
The codes are terminal-palette codes rather than RGB. A 16-color terminal
renders them in its theme's colors, so nothing degrades a color profile. The
command color is the one non-bright code, so a command you would type reads
differently from a name in a listing. Code returns a code or the empty string
for a caller building a string by interpolation. On reports whether the
palette emits color.
There is no dim. Its contrast is whatever the terminal theme makes it, and on many themes it is unreadable.
rest := gotermstyle.Clip(path, utf8.RuneCountInString(prefix)) // measure plain text
fmt.Println(prefix + p.Command(rest)) // color after clippingClip(text, used) shortens text to fit the terminal beside used other
columns. Pass the width of the uncolored surrounding text and color the result
afterwards. A clip then cannot land inside an escape sequence. With fewer than
two columns of room, Clip returns the text whole. An overflowing row says
more than a bare ellipsis.
Columns reads $COLUMNS first, then the terminal on stdout, then
DefaultColumns. That is the order Python's shutil.get_terminal_size uses.
The terminal query is a TIOCGWINSZ ioctl through the standard library's
syscall. It runs on Linux, macOS and the BSDs, Android and iOS included.
Everywhere else $COLUMNS or the default answers.
Pad a colored column by hand. text/tabwriter counts an escape sequence as
visible width, so one colored cell shifts every column after it. A padded
fmt verb has the same fault:
fmt.Fprintf(w, "%-*s %s\n", width, p.Cyan(name), address) // wrong: the width counts the escape
gap := strings.Repeat(" ", width-utf8.RuneCountInString(name))
fmt.Fprintf(w, "%s%s %s\n", p.Cyan(name), gap, address)Widths are runes, the unit Python's len counts on a str. A multibyte name
aligns the same here as in pytermstyle. Both assume a wide rune or a grapheme
cluster is one column, so CJK and emoji misalign. That is the one thing
lipgloss would buy, and this package accepts the gap deliberately.
import "github.com/datapointchris/gotermstyle/cobrahelp"
cobrahelp.Install(root)Every screen then says what the command is, and lists every leaf command
beneath it. A command with children gets no row of its own, even one that runs.
Each row is spelled from the screen's own command down to the leaf, arguments
included, so a command three levels down is on the root rather than three
screens away. The examples and flags follow. A section is a group the command
declares with AddGroup, and commands keep the order the tree adds them, so
Install turns off cobra's alphabetical sorting for every command tree in the
process. A command no group claims sits under "Other commands". Descriptions
wrap at 80 columns, and a command line wider than 40 sits alone with its
description beneath.
It replaces cobra's help and usage templates outright rather than patching
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.
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:
{
"$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 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.2takesmarkdown.heading. Then it takes the built-in default,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.
nulland""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. DefaultPathis$TUI_THEME_FILEwhen set, else$XDG_CONFIG_HOME/tui-theme/current.json, else~/.config/tui-theme/current.json.TUI_THEME_FILEpoints every program at another file at once, such as a theme under test.Loadalways 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.Codeis 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 itsRGBfeature render.Color.BackgroundCodesets a background alone, with no reset, so a program can lay a selection's background under cells that keep their own colors.dimis 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.
A Go tool on cobra prints its help monochrome. A Python tool on Typer and Rich
prints its help in color. So does a bash script that sources formatting.sh.
The gap follows the stack rather than the tool, so it wants one answer per
stack. Typer renders help through Rich, and color arrives with it. Cobra
renders help through text/template. A template emits plain strings and has no
notion of a style it could apply or strip, so nothing arrives for free.
Good libraries exist. lipgloss is the Go peer of Rich. Its tables, lists, layers and ANSI-aware width do more than this package does.
The value bought here is narrower. It is a Go screen that looks the same as its
Python and bash neighbors on PATH, from a root package that imports only the
standard library. Three alternatives were weighed against that:
charmbracelet/fangowns command execution.fang.Executewraps the root command'sExecuteContext, which a tool with a self-update wrapper already owns. It installs its own help function, so a help template or help function the tool set is bypassed. Its theme is Charm's house style, in lipgloss colors that switch between light and dark variants. Fixed ANSI codes are a different model. Its README calls it experimental.ivanpirog/coloredcobrais the closest drop-in and loses the least. It rewrites cobra's usage template with regular expressions, and a tool may already patch that template. It does nothing for grouping or row width. Two things mutating one template that cobra is free to change is worse than one thing owned outright.- lipgloss by hand gives the most control for the most code. Its value here would be wide-rune width alone.
Typer is the counter-example that settled it. Its Rich renderer styles help text, option defaults and panel borders dim. Dim therefore reaches every Typer help screen, and no source file in the tool asks for it. A framework that renders for you applies its opinions where no source grep will find them.
| Language | Library | Lives in |
|---|---|---|
| Go | gotermstyle |
this repo |
| Python | pytermstyle |
datapointchris/pytermstyle |
| bash | formatting.sh + colors.sh |
configs/common/.local/shell/ in datapointchris/dotfiles |
The three emit the same escape codes for the same roles. The Go tests assert each code as a literal, and no test reads the other two libraries. A change to the palette is a change to all three.
The bytes differ in two places. pytermstyle and formatting.sh open a help
row's color before its two-space indent, and Go opens it after. They also write
a reset on a continuation row, which opens no color, and Go writes none.
The API differs from the Python in one place. pytermstyle and formatting.sh
keep the buffered rows and the section counter in globals, so pytermstyle's
tests must reset them between screens. Go runs its tests in one process and its
programs concurrently, so that state lives on a Screen here.
MIT.