Skip to content

About

House terminal style for the Go CLIs: palette, section header, help grammar, colour gating. Stdlib-only.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

gotermstyle

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.

Install

go get github.com/datapointchris/gotermstyle@latest

Releases 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@latest

Starting from scratch

Save 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 bytes

Color 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.

Screen — rows buffer per section, so no call site passes a width or a color

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.

Palette — color is decided once per stream, never at a call site

p := gotermstyle.PaletteFor(os.Stdout)     // decide once, against the stream
fmt.Println(p.Cyan(name), p.Command(path)) // paint with the decision

Re-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.

Clip and alignment — widths are counted in runes, on the uncolored text

rest := gotermstyle.Clip(path, utf8.RuneCountInString(prefix)) // measure plain text
fmt.Println(prefix + p.Command(rest))                          // color after clipping

Clip(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.

cobrahelp — one call renders every screen of a cobra tree

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.

tuitheme — every terminal UI reads its colors from one theme file

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.2 takes markdown.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. 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.
  • Color.BackgroundCode sets a background alone, with no reset, so a program can lay a selection's background under cells that keep their own colors.
  • 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 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.

Why not a third-party library — none matches the neighbors at zero cost

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/fang owns command execution. fang.Execute wraps the root command's ExecuteContext, 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/coloredcobra is 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.

Relationship to the other two — the same screen, not the same bytes

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.

License

MIT.

About

House terminal style for the Go CLIs: palette, section header, help grammar, colour gating. Stdlib-only.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages