Architecture¶
tuilib is a set of layers. Each one owns a single concern, and the rules that keep the library consistent fall out of which layer owns what.
tea.Program
└── app.Model the shell: chrome, global keys, theme, mouse, console
├── breadcrumb.Model 1 row: the stack's titles
├── screen.Stack push / pop / replace, result passing
│ └── your Screen state + Update + a layout.Node tree
│ └── layout.Node VStack / HStack / ZStack of Fixed / Flex items
│ └── component list, table, tree, … each owning a pane.Pane
└── statusbar.Model 1 row: "? help", messages, console badge, version
The app shell¶
app.New(app.Options{Root: …, Themes: …}) returns a tea.Model you hand to
tea.NewProgram. It owns everything around your screens:
- the breadcrumb (one crumb per screen on the stack, clickable),
- the statusbar (help affordance, transient messages, console badge, version),
- global keys: quit, esc-to-pop, theme cycling, suspend, the
?overlay, and the opt-in output-console and action-menu keys, - theme resolution from an env var and the user's config file,
- mouse: enabling reporting, counting double clicks, routing clicks on its own chrome,
- the output console, the action menu and the help overlay.
A screen never sees the terminal size and never draws chrome. See The app shell.
Screens and the stack¶
A screen.Screen is your unit of UI: it holds its components, handles its
messages, and describes its layout. Screens live on a screen.Stack. Data
moves through the stack in three directions:
| Direction | How |
|---|---|
| parent → child | construct the child with what it needs: screen.Push(newDetail(item, t)) |
| child → parent | screen.Pop(result); the uncovered parent receives it in OnEnter(result) |
| self → self | screen.Replace(fresh) swaps the top without disturbing the parent |
Layout¶
Layout() returns a tree of layout.Node. Containers split their rect among
Fixed(n, …) and Flex(weight, …) children, and leaves are components
wrapped with layout.Sized(&c). Each component is handed a geom.Rect: its
absolute position and size. The position is what makes mouse support
work without injecting anything into the rendered string; a component stores
its rect and hit-tests clicks against it. See Layout.
Components own their pane¶
Every interactive component bundles a pane.Pane: a bordered, titled,
scrollable region with a scrollbar, slots on its border, horizontal scroll and
a loading spinner. So:
- label a component with its
Titleoption, which draws on the border; never render a label line above it, and never wrap it in another pane; - a filterable component draws its filter inside its own pane, as a pinned row above a rule, so it's unambiguous which pane a filter belongs to;
- a component owns its geometry: it knows where its rows, glyphs and buttons are, so it's the one that decides a click was its own.
The exceptions are the bars (breadcrumb, statusbar), the help footer, the
layout primitives, and pkg/form, which is a vertical stack of bordered
fields.
The component shape¶
Every component follows the same pattern:
opts := t.Table() // 1. start from the theme builder
opts.Title = "Deployments" // 2. set what's specific to this instance
opts.Columns = cols
s.table = table.New(opts) // 3. construct
s.table, cmd = s.table.Update(msg) // 4. forward every message
layout.Sized(&s.table) // 5. place it
Stateful components add State() / Restore(State) for rebuilding,
Help() / HelpSections() for key hints, and Focus / Blur / Focused
for focus. See Components.
How messages flow¶
Bubble Tea delivers every message to app.Model.Update. From there:
- Shell-level messages are handled and consumed: window resize, theme
swap, statusbar requests (
app.Info,app.Error), stack operations, console and action-menu traffic. - Global keys are checked next, unless the active screen reports
IsCapturingKeys(). A focused text field getsqas a letter, not as quit. - Everything else goes to the stack, which routes by kind:
tea.KeyMsgandmouse.Msggo to the top screen only. A covered screen must not act on input meant for what covers it.- All other messages (timers, fetch results, spinner ticks) fan out to every screen on the stack, so a polling screen keeps polling while a child is pushed over it and its data is current when you return.
- Inside a screen, forward every message to every component. Each
component decides what to act on. The exceptions are about user input
aimed at what's on screen:
- with a
focus.Group, keys go to the focused component only, while mouse events go to all components (each tests its own rect); pkg/tabsends keys and mouse to the active tab body only; other messages fan out to every body.
- with a
Theme swaps and state¶
theme.Theme is one struct of semantic color tokens, border shapes and
glyphs. Each component has a builder on it (t.List(), t.Table(), …)
returning pre-styled Options. When the theme changes, the shell calls
SetTheme(t) on every screen on the stack, and each screen rebuilds its
components from the new builders.
Rebuilding loses nothing because each stateful component exposes an opaque
State() covering everything Options doesn't set: rows, cursor, scroll,
filter, sort, marks, expansion, follow, remote query state, row activity.
Restore replays it in the order that keeps each part valid. See
Theming and state.
Focus, keys and mouse¶
- Keys: arrows and
hjklare reserved for scrolling everywhere;tab/shift+tabcycle focus between panes;entermeans "open the selection". Every component's bindings live in an overridableOptions.Keysstruct. - Focus: a screen with several interactive components holds a
focus.Groupover them, which owns cycling and grants focus to a clicked component. - Mouse is opt-in (
app.Options.Mouse = app.MouseClick). Click focuses and selects; double-click is enter; the wheel scrolls whatever is under the pointer; library-drawn chrome (headers, glyphs, scrollbars, tabs, crumbs, buttons) is clickable.
Async work¶
Every component is synchronous; nothing in the library spawns a goroutine
that touches a model. Long-running work runs in a tea.Cmd and comes back as
a message:
pkg/pollemitsRefreshMsgon an interval; your screen fetches.pkg/sourceandpkg/remotedecide what to fetch for paged data; your fetch function does the I/O under a context the library cancels.pkg/activity(throughtable,listandtree) shows per-row spinners driven by the server's status.pkg/runnerhands the terminal to a subprocess, captures one in the background, or streams a Go function's output into the console.
Package map¶
| Layer | Packages |
|---|---|
| Shell and navigation | app, screen, tab |
| Layout and geometry | layout, geom |
| Look | theme, glyph, ansi, config |
| Input | focus, mouse, help, action |
| Containers and text | pane, textview, logview |
| Data views | list, table, tree, inspector, eventlog, metrics |
| Inputs and modals | filter, input, toggle, form, confirm, alert |
| Bars | breadcrumb, statusbar |
| Data plumbing | poll, source, remote, query, activity, resume |
| Processes and logs | runner, output |
Leaf packages (geom, query, glyph, activity, runner) import
nothing else from tuilib, so any package can depend on them without a cycle.