jolt-tui
glimmer's terminal backend, behind the retained-tree C ABI — libjolttui.so.
glimmer-tui is the design this
follows: the same tags, the same props, the same keyboard, and the same rule
that painting goes through a grid so a test needs no terminal. What moves is
where the widget layer lives. There it is jolt over ncurses; here it is Rust
behind the ABI libvidya already exports, so the jolt side picks a GPU window
or a terminal by naming a different shared object and changing nothing else.
glimmer (reactive cells, components, the reconciler)
│
┌────────────────┴────────────────┐
libvidya.so libjolttui.so
egui, a GPU window a terminal, in cells
Why the tree is down here
A reconciler needs widgets to patch. A terminal has none — it has a grid you
overwrite — so the same problem egui poses turns up again, and gets the same
answer: the node arena lives in Rust, the caller mutates it by integer handle,
and nothing is drawn until tui_frame walks the whole tree at once.
Two things follow, both of them the reason for the arrangement rather than
accidents of it:
- FFI traffic tracks edits, not frames. A screen that is not changing costs
no crossings; only what the reconciler actually changed is sent. - Painting is testable. Everything above
term.rswrites into aScreen—
a grid of styled cells — and onlyterm.rsemits an escape sequence.
tui_headlessopens a session with no terminal at all,tui_feed_keytypes
into it, andtui_screen_linereads back what was painted. That is not a
second implementation for tests; it is the same code path with the writer
taken off the end.
Handlers do not cross the boundary. A node reports that it was clicked and
the caller looks up whose :on-click that was, exactly as with libvidya.
The ABI
include/jolttui.h is the reference: tags, props, events,
key names, and the borrowed-string rule. In outline:
| session | tui_open / tui_headless / tui_close, tui_should_close, tui_quit |
| loop | tui_tick(timeout_ms) handles input, tui_frame lays out, paints and flushes |
| nodes | tui_node_new / _free / _append / _remove / _insert_after / _replace |
| props | tui_node_set_str / _num / _bool, and the matching reads |
| reading back | tui_node_tag, tui_node_child_at, tui_tree_dump, tui_screen_line |
| events | tui_tree_poll_event and the four accessors |
| input by hand | tui_feed_key / _click / _wheel, tui_focus |
Keyboard
Focus is a ring in paint order over the widgets that can take it — buttons,
checkbuttons, entries, listboxes. Tab and Shift-Tab walk it, :autofocus
claims it on the first frame, and a widget that is unmounted or turned
insensitive gives it up rather than stranding the focus on nothing.
| Enter, Space | activate the focused widget |
arrows, j/k, Page Up/Down, g/G |
move a list's cursor |
| Ctrl-A/E, Ctrl-W, Ctrl-U/K, Alt-B/F, Home/End | readline editing in an entry |
| up/down, Shift-Enter (Alt-Enter, Ctrl-J) | a row at a time, and a new row, in an :entry of more than one :rows |
| Esc | closes the topmost :overlay |
| Ctrl-C, Ctrl-Q | quit |
An :entry with :rows above one — or with a newline already in its text — is
a box rather than a line: it wraps, it scrolls to keep the caret in view, Home
and End are about the row the caret is on, and a click lands on the character it
landed on rather than on the same row every time. Enter still activates, so the
compose bar of a chat client sends on Enter and breaks the line on Shift-Enter;
Alt-Enter and Ctrl-J do the same on a terminal that cannot tell Shift-Enter from
Enter. Up and down off the ends of the text are not the field's keys and go to
the caller, so arrowing out of a field still works.
Anything nothing here wanted comes out as a key event on the focused node,
named the way it is fed in — "ctrl+u", "page-down", "f5". Bubbling it to a
container's :on-key belongs to the caller: it holds the handlers.
Layout
Every node answers two sizes — natural and minimum. A box hands out the natural
ones when there is room, shrinks them proportionally toward the minimums when
there is not, and gives the surplus to whoever set :hexpand / :vexpand.
:width-request and :height-request are a floor on both, so asking for four
rows gets four rows even when space is short.
Building
cargo build -p jolt-tui --release # target/release/libjolttui.so
cargo test -p jolt-tui # the whole widget layer, headless
cargo run -p jolt-tui --example showcase # every tag, in a terminal
--no-default-features drops crossterm and with it tui_open — the tree, the
layout, the painter and tui_headless all still work, which is what makes this
crate buildable and checkable on a machine with no terminal crate and no TTY.
Limits
- One session per process, on one thread. The session lives in thread-local
storage, so a call from another thread is inert rather than unsound — the
same rule libvidya keeps. - One cell per character. A wide CJK glyph or an emoji is measured as one
column and will crowd its neighbour. Widths are a table lookup away; nothing
in the design is in the way of adding one. - No
:table,:paginatoror:helpyet. glimmer-tui has them; a table is
a keyed listbox of rows here for now. Each is widget-layer work in
src/paint.rsplus a measure insrc/layout.rsand a tag.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 |
|