gotermstyle

package module
v0.1.2 Latest Latest
Warning

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

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

README

gotermstyle

The house terminal style for the Go command-line tools — palette, section header, help grammar, and the rules about when any of it is emitted at all.

Zero third-party dependencies, by this repo's own choice.

Using it

import "github.com/datapointchris/gotermstyle"

A Screen renders a help screen to one writer, with color resolved once against that writer:

s := gotermstyle.New(cmd.OutOrStdout())

s.HelpHeader("menu labs", "Small experiments worth revisiting.")
s.HelpUsage("menu labs <verb>")

s.HelpSection("Commands")
s.HelpRow("menu labs list", "", "List every lab")
s.HelpRow("menu labs show", "<id>", "Show one lab")

s.HelpEnd()

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

For output that is not a help screen:

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

Clip(text, used) shortens text to fit alongside used other columns. Pass the width of the uncolored surrounding text and color the result afterwards, so a clip can never land inside an escape sequence.

Color and alignment together

Widths are measured on the uncolored string and the color applied after. Pad by hand for the same reason: text/tabwriter counts an escape sequence as visible width, so one colored cell shifts every column after it.

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)

Why this exists

Every Go CLI on the fleet renders monochrome help, and every Python and bash one does not. Counting ANSI escapes in each tool's root help screen, through a pty:

Stack Tools Escapes
Go / cobra icb, forge, todoui, toolbox, ifiles 0 each
Python / Typer + Rich indy, relate, syncer, dectl 26–45
bash theme, font, safekeep, menu, packages 9–36

The gap is stack-shaped rather than tool-shaped, so it wants one answer rather than five. Cobra renders help through text/template, which emits strings and has no notion of a style it could apply or strip, so nothing arrives for free the way rich_help_panel does in Typer.

Why not a third-party library

Not because none is good enough. lipgloss is Rich-class and is a registered exemplar here — it has tables, lists, layers and genuinely ANSI-aware width, which is more than this package does.

The value being bought is narrower: a Go screen that looks the same as its Python and bash neighbors on PATH, at zero dependency cost. The alternatives considered and the reason each was rejected are in the planning doc.

The counter-example that settled it: Typer's Rich renderer emits 23–38 dim sequences into every help screen on this fleet, none of them in any source file, while standards/cli-design.md bans dim outright for being unreadable on half the terminal themes in use. A framework that renders for you applies its opinions where no source grep will find them.

Relationship to the other two

Language Library Lives in
Go gotermstyle this repo
Python pytermstyle ~/tools/pytermstyle
bash formatting.sh + colors.sh ~/dotfiles/configs/common/.local/shell/

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 three draw the same screen, not the same bytes. 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, which costs pytermstyle a reset helper its tests must call. Go runs its tests in one process and its programs concurrently, so that state lives on a Screen here.

Constraints

  • No third-party dependencies. This is the repo's choice rather than a fleet rule — standards/repo-structure.md retired the blanket ban, and what binds now is containment: a CLI frontend lives in its own package so a consumer importing only the core inherits nothing. The choice stands on its own here because everything this does is a string. Terminal detection is os.Stat plus os.ModeCharDevice rather than golang.org/x/term, and Columns reaches TIOCGWINSZ through stdlib syscall, so importing this downloads nothing and moves no Go floor.
  • No dim. standards/cli-design.md § "Never use [dim]".
  • Color is emitted only where something will render it. NO_COLOR is the user's preference and wins; FORCE_COLOR overrides the terminal detection.
  • Widths are measured in runes, the unit pytermstyle's len counts on a str, so a multibyte name aligns the same in both. Both assume a wide rune or a grapheme cluster is one column. That is the one thing lipgloss would buy, and a deliberate acceptance.

Licence

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 ~/.local/shell/formatting.sh for the bash ones. 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. menucore was named for one family and ended up holding the house style for all of them; the split that fixed it is the reason this package is named for the language rather than for a caller.

Dependencies

None, and that is a constraint rather than a coincidence — the same one goselfupdate holds. Terminal detection is os.Stat plus os.ModeCharDevice, not golang.org/x/term, so a tool importing this downloads nothing and its own Go floor is unaffected.

Using it

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

s := gotermstyle.New(cmd.OutOrStdout())
s.HelpHeader("menu labs", "Small experiments worth revisiting.")
s.HelpUsage("menu labs <verb>")

s.HelpSection("Commands")
s.HelpRow("menu labs list", "", "List every lab")
s.HelpRow("menu labs 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 design, the measurements behind it, and the libraries that were considered and rejected are in .planning/design.md.

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 `~/dotfiles/configs/common/.local/shell/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. See standards/cli-design.md § "Never use `[dim]`", which rules it out for being unreadable against half the terminal themes in use.

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. It is the idiom goselfupdate/autoupdate uses.

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 on the fleet 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.

Jump to

Keyboard shortcuts

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