nandi/jolt-nativepublic Fork 0
228672deff39ee418d1b734ed0920ce16985e8e1
Commits
Clone
git clone https://git.rickub.com/nandi/jolt-native.git
git clone ssh://git@rickub.com/nandi/jolt-native.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

Give the terminal's entry a caret that means what it says 361b4dc · on 228672deff39ee418d1b734ed0920ce16985e8e1 · nandi · 9d ago
README.md · 116 lines · 5.6 KBmarkdown
Blame HistoryOpen raw

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.rs writes into a Screen
    a grid of styled cells — and only term.rs emits an escape sequence.
    tui_headless opens a session with no terminal at all, tui_feed_key types
    into it, and tui_screen_line reads 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, :paginator or :help yet. glimmer-tui has them; a table is
    a keyed listbox of rows here for now. Each is widget-layer work in
    src/paint.rs plus a measure in src/layout.rs and 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
# jolt-tui

glimmer's **terminal** backend, behind the retained-tree C ABI — `libjolttui.so`.

[glimmer-tui](https://github.com/jolt-lang/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.rs` writes into a `Screen`  a grid of styled cells — and only `term.rs` emits an escape sequence.
  `tui_headless` opens a session with no terminal at all, `tui_feed_key` types
  into it, and `tui_screen_line` reads 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`](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

```bash
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`, `:paginator` or `:help` yet.** glimmer-tui has them; a table is
  a keyed listbox of rows here for now. Each is widget-layer work in
  `src/paint.rs` plus a measure in `src/layout.rs` and a tag.