* mouse: the wheel's four directions and the side buttons parseSGRMouse read a report's low two bits alone, so a wheel tilted left or right (66, 67) reported as WheelDown, and a mouse's back and forward buttons (128, 129) as a left or middle press. It now decodes by bank: bits 0-1 number a button within the main three and none, the wheel's four directions (bit 6), or buttons 8 to 11 (bit 7). WheelLeft and WheelRight join MouseButton after Forward, so every existing value keeps its number; Back and Forward are reported for buttons 8 and 9; buttons 10 and 11, and a report with both bank bits set, are dropped as KeyNone. A wheel step is a press with no release in every direction. * tui: ScrollThumb and ScrollDrag ScrollThumb is the thumb ScrollBarStyled draws, its first row and height, exported so an application hit-tests the bar it drew. ScrollDrag drives a bar by the pointer, rows counted from the track's top: a press on the thumb takes hold where it lands, a press on the track moves the thumb's middle there and takes hold, and each drag keeps the held row under the pointer, clamped at the track's ends, at the nearest offset when there are fewer offsets than rows. Each call returns the offset to show. A table of SGR reports covers each bank and drops the buttons past 9; a property test over every track of 3 to 10 rows and content up to 30 checks each press and drag against the thumb ScrollThumb draws.
293 lines
11 KiB
Markdown
293 lines
11 KiB
Markdown
# 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.
|
|
|
|
```go
|
|
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
|
|
|
|
```go
|
|
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
|
|
|
|
```go
|
|
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
|
|
|
|
```go
|
|
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.
|
|
|
|
```go
|
|
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
|
|
|
|
```go
|
|
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)
|
|
```
|
|
|
|
`ScrollThumb` is the thumb a bar draws. `ScrollDrag` drives one by the
|
|
pointer, rows counted from the track's top: a press on the thumb takes hold
|
|
of it, a press on the track moves the thumb's middle there and takes hold, and
|
|
a drag keeps the held row under the pointer, each returning the offset to show.
|
|
|
|
```go
|
|
var drag tui.ScrollDrag // kept across events
|
|
bar := r.Sub(r.W-1, 0, 1, r.H) // the column ScrollBar draws, in screen cells at bar.X, bar.Y
|
|
|
|
// per left button event:
|
|
switch ev.MouseAction {
|
|
case terminal.MouseActionPress:
|
|
if ev.MouseX == bar.X {
|
|
state.ScrollTo(drag.Press(ev.MouseY-bar.Y, bar.H, state.Offset, state.Visible, state.Total))
|
|
}
|
|
case terminal.MouseActionDrag:
|
|
state.ScrollTo(drag.Drag(ev.MouseY-bar.Y, bar.H, state.Offset, state.Visible, state.Total))
|
|
case terminal.MouseActionRelease:
|
|
drag.Release()
|
|
}
|
|
```
|
|
|
|
### Modal dialog
|
|
|
|
```go
|
|
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
|
|
|
|
```go
|
|
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
|
|
|
|
```go
|
|
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`, and `ScrollDrag` maps a
|
|
scrollbar's rows to offsets; other widgets require application-side
|
|
geometry from the regions used.
|