Files
terminal/tui/README.md
T

9.8 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 (at line breaks and spaces; a path or key longer than the line breaks after ., ,, :, /, [ or ]).

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, dashed and ASCII 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 (a radio mark, a pointer, the focus mark), so it reads without color. A reverse-video Selected with no color of its own (Theme16, MonoTheme) keeps the terminal's own foreground in every cell drawn on it, so the cursor row is one bar. GlyphsUnicode, GlyphsCP437 (text consoles) and GlyphsASCII (outside UTF-8) are the glyph sets; Glyphs.Line is the line type of frames, rules and wires.

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.Radio(0, 0, []string{"raw", "txt", "json"}, format, th) // ○ raw  ● txt  ○ json
y += rows

form.Group(y, "tls", "on, pinned", tlsOpen, focus.Index == 2, th)
  • Focus is the focus ring: HandleKey moves it on Tab and Shift+Tab.
  • Field draws the focus mark, the label, the required mark, and the error, or the help while focused, wrapped, and returns the region for the value. The label leaves the value at least half the row; label width 0 stacks the label above the value, for narrow screens.
  • TextInput draws a TextFieldState, its placeholder muted while empty, scrolled to the cursor with a muted … where text is out of view. TextFieldState.Accept filters typed and pasted runes (AcceptInteger, AcceptNumber); Paste inserts an EventPaste's text with line breaks as spaces and control characters dropped.
  • Radio draws radio buttons in a row, the chosen one marked; too wide, it shows the chosen one and its neighbours. StepChoice moves it on Left and Right.
  • Toggle draws an on/off switch.
  • Group draws a foldable group's header, and its summary while folded.
  • OptionListState and OptionList filter options by what is typed, matching name or hint regardless of case. Hints sit beside the names, or, narrow, the cursor's hint wraps below them; Rows(w) is the height to size a dialog by. With Menu there is no filter and Pick finds an option by its Key, shown before its name.
  • Frame fills a region, borders it with its title, and returns the inside; sized with Center to what it holds, a dialog ends on its last line.
  • Rule draws a line with a title and a hint, dropping the hint first.
  • Wires lay lines on a grid by the arms that meet in each cell (H, V), and DrawWires draws every junction, tee and crossing joined.

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 (ClipToViewport maps content rows to visible rows, EnsureRange keeps a range in view); Region.Window draws content taller than the region off screen and shows the rows in view
  • TreeState + TreeExpansion + TreeBuilder — cursor/scroll, expand/collapse keyed state, hierarchical → flat visible-node list
  • EditorState, TextFieldState — text content, cursor, scroll, key handling
  • MasonryState — multi-column layout calculation over a viewport
  • Free functions: AdjustScroll, ClampScroll, ClampCursor, ScrollPercent, PageDelta

Notes

  • Width calculations count runes, one column each, as Flush draws a wide or joining rune as U+FFFD.
  • Zero-value color.RGB in 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: TabBar returns []TabBounds; other widgets require application-side geometry from the regions used.