214 lines
10 KiB
Markdown
214 lines
10 KiB
Markdown
# tai TUI framework
|
|
|
|
A retained, DOM-like terminal UI toolkit living entirely in `tai.py`
|
|
(standard library only, no dependencies). Components are plain Python
|
|
objects in a tree; a compositor merges them bottom-to-top by layer and
|
|
z-order every frame; one synchronous tick loop drives input, timers,
|
|
animations, and rendering.
|
|
|
|
Run the live tour with `/demo` in the REPL or `./tai.py --demo`.
|
|
`./tai.py --demo-shot` prints one composed frame as plain text (the
|
|
headless smoke path). Covered by `test_tai.py`'s `Tui*Tests` classes
|
|
plus live pty checks; user-facing summary in [README.md](README.md#tui-framework).
|
|
|
|
Design background and rejected alternatives:
|
|
[research/tui-framework-design/conclusions/retained-dom-compositor-tui.md](research/tui-framework-design/conclusions/retained-dom-compositor-tui.md).
|
|
|
|
## Quick start
|
|
|
|
```python
|
|
import tai
|
|
|
|
app = tai.TuiApp()
|
|
root = tai.TuiPanel(flow=None, style=tai.TuiStyle(bg="black"))
|
|
|
|
def answered(value):
|
|
print("picked:", value)
|
|
|
|
root.add(tai.TuiQuestion(
|
|
"Deploy to production?",
|
|
["Ship it", "Staging first", "Abort"],
|
|
on_done=answered, title="Confirm", w=44,
|
|
))
|
|
app.mount(root)
|
|
app.run()
|
|
```
|
|
|
|
Keys: Tab / Shift+Tab move focus, arrows + Enter drive lists and
|
|
buttons, Esc closes dialogs, `q` quits demo apps, Ctrl+C always quits.
|
|
|
|
## Architecture
|
|
|
|
- **Tree.** `TuiPanel` is the base of everything: geometry, children,
|
|
paint, event dispatch. `TuiRows` / `TuiCols` preset flow direction.
|
|
`add()` / `remove()` / `find(id)` mutate the tree; Mount/Unmount
|
|
events fire on bind.
|
|
- **Layout.** Two phases: `measure()` (intrinsic content size, chrome
|
|
excluded) then `arrange()` (absolute rects plus clip rects). Docked
|
|
children carve edges first, flow children fill the remainder, and
|
|
positioned children (x/y given) overlay out of flow. See sizes below.
|
|
- **Compositor.** Each panel paints styled text runs for its own rect.
|
|
Per screen row the compositor collects cuts (every run edge),
|
|
slices runs into aligned chops, drops occluded chops, and merges
|
|
the topmost survivor per interval. Frames diff against the previous
|
|
frame; one single-write update wrapped in synchronized-output
|
|
markers (`CSI ? 2026 h/l`, ignored where unsupported) keeps output
|
|
atomic and flicker-free.
|
|
- **Hit-testing.** Panels index into an 8x4-cell spatial grid rebuilt
|
|
on every layout; clicks resolve topmost-first inside clip rects.
|
|
- **Loop.** `select()` over stdin plus sub-terminal ptys at 30 fps.
|
|
Dirty flags skip idle frames entirely. Timers (`after`/`every`),
|
|
animations, widget `on_tick`, and terminal polling all run on the
|
|
same clock. Terminal state (raw mode, alt screen, mouse, cursor,
|
|
SIGWINCH handler, child ptys) restores in a `finally`.
|
|
|
|
## Geometry
|
|
|
|
Sizes accept `int` cells, `"50%"` (of the parent content box), `"1fr"`
|
|
(share of leftover flow space, largest-remainder exact), and `"auto"`
|
|
(measured content). Positions accept ints (negative counts back from
|
|
the far edge), percents, and `"center"`. Sizes are content-box: border
|
|
and padding add outside the given w/h (CSS `content-box`).
|
|
|
|
- `flow="rows"` stacks children top to bottom; `"cols"` left to right;
|
|
`None` places unpositioned children at the origin (use explicit
|
|
geometry or docking instead).
|
|
- `align` / `valign` (`left`/`center`/`right`, `top`/`middle`/`bottom`)
|
|
place children in leftover main-axis space and center them on the
|
|
cross axis. Rows-cross `auto` fills the width; cols-cross `auto`
|
|
hugs content (Textual parity).
|
|
- `dock="top|bottom|left|right"` pins a child to an edge outside the
|
|
flow; docked panels never scroll.
|
|
- `layer` names a z band (`base` 0, `panel` 10, `dialog` 20, `toast` 30);
|
|
`z` offsets inside the band; ties break by creation order. There is
|
|
no limit on layers, dialogs, nesting, or components.
|
|
- `offset=(dx, dy)` shifts a panel visually after layout.
|
|
- `padding` (int, `(v, h)`, or `(t, r, b, l)`), `gap` (flow spacing),
|
|
`border` with `title`, `scroll_x` / `scroll_y` with `scrollable`.
|
|
|
|
## Events
|
|
|
|
`TuiEvent` subclasses: Key, Click, Scroll, Focus, Blur, Resize, Tick,
|
|
Mount, Unmount, Select, Press, Close. Dispatch calls `on_<name>` on
|
|
the target, then walks base classes implicitly (plain attribute
|
|
lookup), then bubbles to the parent while `bubble` is true.
|
|
`event.stop()` halts everything. Custom events: subclass `TuiEvent`,
|
|
set `name`, `panel.emit()` it. Widget callbacks (`on_press`,
|
|
`on_select`, `on_close`) are the same namespace: assigning one sets
|
|
the handler, and it receives the event object (with `panel`, and
|
|
`index`/`option`/`value` for Select). Only `on_done` (Question) is a
|
|
plain callback taking the answered value.
|
|
|
|
Focus: `focusable` panels join a chain sorted by on-screen (y, x).
|
|
Tab / Shift+Tab ALWAYS cycle with wraparound, even out of a live
|
|
terminal: no widget may swallow the only focus-movement keys
|
|
(keyboard trap). Tradeoff: shells lose Tab-completion; click or Tab
|
|
into a terminal to type, Tab out to leave. Clicks focus the topmost
|
|
focusable under the cursor. An open modal traps the chain inside its
|
|
subtree and swallows outside clicks. `set_focus`, `focus_first`,
|
|
`focus_next`, `focus_prev`, `ensure_focus` manage it; Focus/Blur
|
|
events fire on change and never bubble. Automatic focus (mount,
|
|
reset) prefers non-terminal widgets via `auto_focus` so keys keep
|
|
bubbling to app handlers by default; opening a dialog remembers the
|
|
previous focus and closing it restores that panel (validated as
|
|
still attached and shown) before falling back to `auto_focus`.
|
|
Stale focus on a removed panel is detected by tree attachment, not
|
|
just visibility.
|
|
|
|
## Mouse and keyboard
|
|
|
|
Mouse enables `?1000h` + `?1006h` (clicks plus wheel, no motion
|
|
flood). The parser accepts SGR (`ESC [ < b ; x ; y M/m`) and legacy
|
|
(`ESC [ M bxy`) reports, 1-indexed to 0-indexed, with shift/alt/ctrl
|
|
modifiers. Split reports across reads are buffered, never parsed
|
|
partial; parsed reports are consumed and never leak into text input.
|
|
|
|
Keys: printable text (UTF-8), enter, tab, backtab (Shift+Tab),
|
|
backspace, escape (lone ESC after input idles; ESC+key is alt+key),
|
|
arrows, home/end, insert/delete, pageup/pagedown, F1-F12, ctrl+letter,
|
|
and modified arrows (`shift+up` style). Tab/Shift+Tab always move
|
|
focus (see Focus above); terminals receive every other key.
|
|
Ctrl+C always quits the app loop.
|
|
|
|
Kitty keyboard: setup pushes progressive-enhancement flags 10
|
|
(all-keys-as-escape-codes plus event types) best-effort and pops on
|
|
teardown; unsupported terminals ignore both bytes. The parser decodes
|
|
`CSI unicode-key-code:alternate ; mods:event-type ; text u` next to
|
|
the legacy grammar, so kitty and legacy terminals share one stream:
|
|
bare modifier presses arrive as `shift`/`ctrl`/`alt`/`super` key
|
|
events (press only; repeats and releases are dropped, text-key
|
|
repeats still type), text stays legacy-identical (associated-text
|
|
tail wins, shift+letter uppercases, US-shift map covers the rest),
|
|
super/hyper/meta/locks are dropped. Only kitty-protocol terminals
|
|
(kitty, WezTerm, foot, ghostty) report bare Shift at all; everywhere
|
|
else no bytes exist to detect, so double-Shift chords only work
|
|
there. `TuiDoubleTap(timeout=0.4)` matches two same-key presses on
|
|
an injectable clock; set `app.on_double_tap` to a callback returning
|
|
True when it acted (falsy falls through to normal handling, so fast
|
|
double-Tab still cycles).
|
|
|
|
## Animation and timers
|
|
|
|
```python
|
|
panel.animate("x", 40, duration=0.6, easing="in_out_cubic", on_done=next_step)
|
|
app.after(2.0, popup_tip)
|
|
app.every(0.5, poll_status)
|
|
```
|
|
|
|
`animate()` tweens any numeric attribute (x, y, w, h relayout;
|
|
anything else repaints) with `duration` or `speed`, `delay`, and an
|
|
easing (`linear`, `in|out|in_out` quad/cubic). Timers take plain
|
|
callbacks; exceptions in callbacks never break the loop.
|
|
|
|
## Components
|
|
|
|
- `TuiText(text, text_align, wrap)` - wrapped static text.
|
|
- `TuiButton(label, on_press)` - Enter/Space/click; focused style;
|
|
`on_press(event)` fires per press.
|
|
- `TuiOptions(options, multi, on_select)` - virtualized list; options
|
|
as strings, `(label, value)` pairs, or dicts. Arrows/home/end/pgup/
|
|
pgdn/wheel move, Enter/Space/click selects; `on_select(event)`
|
|
fires per pick and the event also bubbles.
|
|
- `TuiDialog(*children, on_close)` - centered modal; Esc closes;
|
|
`buttons(("OK", cb), ...)` adds a button row (callbacks take the
|
|
Press event); `on_close(event)` fires on close.
|
|
- `TuiQuestion(prompt, options, on_done, multi)` - prompt plus
|
|
options; single-select answers on first pick, multi-select via OK.
|
|
- `TuiTextBox(text)` - multiline editor; arrows/home/end/pgup/pgdn
|
|
move (no line wrap), Enter splits, Backspace/Delete join,
|
|
click positions the cursor, Escape bubbles for dialogs;
|
|
`.value` joins lines.
|
|
- `TuiPromptDialog(label, box_h, on_execute)` - label plus a
|
|
`box_h`-row editor with OK/Cancel; OK runs
|
|
`on_execute(value)` then closes. The demo opens one on
|
|
double-Shift and feeds accepted text to the shell.
|
|
- `TuiTerminal(cmd, interactive)` - real pty child streamed through
|
|
the VT screen; click to focus and type; read-only mode for logs.
|
|
- `TuiProgress(value)` - fraction bar; `set()` clamps 0..1.
|
|
- `TuiLog` - virtualized scrollback; `append(text, style)` follows
|
|
the tail while pinned; wheel/pgup/pgdn/home/end navigate.
|
|
|
|
## Terminal widget notes
|
|
|
|
`spawn()` forks the command onto a pty sized to the widget; resizes
|
|
propagate via SIGWINCH-winning `TIOCSWINSZ`. Input queues and drains
|
|
cooperatively with output reads (never a blocking write from a
|
|
handler). Exited children keep their last screen with the exit status;
|
|
teardown kills survivors so demos never leak processes. Without `pty`
|
|
the widget shows a plain error line instead of crashing.
|
|
|
|
## Gaps and non-goals (v1)
|
|
|
|
- Wide (CJK/emoji) characters count as one column; columns around
|
|
them misalign. Structured to fix with a width table later.
|
|
- No stylesheet language (constructor kwargs instead), no bracketed
|
|
paste, only the key-event subset of the kitty protocol (no
|
|
release tracking, no super/hyper/meta, no alternate-key shortcut
|
|
matching), no true opacity blending.
|
|
- No per-widget async tasks; blocking work belongs in threads that
|
|
post back via timers.
|
|
- The VT parser drops provably-malformed tails past 64KB and caps
|
|
parsed CSI params at 64; these are corruption guards, not UI
|
|
limits. UI multiplicity itself (layers, dialogs, depth,
|
|
components, animations) is intentionally unbounded.
|