8.5 KiB
tui
Immediate-mode widget toolkit on top of the terminal cell buffer. No retained
widget tree, no framework loop: the application owns a []terminal.Cell buffer
and all state; tui provides regions, layout math, render functions, and
plain-struct state helpers. Every frame is a full logical redraw — the
terminal diff layer keeps actual output minimal.
Core concept: Region
A Region is a bounds-checked rectangular view over a cell slice. All drawing
goes through regions; coordinates are region-relative. Sub-regions nest and
clip to parent bounds, so widgets cannot draw outside their allotted area.
w, h := term.Size()
cells := make([]terminal.Cell, w*h)
root := tui.NewRegion(cells, w, 0, 0, w, h)
panel := root.Sub(2, 1, 40, 10) // clipped view
inner := panel.Inset(1) // shrink 1 cell on all sides
Out-of-bounds writes are silently dropped — no bounds management needed in widget code.
Quick start
term := terminal.New()
term.Init()
defer term.Fini()
w, h := term.Size()
cells := make([]terminal.Cell, w*h)
list := tui.NewScrollState(len(items), h-2)
list.Selection = 0
for {
root := tui.NewRegion(cells, w, 0, 0, w, h)
root.Fill(color.Gunmetal)
root.Box(tui.LineRounded, color.SteelBlue)
content := root.Inset(1)
content.List(buildItems(items), list.Selection, list.Offset, tui.ListOpts{
CursorBg: color.DarkSlate,
})
content.ScrollBar(content.W-1, list.Offset, list.Visible, list.Total,
color.IronGray)
term.Flush(cells, w, h)
ev := term.PollEvent()
switch ev.Type {
case terminal.EventKey:
switch ev.Key {
case terminal.KeyUp:
list.SelectPrev()
case terminal.KeyDown:
list.SelectNext()
case terminal.KeyEscape:
return
}
case terminal.EventResize:
w, h = ev.Width, ev.Height
cells = make([]terminal.Cell, w*h)
list.SetVisible(h - 2)
}
}
Layout
cols := tui.SplitH(root, 0.3, 0.7) // ratio split, normalized
rows := tui.SplitV(cols[1], 0.5, 0.5)
side, main := tui.SplitHFixed(root, 24) // fixed left width
top, rest := tui.SplitVFixed(root, 3) // fixed top height
dlg := tui.Center(root, 50, 12) // centered sub-region
The last ratio segment absorbs rounding remainder — no gaps.
Text and style
r.Text(x, y, "label", fg, bg, terminal.AttrNone)
r.TextCenter(y, "title", fg, bg, terminal.AttrBold)
r.TextRight(y, "hint", fg, bg, terminal.AttrDim)
lines := r.TextBlock(x, y, longText, fg, bg, attr) // word-wrapped, returns line count
r.TextStyled(x, y, s, tui.Style{Fg: fg, Bg: bg, Attr: attr})
String utilities operate on rune counts: RuneLen, Truncate /
TruncateLeft / TruncateMiddle (ellipsis variants), PadLeft / PadRight /
PadCenter, WrapText.
Style{Fg, Bg, Attr} bundles cell appearance; most widget option structs
accept it.
Widgets
Widgets are stateless render functions (mostly Region methods). Application
state lives in plain structs passed by pointer. Available renderers:
boxes and lines (Box, BoxFilled, HLine, VLine — single, double,
rounded, heavy line types), List, Table, Tree, TabBar, KeyValue /
KeyValueWrap, Progress / ProgressV / Gauge / Spinner,
ProgressOverlay, Sparkline / SparklineV, Input / TextField,
Editor, Modal / Overlay / ConfirmDialog, ScrollBar /
ScrollIndicator, masonry layout.
Representative patterns below; remaining widgets follow the same opts-struct + state-struct shape — read the source for full options.
Theme and form controls
A Theme is one Style per role (Text, Muted, Accent, Selected,
Input, Cursor, Error, Border) plus the Glyphs widgets draw. A
role's Attr may carry color bits, a palette index or the terminal's own
color, so an application builds one theme per color tier and every control
draws alike in each: DefaultTheme is dark true color, Theme16 uses the
ANSI 16 on the terminal's own background, MonoTheme uses attributes
alone, and ThemeFor(term.ColorMode()) picks among them. A selection always changes a glyph (brackets, a pointer,
the focus mark), so it reads without color. GlyphsUnicode, GlyphsCP437
(text consoles) and GlyphsASCII (outside UTF-8) are the glyph sets.
th := tui.DefaultTheme
root.FillStyle(th.Text) // the panel the controls draw on
value, rows := form.Field(y, 10, tui.Field{Label: "port", Required: true,
Help: "TCP port to listen on", Error: portErr}, focus.Index == 0, th)
value.TextInput(port, "8080", focus.Index == 0, th) // the default as placeholder
y += rows
value, rows = form.Field(y, 10, tui.Field{Label: "format"}, focus.Index == 1, th)
value.Choice(0, 0, []string{"raw", "txt", "json"}, format, th) // raw [txt] json
y += rows
form.Group(y, "tls", "on, pinned", tlsOpen, focus.Index == 2, th)
Focusis the focus ring:HandleKeymoves it on Tab and Shift+Tab.Fielddraws the focus mark, the label, the required mark, and the error, or the help while focused, and returns the region for the value.TextInputdraws aTextFieldState, its placeholder muted while empty.TextFieldState.Acceptfilters typed and pasted runes (AcceptInteger,AcceptNumber);Pasteinserts anEventPaste's text with line breaks as spaces and control characters dropped.Choicedraws a segmented choice;StepChoicemoves it on Left and Right.Toggledraws an on/off switch.Groupdraws a foldable group's header, and its summary while folded.OptionListStateandOptionListfilter options by what is typed, matching name or hint regardless of case.
Scrollable list with scrollbar
items := make([]tui.ListItem, 0, len(files))
for _, f := range files {
items = append(items, tui.ListItem{
Icon: '▸', IconFg: color.Amber,
Text: f.Name, TextStyle: tui.Style{Fg: terminal.LightGray},
})
}
r.List(items, state.Selection, state.Offset, tui.ListOpts{CursorBg: color.DarkSlate})
r.ScrollBar(r.W-1, state.Offset, state.Visible, state.Total, color.IronGray)
Modal dialog
dlg := tui.Center(root, 50, 12)
content := dlg.Modal(tui.ModalOpts{
Title: "Settings",
Border: tui.LineDouble,
BorderFg: color.SteelBlue,
TitleFg: terminal.White,
Bg: color.DarkSlate,
})
content.TextBlock(0, 0, body, fg, color.DarkSlate, terminal.AttrNone)
Modal fills, borders, titles, and returns the content region. Overlay
adds fullscreen/floating/shadow variants; ConfirmDialog adds yes/no buttons
with focus state.
Progress overlay
prog := tui.NewProgressState(tui.DefaultProgressOpts("Indexing", "Scanning...",
tui.ProgressDeterminate))
// per frame:
prog.Tick()
prog.SetProgress(done / total)
if prog.Visible {
root.ProgressOverlay(prog.Opts)
}
Five progress types (spinner, determinate, indeterminate, pulse, dots), eight spinner styles, eight bar styles, seven frame styles — combinable via opts.
Multi-line editor
ed := tui.NewEditorState(initialText)
// input:
if ev.Type == terminal.EventKey {
ed.HandleKey(ev.Key, ev.Rune, ev.Modifiers) // full emacs-style bindings built in
}
// render:
r.Editor(ed, tui.EditorOpts{LineNumbers: true, Border: tui.LineSingle, Focused: true})
text := ed.Value()
TextFieldState + TextField provide the single-line equivalent
(placeholder, prefix, password mask, max length).
State helpers
Pure logic, no rendering — usable independently:
ScrollState— item-index scrolling with selection (SelectNext/Prev,EnsureVisible,PageUp/Down,AtTop/AtBottom)ViewportScroll— row-based content scrolling with viewport clipping (ClipToViewportmaps content rows to visible rows)TreeState+TreeExpansion+TreeBuilder— cursor/scroll, expand/collapse keyed state, hierarchical → flat visible-node listEditorState,TextFieldState— text content, cursor, scroll, key handlingMasonryState— multi-column layout calculation over a viewport- Free functions:
AdjustScroll,ClampScroll,ClampCursor,ScrollPercent,PageDelta
Notes
- Width calculations count runes, not terminal columns; East Asian wide characters and combining marks are not width-aware.
- Zero-value
color.RGBin style fields generally means "inherit" (widget default or row background) — check specific widget docs. A zero background with no Bg color bits is transparent: the cell keeps the background beneath it, bits and all. - Mouse hit testing:
TabBarreturns[]TabBounds; other widgets require application-side geometry from the regions used.