tui

package module
v0.3.12 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 6, 2026 License: BSD-3-Clause Imports: 15 Imported by: 0

README

go-widgets/tui

CI pkg.go.dev coverage go license

Terminal I/O layer around go-widgets/painter's CellPainter. Renders a widget tree as a self-contained ANSI stream sized to the current terminal, so the same widget code that produces pixels for a WUI or GUI back-end also produces text for a terminal.

What it is (and what it isn't)

The package is deliberately minimal — no raw mode, no keyboard event loop, no alt-screen management. It ships a snapshot model: render one frame to a writer and return. Compose it with a caller-managed event loop or a CLI that prints once and exits.

import (
    "os"

    "github.com/go-widgets/painter"
    "github.com/go-widgets/tui"
)

widgets := []painter.Widget{
    &painter.Label{Bounds: painter.Rect{X: 2, Y: 1, W: 30, H: 1}, Text: "GO WIDGETS TUI"},
    &painter.Button{Bounds: painter.Rect{X: 2, Y: 3, W: 12, H: 3}, Label: "OK"},
    &painter.ProgressBar{Bounds: painter.Rect{X: 2, Y: 8, W: 30, H: 3}, Value: 0.72},
}
_ = tui.RenderOnce(os.Stdout, widgets, nil) // nil theme = LightTheme

Interactive tui.App runner (v0.3.x)

For a full interactive experience — raw mode + alt-screen + input loop + resize + cleanup — instantiate a tui.App:

import (
    "os"
    "github.com/go-widgets/toolkit"
    "github.com/go-widgets/tui"
)

func main() {
    app := tui.NewApp()
    app.Root = buildScene()          // toolkit.Widget hierarchy
    app.Keys["q"] = func(a *tui.App) { a.Quit() }
    app.Keys["Ctrl+C"] = func(a *tui.App) { a.Quit() }
    os.Exit(app.Run())
}

App.Run() enters alt-screen, sets raw mode, spawns a stdin reader, dispatches events to Root.OnEvent (with global handlers running first), reacts to SIGWINCH for resize, and always restores the terminal on exit — even on panic, via a deferred TTY.Leave inside a defer/recover chain.

Keys handlers may call a.Consume() to prevent the current event from also reaching Root — needed for mode-switching editors (pressing i to enter edit mode without also inserting i into the buffer).

Two reference demos ship in cmd/:

  • cmd/tui-explorer — k9s-style file browser (file list + preview pane + arrow-key navigation + help/search popovers).
  • cmd/tui-editor — loom-style modal text editor (View / Edit / Palette modes + file open + Ctrl+S save + Ctrl+P palette).

Both are exercised by real pty-based e2e tests (//go:build unix && integration) that spawn the binary under a real terminal, send real key bytes, and assert on the rendered frame. These tests catch layout + event-loop bugs that seam-based unit tests miss — see the v0.3.2 / v0.3.3 / v0.3.4 release notes for the concrete regressions this protocol caught.

Cell-mode widget guidance

Most toolkit widgets are designed for pixel rendering — their internal pad constants (AlertPadY = 8, MenuBarH = 22, …) are pixels in PixelPainter mode. In tui.RenderToolkit / tui.App cell mode, those same integers count CELLS, so widgets that lean on large pad constants render at cell-inappropriate sizes.

Cell-native (render at ~1 cell per glyph — safe in tui.App): Label, TextView, Entry, Button, Popover.

Pixel-tuned (usable but visually inflated in cell mode): Alert, Card, Stat, HeaderBar, Toast, Banner.

Pixel-only (render poorly at any small cell size — replace with local cell-native helpers in tui.App consumers): MenuBar, Statusbar, TreeView, Notebook, HPaned, VPaned, Scale, LevelBar.

The cmd/tui-explorer + cmd/tui-editor demos illustrate the pattern: they use local packedVBox, hSplit, fileList helpers instead of the pixel-tuned toolkit equivalents.

Sizing

Two variants, take your pick:

  • RenderOnceSized(w, cols, rows, widgets, theme) — explicit dimensions. The reliable form for tests, size-aware callers, and headless renderers.
  • RenderOnce(w, widgets, theme) — queries EnvSize() (COLUMNS / LINES environment variables) and falls back to DefaultCols x DefaultRows (80 x 24) when the environment does not report a size.

The env-vars-only strategy is a deliberate trade-off: it keeps the package stdlib-only — no TIOCGWINSZ ioctl per platform, no cgo, no dependency on golang.org/x/term. Interactive shells that need dynamic sizes should export COLUMNS / LINES from their prompt hook, or pass an explicit size to RenderOnceSized.

Try it

# Default terminal size (or COLUMNS / LINES if set)
go run ./cmd/tui-snapshot

# Force a specific size + dark theme
go run ./cmd/tui-snapshot --cols=100 --rows=25 --theme=dark

The tui-snapshot demo mirrors painter/cmd/tui-demo: same three-widget layout (label, two buttons, progress bar), same theme selection.

For a broader showcase using the real go-widgets/toolkit widgets (Button, ToggleButton, Switch, Entry, ProgressBar, Alert, Stat, Timeline …) rendered through the same cell backend:

# Toolkit widget catalogue in two columns
go run ./cmd/tui-catalogue

# Force size + dark theme
go run ./cmd/tui-catalogue --cols=100 --rows=25 --theme=dark

Internally uses tui.RenderToolkit (the bridge that accepts toolkit.Widget and translates toolkit.Theme to what painter.CellPainter expects) so the on-screen widgets are the same objects a wasm gallery or a native window would compose.

For the interactive demos (tui.App powered):

# k9s-style file browser with arrow navigation
go run ./cmd/tui-explorer

# vi-style modal editor
go run ./cmd/tui-editor --file=path/to/file.txt

To run the pty-based end-to-end integration tests:

go test -tags integration ./cmd/tui-explorer/... ./cmd/tui-editor/...

These verify the rendered frame after real key input in a real pty, catching layout + interaction bugs that unit tests miss.

Design axiom

Same as the rest of go-widgets: dependency-free, stdlib-only, 100% coverage (library packages and cmd/). CI enforces the coverage gate on every push.

License

BSD-3-Clause. See LICENSE.

Documentation

Overview

Package tui is the terminal-I/O layer around github.com/go-widgets/painter's CellPainter. It renders a widget tree into an ANSI cell stream sized to the current terminal, so the same widget code that produces pixels for a WUI or GUI back-end also produces text for a terminal — no widget changes required.

The package is deliberately minimal: no raw mode, no keyboard event loop, no alt-screen management. It ships a "snapshot" model — render one frame to a writer and return — that composes cleanly with either a caller-managed event loop or a CLI that prints once and exits. Higher-level facilities (an App runner with input, resize, and cleanup handling) live in a follow-up cycle.

Sizing follows the caller's preference:

  • RenderOnceSized takes explicit (cols, rows) — the reliable form used by tests, size-aware callers, and headless renderers.
  • RenderOnce queries the size from environment variables (EnvSize) and falls back to [DefaultCols]x[DefaultRows] when the environment does not report a size. This keeps the package stdlib-only — no ioctl, no cgo, no dependency on golang.org/x/term.

Both variants write a self-contained ANSI stream to the caller's writer via painter.CellPainter.WriteANSI; the caller is responsible for the surrounding terminal state (raw mode, alt screen, cursor visibility) if any.

Index

Constants

View Source
const (
	DefaultCols = 80
	DefaultRows = 24
)

DefaultCols and DefaultRows are the terminal dimensions used when neither the environment nor the caller supplies a size — the classic VT100 defaults every terminal emulator honours as a floor.

View Source
const EventTick toolkit.EventKind = 100

EventTick is the toolkit.EventKind the App emits at a caller- configured tick rate. Widgets that animate (Toast, Spinner) match on this kind to advance a frame counter and repaint. The value is deliberately placed above the toolkit's own iota range so it can never collide with a widget-produced Click / KeyDown / Char event.

Variables

This section is empty.

Functions

func EnvSize

func EnvSize() (cols, rows int, ok bool)

EnvSize reads the terminal size from the COLUMNS and LINES environment variables. It returns (cols, rows, true) when both parse cleanly and hold positive integers; otherwise it returns (0, 0, false) and the caller should substitute defaults.

The env-vars-only strategy is a deliberate trade-off: it keeps the package stdlib-only (no TIOCGWINSZ ioctl per platform, no cgo, no golang.org/x/term dependency). Interactive shells that need dynamic sizes should export COLUMNS / LINES from their prompt hook, or pass an explicit size to RenderOnceSized.

func RenderOnce

func RenderOnce(w io.Writer, widgets []painter.Widget, theme *painter.Theme) error

RenderOnce paints the widget tree into a fresh CellPainter sized to SizeOrDefault, fills the background with theme.Background, calls each widget's Draw, and writes the resulting ANSI stream to w. It is the snapshot form: no raw mode, no event loop; one frame then return.

The theme argument may be nil, in which case painter.LightTheme is used. This keeps CLI callers ergonomic (`tui.RenderOnce(os.Stdout, widgets, nil)`) without forcing them to import painter just to name the default theme.

func RenderOnceSized

func RenderOnceSized(w io.Writer, cols, rows int, widgets []painter.Widget, theme *painter.Theme) error

RenderOnceSized is like RenderOnce but uses the explicit (cols, rows) dimensions instead of querying the environment. Cols and rows must both be positive; a non-positive value falls back to the corresponding default so callers cannot accidentally produce an empty render.

func RenderToolkit added in v0.2.0

func RenderToolkit(w io.Writer, widgets []toolkit.Widget, theme *toolkit.Theme) error

RenderToolkit paints a toolkit.Widget tree into a fresh CellPainter sized to SizeOrDefault, fills the background with theme.Background, calls each widget's Draw, and writes the resulting ANSI stream to w.

It is the toolkit-widget counterpart of RenderOnce (which renders painter.Widget). The two exist side-by-side because toolkit.Theme carries fields (SurfaceAlt, OnBackground, Extra) that painter.Theme does not, so a toolkit widget cannot be rendered through RenderOnce without losing theme surface.

The theme argument may be nil, in which case toolkit.DefaultLight is used.

func RenderToolkitSized added in v0.2.0

func RenderToolkitSized(w io.Writer, cols, rows int, widgets []toolkit.Widget, theme *toolkit.Theme) error

RenderToolkitSized is like RenderToolkit but uses the explicit (cols, rows) dimensions instead of querying the environment. Cols and rows must both be positive; a non-positive value falls back to the corresponding default so callers cannot accidentally produce an empty render.

func SizeOrDefault

func SizeOrDefault() (cols, rows int)

SizeOrDefault returns EnvSize when the environment reports a size, otherwise [DefaultCols]x[DefaultRows]. This is what RenderOnce consumes.

Types

type App added in v0.3.0

type App struct {
	// Root is the widget tree the App draws and dispatches events
	// to. Callers wire the composition once and mutate its fields
	// during OnKey callbacks; the next repaint reflects the state.
	Root toolkit.Widget

	// Theme cascades through the widget tree on every draw. Defaults
	// to [toolkit.DefaultLight] if left nil.
	Theme *toolkit.Theme

	// Keys is a map from a key Code (matching InputParser output —
	// "q", "Ctrl+C", "Up", "Enter", …) to a handler. Handlers can
	// mutate App state (e.g. call [App.Quit]) or the widget tree.
	// Global handlers run BEFORE the event reaches Root.OnEvent — the
	// App always dispatches to Root afterwards.
	Keys map[string]func(*App)

	// TickHz sets the auto-tick frequency in Hz. 0 disables ticks. A
	// widget subscribing to EventTick needs TickHz > 0 to animate.
	TickHz int
	// contains filtered or unexported fields
}

App is an interactive TUI runner. Instantiate one, set App.Root to your widget tree, optionally register keybindings + a tick rate, and call App.Run. The App handles alt-screen + raw mode, parses stdin into toolkit.Event values, dispatches them to the widget tree, repaints on demand, and cleans up on exit — including on panic, via a deferred TTY.Leave.

func NewApp added in v0.3.0

func NewApp() *App

NewApp returns an App wired to the real terminal: OpenTTY, os.Stdin, os.Stdout, real Unix signal handling, and time.NewTicker for the tick channel. Tests build an App by hand and swap the seams instead.

func (*App) Consume added in v0.3.1

func (a *App) Consume()

Consume tells the event loop that the current Keys handler fully handled the event and it must NOT propagate to Root.OnEvent. Without Consume, every key that matches a Keys handler also reaches Root — which is fine for global shortcuts (Ctrl+C to quit still lets the Root repaint from its own state), but wrong for mode-switching editors where pressing 'i' to enter edit mode would otherwise ALSO insert 'i' into the underlying TextView.

Idempotent within a single event dispatch. The event loop clears the flag at the top of every event so a Consume from a previous event never affects the next one.

func (*App) IsQuitting added in v0.3.0

func (a *App) IsQuitting() bool

IsQuitting reports whether Quit has been called (whether the loop is on its way to exit). Non-blocking. Useful for consumer tests that verify a key handler triggered a quit without spinning the event loop.

func (*App) Quit added in v0.3.0

func (a *App) Quit()

Quit signals the event loop to exit on the next iteration. Safe to call from a key handler or a goroutine, and idempotent: a second call is a no-op (the underlying quit channel is closed exactly once via sync.Once).

func (*App) Refresh added in v0.3.0

func (a *App) Refresh()

Refresh marks the frame dirty so the next iteration repaints. It also nudges the wake channel so a loop currently blocked in select unblocks — enabling async I/O goroutines to schedule a redraw without racing an unrelated event.

func (*App) Run added in v0.3.0

func (a *App) Run() (exitCode int)

Run blocks, driving the event loop until Quit is called or an interrupt signal arrives. Returns the exit code (0 by default; 1 if TTY setup fails; 2 if a key handler or widget panics). Terminal state is guaranteed to be restored on exit — even on panic — via a deferred TTY.Leave.

The return is named (exitCode) so a panic recovered by the deferred recover func can overwrite it; without the named return the panic path would still yield the pre-panic value.

func (*App) SetOpenTTYFn added in v0.3.0

func (a *App) SetOpenTTYFn(fn func(*os.File) (TTY, error))

SetOpenTTYFn overrides the TTY factory the event loop uses at Run time. Consumer tests inject a fake TTY (or an error) so they can drive Run() without needing a real controlling terminal. Passing a factory that returns an error is a valid way to force Run() to exit with a non-zero code before any raw-mode side effect.

type Cell added in v0.3.6

type Cell struct {
	Rune rune
	Fg   Color
	Bg   Color
}

Cell is one grid position after ANSI-decoding a terminal frame: the rune actually written, plus the foreground and background colors that were active when it was written. All zero-value colors mean "default" (nothing set). Cell exists so consumer tests can assert on the visual output at cell-precision — the previous text-strip protocol dropped color information and let bugs like "highlight rendered as `█` characters" ship undetected.

type Color added in v0.3.6

type Color struct {
	R, G, B uint8
	Set     bool // false = default/reset, true = explicitly set
}

Color carries the 24-bit RGB values decoded from a CSI SGR "38;2" (foreground) or "48;2" (background) sequence. Zero value means "default" — SGR 0 or no color set.

type InputParser added in v0.3.0

type InputParser struct {
	// contains filtered or unexported fields
}

InputParser reads bytes from a terminal stdin stream and emits toolkit.Event values. A single call to InputParser.Feed may produce zero, one, or many events: typing "abc" yields three toolkit.EventChar events, pasting a control sequence like "\x1b[A" yields one arrow-up event but consumes three bytes.

The toolkit exposes toolkit.EventKeyDown for named-key presses (Enter, Tab, arrows, Escape, …) and toolkit.EventChar for printable characters — the parser routes accordingly.

The pending buffer holds the incomplete tail of the last Feed call: a lone ESC (0x1B) whose next byte has not arrived, an ESC [ … partial CSI sequence still waiting for its final byte (0x40..0x7E), or a partial UTF-8 rune (1–3 bytes of a multi-byte codepoint split across two Reads). It is consumed automatically on the next Feed call, or drained by InputParser.Flush on an input-idle deadline.

func NewInputParser added in v0.3.0

func NewInputParser() *InputParser

NewInputParser returns a fresh parser with an empty buffer.

func (*InputParser) Feed added in v0.3.0

func (p *InputParser) Feed(b []byte) []toolkit.Event

Feed hands b bytes to the parser and returns any events that completed as a result. Bytes belonging to an incomplete sequence (a lone ESC, a partial CSI, a partial UTF-8 rune) are buffered internally and consumed on the next Feed call.

The classic Escape-vs-CSI ambiguity is resolved as follows: a lone ESC at the end of the buffer is held pending; on the next Feed a leading '[' starts a CSI sequence, any other byte causes the held ESC to be emitted as "Escape" and the following byte is then processed as a fresh input. Callers waiting on an idle deadline flush the held ESC via InputParser.Flush.

func (*InputParser) Flush added in v0.3.0

func (p *InputParser) Flush() []toolkit.Event

Flush emits any pending ESC (or partial CSI) as an "Escape" event and clears the buffer. Callers invoke it on an input-idle deadline to resolve the Escape-vs-CSI ambiguity in favour of a plain Escape. A partial UTF-8 rune is discarded silently — its tail bytes would be indistinguishable from an unrelated new keystroke by the time an idle timeout fires.

type TTY added in v0.3.0

type TTY interface {
	// Enter puts the terminal in raw mode, saves the current
	// termios state, switches to the alt-screen, and hides the
	// cursor. Idempotent — a second call while entered is a no-op.
	Enter() error

	// Leave reverses Enter: restores the alt-screen, shows the
	// cursor, and restores the saved termios state. Always safe to
	// call, including from a defer or a panic recovery.
	Leave() error

	// Size returns the current terminal dimensions in cells.
	Size() (cols, rows int, err error)
}

TTY is the abstraction the App runner uses to talk to a real terminal. Implementations set raw mode, hide the cursor, switch to the alt-screen, and reverse everything on Close. The mock implementation used in tests satisfies the same interface without touching a real terminal.

func OpenTTY added in v0.3.0

func OpenTTY(f *os.File) (TTY, error)

OpenTTY opens the terminal associated with the given file (which must be a tty — os.Stdout in normal use). Returns an error on non-Unix platforms or if the file is not a tty.

type TermGrid added in v0.3.6

type TermGrid struct {
	Rows  int
	Cols  int
	Cells []Cell
}

TermGrid is a 2D cell buffer produced by DecodeANSI. Rows and Cols are the grid dimensions; Cells is row-major (Cells[y*Cols+x]).

func DecodeANSI added in v0.3.6

func DecodeANSI(stream []byte, cols, rows int) *TermGrid

DecodeANSI parses stream as a sequence of CSI SGR + printable runes + cursor-positioning + line breaks, and lays every printable rune into a Cell at the current cursor position with the current foreground / background color. Only the subset of CSI a painter.CellPainter emits is honored:

  • CSI H → cursor to (1, 1)
  • CSI r ; c H → cursor to (c, r), 1-indexed
  • CSI 0 m → reset colors
  • CSI 38 ; 2 ; R ; G ; B m → foreground truecolor
  • CSI 48 ; 2 ; R ; G ; B m → background truecolor
  • CSI ?1049 h / l → alt-screen enter/leave (recognised, no-op)
  • CSI ?25 h / l → cursor show/hide (recognised, no-op)

Unknown CSI sequences consume their bytes and are ignored so a caller can freely pass a frame that also contains cursor styling or bracketed paste. \n and \r reset the cursor to column 0 and advance the row (LF) or stay on the same row (CR).

func (*TermGrid) At added in v0.3.6

func (g *TermGrid) At(x, y int) Cell

At returns the cell at (x, y). Out-of-bounds coordinates return the zero Cell — callers that care about bounds should check dims.

func (*TermGrid) Row added in v0.3.6

func (g *TermGrid) Row(y int) []Cell

Row returns the y-th row as a Cell slice. Panics on out-of-bounds y — mirrors slice indexing conventions.

func (*TermGrid) RowText added in v0.3.6

func (g *TermGrid) RowText(y int) string

RowText joins the Rune of every cell in row y into a string. Non- printable / space runes stay as ' '. Useful for coarse "does row Y contain this text" assertions without giving up the color info the underlying grid still carries.

Directories

Path Synopsis
cmd
tui-catalogue command
tui-catalogue renders the complete go-widgets/toolkit widget catalogue (every kind with a public constructor) into a scrollable cell-grid frame written to stdout as an ANSI stream.
tui-catalogue renders the complete go-widgets/toolkit widget catalogue (every kind with a public constructor) into a scrollable cell-grid frame written to stdout as an ANSI stream.
tui-editor command
tui-editor is the reference loom-style interactive demo for tui.App: a modal (vi-inspired) text editor built on top of the existing toolkit widgets — TextView for the buffer, Statusbar for the mode + cursor + filename indicator, MenuBar for the chrome, and a Popover-wrapped SearchEntry for the command palette.
tui-editor is the reference loom-style interactive demo for tui.App: a modal (vi-inspired) text editor built on top of the existing toolkit widgets — TextView for the buffer, Statusbar for the mode + cursor + filename indicator, MenuBar for the chrome, and a Popover-wrapped SearchEntry for the command palette.
tui-explorer command
tui-explorer is the reference interactive demo for tui.App: a small k9s-style file-browser mockup composed from toolkit widgets.
tui-explorer is the reference interactive demo for tui.App: a small k9s-style file-browser mockup composed from toolkit widgets.
tui-snapshot command
tui-snapshot renders a sample widget tree to stdout as an ANSI stream.
tui-snapshot renders a sample widget tree to stdout as an ANSI stream.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL