gotermstyle

package module
v0.2.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 18, 2026 License: MIT Imports: 8 Imported by: 0

README

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 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, its execution and its help function, and calls this from inside them. 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 module requires nothing outside the standard library, by its own choice.

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; go.sum gains its lines and no others
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 command a caller can run beneath it. 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. 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.

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, with no dependency. 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.

Documentation

Overview

Package gotermstyle is the house terminal style for the Go command-line tools.

It is the Go counterpart to two things that already exist: pytermstyle (https://github.com/datapointchris/pytermstyle) for the Python tools, and formatting.sh for the bash ones. The README says where each lives. The parallel is the point — a Go screen, a Python screen and a bash screen sitting next to each other on PATH should be indistinguishable, which is a property no third-party styling library can provide, because each brings its own aesthetic.

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.

The membership test: it belongs here only if two tools would otherwise write it identically *and* writing it differently would be visible on screen. Everything else stays in the tool. The package is named for the language rather than for a caller, because a style package named for one family of tools ends up holding the house style for all of them.

Dependencies

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.

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:

go list -m -f '{{.GoVersion}}' github.com/datapointchris/gotermstyle@latest

Using it

Resolve a Screen against the writer the output is going to, then render:

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.HelpEnd()

HelpRow buffers rather than writing, so the flush sizes the description column from the longest row in the section. No call site passes a width or a color. Use HelpText rather than writing directly for prose between rows, so pending rows flush ahead of it.

For output that is not a help screen, Palette paints and Clip fits:

p := gotermstyle.PaletteFor(os.Stdout)
fmt.Println(p.Cyan(name), p.Command(path))

Where the state lives, and why it differs from the Python

pytermstyle and formatting.sh keep the buffered rows and the section counter in globals, which is affordable in a one-shot CLI and costs pytermstyle a reset helper its tests have to call. Go runs its tests in one process and its programs concurrently, so that state lives on a Screen here. The rendered screen is the same; only the state's owner differs.

The libraries that were considered and rejected, and why, are in the README.

Index

Constants

View Source
const (
	CodeCyan    = "\033[0;96m"
	CodeYellow  = "\033[0;93m"
	CodeGreen   = "\033[0;92m"
	CodeRed     = "\033[0;91m"
	CodeBlue    = "\033[0;94m"
	CodeMagenta = "\033[0;95m"
	CodeWhite   = "\033[0;97m"
	// CodeCommand is the one non-bright entry. A command you would type reads
	// differently from a name in a listing, and that difference is the point.
	CodeCommand = "\033[0;36m"
	CodeBold    = "\033[1m"
	CodeReset   = "\033[0m"
)

The house palette, as the escape sequences themselves. These are the codes the bash library's colors.sh exports and pytermstyle interpolates, so a Go screen, a Python screen and a bash screen sitting next to each other on PATH are the same product.

They are terminal-palette codes rather than absolute RGB, which is why no color-profile degradation is needed: a 16-color terminal renders them correctly, and TERM=dumb is handled by the gate instead.

There is deliberately no dim. Its contrast is whatever the terminal theme makes it, and on many themes it is unreadable.

View Source
const Bar = "" /* 150-byte string literal not displayed */

Bar is the rule a header draws above and below its title.

View Source
const DefaultColumns = 80

DefaultColumns is the width assumed when nothing will say what it really is.

Variables

This section is empty.

Functions

func Clip

func Clip(text string, used int) string

Clip shortens text to fit the terminal alongside used other columns.

Callers pass the length of the uncolored surrounding text and color the result afterwards, so a clip can never land inside an escape sequence and leak a raw code onto the screen.

With no room to say anything the text is returned whole: truncating to a bare ellipsis loses more than it saves, and a row that overflows is more use than a row that says nothing.

Every character this clips around is assumed one column wide. That holds for the ↳ · … this family is built for and does not hold for CJK or emoji, which is the one thing a wide-rune library would buy and is a deliberate acceptance rather than an oversight.

func Columns

func Columns() int

Columns is the terminal width. $COLUMNS is consulted first so a caller can override it and a test can set it, then the terminal itself, then DefaultColumns.

This is the order Python's shutil.get_terminal_size uses, which is what the counterpart library resolves through.

func Enabled

func Enabled(w io.Writer) bool

Enabled reports whether escape sequences written to w will reach something that can render them.

NO_COLOR is the user saying they do not want color (no-color.org), so it wins outright. FORCE_COLOR answers a different question — "is this a terminal" — for a caller that knows the detection is wrong: a CI log that renders escapes, a pager invoked with -R, a test that needs the codes in order to assert on them. Preference beats detection, so NO_COLOR is first.

The terminal check is os.Stat plus os.ModeCharDevice rather than golang.org/x/term, which would cost this package the zero-dependency guarantee it is built around.

func SectionColor

func SectionColor(title string, index int) string

SectionColor is the code a section heading renders in, by name first and by position second. Matching is case-insensitive because a heading is written for a reader rather than for this lookup.

Types

type Palette

type Palette struct {
	// contains filtered or unexported fields
}

Palette decides whether the codes above are emitted. Resolve it once, against the stream the output is going to, and pass it around — re-deciding per call site is how one screen ends up half-colored.

func NewPalette

func NewPalette(enabled bool) Palette

NewPalette returns a palette that emits color, or does not.

func PaletteFor

func PaletteFor(w io.Writer) Palette

PaletteFor resolves the gate against w. This is the constructor to reach for; NewPalette is for a caller that has already decided, and for tests.

func (Palette) Blue

func (p Palette) Blue(text string) string

func (Palette) Bold

func (p Palette) Bold(text string) string

func (Palette) Code

func (p Palette) Code(code string) string

Code returns the sequence when color is on and the empty string when it is not, so an interpolating caller can use the constants directly.

func (Palette) Command

func (p Palette) Command(text string) string

func (Palette) Cyan

func (p Palette) Cyan(text string) string

The named colors, for the call sites that read better without the constant.

func (Palette) Green

func (p Palette) Green(text string) string

func (Palette) Magenta

func (p Palette) Magenta(text string) string

func (Palette) On

func (p Palette) On() bool

On reports whether this palette emits color. A caller building a string by interpolation rather than through Paint needs to ask.

func (Palette) Paint

func (p Palette) Paint(code, text string) string

Paint wraps text in code and resets after it. An empty code returns the text untouched, which is what a continuation row wants.

func (Palette) Red

func (p Palette) Red(text string) string

func (Palette) White

func (p Palette) White(text string) string

func (Palette) Yellow

func (p Palette) Yellow(text string) string

type Screen

type Screen struct {
	// contains filtered or unexported fields
}

Screen renders one help screen to one writer.

It carries the buffered rows and the section counter that pytermstyle and formatting.sh both keep in globals. Go runs its tests in one process and its programs concurrently, so a value is the same code without the reset helper those two need, and makes concurrent use a non-question. It draws the screen those two draw; only where the state lives differs.

func New

func New(w io.Writer) *Screen

New returns a Screen writing to w, with color resolved once against w.

func NewWith

func NewWith(w io.Writer, p Palette) *Screen

NewWith returns a Screen using a palette the caller has already resolved. A tool that renders to several streams resolves once and shares it; a test asserts on the codes by passing NewPalette(true).

func (*Screen) Header

func (s *Screen) Header(title string)

Header draws a boxed section header: rule, title, rule, blank line. This is the standalone header a command prints over its own output, not the one that opens a help screen.

func (*Screen) HelpEnd

func (s *Screen) HelpEnd()

HelpEnd closes the screen and resets it for the next one.

func (*Screen) HelpHeader

func (s *Screen) HelpHeader(name, tagline string)

HelpHeader opens a help screen. The blank line leads rather than trails, which is what separates the screen from the command that asked for it.

func (*Screen) HelpRow

func (s *Screen) HelpRow(name, args, description string)

HelpRow buffers one row. Nothing is written until the section ends, because the description column is sized from the longest row in the section and no call site should have to pass a width.

A row with an empty name is a continuation of the one above it.

func (*Screen) HelpSection

func (s *Screen) HelpSection(title string)

HelpSection opens a section, flushing whatever rows the previous one buffered.

func (*Screen) HelpText

func (s *Screen) HelpText(lines ...string)

HelpText prints prose between rows. It flushes first, so a line written after a row cannot appear above it.

func (*Screen) HelpUsage

func (s *Screen) HelpUsage(lines ...string)

HelpUsage prints the invocation lines. The "Usage: " label belongs to this library rather than to the caller, so every screen it draws labels it identically. Extra lines align under the first.

func (*Screen) Palette

func (s *Screen) Palette() Palette

Palette is the resolved palette, for the parts of a screen this grammar does not render.

Directories

Path Synopsis
Package cobrahelp renders every help screen of a cobra command tree in the help grammar: what the command is, then every command a caller can run beneath it, spelled from the screen's own command down to the leaf with its arguments, then its examples and flags.
Package cobrahelp renders every help screen of a cobra command tree in the help grammar: what the command is, then every command a caller can run beneath it, spelled from the screen's own command down to the leaf with its arguments, then its examples and flags.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL