Documentation
¶
Overview ¶
Package ui decides, in one place, how the flow command line looks.
A command line has two audiences that want opposite things. A person reading a terminal wants a status to be findable at a glance, which is what colour and weight are for. A program reading a pipe wants bytes it can parse, and an escape sequence in the middle of them is corruption. Almost every defect in this area comes from a decision made per call site, where the call site cannot see which audience it has.
So the decision is made once, per stream, from what that stream can actually do, and everything else asks. Detect answers "what is this stream", New builds the writers and styles that follow from the answer, and nothing else in the CLI consults the environment or checks for a terminal.
Three properties are load-bearing, and each of them is a way people get burned:
- A pipe receives no escape sequences at all. Not fewer — none. Detection is per stream, so `flow get x | jq` can be plain on stdout while the status line on stderr is styled, in one invocation.
- Every style survives its own removal. Meaning is carried by the words and the layout; colour and weight only make the meaning faster to find. That is what a log file, a screen reader, and a colour-blind reader all receive.
- The palette is declared for both backgrounds. A terminal's background is the user's choice, and a palette that assumes one is unreadable for half its audience.
See docs/CLI.md for the reasoning at length.
Index ¶
- Constants
- func ClampWidth(columns int) int
- func EscapeControl(s string) string
- func Trim(text string, width int) string
- type Capabilities
- type Palette
- type Span
- type SymbolSet
- type Theme
- func (t Theme) Pill(tone Tone, label string) string
- func (t Theme) Plain() bool
- func (t Theme) ProseText(text string) string
- func (t Theme) RenderProse(base lipgloss.Style, text string) string
- func (t Theme) RenderSpan(base lipgloss.Style, s Span) string
- func (t Theme) SpanStyle() lipgloss.Style
- func (t Theme) SpanText(s Span) string
- func (t Theme) Tone(tone Tone) lipgloss.Style
- type Tone
- type UI
Constants ¶
const BackgroundEnv = "FLOWSTATE_BACKGROUND"
BackgroundEnv names the variable that settles the terminal background without asking for it.
The same escape hatch as SymbolsEnv and, unlike it, also a way out of a *pause*: the question is asked over the terminal itself, and a terminal that answers nothing at all is waited on for four seconds. Setting this takes `flow --help` on such a terminal from 4.02s to 0.02s, measured. See [terminalIsDark].
const SymbolsEnv = "FLOWSTATE_SYMBOLS"
SymbolsEnv names the variable that overrides symbol selection, for the case where the detection below guesses wrong on somebody's terminal.
Variables ¶
This section is empty.
Functions ¶
func ClampWidth ¶
ClampWidth bounds a measured terminal width the way this package bounds every other one.
Exported because a full-screen view is told its size by its own event loop rather than by Detect, and a resize that escaped the clamp would let the one surface that repaints grow to 300 columns while every surface that prints stayed at 100. Two answers to "how wide is the text" in one program is one too many.
func EscapeControl ¶
EscapeControl renders control characters as their escaped spelling, for text a terminal is about to be handed and this process did not write.
A workload's failure message, a signal's name, a task's error — these are chosen by whoever ran the workload, and a line printed from one is a line somebody else composed. Passed through bare, a newline in it fabricates rows that look like the command's own output, a tab breaks the column alignment a reader is scanning down, and an ANSI escape restyles or clears the terminal. A `flow timeline` row promising one event per line, or a `flow get` line promising one retrying step, is a promise the text can otherwise break.
Text only. A machine-readable answer (`-o json`) carries the value as it is, because a consumer parsing JSON is not a terminal interpreting bytes, and escaping there would hand back something that is not what the run produced.
Why this is written twice ¶
`flowtest`'s `escapeControlRunes` is the same rule at the same threat, added for the same reason (Codex, #1052), and this is not a second opinion about it. It is a second *copy*, because there is no import direction that would let one serve both: `pkg/flowstate/v1/flowtest` is a library and cannot reach a package internal to `cmd/flow`, and a CLI terminal-rendering helper does not belong in the public API of the test harness. Both are exercised against the shapes that matter — a newline, a tab, an escape — so a change to one that the other does not follow shows up as a test that disagrees with its sibling rather than as a surface that quietly stopped escaping.
func Trim ¶
Trim cuts a rendered string to a width, measuring what will be displayed.
Display width rather than bytes, which is the whole reason this exists rather than slicing: a styled string carries escape sequences that occupy no columns, and a line that has been through a theme is mostly them. lipgloss measures correctly underneath; naming it once keeps every surface trimming the same way.
Trim the *whole* line, once, at the end. A line is usually several parts — a pill, a message, some fields — and trimming each to the full width puts the line over it by however wide the other parts are.
Types ¶
type Capabilities ¶
type Capabilities struct {
// Profile is how much colour the stream carries, and it is the value the
// writer degrades against. colorprofile resolves NO_COLOR, CLICOLOR_FORCE,
// TERM=dumb, tmux and terminfo itself, so those are deliberately not
// re-implemented here — one implementation of that logic is the point.
Profile colorprofile.Profile
// TTY reports whether the stream is a terminal. Distinct from Profile,
// because CLICOLOR_FORCE asks for colour through a pipe and a person who
// asked for that should get it.
TTY bool
// Dark reports whether the terminal background is dark, deciding which half
// of every colour pair is used. See [darkBackground] for what it costs to
// find out and the three cheaper answers that come first.
Dark bool
// Width is the usable columns, already bounded. Zero-width terminals and
// pipes both report the fallback, so a caller never divides by nothing.
Width int
// Height is the rows, or zero where there are none to count.
//
// Zero rather than a fallback, deliberately, and the asymmetry with Width is the
// point: a caller laying out text always needs *some* measure to wrap against,
// so guessing 80 columns is better than nothing. Nothing needs a guessed number
// of rows — a stream with no height is one nobody is scrolling — and a fallback
// would let a full-screen view believe it had 24 rows of a pipe to fill.
Height int
// Unicode reports whether restrained typographic marks are safe to emit.
Unicode bool
}
Capabilities is what one output stream can do.
Every field is derived rather than configured: a person does not tell us their terminal supports 256 colours, they have a terminal that does or does not. Two carry an override anyway (SymbolsEnv, BackgroundEnv), and both are for the case where the derivation is wrong on somebody's terminal and only they can see that — which is a different thing from asking them to describe it.
func Detect ¶
func Detect(in, out *os.File, environ []string) Capabilities
Detect answers what a stream is.
in is needed as well as out because asking a terminal for its background colour is a question written to the terminal and an answer read back from it, so it needs both halves. It is only asked when out is genuinely a terminal: against a pipe the query has nobody to answer it and waits out its own two-second timeout, which would put a two-second pause in front of every piped command.
func (Capabilities) Symbols ¶
func (c Capabilities) Symbols() SymbolSet
Symbols returns the set this stream may use.
type Palette ¶
type Palette struct {
Muted color.Color
Strong color.Color
Accent color.Color
Success color.Color
Warning color.Color
Danger color.Color
Info color.Color
// OnFill is the text laid over a filled background. Not a role of its own: it
// is whatever contrasts with a fill, and every fill here is saturated enough
// that one answer serves all of them.
OnFill color.Color
// Surface is a filled background for a block of text rather than a token. Low
// contrast on purpose: it groups, it does not announce.
Surface color.Color
}
Palette is each role resolved to a colour, before any style is built from it.
Separated from Theme because the colours and the styles built from them have different lifetimes: a role is one decision, and the several styles that use it are not. Deriving everything from one palette is what keeps `flow --help` and `flow list` looking like one program rather than two that happen to ship in one binary — the help page having been drawn by a library, once, and by this palette only because the library was handed a translation of it.
func NewPalette ¶
func NewPalette(pick lipgloss.LightDarkFunc) Palette
NewPalette resolves every role against a background.
Charm's palette supplies the hues, used for their design rather than their branding: the values are built as a set and hold contrast against both backgrounds, which is the hard part of choosing colours and not worth redoing by eye. Each pair is (light background, dark background) — the darker value first, because it is the one that has to be legible on a light terminal.
type Span ¶
type Span struct {
// Text is the span's content. For a code span this excludes the backticks,
// which are markup rather than text.
Text string
// Code reports whether Text was written inside backticks.
Code bool
}
Span is one piece of prose: either literal text, or a code span with its backticks already removed.
func ParseSpans ¶
ParseSpans splits prose into its literal runs and its code spans.
A backtick opens a span only when a second one closes it on the same line. An odd one out is text, whether somebody writing about a backtick or a string that arrived from somewhere this dialect does not govern, and text is what it stays, because silently swallowing a mark is how a renderer eats a character that mattered. Keeping a span inside one line is also what lets a caller split prose into lines first and parse each of them, and get the same answer as parsing the whole.
type SymbolSet ¶
type SymbolSet struct {
// Outcome marks, for a line reporting how something went.
Success string
Failure string
Warning string
Waiting string
Running string
Skipped string
// Structure marks, for relating one line to another.
Bullet string
Arrow string
Ellipsis string
// Divider fills a horizontal rule.
Divider string
}
SymbolSet is the marks one stream may use.
A value rather than package state, because two streams of one process can have different answers — a piped stdout and a terminal stderr — and a package-level set would force one of them to be wrong.
type Theme ¶
type Theme struct {
// Muted is secondary text: a placeholder, a hint, a unit. Never the only
// carrier of anything.
Muted lipgloss.Style
// Strong is emphasis within a line, for the token a reader is scanning for.
Strong lipgloss.Style
// Accent is the product's own voice: a heading, a command in an example.
Accent lipgloss.Style
// The four outcome roles. Info is deliberately distinct from Accent: one says
// "this is us talking", the other says "this is how it went".
Success lipgloss.Style
Warning lipgloss.Style
Danger lipgloss.Style
Info lipgloss.Style
// Header is a table's column row.
Header lipgloss.Style
// contains filtered or unexported fields
}
Theme resolves colour roles against a background and a colour depth.
func NewTheme ¶
func NewTheme(dark bool, caps Capabilities) Theme
NewTheme resolves the palette for a background and a stream's colour depth.
The depth matters as well as the background: below ANSI there is no colour to resolve to, so every role collapses to weight — bold and faint — which the writer still carries. Each step down loses emphasis and no information.
func (Theme) Pill ¶
Pill renders a label inside a filled background.
For the one value on a line that a reader is scanning for — a run's status in a listing, an outcome in a report. It is deliberately rare: a line with three pills has none, because the point of a filled background is that the eye lands on it before it lands on anything else.
The label is upper-cased rather than decorated, so that the pill still reads as a status once the background is gone.
func (Theme) ProseText ¶
ProseText is prose as it reaches the screen, carrying no styling.
For the surfaces that measure or truncate before they style, and for the ones whose whole line is styled as one thing (an example's command) rather than piece by piece.
func (Theme) RenderProse ¶
RenderProse renders prose inline, without wrapping it.
Callers with a width to respect want the wrapping form in cmd/flow instead: a wrap point cannot be found in a string that already carries escape sequences, which is why measuring and styling are two steps rather than one.
func (Theme) RenderSpan ¶
RenderSpan renders one span: literal text in base, a code span in the span style, and neither styled at all on a plain surface.
func (Theme) SpanStyle ¶
SpanStyle is the style a code span carries.
Theme.Strong rather than a role of its own, because a code span in prose is precisely what Strong is for, the token in a line a reader is scanning for, and a second token resolving to the same emphasis would be two names for one decision, which is the drift docs/CLI_DESIGN.md's token section exists to stop.
func (Theme) SpanText ¶
SpanText is one span as it reaches the screen, carrying no styling.
A code span keeps its marks exactly when there is no style to replace them with. This is the string a width is measured from: the marks change a span's width when they come off, so anything measuring the authored text measures a line that is not the one printed.
type UI ¶
type UI struct {
// Out carries the answer — a table, a JSON document, the thing a pipe reads.
Out io.Writer
// Err carries the account of it — status, warnings, what to do next.
Err io.Writer
// Caps is what the *answer* stream can do. Styling decisions about Err use
// ErrCaps, because the two are genuinely independent: `flow get x | jq` has a
// piped stdout and a terminal stderr.
Caps Capabilities
ErrCaps Capabilities
// Theme styles what goes to Out, and ErrTheme what goes to Err.
//
// Two themes rather than one for the same reason there are two Capabilities:
// the streams are independent, and a single theme has to be resolved against
// one of them. Resolved against stdout, `flow get x | jq` writes an *unstyled*
// status to a terminal stderr, because the palette collapsed to plain for the
// pipe that was never going to receive it. Resolved against stderr, the
// opposite: escape sequences into `jq`.
//
// So the rule is the package's own, applied one level further down than it was:
// the decision is made once *per stream*, and a call site picks the theme
// belonging to the writer it is about to write to.
Theme Theme
ErrTheme Theme
}
UI is the CLI's rendering surface: two writers that degrade to what their stream can carry, and the styles that go with them.
func ForCapabilities ¶
func ForCapabilities(out, errOut io.Writer, caps, errCaps Capabilities) *UI
ForCapabilities builds a surface for streams whose capabilities are already known.
The one place the wiring lives, so New and Plain differ only in where the answers come from — detected from real files, or asserted. That matters for the asserted case in particular: a test describing a terminal it does not have is testing the same construction a terminal gets, rather than a hand-built struct that will still compile after this one changes.
func Plain ¶
Plain returns a surface that writes to the given writers and styles nothing.
For tests, and for the case where a caller already holds writers rather than files. It is the same code path as a pipe, which is the point: the unstyled rendering is not a separate implementation that can drift from the styled one.