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