Skip to content

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. Table sizes and truncates against term.GetSize(os.Stdout.Fd()), not against the renderer's writer. A renderer pointed at a bytes.Buffer, a file, or os.Stderr is still measured against the process's stdout, and falls back to 80 columns when that is not a terminal. WithMaxWidth overrides the measurement; WithNoTruncation bypasses it entirely.
  • Markdown styling. Render and RenderMarkdown wrap to the same detected width and pick glamour's built-in dark or light style from lipgloss.HasDarkBackground(os.Stdin, os.Stdout), defaulting to dark.

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.