The Renderer façade¶
output deliberately exposes one public entry point — output.New returning a
*Renderer — rather than a scatter of independent constructors
(NewWriter, NewTableWriter, NewStatus, NewProgress, Spin). This page
explains why.
Configure once, read everywhere¶
A CLI's output concerns share four decisions: where output goes, which format
it is in, how it is styled, and whether the destination is interactive. Those
belong together and rarely change within a command. The Renderer fixes them at
construction, and every method reads them:
r := output.New(
output.WithWriter(os.Stdout),
output.WithFormat(output.FormatJSON),
)
r.Emit(resp) // knows the writer and that we are in JSON mode
r.Table(rows) // same writer, same format — no re-passing
The alternative — threading (writer, format) through every call — is what the
predecessor package did, and it meant every call site re-derived the same two
values, usually from a *cobra.Command. The façade removes that repetition and
the coupling behind it.
No hidden global state¶
The predecessor reached for os.Stderr, os.Stdout, os.Stdin and the CI
environment variable inside its render calls. That made behaviour depend on
process globals and forced tests to swap os.Stdout or set env vars — hostile to
parallelism.
The Renderer takes its writer and interactivity as injected values. Tests
construct output.New(output.WithWriter(&buf), output.WithInteractive(true)) and
assert on the buffer, fully in parallel, with no global mutation. IsInteractive()
remains exported as the default detector the renderer falls back to when
WithInteractive is not supplied — so production behaviour is unchanged.
What still reads process globals¶
Two decisions are not injected, and it is worth knowing which, because they are the ones that behave differently in a test from a terminal:
- Terminal width for text tables.
Tablesizes and truncates againstterm.GetSize(os.Stdout.Fd()), not against the renderer's writer. A renderer pointed at abytes.Buffer, a file, oros.Stderris still measured against the process's stdout, and falls back to 80 columns when that is not a terminal.WithMaxWidthoverrides the measurement;WithNoTruncationbypasses it entirely. - Markdown styling.
RenderandRenderMarkdownwrap to the same detected width and pick glamour's built-indarkorlightstyle fromlipgloss.HasDarkBackground(os.Stdin, os.Stdout), defaulting todark.
Both degrade to a fixed, deterministic answer rather than failing, so tests are
still repeatable — a buffer-backed table always lays out at 80 columns. The
distinction the façade actually makes is between configuration (writer,
format, theme, interactivity), which is injected, and terminal geometry,
which is measured. Making geometry injectable too would mean a width option on
every renderer for the benefit of one method; WithMaxWidth on the call that
needs it was judged the smaller cost.
One format matrix¶
Because the format lives on the renderer, Write can honour the whole matrix from
a single method: text via a caller function, JSON/YAML by marshalling, and
CSV/TSV/Markdown by tabulating. Table is the specialised entry point for tabular
data with column options; Write is the general envelope. Both funnel structured
formats through the same rendering core, so a value renders identically whichever
door it comes through.
The progress family shares the renderer¶
Status(), Progress() and Spin() inherit the renderer's writer, theme and
interactivity. A tool configures the destination and palette once and every piece
of feedback is consistent — and every piece degrades to plain lines together when
output is not a terminal.