nandi/jolt-nativepublic Fork 0
a7f6202
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.

Paint the same tree into a terminal, for the machines with no window

glimmer's reconciler needs widgets to patch, and a terminal has none — the
same problem egui posed, and the same answer: the node arena lives in Rust,
the caller mutates it by handle, and nothing is drawn until the frame call
walks it. So libjolttui exports the tree ABI libvidya already does, and the
jolt side chooses a window or a terminal by naming a different object.

Painting goes through a grid of cells and only term.rs turns one into an
escape sequence, which is what makes tui_headless the same code path with
the writer taken off the end: 48 tests mount real nodes, type real keys and
assert on the rows that came out, with no TTY anywhere.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
nandi committed 2026-09-01T22:10:56-07:00 Browse files
a7f6202 parent: a9f3673
modified BUCK +8 -1
@@ -8,6 +8,13 @@ genrule(
88 visibility = ["PUBLIC"],
99 )
1010
11+genrule(
12+ name = "libjolttui",
13+ out = "libjolttui.so",
14+ cmd = "cp $(location //crates/jolt-tui:jolt-tui[shared]) $OUT",
15+ visibility = ["PUBLIC"],
16+)
17+
1118 genrule(
1219 name = "libjoltmoq",
1320 out = "libjoltmoq.so",
@@ -17,7 +24,7 @@ genrule(
1724
1825 filegroup(
1926 name = "libs",
20- srcs = [":libjoltmoq", ":libvidya"],
27+ srcs = [":libjoltmoq", ":libjolttui", ":libvidya"],
2128 visibility = ["PUBLIC"],
2229 )
2330
@@ -8,6 +8,13 @@ genrule(
8 visibility = ["PUBLIC"],8 visibility = ["PUBLIC"],
9 )9 )
10 10
11+genrule(
12+ name = "libjolttui",
13+ out = "libjolttui.so",
14+ cmd = "cp $(location //crates/jolt-tui:jolt-tui[shared]) $OUT",
15+ visibility = ["PUBLIC"],
16+)
17+
11 genrule(18 genrule(
12 name = "libjoltmoq",19 name = "libjoltmoq",
13 out = "libjoltmoq.so",20 out = "libjoltmoq.so",
@@ -17,7 +24,7 @@ genrule(
17 24
18 filegroup(25 filegroup(
19 name = "libs",26 name = "libs",
20- srcs = [":libjoltmoq", ":libvidya"],27+ srcs = [":libjoltmoq", ":libjolttui", ":libvidya"],
21 visibility = ["PUBLIC"],28 visibility = ["PUBLIC"],
22 )29 )
23 30
modified Cargo.lock +45 -0
@@ -1164,6 +1164,20 @@ version = "0.8.22"
11641164 source = "registry+https://github.com/rust-lang/crates.io-index"
11651165 checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17"
11661166
1167+[[package]]
1168+name = "crossterm"
1169+version = "0.28.1"
1170+source = "registry+https://github.com/rust-lang/crates.io-index"
1171+checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6"
1172+dependencies = [
1173+ "bitflags 2.13.1",
1174+ "mio",
1175+ "parking_lot",
1176+ "rustix 0.38.44",
1177+ "signal-hook",
1178+ "signal-hook-mio",
1179+]
1180+
11671181 [[package]]
11681182 name = "crunchy"
11691183 version = "0.2.4"
@@ -3258,6 +3272,15 @@ dependencies = [
32583272 "v4l2r",
32593273 ]
32603274
3275+[[package]]
3276+name = "jolt-tui"
3277+version = "0.1.0"
3278+dependencies = [
3279+ "crossterm",
3280+ "jolt-abi",
3281+ "log",
3282+]
3283+
32613284 [[package]]
32623285 name = "js-sys"
32633286 version = "0.3.103"
@@ -3491,6 +3514,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
34913514 checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427"
34923515 dependencies = [
34933516 "libc",
3517+ "log",
34943518 "wasi",
34953519 "windows-sys 0.61.2",
34963520 ]
@@ -6019,6 +6043,27 @@ version = "2.0.1"
60196043 source = "registry+https://github.com/rust-lang/crates.io-index"
60206044 checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
60216045
6046+[[package]]
6047+name = "signal-hook"
6048+version = "0.3.18"
6049+source = "registry+https://github.com/rust-lang/crates.io-index"
6050+checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2"
6051+dependencies = [
6052+ "libc",
6053+ "signal-hook-registry",
6054+]
6055+
6056+[[package]]
6057+name = "signal-hook-mio"
6058+version = "0.2.5"
6059+source = "registry+https://github.com/rust-lang/crates.io-index"
6060+checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc"
6061+dependencies = [
6062+ "libc",
6063+ "mio",
6064+ "signal-hook",
6065+]
6066+
60226067 [[package]]
60236068 name = "signal-hook-registry"
60246069 version = "1.4.8"
@@ -1164,6 +1164,20 @@ version = "0.8.22"
1164 source = "registry+https://github.com/rust-lang/crates.io-index"1164 source = "registry+https://github.com/rust-lang/crates.io-index"
1165 checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17"1165 checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17"
1166 1166
1167+[[package]]
1168+name = "crossterm"
1169+version = "0.28.1"
1170+source = "registry+https://github.com/rust-lang/crates.io-index"
1171+checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6"
1172+dependencies = [
1173+ "bitflags 2.13.1",
1174+ "mio",
1175+ "parking_lot",
1176+ "rustix 0.38.44",
1177+ "signal-hook",
1178+ "signal-hook-mio",
1179+]
1180+
1167 [[package]]1181 [[package]]
1168 name = "crunchy"1182 name = "crunchy"
1169 version = "0.2.4"1183 version = "0.2.4"
@@ -3258,6 +3272,15 @@ dependencies = [
3258 "v4l2r",3272 "v4l2r",
3259 ]3273 ]
3260 3274
3275+[[package]]
3276+name = "jolt-tui"
3277+version = "0.1.0"
3278+dependencies = [
3279+ "crossterm",
3280+ "jolt-abi",
3281+ "log",
3282+]
3283+
3261 [[package]]3284 [[package]]
3262 name = "js-sys"3285 name = "js-sys"
3263 version = "0.3.103"3286 version = "0.3.103"
@@ -3491,6 +3514,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
3491 checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427"3514 checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427"
3492 dependencies = [3515 dependencies = [
3493 "libc",3516 "libc",
3517+ "log",
3494 "wasi",3518 "wasi",
3495 "windows-sys 0.61.2",3519 "windows-sys 0.61.2",
3496 ]3520 ]
@@ -6019,6 +6043,27 @@ version = "2.0.1"
6019 source = "registry+https://github.com/rust-lang/crates.io-index"6043 source = "registry+https://github.com/rust-lang/crates.io-index"
6020 checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"6044 checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
6021 6045
6046+[[package]]
6047+name = "signal-hook"
6048+version = "0.3.18"
6049+source = "registry+https://github.com/rust-lang/crates.io-index"
6050+checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2"
6051+dependencies = [
6052+ "libc",
6053+ "signal-hook-registry",
6054+]
6055+
6056+[[package]]
6057+name = "signal-hook-mio"
6058+version = "0.2.5"
6059+source = "registry+https://github.com/rust-lang/crates.io-index"
6060+checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc"
6061+dependencies = [
6062+ "libc",
6063+ "mio",
6064+ "signal-hook",
6065+]
6066+
6022 [[package]]6067 [[package]]
6023 name = "signal-hook-registry"6068 name = "signal-hook-registry"
6024 version = "1.4.8"6069 version = "1.4.8"
modified Cargo.toml +1 -0
@@ -21,6 +21,7 @@ resolver = "2"
2121 members = [
2222 "crates/jolt-abi",
2323 "crates/jolt-moq",
24+ "crates/jolt-tui",
2425 "crates/jolt-vidya",
2526 "crates/vidya-core",
2627 ]
@@ -21,6 +21,7 @@ resolver = "2"
21 members = [21 members = [
22 "crates/jolt-abi",22 "crates/jolt-abi",
23 "crates/jolt-moq",23 "crates/jolt-moq",
24+ "crates/jolt-tui",
24 "crates/jolt-vidya",25 "crates/jolt-vidya",
25 "crates/vidya-core",26 "crates/vidya-core",
26 ]27 ]
modified README.md +2 -1
@@ -13,11 +13,12 @@ anything else would mean writing them badly.
1313 crates/jolt-abi the rules every library here keeps at its edge
1414 crates/jolt-vidya the retained-tree C ABI → libvidya.so
1515 crates/vidya-core the egui semantic layer behind it (theme, widgets, fonts)
16+crates/jolt-tui the same tree ABI, painted into a terminal → libjolttui.so
1617 crates/jolt-moq freeq's AV media plane → libjoltmoq.so
1718 jolt/glimmer-vidya the jolt side of libvidya: glimmer's backend
1819 ```
1920
20-Both objects land in one `target/release`, so a consumer points
21+All three objects land in one `target/release`, so a consumer points
2122 `LD_LIBRARY_PATH` at one directory and names in `:jolt/native` only the ones it
2223 actually wants.
2324
@@ -13,11 +13,12 @@ anything else would mean writing them badly.
13 crates/jolt-abi the rules every library here keeps at its edge13 crates/jolt-abi the rules every library here keeps at its edge
14 crates/jolt-vidya the retained-tree C ABI → libvidya.so14 crates/jolt-vidya the retained-tree C ABI → libvidya.so
15 crates/vidya-core the egui semantic layer behind it (theme, widgets, fonts)15 crates/vidya-core the egui semantic layer behind it (theme, widgets, fonts)
16+crates/jolt-tui the same tree ABI, painted into a terminal → libjolttui.so
16 crates/jolt-moq freeq's AV media plane → libjoltmoq.so17 crates/jolt-moq freeq's AV media plane → libjoltmoq.so
17 jolt/glimmer-vidya the jolt side of libvidya: glimmer's backend18 jolt/glimmer-vidya the jolt side of libvidya: glimmer's backend
18 ```19 ```
19 20
20-Both objects land in one `target/release`, so a consumer points21+All three objects land in one `target/release`, so a consumer points
21 `LD_LIBRARY_PATH` at one directory and names in `:jolt/native` only the ones it22 `LD_LIBRARY_PATH` at one directory and names in `:jolt/native` only the ones it
22 actually wants.23 actually wants.
23 24
added crates/jolt-tui/BUCK +28 -0
new file mode 100644
@@ -0,0 +1,28 @@
1+# The C ABI Jolt loads for a terminal UI: libjolttui.so.
2+#
3+# The same shape as //crates/jolt-vidya, and for the same reason: one glimmer
4+# backend on the jolt side, and a choice of shared object underneath it.
5+rust_library(
6+ name = "jolt-tui",
7+ srcs = glob(["src/**/*.rs"]),
8+ crate = "jolttui",
9+ crate_root = "src/lib.rs",
10+ edition = "2021",
11+ features = ["terminal"],
12+ preferred_linkage = "shared",
13+ # Without this buck names the library after the target — lib_jolt-tui.so —
14+ # and that name, not the filename, is what a dependent's DT_NEEDED gets.
15+ soname = "libjolttui.so",
16+ # As on the vidya cdylib: cross-crate inlining across the graph, one codegen
17+ # unit so the dedup is not fighting parallelism inside the crate.
18+ rustc_flags = [
19+ "-Clto=fat",
20+ "-Ccodegen-units=1",
21+ ],
22+ visibility = ["PUBLIC"],
23+ deps = [
24+ "//crates/jolt-abi:jolt-abi",
25+ "//third-party/rust:crossterm",
26+ "//third-party/rust:log",
27+ ],
28+)
new file mode 100644
@@ -0,0 +1,28 @@
1+# The C ABI Jolt loads for a terminal UI: libjolttui.so.
2+#
3+# The same shape as //crates/jolt-vidya, and for the same reason: one glimmer
4+# backend on the jolt side, and a choice of shared object underneath it.
5+rust_library(
6+ name = "jolt-tui",
7+ srcs = glob(["src/**/*.rs"]),
8+ crate = "jolttui",
9+ crate_root = "src/lib.rs",
10+ edition = "2021",
11+ features = ["terminal"],
12+ preferred_linkage = "shared",
13+ # Without this buck names the library after the target — lib_jolt-tui.so —
14+ # and that name, not the filename, is what a dependent's DT_NEEDED gets.
15+ soname = "libjolttui.so",
16+ # As on the vidya cdylib: cross-crate inlining across the graph, one codegen
17+ # unit so the dedup is not fighting parallelism inside the crate.
18+ rustc_flags = [
19+ "-Clto=fat",
20+ "-Ccodegen-units=1",
21+ ],
22+ visibility = ["PUBLIC"],
23+ deps = [
24+ "//crates/jolt-abi:jolt-abi",
25+ "//third-party/rust:crossterm",
26+ "//third-party/rust:log",
27+ ],
28+)
added crates/jolt-tui/Cargo.toml +26 -0
new file mode 100644
@@ -0,0 +1,26 @@
1+[package]
2+name = "jolt-tui"
3+version.workspace = true
4+edition.workspace = true
5+license.workspace = true
6+description = "glimmer's terminal backend behind a C ABI"
7+publish = false
8+
9+# Produces `libjolttui.so`. The retained-tree ABI `libvidya` exports, painted
10+# into a terminal instead of a window — so the jolt side picks a backend by
11+# naming a different shared object.
12+[lib]
13+name = "jolttui"
14+crate-type = ["cdylib", "rlib"]
15+
16+[features]
17+# On by default; without it the crate still builds the tree, the layout and the
18+# painter, and `tui_headless` still works. That is what CI and the test suite
19+# use, and it is why a machine with no TTY can still build and check this.
20+default = ["terminal"]
21+terminal = ["dep:crossterm"]
22+
23+[dependencies]
24+jolt-abi.workspace = true
25+log.workspace = true
26+crossterm = { version = "0.28", optional = true, default-features = false, features = ["events"] }
new file mode 100644
@@ -0,0 +1,26 @@
1+[package]
2+name = "jolt-tui"
3+version.workspace = true
4+edition.workspace = true
5+license.workspace = true
6+description = "glimmer's terminal backend behind a C ABI"
7+publish = false
8+
9+# Produces `libjolttui.so`. The retained-tree ABI `libvidya` exports, painted
10+# into a terminal instead of a window — so the jolt side picks a backend by
11+# naming a different shared object.
12+[lib]
13+name = "jolttui"
14+crate-type = ["cdylib", "rlib"]
15+
16+[features]
17+# On by default; without it the crate still builds the tree, the layout and the
18+# painter, and `tui_headless` still works. That is what CI and the test suite
19+# use, and it is why a machine with no TTY can still build and check this.
20+default = ["terminal"]
21+terminal = ["dep:crossterm"]
22+
23+[dependencies]
24+jolt-abi.workspace = true
25+log.workspace = true
26+crossterm = { version = "0.28", optional = true, default-features = false, features = ["events"] }
added crates/jolt-tui/README.md +106 -0
new file mode 100644
@@ -0,0 +1,106 @@
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.
new file mode 100644
@@ -0,0 +1,106 @@
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.
added crates/jolt-tui/examples/showcase.rs +109 -0
new file mode 100644
@@ -0,0 +1,109 @@
1+//! Every tag this backend has, driven through the C ABI itself.
2+//!
3+//! cargo run -p jolt-tui --example showcase
4+//!
5+//! It calls `tui_*` exactly as jolt does — nodes by handle, props by name,
6+//! events polled off a queue — so it doubles as a check that the ABI is usable
7+//! from the outside and not only from the inside.
8+//!
9+//! Tab and Shift-Tab move, Enter and Space activate, j/k and the arrows move
10+//! the list, the wheel scrolls, Ctrl-C or Ctrl-Q quits.
11+
12+use std::ffi::{CStr, CString};
13+
14+use jolttui::*;
15+
16+fn node(tag: &str) -> i32 {
17+ let tag = CString::new(tag).unwrap();
18+ unsafe { tui_node_new(tag.as_ptr()) }
19+}
20+
21+fn set(id: i32, key: &str, value: &str) {
22+ let (key, value) = (CString::new(key).unwrap(), CString::new(value).unwrap());
23+ unsafe { tui_node_set_str(id, key.as_ptr(), value.as_ptr()) }
24+}
25+
26+fn set_num(id: i32, key: &str, value: f64) {
27+ let key = CString::new(key).unwrap();
28+ unsafe { tui_node_set_num(id, key.as_ptr(), value) }
29+}
30+
31+fn get(id: i32, key: &str) -> String {
32+ let key = CString::new(key).unwrap();
33+ unsafe { CStr::from_ptr(tui_node_get_str(id, key.as_ptr())) }
34+ .to_string_lossy()
35+ .into_owned()
36+}
37+
38+fn child(parent: i32, tag: &str, props: &[(&str, &str)]) -> i32 {
39+ let id = node(tag);
40+ for (key, value) in props {
41+ set(id, key, value);
42+ }
43+ tui_node_append(parent, id);
44+ id
45+}
46+
47+fn main() {
48+ if tui_open(1) == 0 {
49+ eprintln!("showcase: this example needs a terminal");
50+ return;
51+ }
52+ let root = tui_tree_root();
53+ let page = child(root, "vbox", &[]);
54+ set_num(page, "margin", 1.0);
55+ set_num(page, "spacing", 1.0);
56+
57+ child(page, "title", &[("label", "jolt-tui")]);
58+ let status = child(
59+ page,
60+ "dim-label",
61+ &[("label", "tab moves · enter activates · ctrl-c quits")],
62+ );
63+
64+ let row = child(page, "hbox", &[("orientation", "horizontal")]);
65+ set_num(row, "spacing", 2.0);
66+ child(row, "button", &[("label", "count")]);
67+ child(
68+ row,
69+ "button",
70+ &[("label", "reset"), ("kind", "destructive")],
71+ );
72+ child(row, "checkbutton", &[("label", "live")]);
73+
74+ child(page, "entry", &[("placeholder", "type something")]);
75+ let bar = child(page, "progress", &[("label", "")]);
76+ let frame = child(page, "frame", &[("label", "Rows")]);
77+ let list = child(frame, "listbox", &[]);
78+ for name in ["alpha", "beta", "gamma", "delta"] {
79+ child(list, "label", &[("label", name)]);
80+ }
81+
82+ let mut count = 0.0f64;
83+ while tui_should_close() == 0 {
84+ tui_tick(50);
85+ while tui_tree_poll_event() == 1 {
86+ let node = tui_tree_event_node();
87+ let name = unsafe { CStr::from_ptr(tui_tree_event_name()) }
88+ .to_string_lossy()
89+ .into_owned();
90+ let text = unsafe { CStr::from_ptr(tui_tree_event_text()) }
91+ .to_string_lossy()
92+ .into_owned();
93+ match name.as_str() {
94+ "click" => {
95+ let label = get(node, "label");
96+ count = if label == "reset" { 0.0 } else { count + 1.0 };
97+ set_num(bar, "value", (count / 10.0).min(1.0));
98+ set(status, "label", &format!("{label}: {count}"));
99+ }
100+ "select" | "activate" | "toggled" | "change" => {
101+ set(status, "label", &format!("{name} {text}"));
102+ }
103+ _ => {}
104+ }
105+ }
106+ tui_frame();
107+ }
108+ tui_close();
109+}
new file mode 100644
@@ -0,0 +1,109 @@
1+//! Every tag this backend has, driven through the C ABI itself.
2+//!
3+//! cargo run -p jolt-tui --example showcase
4+//!
5+//! It calls `tui_*` exactly as jolt does — nodes by handle, props by name,
6+//! events polled off a queue — so it doubles as a check that the ABI is usable
7+//! from the outside and not only from the inside.
8+//!
9+//! Tab and Shift-Tab move, Enter and Space activate, j/k and the arrows move
10+//! the list, the wheel scrolls, Ctrl-C or Ctrl-Q quits.
11+
12+use std::ffi::{CStr, CString};
13+
14+use jolttui::*;
15+
16+fn node(tag: &str) -> i32 {
17+ let tag = CString::new(tag).unwrap();
18+ unsafe { tui_node_new(tag.as_ptr()) }
19+}
20+
21+fn set(id: i32, key: &str, value: &str) {
22+ let (key, value) = (CString::new(key).unwrap(), CString::new(value).unwrap());
23+ unsafe { tui_node_set_str(id, key.as_ptr(), value.as_ptr()) }
24+}
25+
26+fn set_num(id: i32, key: &str, value: f64) {
27+ let key = CString::new(key).unwrap();
28+ unsafe { tui_node_set_num(id, key.as_ptr(), value) }
29+}
30+
31+fn get(id: i32, key: &str) -> String {
32+ let key = CString::new(key).unwrap();
33+ unsafe { CStr::from_ptr(tui_node_get_str(id, key.as_ptr())) }
34+ .to_string_lossy()
35+ .into_owned()
36+}
37+
38+fn child(parent: i32, tag: &str, props: &[(&str, &str)]) -> i32 {
39+ let id = node(tag);
40+ for (key, value) in props {
41+ set(id, key, value);
42+ }
43+ tui_node_append(parent, id);
44+ id
45+}
46+
47+fn main() {
48+ if tui_open(1) == 0 {
49+ eprintln!("showcase: this example needs a terminal");
50+ return;
51+ }
52+ let root = tui_tree_root();
53+ let page = child(root, "vbox", &[]);
54+ set_num(page, "margin", 1.0);
55+ set_num(page, "spacing", 1.0);
56+
57+ child(page, "title", &[("label", "jolt-tui")]);
58+ let status = child(
59+ page,
60+ "dim-label",
61+ &[("label", "tab moves · enter activates · ctrl-c quits")],
62+ );
63+
64+ let row = child(page, "hbox", &[("orientation", "horizontal")]);
65+ set_num(row, "spacing", 2.0);
66+ child(row, "button", &[("label", "count")]);
67+ child(
68+ row,
69+ "button",
70+ &[("label", "reset"), ("kind", "destructive")],
71+ );
72+ child(row, "checkbutton", &[("label", "live")]);
73+
74+ child(page, "entry", &[("placeholder", "type something")]);
75+ let bar = child(page, "progress", &[("label", "")]);
76+ let frame = child(page, "frame", &[("label", "Rows")]);
77+ let list = child(frame, "listbox", &[]);
78+ for name in ["alpha", "beta", "gamma", "delta"] {
79+ child(list, "label", &[("label", name)]);
80+ }
81+
82+ let mut count = 0.0f64;
83+ while tui_should_close() == 0 {
84+ tui_tick(50);
85+ while tui_tree_poll_event() == 1 {
86+ let node = tui_tree_event_node();
87+ let name = unsafe { CStr::from_ptr(tui_tree_event_name()) }
88+ .to_string_lossy()
89+ .into_owned();
90+ let text = unsafe { CStr::from_ptr(tui_tree_event_text()) }
91+ .to_string_lossy()
92+ .into_owned();
93+ match name.as_str() {
94+ "click" => {
95+ let label = get(node, "label");
96+ count = if label == "reset" { 0.0 } else { count + 1.0 };
97+ set_num(bar, "value", (count / 10.0).min(1.0));
98+ set(status, "label", &format!("{label}: {count}"));
99+ }
100+ "select" | "activate" | "toggled" | "change" => {
101+ set(status, "label", &format!("{name} {text}"));
102+ }
103+ _ => {}
104+ }
105+ }
106+ tui_frame();
107+ }
108+ tui_close();
109+}
added crates/jolt-tui/include/jolttui.h +219 -0
new file mode 100644
@@ -0,0 +1,219 @@
1+/*
2+ * jolt-tui — glimmer's terminal backend, behind the retained-tree ABI.
3+ *
4+ * The tree half of `vidya_tree.h` with a terminal under it instead of a GPU
5+ * window: nodes are integer handles, mutated by the calls below, and nothing is
6+ * drawn until `tui_frame`, which lays the whole tree out and paints it at once.
7+ * Interactions come back as a queue the caller drains and routes to its own
8+ * handlers — a callback cannot cross this boundary, so identity does instead.
9+ *
10+ * It is deliberately the same shape as libvidya's, because it is the same
11+ * glimmer backend on the other side: one reconciler, and a choice of shared
12+ * object. The differences are the ones a terminal actually forces — a focus
13+ * ring and keys instead of a pointer and hit testing, cells instead of points,
14+ * and colours that are the terminal's rather than a theme's.
15+ *
16+ * Every call stays on the thread that opened the session, which is where the
17+ * state lives; a call from another thread is inert rather than unsound.
18+ *
19+ * Tags are hiccup names without the colon:
20+ *
21+ * containers window box hbox vbox frame scroll overlay listbox
22+ * widgets label title title-2 dim-label button checkbutton entry
23+ * separator spacer progress spinner
24+ *
25+ * An unrecognized tag is kept and paints as a vertical box, so a caller ahead
26+ * of this backend still sees its children.
27+ *
28+ * Props are string-keyed, in the same names:
29+ *
30+ * every node sensitive (bool), margin, padding, width-request,
31+ * height-request, halign / valign
32+ * ("fill"|"start"|"center"|"end"), hexpand / vexpand (bool),
33+ * color, bg, bold, dim, underline, reverse, blink, italic
34+ * box orientation ("horizontal"|"vertical"), spacing
35+ * frame label (drawn into the top border)
36+ * scroll offset (rows, clamped to the content and written back)
37+ * overlay label; floats centred over everything else
38+ * text widgets label, or text
39+ * button label, kind ("default"|"primary"|"destructive")
40+ * checkbutton label, active (bool)
41+ * entry text, placeholder, rows, autofocus (bool)
42+ * listbox selected (row index; -1 for no cursor). Its children are
43+ * the rows, one node each.
44+ * spacer size
45+ * progress value (0..1), label
46+ *
47+ * A colour is a name (`red`, `bright-blue`, `default`), an index into the
48+ * xterm 256-colour palette (`"33"`), a hex triple (`#ff6432`, `#f64`), or
49+ * `"r,g,b"`. Colour and attributes are inherited by a node's whole subtree.
50+ */
51+#ifndef JOLTTUI_H
52+#define JOLTTUI_H
53+
54+#if defined(_WIN32)
55+# if defined(JOLTTUI_BUILD)
56+# define JOLTTUI_API __declspec(dllexport)
57+# else
58+# define JOLTTUI_API __declspec(dllimport)
59+# endif
60+#elif defined(__GNUC__)
61+# define JOLTTUI_API __attribute__((visibility("default")))
62+#else
63+# define JOLTTUI_API
64+#endif
65+
66+#ifdef __cplusplus
67+extern "C" {
68+#endif
69+
70+/* The session
71+ *
72+ * `tui_open` takes the terminal: raw mode, the alternate screen, no cursor
73+ * unless a focused entry asks for one, and mouse reporting when `mouse` is
74+ * non-zero. 1 on success, 0 if a session is already open or the terminal
75+ * refused. `tui_close` gives all of it back and is safe to call twice.
76+ *
77+ * `tui_headless` opens a session of a fixed size with no terminal at all. The
78+ * whole widget layer works there — layout, painting, focus, keys fed by hand —
79+ * and `tui_screen_line` reads the result back, which is what a test suite and
80+ * CI use. It is the same code path a real session paints through.
81+ */
82+JOLTTUI_API int tui_open(int mouse);
83+JOLTTUI_API int tui_headless(int width, int height);
84+JOLTTUI_API void tui_close(void);
85+
86+/* 1 once Ctrl-C, Ctrl-Q or `tui_quit` has asked the loop to stop — and before
87+ * a session is open, so a loop written around it cannot spin forever. */
88+JOLTTUI_API int tui_should_close(void);
89+JOLTTUI_API void tui_quit(void);
90+
91+/*
92+ * One turn of the loop.
93+ *
94+ * `tui_tick` waits up to `timeout_ms` for input, handles everything that
95+ * arrived — keys, clicks, the wheel, a resize — and answers how many things it
96+ * handled, so a caller can skip a repaint when nothing did. `tui_frame` lays
97+ * the tree out, paints it, and sends only the cells that changed.
98+ *
99+ * A headless session ticks to 0 and is fed with the calls below instead.
100+ */
101+JOLTTUI_API int tui_tick(int timeout_ms);
102+JOLTTUI_API void tui_frame(void);
103+
104+/* The screen, in cells. 0 before a session is open. */
105+JOLTTUI_API int tui_screen_width(void);
106+JOLTTUI_API int tui_screen_height(void);
107+
108+/*
109+ * One painted row as text, trailing blanks trimmed — what a test asserts on
110+ * and what a bug report pastes. The returned pointer belongs to the library
111+ * and is valid only until the next string-returning read on this thread.
112+ */
113+JOLTTUI_API const char *tui_screen_line(int y);
114+
115+/*
116+ * Input by hand.
117+ *
118+ * `tui_feed_key` takes a key by name, as the terminal's own keys are named:
119+ * "a", "space", "enter", "tab", "shift+tab", "esc", "up", "page-down",
120+ * "ctrl+u", "alt+f", "f5". It answers 1 when the backend acted on it and 0
121+ * when it went out as a `key` event instead. `tui_feed_click` and
122+ * `tui_feed_wheel` do the same for the mouse; the wheel's `by` is in rows and
123+ * negative is up.
124+ *
125+ * These are the same entry points a real terminal's input arrives through, so
126+ * a test drives the UI exactly as a person does.
127+ */
128+JOLTTUI_API int tui_feed_key(const char *name);
129+JOLTTUI_API int tui_feed_click(int x, int y);
130+JOLTTUI_API int tui_feed_wheel(int x, int y, int by);
131+
132+/* The focused node, 0 for none. Focus follows the ring — Tab and Shift-Tab
133+ * walk it in paint order, `autofocus` claims it on the first frame, and a
134+ * widget that is unmounted or turned insensitive gives it up. */
135+JOLTTUI_API int tui_focus(void);
136+
137+/* The window node, created with the session. Mount everything under it. */
138+JOLTTUI_API int tui_tree_root(void);
139+
140+JOLTTUI_API int tui_node_new(const char *tag);
141+JOLTTUI_API void tui_node_free(int node);
142+JOLTTUI_API int tui_node_exists(int node);
143+
144+JOLTTUI_API void tui_node_set_str(int node, const char *key, const char *value);
145+JOLTTUI_API void tui_node_set_num(int node, const char *key, double value);
146+JOLTTUI_API void tui_node_set_bool(int node, const char *key, int value);
147+JOLTTUI_API void tui_node_clear_props(int node);
148+
149+/*
150+ * Reads answer the empty string / 0 for a prop that is unset or of another
151+ * type. The returned pointer belongs to the library and is valid only until
152+ * the next string-returning call of its family on this thread — props, tags
153+ * and screen lines are one family, dumps another, and an event's name and its
154+ * text are each their own, so an event can be read whole.
155+ */
156+JOLTTUI_API const char *tui_node_get_str(int node, const char *key);
157+JOLTTUI_API double tui_node_get_num(int node, const char *key);
158+JOLTTUI_API int tui_node_get_bool(int node, const char *key);
159+
160+/* Reading the structure back. `tui_node_tag` answers the canonical tag name
161+ * ("box" for both hbox and vbox); `tui_node_child_at` answers 0 past the end. */
162+JOLTTUI_API const char *tui_node_tag(int node);
163+JOLTTUI_API int tui_node_parent(int node);
164+JOLTTUI_API int tui_node_child_count(int node);
165+JOLTTUI_API int tui_node_child_at(int node, int index);
166+
167+/*
168+ * The subtree at `node` as pretty-printed hiccup — `[:tag {props} children...]`,
169+ * one node to a line. `node` 0 means the root, so `tui_tree_dump(0)` is the
170+ * whole window. It answers what the tree *is*, read back from the arena,
171+ * rather than what a caller meant to build. Props are sorted, so two dumps of
172+ * the same tree compare as text; handlers are absent because they never
173+ * crossed this boundary.
174+ */
175+JOLTTUI_API const char *tui_tree_dump(int node);
176+
177+JOLTTUI_API int tui_node_append(int parent, int child);
178+/* Unparents AND frees `child` with everything under it. */
179+JOLTTUI_API void tui_node_remove(int parent, int child);
180+/* Moves `child` after `sibling`; `sibling` 0 means the first position. */
181+JOLTTUI_API int tui_node_insert_after(int parent, int child, int sibling);
182+/* Puts `new_child` where `old_child` was, and frees `old_child`. */
183+JOLTTUI_API int tui_node_replace(int parent, int old_child, int new_child);
184+
185+/*
186+ * Drain interactions. `tui_tree_poll_event` dequeues one and answers 1 while
187+ * there was one; the accessors describe whichever was dequeued last.
188+ *
189+ * Event names are glimmer's handler props without the `on-`:
190+ *
191+ * click a button was pressed no payload
192+ * toggled a checkbutton changed num is the new state
193+ * change an entry's text changed text is the new text
194+ * activate Enter in an entry or a list text is the text or the row
195+ * select a list's cursor moved text is the row, num its index
196+ * scroll a viewport moved num is the new offset
197+ * close Esc in an overlay no payload
198+ * key a key nothing here wanted text is the key's name
199+ *
200+ * A widget does not own its value: `toggled`, `change`, `select` and `scroll`
201+ * write the new state back into the node's props as well, so a caller that
202+ * ignores the event still sees a working control, and the next prop write is
203+ * what settles it.
204+ *
205+ * A `key` event is reported on the focused node, or on the window when nothing
206+ * has focus. Bubbling it to a container's `:on-key` is the caller's to do: it
207+ * holds the handlers and knows the tree.
208+ */
209+JOLTTUI_API int tui_tree_poll_event(void);
210+JOLTTUI_API int tui_tree_event_node(void);
211+JOLTTUI_API const char *tui_tree_event_name(void);
212+JOLTTUI_API const char *tui_tree_event_text(void);
213+JOLTTUI_API double tui_tree_event_num(void);
214+
215+#ifdef __cplusplus
216+}
217+#endif
218+
219+#endif /* JOLTTUI_H */
new file mode 100644
@@ -0,0 +1,219 @@
1+/*
2+ * jolt-tui — glimmer's terminal backend, behind the retained-tree ABI.
3+ *
4+ * The tree half of `vidya_tree.h` with a terminal under it instead of a GPU
5+ * window: nodes are integer handles, mutated by the calls below, and nothing is
6+ * drawn until `tui_frame`, which lays the whole tree out and paints it at once.
7+ * Interactions come back as a queue the caller drains and routes to its own
8+ * handlers — a callback cannot cross this boundary, so identity does instead.
9+ *
10+ * It is deliberately the same shape as libvidya's, because it is the same
11+ * glimmer backend on the other side: one reconciler, and a choice of shared
12+ * object. The differences are the ones a terminal actually forces — a focus
13+ * ring and keys instead of a pointer and hit testing, cells instead of points,
14+ * and colours that are the terminal's rather than a theme's.
15+ *
16+ * Every call stays on the thread that opened the session, which is where the
17+ * state lives; a call from another thread is inert rather than unsound.
18+ *
19+ * Tags are hiccup names without the colon:
20+ *
21+ * containers window box hbox vbox frame scroll overlay listbox
22+ * widgets label title title-2 dim-label button checkbutton entry
23+ * separator spacer progress spinner
24+ *
25+ * An unrecognized tag is kept and paints as a vertical box, so a caller ahead
26+ * of this backend still sees its children.
27+ *
28+ * Props are string-keyed, in the same names:
29+ *
30+ * every node sensitive (bool), margin, padding, width-request,
31+ * height-request, halign / valign
32+ * ("fill"|"start"|"center"|"end"), hexpand / vexpand (bool),
33+ * color, bg, bold, dim, underline, reverse, blink, italic
34+ * box orientation ("horizontal"|"vertical"), spacing
35+ * frame label (drawn into the top border)
36+ * scroll offset (rows, clamped to the content and written back)
37+ * overlay label; floats centred over everything else
38+ * text widgets label, or text
39+ * button label, kind ("default"|"primary"|"destructive")
40+ * checkbutton label, active (bool)
41+ * entry text, placeholder, rows, autofocus (bool)
42+ * listbox selected (row index; -1 for no cursor). Its children are
43+ * the rows, one node each.
44+ * spacer size
45+ * progress value (0..1), label
46+ *
47+ * A colour is a name (`red`, `bright-blue`, `default`), an index into the
48+ * xterm 256-colour palette (`"33"`), a hex triple (`#ff6432`, `#f64`), or
49+ * `"r,g,b"`. Colour and attributes are inherited by a node's whole subtree.
50+ */
51+#ifndef JOLTTUI_H
52+#define JOLTTUI_H
53+
54+#if defined(_WIN32)
55+# if defined(JOLTTUI_BUILD)
56+# define JOLTTUI_API __declspec(dllexport)
57+# else
58+# define JOLTTUI_API __declspec(dllimport)
59+# endif
60+#elif defined(__GNUC__)
61+# define JOLTTUI_API __attribute__((visibility("default")))
62+#else
63+# define JOLTTUI_API
64+#endif
65+
66+#ifdef __cplusplus
67+extern "C" {
68+#endif
69+
70+/* The session
71+ *
72+ * `tui_open` takes the terminal: raw mode, the alternate screen, no cursor
73+ * unless a focused entry asks for one, and mouse reporting when `mouse` is
74+ * non-zero. 1 on success, 0 if a session is already open or the terminal
75+ * refused. `tui_close` gives all of it back and is safe to call twice.
76+ *
77+ * `tui_headless` opens a session of a fixed size with no terminal at all. The
78+ * whole widget layer works there — layout, painting, focus, keys fed by hand —
79+ * and `tui_screen_line` reads the result back, which is what a test suite and
80+ * CI use. It is the same code path a real session paints through.
81+ */
82+JOLTTUI_API int tui_open(int mouse);
83+JOLTTUI_API int tui_headless(int width, int height);
84+JOLTTUI_API void tui_close(void);
85+
86+/* 1 once Ctrl-C, Ctrl-Q or `tui_quit` has asked the loop to stop — and before
87+ * a session is open, so a loop written around it cannot spin forever. */
88+JOLTTUI_API int tui_should_close(void);
89+JOLTTUI_API void tui_quit(void);
90+
91+/*
92+ * One turn of the loop.
93+ *
94+ * `tui_tick` waits up to `timeout_ms` for input, handles everything that
95+ * arrived — keys, clicks, the wheel, a resize — and answers how many things it
96+ * handled, so a caller can skip a repaint when nothing did. `tui_frame` lays
97+ * the tree out, paints it, and sends only the cells that changed.
98+ *
99+ * A headless session ticks to 0 and is fed with the calls below instead.
100+ */
101+JOLTTUI_API int tui_tick(int timeout_ms);
102+JOLTTUI_API void tui_frame(void);
103+
104+/* The screen, in cells. 0 before a session is open. */
105+JOLTTUI_API int tui_screen_width(void);
106+JOLTTUI_API int tui_screen_height(void);
107+
108+/*
109+ * One painted row as text, trailing blanks trimmed — what a test asserts on
110+ * and what a bug report pastes. The returned pointer belongs to the library
111+ * and is valid only until the next string-returning read on this thread.
112+ */
113+JOLTTUI_API const char *tui_screen_line(int y);
114+
115+/*
116+ * Input by hand.
117+ *
118+ * `tui_feed_key` takes a key by name, as the terminal's own keys are named:
119+ * "a", "space", "enter", "tab", "shift+tab", "esc", "up", "page-down",
120+ * "ctrl+u", "alt+f", "f5". It answers 1 when the backend acted on it and 0
121+ * when it went out as a `key` event instead. `tui_feed_click` and
122+ * `tui_feed_wheel` do the same for the mouse; the wheel's `by` is in rows and
123+ * negative is up.
124+ *
125+ * These are the same entry points a real terminal's input arrives through, so
126+ * a test drives the UI exactly as a person does.
127+ */
128+JOLTTUI_API int tui_feed_key(const char *name);
129+JOLTTUI_API int tui_feed_click(int x, int y);
130+JOLTTUI_API int tui_feed_wheel(int x, int y, int by);
131+
132+/* The focused node, 0 for none. Focus follows the ring — Tab and Shift-Tab
133+ * walk it in paint order, `autofocus` claims it on the first frame, and a
134+ * widget that is unmounted or turned insensitive gives it up. */
135+JOLTTUI_API int tui_focus(void);
136+
137+/* The window node, created with the session. Mount everything under it. */
138+JOLTTUI_API int tui_tree_root(void);
139+
140+JOLTTUI_API int tui_node_new(const char *tag);
141+JOLTTUI_API void tui_node_free(int node);
142+JOLTTUI_API int tui_node_exists(int node);
143+
144+JOLTTUI_API void tui_node_set_str(int node, const char *key, const char *value);
145+JOLTTUI_API void tui_node_set_num(int node, const char *key, double value);
146+JOLTTUI_API void tui_node_set_bool(int node, const char *key, int value);
147+JOLTTUI_API void tui_node_clear_props(int node);
148+
149+/*
150+ * Reads answer the empty string / 0 for a prop that is unset or of another
151+ * type. The returned pointer belongs to the library and is valid only until
152+ * the next string-returning call of its family on this thread — props, tags
153+ * and screen lines are one family, dumps another, and an event's name and its
154+ * text are each their own, so an event can be read whole.
155+ */
156+JOLTTUI_API const char *tui_node_get_str(int node, const char *key);
157+JOLTTUI_API double tui_node_get_num(int node, const char *key);
158+JOLTTUI_API int tui_node_get_bool(int node, const char *key);
159+
160+/* Reading the structure back. `tui_node_tag` answers the canonical tag name
161+ * ("box" for both hbox and vbox); `tui_node_child_at` answers 0 past the end. */
162+JOLTTUI_API const char *tui_node_tag(int node);
163+JOLTTUI_API int tui_node_parent(int node);
164+JOLTTUI_API int tui_node_child_count(int node);
165+JOLTTUI_API int tui_node_child_at(int node, int index);
166+
167+/*
168+ * The subtree at `node` as pretty-printed hiccup — `[:tag {props} children...]`,
169+ * one node to a line. `node` 0 means the root, so `tui_tree_dump(0)` is the
170+ * whole window. It answers what the tree *is*, read back from the arena,
171+ * rather than what a caller meant to build. Props are sorted, so two dumps of
172+ * the same tree compare as text; handlers are absent because they never
173+ * crossed this boundary.
174+ */
175+JOLTTUI_API const char *tui_tree_dump(int node);
176+
177+JOLTTUI_API int tui_node_append(int parent, int child);
178+/* Unparents AND frees `child` with everything under it. */
179+JOLTTUI_API void tui_node_remove(int parent, int child);
180+/* Moves `child` after `sibling`; `sibling` 0 means the first position. */
181+JOLTTUI_API int tui_node_insert_after(int parent, int child, int sibling);
182+/* Puts `new_child` where `old_child` was, and frees `old_child`. */
183+JOLTTUI_API int tui_node_replace(int parent, int old_child, int new_child);
184+
185+/*
186+ * Drain interactions. `tui_tree_poll_event` dequeues one and answers 1 while
187+ * there was one; the accessors describe whichever was dequeued last.
188+ *
189+ * Event names are glimmer's handler props without the `on-`:
190+ *
191+ * click a button was pressed no payload
192+ * toggled a checkbutton changed num is the new state
193+ * change an entry's text changed text is the new text
194+ * activate Enter in an entry or a list text is the text or the row
195+ * select a list's cursor moved text is the row, num its index
196+ * scroll a viewport moved num is the new offset
197+ * close Esc in an overlay no payload
198+ * key a key nothing here wanted text is the key's name
199+ *
200+ * A widget does not own its value: `toggled`, `change`, `select` and `scroll`
201+ * write the new state back into the node's props as well, so a caller that
202+ * ignores the event still sees a working control, and the next prop write is
203+ * what settles it.
204+ *
205+ * A `key` event is reported on the focused node, or on the window when nothing
206+ * has focus. Bubbling it to a container's `:on-key` is the caller's to do: it
207+ * holds the handlers and knows the tree.
208+ */
209+JOLTTUI_API int tui_tree_poll_event(void);
210+JOLTTUI_API int tui_tree_event_node(void);
211+JOLTTUI_API const char *tui_tree_event_name(void);
212+JOLTTUI_API const char *tui_tree_event_text(void);
213+JOLTTUI_API double tui_tree_event_num(void);
214+
215+#ifdef __cplusplus
216+}
217+#endif
218+
219+#endif /* JOLTTUI_H */
added crates/jolt-tui/src/keys.rs +105 -0
new file mode 100644
@@ -0,0 +1,105 @@
1+//! Key names, and the two word motions the entry needs.
2+//!
3+//! A key crosses this ABI as a string — `"ctrl+u"`, `"page-down"`, `"a"` —
4+//! because that is the only shape jolt can match on, and because a name is
5+//! something a test can type. The same names glimmer-tui's `k/match?` takes.
6+
7+/// Where the word before `at` starts.
8+pub fn word_left(text: &[char], at: usize) -> usize {
9+ let mut i = at.min(text.len());
10+ while i > 0 && text[i - 1].is_whitespace() {
11+ i -= 1;
12+ }
13+ while i > 0 && !text[i - 1].is_whitespace() {
14+ i -= 1;
15+ }
16+ i
17+}
18+
19+/// Where the word after `at` ends.
20+pub fn word_right(text: &[char], at: usize) -> usize {
21+ let mut i = at.min(text.len());
22+ while i < text.len() && text[i].is_whitespace() {
23+ i += 1;
24+ }
25+ while i < text.len() && !text[i].is_whitespace() {
26+ i += 1;
27+ }
28+ i
29+}
30+
31+#[cfg(feature = "terminal")]
32+mod terminal {
33+ use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
34+
35+ /// The name for one key event, or `None` for a key with no name — a bare
36+ /// modifier press, or a media key nothing here would do anything with.
37+ pub fn name(event: KeyEvent) -> Option<String> {
38+ let mods = event.modifiers;
39+ let base = match event.code {
40+ KeyCode::Char(' ') => "space".to_owned(),
41+ KeyCode::Char(c) => {
42+ // Shift is already in the character the terminal sent; saying
43+ // it twice would make `A` into `shift+a`, which nothing wants
44+ // to match on.
45+ let mut name = c.to_string();
46+ if mods.contains(KeyModifiers::CONTROL) || mods.contains(KeyModifiers::ALT) {
47+ name = c.to_lowercase().to_string();
48+ }
49+ name
50+ }
51+ KeyCode::Enter => "enter".into(),
52+ KeyCode::Tab => "tab".into(),
53+ KeyCode::BackTab => return Some("shift+tab".into()),
54+ KeyCode::Backspace => "backspace".into(),
55+ KeyCode::Delete => "delete".into(),
56+ KeyCode::Insert => "insert".into(),
57+ KeyCode::Esc => "esc".into(),
58+ KeyCode::Up => "up".into(),
59+ KeyCode::Down => "down".into(),
60+ KeyCode::Left => "left".into(),
61+ KeyCode::Right => "right".into(),
62+ KeyCode::Home => "home".into(),
63+ KeyCode::End => "end".into(),
64+ KeyCode::PageUp => "page-up".into(),
65+ KeyCode::PageDown => "page-down".into(),
66+ KeyCode::F(n) => format!("f{n}"),
67+ _ => return None,
68+ };
69+ let mut out = String::new();
70+ if mods.contains(KeyModifiers::CONTROL) {
71+ out.push_str("ctrl+");
72+ }
73+ if mods.contains(KeyModifiers::ALT) {
74+ out.push_str("alt+");
75+ }
76+ if mods.contains(KeyModifiers::SHIFT) && !matches!(event.code, KeyCode::Char(_)) {
77+ out.push_str("shift+");
78+ }
79+ out.push_str(&base);
80+ Some(out)
81+ }
82+}
83+
84+#[cfg(feature = "terminal")]
85+pub use terminal::name;
86+
87+#[cfg(test)]
88+mod tests {
89+ use super::*;
90+
91+ fn chars(text: &str) -> Vec<char> {
92+ text.chars().collect()
93+ }
94+
95+ #[test]
96+ fn word_motions_step_over_the_gap_and_then_the_word() {
97+ let text = chars("one two three");
98+ assert_eq!(word_left(&text, 13), 8);
99+ assert_eq!(word_left(&text, 8), 4);
100+ assert_eq!(word_left(&text, 0), 0);
101+ assert_eq!(word_right(&text, 0), 3);
102+ assert_eq!(word_right(&text, 3), 7);
103+ assert_eq!(word_right(&text, 13), 13);
104+ }
105+}
new file mode 100644
@@ -0,0 +1,105 @@
1+//! Key names, and the two word motions the entry needs.
2+//!
3+//! A key crosses this ABI as a string — `"ctrl+u"`, `"page-down"`, `"a"` —
4+//! because that is the only shape jolt can match on, and because a name is
5+//! something a test can type. The same names glimmer-tui's `k/match?` takes.
6+
7+/// Where the word before `at` starts.
8+pub fn word_left(text: &[char], at: usize) -> usize {
9+ let mut i = at.min(text.len());
10+ while i > 0 && text[i - 1].is_whitespace() {
11+ i -= 1;
12+ }
13+ while i > 0 && !text[i - 1].is_whitespace() {
14+ i -= 1;
15+ }
16+ i
17+}
18+
19+/// Where the word after `at` ends.
20+pub fn word_right(text: &[char], at: usize) -> usize {
21+ let mut i = at.min(text.len());
22+ while i < text.len() && text[i].is_whitespace() {
23+ i += 1;
24+ }
25+ while i < text.len() && !text[i].is_whitespace() {
26+ i += 1;
27+ }
28+ i
29+}
30+
31+#[cfg(feature = "terminal")]
32+mod terminal {
33+ use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
34+
35+ /// The name for one key event, or `None` for a key with no name — a bare
36+ /// modifier press, or a media key nothing here would do anything with.
37+ pub fn name(event: KeyEvent) -> Option<String> {
38+ let mods = event.modifiers;
39+ let base = match event.code {
40+ KeyCode::Char(' ') => "space".to_owned(),
41+ KeyCode::Char(c) => {
42+ // Shift is already in the character the terminal sent; saying
43+ // it twice would make `A` into `shift+a`, which nothing wants
44+ // to match on.
45+ let mut name = c.to_string();
46+ if mods.contains(KeyModifiers::CONTROL) || mods.contains(KeyModifiers::ALT) {
47+ name = c.to_lowercase().to_string();
48+ }
49+ name
50+ }
51+ KeyCode::Enter => "enter".into(),
52+ KeyCode::Tab => "tab".into(),
53+ KeyCode::BackTab => return Some("shift+tab".into()),
54+ KeyCode::Backspace => "backspace".into(),
55+ KeyCode::Delete => "delete".into(),
56+ KeyCode::Insert => "insert".into(),
57+ KeyCode::Esc => "esc".into(),
58+ KeyCode::Up => "up".into(),
59+ KeyCode::Down => "down".into(),
60+ KeyCode::Left => "left".into(),
61+ KeyCode::Right => "right".into(),
62+ KeyCode::Home => "home".into(),
63+ KeyCode::End => "end".into(),
64+ KeyCode::PageUp => "page-up".into(),
65+ KeyCode::PageDown => "page-down".into(),
66+ KeyCode::F(n) => format!("f{n}"),
67+ _ => return None,
68+ };
69+ let mut out = String::new();
70+ if mods.contains(KeyModifiers::CONTROL) {
71+ out.push_str("ctrl+");
72+ }
73+ if mods.contains(KeyModifiers::ALT) {
74+ out.push_str("alt+");
75+ }
76+ if mods.contains(KeyModifiers::SHIFT) && !matches!(event.code, KeyCode::Char(_)) {
77+ out.push_str("shift+");
78+ }
79+ out.push_str(&base);
80+ Some(out)
81+ }
82+}
83+
84+#[cfg(feature = "terminal")]
85+pub use terminal::name;
86+
87+#[cfg(test)]
88+mod tests {
89+ use super::*;
90+
91+ fn chars(text: &str) -> Vec<char> {
92+ text.chars().collect()
93+ }
94+
95+ #[test]
96+ fn word_motions_step_over_the_gap_and_then_the_word() {
97+ let text = chars("one two three");
98+ assert_eq!(word_left(&text, 13), 8);
99+ assert_eq!(word_left(&text, 8), 4);
100+ assert_eq!(word_left(&text, 0), 0);
101+ assert_eq!(word_right(&text, 0), 3);
102+ assert_eq!(word_right(&text, 3), 7);
103+ assert_eq!(word_right(&text, 13), 13);
104+ }
105+}
added crates/jolt-tui/src/layout.rs +490 -0
new file mode 100644
@@ -0,0 +1,490 @@
1+//! Two sizes per node, and how a box shares out what it has.
2+//!
3+//! Every node answers a *natural* size — what it would like — and a *minimum*
4+//! — what it can survive on. A container hands out its natural sizes when there
5+//! is room, shrinks them proportionally toward the minimums when there is not,
6+//! and gives the surplus to whoever asked to expand. `:width-request` and
7+//! `:height-request` are a floor on both numbers, so asking for four rows gets
8+//! four rows even when space is short.
9+//!
10+//! Nothing here touches a terminal or the screen grid: sizes are arithmetic on
11+//! the tree, which is why the layout tests below need no TTY.
12+
13+use crate::screen::Rect;
14+use crate::tree::{Props, Tag, Tree};
15+
16+/// How a child that is not filling its cross axis sits in the space it was
17+/// given. `:halign` and `:valign` in the props.
18+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
19+pub enum Align {
20+ Fill,
21+ Start,
22+ Center,
23+ End,
24+}
25+
26+impl Align {
27+ pub fn parse(text: &str) -> Self {
28+ match text {
29+ "start" => Self::Start,
30+ "center" | "centre" => Self::Center,
31+ "end" => Self::End,
32+ _ => Self::Fill,
33+ }
34+ }
35+
36+ /// Where a span of `size` sits inside `avail`.
37+ fn offset(self, size: u16, avail: u16) -> u16 {
38+ let slack = avail.saturating_sub(size);
39+ match self {
40+ Self::Fill | Self::Start => 0,
41+ Self::Center => slack / 2,
42+ Self::End => slack,
43+ }
44+ }
45+}
46+
47+/// Whether a box stacks its children across or down.
48+pub fn horizontal(props: &Props) -> bool {
49+ props.str("orientation") == "horizontal"
50+}
51+
52+/// The cells a node gives up on each side before its content starts. `:margin`
53+/// and `:padding` are one inset here — a terminal cell has no border between
54+/// them to tell them apart, and a caller that sets both means both.
55+pub fn inset(tag: &Tag, props: &Props) -> u16 {
56+ let own = props.cells("margin", 0) + props.cells("padding", 0);
57+ // A frame — and an overlay, which is a frame that floats — spends a cell a
58+ // side on its border.
59+ own + if matches!(tag, Tag::Frame | Tag::Overlay) {
60+ 1
61+ } else {
62+ 0
63+ }
64+}
65+
66+/// Break `text` to `width` columns, on spaces where it can and mid-word where
67+/// it must. Explicit newlines are always breaks.
68+pub fn wrap(text: &str, width: u16) -> Vec<String> {
69+ if width == 0 {
70+ return Vec::new();
71+ }
72+ let width = width as usize;
73+ let mut lines = Vec::new();
74+ for paragraph in text.split('\n') {
75+ let mut line = String::new();
76+ let mut len = 0usize;
77+ for word in paragraph.split(' ') {
78+ let word_len = word.chars().count();
79+ if len > 0 && len + 1 + word_len > width {
80+ lines.push(std::mem::take(&mut line));
81+ len = 0;
82+ }
83+ if word_len > width {
84+ // Longer than the whole line: break it where the line ends
85+ // rather than let it run off the edge.
86+ for ch in word.chars() {
87+ if len == width {
88+ lines.push(std::mem::take(&mut line));
89+ len = 0;
90+ }
91+ line.push(ch);
92+ len += 1;
93+ }
94+ continue;
95+ }
96+ if len > 0 {
97+ line.push(' ');
98+ len += 1;
99+ }
100+ line.push_str(word);
101+ len += word_len;
102+ }
103+ lines.push(line);
104+ }
105+ lines
106+}
107+
108+fn columns(text: &str) -> u16 {
109+ text.split('\n')
110+ .map(|line| line.chars().count())
111+ .max()
112+ .unwrap_or(0)
113+ .min(u16::MAX as usize) as u16
114+}
115+
116+/// The longest single word — a label cannot usefully be narrower than this.
117+fn longest_word(text: &str) -> u16 {
118+ text.split([' ', '\n'])
119+ .map(|w| w.chars().count())
120+ .max()
121+ .unwrap_or(0)
122+ .min(u16::MAX as usize) as u16
123+}
124+
125+/// The text an entry shows: its own, or its placeholder when it has none.
126+pub fn entry_text(props: &Props) -> String {
127+ let text = props.str("text");
128+ if text.is_empty() {
129+ props.str("placeholder").to_owned()
130+ } else {
131+ text.to_owned()
132+ }
133+}
134+
135+/// A node's content size before its own request or inset is applied.
136+fn intrinsic_width(tree: &Tree, id: u32, minimum: bool) -> u16 {
137+ let tag = tree.tag(id);
138+ let props = tree.props(id);
139+ let text = props.label();
140+ match tag {
141+ Tag::Button => columns(text).saturating_add(4),
142+ Tag::CheckButton => columns(text).saturating_add(4),
143+ Tag::Entry => {
144+ let want = columns(&entry_text(&props)).saturating_add(1).max(12);
145+ if minimum {
146+ want.min(6)
147+ } else {
148+ want
149+ }
150+ }
151+ Tag::Label | Tag::Title | Tag::DimLabel => {
152+ if minimum {
153+ longest_word(text)
154+ } else {
155+ columns(text)
156+ }
157+ }
158+ Tag::Separator => 1,
159+ Tag::Spacer => props.cells("size", 1),
160+ Tag::Progress => {
161+ if minimum {
162+ 4
163+ } else {
164+ 20
165+ }
166+ }
167+ Tag::Spinner => 1,
168+ Tag::Listbox => tree
169+ .children(id)
170+ .iter()
171+ .map(|c| intrinsic_width(tree, *c, minimum).saturating_add(2))
172+ .max()
173+ .unwrap_or(0),
174+ // Every container measures its children the same way; only the axis
175+ // the sum runs along differs.
176+ Tag::Box | Tag::Window | Tag::Frame | Tag::Scroll | Tag::Overlay | Tag::Unknown(_) => {
177+ let children = tree.children(id);
178+ let spacing = props.cells("spacing", 0);
179+ let sizes = children
180+ .iter()
181+ .map(|c| width(tree, *c, minimum))
182+ .collect::<Vec<_>>();
183+ let content = if horizontal(&props) && matches!(tag, Tag::Box) {
184+ let gaps = spacing.saturating_mul(sizes.len().saturating_sub(1) as u16);
185+ sizes.iter().fold(gaps, |a, b| a.saturating_add(*b))
186+ } else {
187+ sizes.into_iter().max().unwrap_or(0)
188+ };
189+ // A frame's heading sits in its top edge, so it is part of how wide
190+ // the frame has to be — a box narrower than its own label reads as
191+ // a truncated one.
192+ if matches!(tag, Tag::Frame | Tag::Overlay) {
193+ content.max(columns(props.label()).saturating_add(2))
194+ } else {
195+ content
196+ }
197+ }
198+ }
199+}
200+
201+/// A node's natural or minimum width, requests and insets included.
202+pub fn width(tree: &Tree, id: u32, minimum: bool) -> u16 {
203+ let props = tree.props(id);
204+ let pad = inset(&tree.tag(id), &props).saturating_mul(2);
205+ let content = intrinsic_width(tree, id, minimum).saturating_add(pad);
206+ content.max(props.cells("width-request", 0))
207+}
208+
209+/// How tall `id` is when laid out `avail` columns wide.
210+///
211+/// Height depends on width — that is what wrapping means — so there is no
212+/// natural height to ask for on its own.
213+pub fn height_for_width(tree: &Tree, id: u32, avail: u16) -> u16 {
214+ let tag = tree.tag(id);
215+ let props = tree.props(id);
216+ let pad = inset(&tag, &props);
217+ let inner = avail.saturating_sub(pad.saturating_mul(2));
218+ let content = match tag {
219+ Tag::Label | Tag::Title | Tag::DimLabel => wrap(props.label(), inner).len() as u16,
220+ Tag::Button | Tag::CheckButton | Tag::Separator | Tag::Progress | Tag::Spinner => 1,
221+ Tag::Entry => props.cells("rows", 1).max(1),
222+ Tag::Spacer => props.cells("size", 1),
223+ Tag::Listbox => tree.child_count(id) as u16,
224+ Tag::Box | Tag::Window | Tag::Frame | Tag::Scroll | Tag::Overlay | Tag::Unknown(_) => {
225+ let children = tree.children(id);
226+ let spacing = props.cells("spacing", 0);
227+ if horizontal(&props) && matches!(tag, Tag::Box) {
228+ // Across: each child is measured at the width it will get.
229+ let shares = share(tree, id, inner, true);
230+ children
231+ .iter()
232+ .zip(shares)
233+ .map(|(c, w)| height_for_width(tree, *c, w))
234+ .max()
235+ .unwrap_or(0)
236+ } else {
237+ let gaps = spacing.saturating_mul(children.len().saturating_sub(1) as u16);
238+ children
239+ .iter()
240+ .map(|c| height_for_width(tree, *c, inner))
241+ .fold(gaps, |a, b| a.saturating_add(b))
242+ }
243+ }
244+ };
245+ content
246+ .saturating_add(pad.saturating_mul(2))
247+ .max(props.cells("height-request", 0))
248+}
249+
250+/// Share `avail` out among the children of `id` along one axis.
251+///
252+/// `across` picks the axis: true for a horizontal box sharing columns, false
253+/// for a vertical one sharing rows. The rule is the same either way — natural
254+/// sizes first, shrink proportionally toward the minimums when short, and the
255+/// surplus to whoever set `:hexpand` / `:vexpand`.
256+pub fn share(tree: &Tree, id: u32, avail: u16, across: bool) -> Vec<u16> {
257+ let children = tree.children(id);
258+ if children.is_empty() {
259+ return Vec::new();
260+ }
261+ let props = tree.props(id);
262+ let spacing = props.cells("spacing", 0);
263+ let gaps = spacing.saturating_mul((children.len() - 1) as u16);
264+ let room = avail.saturating_sub(gaps) as i64;
265+
266+ let measure = |child: u32, minimum: bool| -> i64 {
267+ if across {
268+ width(tree, child, minimum) as i64
269+ } else {
270+ // Down the page a child's height depends on the width it gets,
271+ // which the caller has already fixed by the time it asks.
272+ height_for_width(tree, child, avail) as i64
273+ }
274+ };
275+
276+ let nat: Vec<i64> = children.iter().map(|c| measure(*c, false)).collect();
277+ let min: Vec<i64> = children
278+ .iter()
279+ .zip(&nat)
280+ .map(|(c, n)| measure(*c, true).min(*n))
281+ .collect();
282+ let total: i64 = nat.iter().sum();
283+ let mut out = nat.clone();
284+
285+ if total > room {
286+ // Short: take the overrun out of whatever each child is willing to give
287+ // up, in proportion to how much that is.
288+ let mut over = total - room;
289+ let slack: i64 = nat.iter().zip(&min).map(|(n, m)| n - m).sum();
290+ if slack > 0 {
291+ for i in 0..out.len() {
292+ let give = ((nat[i] - min[i]) * over.min(slack)) / slack;
293+ out[i] -= give;
294+ }
295+ over -= nat.iter().zip(&out).map(|(n, o)| n - o).sum::<i64>();
296+ }
297+ // Rounding, and children with no slack at all: take the rest off the
298+ // end, which is where a terminal clips anyway.
299+ let mut i = out.len();
300+ while over > 0 && i > 0 {
301+ i -= 1;
302+ let give = (out[i] - min[i]).min(over);
303+ out[i] -= give;
304+ over -= give;
305+ }
306+ } else if total < room {
307+ let key = if across { "hexpand" } else { "vexpand" };
308+ let greedy: Vec<usize> = children
309+ .iter()
310+ .enumerate()
311+ .filter(|(_, c)| tree.props(**c).bool(key, false))
312+ .map(|(i, _)| i)
313+ .collect();
314+ if !greedy.is_empty() {
315+ let extra = room - total;
316+ let each = extra / greedy.len() as i64;
317+ let mut rest = extra % greedy.len() as i64;
318+ for i in greedy {
319+ out[i] += each + if rest > 0 { 1 } else { 0 };
320+ rest -= 1;
321+ }
322+ }
323+ }
324+ out.into_iter()
325+ .map(|n| n.clamp(0, u16::MAX as i64) as u16)
326+ .collect()
327+}
328+
329+/// The rect a child of `size` gets inside `avail` on its cross axis.
330+pub fn place(align: Align, size: u16, avail: u16) -> (u16, u16) {
331+ match align {
332+ Align::Fill => (0, avail),
333+ other => {
334+ let size = size.min(avail);
335+ (other.offset(size, avail), size)
336+ }
337+ }
338+}
339+
340+/// Lay the children of a box out inside `area`.
341+pub fn children_rects(tree: &Tree, id: u32, area: Rect) -> Vec<Rect> {
342+ let props = tree.props(id);
343+ let across = horizontal(&props) && matches!(tree.tag(id), Tag::Box);
344+ let spacing = props.cells("spacing", 0);
345+ let children = tree.children(id);
346+ let shares = share(tree, id, if across { area.w } else { area.h }, across);
347+
348+ let mut out = Vec::with_capacity(children.len());
349+ let mut at = 0u16;
350+ for (child, main) in children.iter().zip(shares) {
351+ let cprops = tree.props(*child);
352+ let rect = if across {
353+ let want = height_for_width(tree, *child, main).max(cprops.cells("height-request", 0));
354+ let (dy, h) = place(Align::parse(cprops.str("valign")), want, area.h);
355+ Rect::new(
356+ area.x.saturating_add(at),
357+ area.y.saturating_add(dy),
358+ main,
359+ h,
360+ )
361+ } else {
362+ let want = width(tree, *child, false);
363+ let (dx, w) = place(Align::parse(cprops.str("halign")), want, area.w);
364+ Rect::new(
365+ area.x.saturating_add(dx),
366+ area.y.saturating_add(at),
367+ w,
368+ main,
369+ )
370+ };
371+ out.push(rect);
372+ at = at.saturating_add(main).saturating_add(spacing);
373+ }
374+ out
375+}
376+
377+#[cfg(test)]
378+mod tests {
379+ use super::*;
380+ use crate::tree::Value;
381+
382+ fn label(tree: &mut Tree, parent: u32, text: &str) -> u32 {
383+ let id = tree.new_node("label");
384+ tree.set(id, "label", Value::Str(text.into()));
385+ tree.append(parent, id);
386+ id
387+ }
388+
389+ #[test]
390+ fn wrapping_breaks_on_spaces_and_mid_word_when_it_must() {
391+ assert_eq!(wrap("one two three", 7), vec!["one two", "three"]);
392+ assert_eq!(
393+ wrap("antidisestablishment", 6),
394+ vec!["antidi", "sestab", "lishme", "nt"]
395+ );
396+ assert_eq!(wrap("a\nb", 10), vec!["a", "b"]);
397+ }
398+
399+ #[test]
400+ fn a_label_is_as_wide_as_its_text_and_as_narrow_as_its_longest_word() {
401+ let mut tree = Tree::new();
402+ let root = tree.root();
403+ let id = label(&mut tree, root, "one two three");
404+ assert_eq!(width(&tree, id, false), 13);
405+ assert_eq!(width(&tree, id, true), 5);
406+ assert_eq!(height_for_width(&tree, id, 7), 2);
407+ }
408+
409+ #[test]
410+ fn a_width_request_is_a_floor_on_both_sizes() {
411+ let mut tree = Tree::new();
412+ let root = tree.root();
413+ let id = label(&mut tree, root, "hi");
414+ tree.set(id, "width-request", Value::Num(20.0));
415+ assert_eq!(width(&tree, id, false), 20);
416+ assert_eq!(width(&tree, id, true), 20);
417+ }
418+
419+ #[test]
420+ fn a_height_request_of_four_rows_gets_four_rows() {
421+ let mut tree = Tree::new();
422+ let root = tree.root();
423+ let id = label(&mut tree, root, "hi");
424+ tree.set(id, "height-request", Value::Num(4.0));
425+ assert_eq!(height_for_width(&tree, id, 10), 4);
426+ }
427+
428+ #[test]
429+ fn a_horizontal_box_gives_the_surplus_to_whoever_expands() {
430+ let mut tree = Tree::new();
431+ let row = tree.new_node("hbox");
432+ tree.set(row, "orientation", Value::Str("horizontal".into()));
433+ let root = tree.root();
434+ tree.append(root, row);
435+ let a = label(&mut tree, row, "aa");
436+ let b = label(&mut tree, row, "bb");
437+ tree.set(b, "hexpand", Value::Bool(true));
438+ assert_eq!(share(&tree, row, 20, true), vec![2, 18]);
439+ let _ = a;
440+ }
441+
442+ #[test]
443+ fn a_short_box_shrinks_toward_the_minimums_rather_than_clipping_the_first_child() {
444+ let mut tree = Tree::new();
445+ let row = tree.new_node("hbox");
446+ tree.set(row, "orientation", Value::Str("horizontal".into()));
447+ let root = tree.root();
448+ tree.append(root, row);
449+ label(&mut tree, row, "one two");
450+ label(&mut tree, row, "three four");
451+ // 17 natural, 10 offered: both give up some, neither goes under its
452+ // longest word.
453+ let shares = share(&tree, row, 10, true);
454+ assert_eq!(shares.iter().sum::<u16>(), 10);
455+ assert!(shares[0] >= 3 && shares[1] >= 5, "{shares:?}");
456+ }
457+
458+ #[test]
459+ fn spacing_comes_off_the_room_before_it_is_shared() {
460+ let mut tree = Tree::new();
461+ let row = tree.new_node("hbox");
462+ tree.set(row, "orientation", Value::Str("horizontal".into()));
463+ tree.set(row, "spacing", Value::Num(2.0));
464+ let root = tree.root();
465+ tree.append(root, row);
466+ let a = label(&mut tree, row, "aa");
467+ let b = label(&mut tree, row, "bb");
468+ tree.set(a, "hexpand", Value::Bool(true));
469+ tree.set(b, "hexpand", Value::Bool(true));
470+ assert_eq!(share(&tree, row, 12, true), vec![5, 5]);
471+ }
472+
473+ #[test]
474+ fn a_centred_child_sits_in_the_middle_of_its_row() {
475+ assert_eq!(place(Align::Center, 4, 10), (3, 4));
476+ assert_eq!(place(Align::End, 4, 10), (6, 4));
477+ assert_eq!(place(Align::Fill, 4, 10), (0, 10));
478+ }
479+
480+ #[test]
481+ fn a_frame_spends_a_cell_a_side_on_its_border() {
482+ let mut tree = Tree::new();
483+ let frame = tree.new_node("frame");
484+ let root = tree.root();
485+ tree.append(root, frame);
486+ label(&mut tree, frame, "hi");
487+ assert_eq!(width(&tree, frame, false), 4);
488+ assert_eq!(height_for_width(&tree, frame, 4), 3);
489+ }
490+}
new file mode 100644
@@ -0,0 +1,490 @@
1+//! Two sizes per node, and how a box shares out what it has.
2+//!
3+//! Every node answers a *natural* size — what it would like — and a *minimum*
4+//! — what it can survive on. A container hands out its natural sizes when there
5+//! is room, shrinks them proportionally toward the minimums when there is not,
6+//! and gives the surplus to whoever asked to expand. `:width-request` and
7+//! `:height-request` are a floor on both numbers, so asking for four rows gets
8+//! four rows even when space is short.
9+//!
10+//! Nothing here touches a terminal or the screen grid: sizes are arithmetic on
11+//! the tree, which is why the layout tests below need no TTY.
12+
13+use crate::screen::Rect;
14+use crate::tree::{Props, Tag, Tree};
15+
16+/// How a child that is not filling its cross axis sits in the space it was
17+/// given. `:halign` and `:valign` in the props.
18+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
19+pub enum Align {
20+ Fill,
21+ Start,
22+ Center,
23+ End,
24+}
25+
26+impl Align {
27+ pub fn parse(text: &str) -> Self {
28+ match text {
29+ "start" => Self::Start,
30+ "center" | "centre" => Self::Center,
31+ "end" => Self::End,
32+ _ => Self::Fill,
33+ }
34+ }
35+
36+ /// Where a span of `size` sits inside `avail`.
37+ fn offset(self, size: u16, avail: u16) -> u16 {
38+ let slack = avail.saturating_sub(size);
39+ match self {
40+ Self::Fill | Self::Start => 0,
41+ Self::Center => slack / 2,
42+ Self::End => slack,
43+ }
44+ }
45+}
46+
47+/// Whether a box stacks its children across or down.
48+pub fn horizontal(props: &Props) -> bool {
49+ props.str("orientation") == "horizontal"
50+}
51+
52+/// The cells a node gives up on each side before its content starts. `:margin`
53+/// and `:padding` are one inset here — a terminal cell has no border between
54+/// them to tell them apart, and a caller that sets both means both.
55+pub fn inset(tag: &Tag, props: &Props) -> u16 {
56+ let own = props.cells("margin", 0) + props.cells("padding", 0);
57+ // A frame — and an overlay, which is a frame that floats — spends a cell a
58+ // side on its border.
59+ own + if matches!(tag, Tag::Frame | Tag::Overlay) {
60+ 1
61+ } else {
62+ 0
63+ }
64+}
65+
66+/// Break `text` to `width` columns, on spaces where it can and mid-word where
67+/// it must. Explicit newlines are always breaks.
68+pub fn wrap(text: &str, width: u16) -> Vec<String> {
69+ if width == 0 {
70+ return Vec::new();
71+ }
72+ let width = width as usize;
73+ let mut lines = Vec::new();
74+ for paragraph in text.split('\n') {
75+ let mut line = String::new();
76+ let mut len = 0usize;
77+ for word in paragraph.split(' ') {
78+ let word_len = word.chars().count();
79+ if len > 0 && len + 1 + word_len > width {
80+ lines.push(std::mem::take(&mut line));
81+ len = 0;
82+ }
83+ if word_len > width {
84+ // Longer than the whole line: break it where the line ends
85+ // rather than let it run off the edge.
86+ for ch in word.chars() {
87+ if len == width {
88+ lines.push(std::mem::take(&mut line));
89+ len = 0;
90+ }
91+ line.push(ch);
92+ len += 1;
93+ }
94+ continue;
95+ }
96+ if len > 0 {
97+ line.push(' ');
98+ len += 1;
99+ }
100+ line.push_str(word);
101+ len += word_len;
102+ }
103+ lines.push(line);
104+ }
105+ lines
106+}
107+
108+fn columns(text: &str) -> u16 {
109+ text.split('\n')
110+ .map(|line| line.chars().count())
111+ .max()
112+ .unwrap_or(0)
113+ .min(u16::MAX as usize) as u16
114+}
115+
116+/// The longest single word — a label cannot usefully be narrower than this.
117+fn longest_word(text: &str) -> u16 {
118+ text.split([' ', '\n'])
119+ .map(|w| w.chars().count())
120+ .max()
121+ .unwrap_or(0)
122+ .min(u16::MAX as usize) as u16
123+}
124+
125+/// The text an entry shows: its own, or its placeholder when it has none.
126+pub fn entry_text(props: &Props) -> String {
127+ let text = props.str("text");
128+ if text.is_empty() {
129+ props.str("placeholder").to_owned()
130+ } else {
131+ text.to_owned()
132+ }
133+}
134+
135+/// A node's content size before its own request or inset is applied.
136+fn intrinsic_width(tree: &Tree, id: u32, minimum: bool) -> u16 {
137+ let tag = tree.tag(id);
138+ let props = tree.props(id);
139+ let text = props.label();
140+ match tag {
141+ Tag::Button => columns(text).saturating_add(4),
142+ Tag::CheckButton => columns(text).saturating_add(4),
143+ Tag::Entry => {
144+ let want = columns(&entry_text(&props)).saturating_add(1).max(12);
145+ if minimum {
146+ want.min(6)
147+ } else {
148+ want
149+ }
150+ }
151+ Tag::Label | Tag::Title | Tag::DimLabel => {
152+ if minimum {
153+ longest_word(text)
154+ } else {
155+ columns(text)
156+ }
157+ }
158+ Tag::Separator => 1,
159+ Tag::Spacer => props.cells("size", 1),
160+ Tag::Progress => {
161+ if minimum {
162+ 4
163+ } else {
164+ 20
165+ }
166+ }
167+ Tag::Spinner => 1,
168+ Tag::Listbox => tree
169+ .children(id)
170+ .iter()
171+ .map(|c| intrinsic_width(tree, *c, minimum).saturating_add(2))
172+ .max()
173+ .unwrap_or(0),
174+ // Every container measures its children the same way; only the axis
175+ // the sum runs along differs.
176+ Tag::Box | Tag::Window | Tag::Frame | Tag::Scroll | Tag::Overlay | Tag::Unknown(_) => {
177+ let children = tree.children(id);
178+ let spacing = props.cells("spacing", 0);
179+ let sizes = children
180+ .iter()
181+ .map(|c| width(tree, *c, minimum))
182+ .collect::<Vec<_>>();
183+ let content = if horizontal(&props) && matches!(tag, Tag::Box) {
184+ let gaps = spacing.saturating_mul(sizes.len().saturating_sub(1) as u16);
185+ sizes.iter().fold(gaps, |a, b| a.saturating_add(*b))
186+ } else {
187+ sizes.into_iter().max().unwrap_or(0)
188+ };
189+ // A frame's heading sits in its top edge, so it is part of how wide
190+ // the frame has to be — a box narrower than its own label reads as
191+ // a truncated one.
192+ if matches!(tag, Tag::Frame | Tag::Overlay) {
193+ content.max(columns(props.label()).saturating_add(2))
194+ } else {
195+ content
196+ }
197+ }
198+ }
199+}
200+
201+/// A node's natural or minimum width, requests and insets included.
202+pub fn width(tree: &Tree, id: u32, minimum: bool) -> u16 {
203+ let props = tree.props(id);
204+ let pad = inset(&tree.tag(id), &props).saturating_mul(2);
205+ let content = intrinsic_width(tree, id, minimum).saturating_add(pad);
206+ content.max(props.cells("width-request", 0))
207+}
208+
209+/// How tall `id` is when laid out `avail` columns wide.
210+///
211+/// Height depends on width — that is what wrapping means — so there is no
212+/// natural height to ask for on its own.
213+pub fn height_for_width(tree: &Tree, id: u32, avail: u16) -> u16 {
214+ let tag = tree.tag(id);
215+ let props = tree.props(id);
216+ let pad = inset(&tag, &props);
217+ let inner = avail.saturating_sub(pad.saturating_mul(2));
218+ let content = match tag {
219+ Tag::Label | Tag::Title | Tag::DimLabel => wrap(props.label(), inner).len() as u16,
220+ Tag::Button | Tag::CheckButton | Tag::Separator | Tag::Progress | Tag::Spinner => 1,
221+ Tag::Entry => props.cells("rows", 1).max(1),
222+ Tag::Spacer => props.cells("size", 1),
223+ Tag::Listbox => tree.child_count(id) as u16,
224+ Tag::Box | Tag::Window | Tag::Frame | Tag::Scroll | Tag::Overlay | Tag::Unknown(_) => {
225+ let children = tree.children(id);
226+ let spacing = props.cells("spacing", 0);
227+ if horizontal(&props) && matches!(tag, Tag::Box) {
228+ // Across: each child is measured at the width it will get.
229+ let shares = share(tree, id, inner, true);
230+ children
231+ .iter()
232+ .zip(shares)
233+ .map(|(c, w)| height_for_width(tree, *c, w))
234+ .max()
235+ .unwrap_or(0)
236+ } else {
237+ let gaps = spacing.saturating_mul(children.len().saturating_sub(1) as u16);
238+ children
239+ .iter()
240+ .map(|c| height_for_width(tree, *c, inner))
241+ .fold(gaps, |a, b| a.saturating_add(b))
242+ }
243+ }
244+ };
245+ content
246+ .saturating_add(pad.saturating_mul(2))
247+ .max(props.cells("height-request", 0))
248+}
249+
250+/// Share `avail` out among the children of `id` along one axis.
251+///
252+/// `across` picks the axis: true for a horizontal box sharing columns, false
253+/// for a vertical one sharing rows. The rule is the same either way — natural
254+/// sizes first, shrink proportionally toward the minimums when short, and the
255+/// surplus to whoever set `:hexpand` / `:vexpand`.
256+pub fn share(tree: &Tree, id: u32, avail: u16, across: bool) -> Vec<u16> {
257+ let children = tree.children(id);
258+ if children.is_empty() {
259+ return Vec::new();
260+ }
261+ let props = tree.props(id);
262+ let spacing = props.cells("spacing", 0);
263+ let gaps = spacing.saturating_mul((children.len() - 1) as u16);
264+ let room = avail.saturating_sub(gaps) as i64;
265+
266+ let measure = |child: u32, minimum: bool| -> i64 {
267+ if across {
268+ width(tree, child, minimum) as i64
269+ } else {
270+ // Down the page a child's height depends on the width it gets,
271+ // which the caller has already fixed by the time it asks.
272+ height_for_width(tree, child, avail) as i64
273+ }
274+ };
275+
276+ let nat: Vec<i64> = children.iter().map(|c| measure(*c, false)).collect();
277+ let min: Vec<i64> = children
278+ .iter()
279+ .zip(&nat)
280+ .map(|(c, n)| measure(*c, true).min(*n))
281+ .collect();
282+ let total: i64 = nat.iter().sum();
283+ let mut out = nat.clone();
284+
285+ if total > room {
286+ // Short: take the overrun out of whatever each child is willing to give
287+ // up, in proportion to how much that is.
288+ let mut over = total - room;
289+ let slack: i64 = nat.iter().zip(&min).map(|(n, m)| n - m).sum();
290+ if slack > 0 {
291+ for i in 0..out.len() {
292+ let give = ((nat[i] - min[i]) * over.min(slack)) / slack;
293+ out[i] -= give;
294+ }
295+ over -= nat.iter().zip(&out).map(|(n, o)| n - o).sum::<i64>();
296+ }
297+ // Rounding, and children with no slack at all: take the rest off the
298+ // end, which is where a terminal clips anyway.
299+ let mut i = out.len();
300+ while over > 0 && i > 0 {
301+ i -= 1;
302+ let give = (out[i] - min[i]).min(over);
303+ out[i] -= give;
304+ over -= give;
305+ }
306+ } else if total < room {
307+ let key = if across { "hexpand" } else { "vexpand" };
308+ let greedy: Vec<usize> = children
309+ .iter()
310+ .enumerate()
311+ .filter(|(_, c)| tree.props(**c).bool(key, false))
312+ .map(|(i, _)| i)
313+ .collect();
314+ if !greedy.is_empty() {
315+ let extra = room - total;
316+ let each = extra / greedy.len() as i64;
317+ let mut rest = extra % greedy.len() as i64;
318+ for i in greedy {
319+ out[i] += each + if rest > 0 { 1 } else { 0 };
320+ rest -= 1;
321+ }
322+ }
323+ }
324+ out.into_iter()
325+ .map(|n| n.clamp(0, u16::MAX as i64) as u16)
326+ .collect()
327+}
328+
329+/// The rect a child of `size` gets inside `avail` on its cross axis.
330+pub fn place(align: Align, size: u16, avail: u16) -> (u16, u16) {
331+ match align {
332+ Align::Fill => (0, avail),
333+ other => {
334+ let size = size.min(avail);
335+ (other.offset(size, avail), size)
336+ }
337+ }
338+}
339+
340+/// Lay the children of a box out inside `area`.
341+pub fn children_rects(tree: &Tree, id: u32, area: Rect) -> Vec<Rect> {
342+ let props = tree.props(id);
343+ let across = horizontal(&props) && matches!(tree.tag(id), Tag::Box);
344+ let spacing = props.cells("spacing", 0);
345+ let children = tree.children(id);
346+ let shares = share(tree, id, if across { area.w } else { area.h }, across);
347+
348+ let mut out = Vec::with_capacity(children.len());
349+ let mut at = 0u16;
350+ for (child, main) in children.iter().zip(shares) {
351+ let cprops = tree.props(*child);
352+ let rect = if across {
353+ let want = height_for_width(tree, *child, main).max(cprops.cells("height-request", 0));
354+ let (dy, h) = place(Align::parse(cprops.str("valign")), want, area.h);
355+ Rect::new(
356+ area.x.saturating_add(at),
357+ area.y.saturating_add(dy),
358+ main,
359+ h,
360+ )
361+ } else {
362+ let want = width(tree, *child, false);
363+ let (dx, w) = place(Align::parse(cprops.str("halign")), want, area.w);
364+ Rect::new(
365+ area.x.saturating_add(dx),
366+ area.y.saturating_add(at),
367+ w,
368+ main,
369+ )
370+ };
371+ out.push(rect);
372+ at = at.saturating_add(main).saturating_add(spacing);
373+ }
374+ out
375+}
376+
377+#[cfg(test)]
378+mod tests {
379+ use super::*;
380+ use crate::tree::Value;
381+
382+ fn label(tree: &mut Tree, parent: u32, text: &str) -> u32 {
383+ let id = tree.new_node("label");
384+ tree.set(id, "label", Value::Str(text.into()));
385+ tree.append(parent, id);
386+ id
387+ }
388+
389+ #[test]
390+ fn wrapping_breaks_on_spaces_and_mid_word_when_it_must() {
391+ assert_eq!(wrap("one two three", 7), vec!["one two", "three"]);
392+ assert_eq!(
393+ wrap("antidisestablishment", 6),
394+ vec!["antidi", "sestab", "lishme", "nt"]
395+ );
396+ assert_eq!(wrap("a\nb", 10), vec!["a", "b"]);
397+ }
398+
399+ #[test]
400+ fn a_label_is_as_wide_as_its_text_and_as_narrow_as_its_longest_word() {
401+ let mut tree = Tree::new();
402+ let root = tree.root();
403+ let id = label(&mut tree, root, "one two three");
404+ assert_eq!(width(&tree, id, false), 13);
405+ assert_eq!(width(&tree, id, true), 5);
406+ assert_eq!(height_for_width(&tree, id, 7), 2);
407+ }
408+
409+ #[test]
410+ fn a_width_request_is_a_floor_on_both_sizes() {
411+ let mut tree = Tree::new();
412+ let root = tree.root();
413+ let id = label(&mut tree, root, "hi");
414+ tree.set(id, "width-request", Value::Num(20.0));
415+ assert_eq!(width(&tree, id, false), 20);
416+ assert_eq!(width(&tree, id, true), 20);
417+ }
418+
419+ #[test]
420+ fn a_height_request_of_four_rows_gets_four_rows() {
421+ let mut tree = Tree::new();
422+ let root = tree.root();
423+ let id = label(&mut tree, root, "hi");
424+ tree.set(id, "height-request", Value::Num(4.0));
425+ assert_eq!(height_for_width(&tree, id, 10), 4);
426+ }
427+
428+ #[test]
429+ fn a_horizontal_box_gives_the_surplus_to_whoever_expands() {
430+ let mut tree = Tree::new();
431+ let row = tree.new_node("hbox");
432+ tree.set(row, "orientation", Value::Str("horizontal".into()));
433+ let root = tree.root();
434+ tree.append(root, row);
435+ let a = label(&mut tree, row, "aa");
436+ let b = label(&mut tree, row, "bb");
437+ tree.set(b, "hexpand", Value::Bool(true));
438+ assert_eq!(share(&tree, row, 20, true), vec![2, 18]);
439+ let _ = a;
440+ }
441+
442+ #[test]
443+ fn a_short_box_shrinks_toward_the_minimums_rather_than_clipping_the_first_child() {
444+ let mut tree = Tree::new();
445+ let row = tree.new_node("hbox");
446+ tree.set(row, "orientation", Value::Str("horizontal".into()));
447+ let root = tree.root();
448+ tree.append(root, row);
449+ label(&mut tree, row, "one two");
450+ label(&mut tree, row, "three four");
451+ // 17 natural, 10 offered: both give up some, neither goes under its
452+ // longest word.
453+ let shares = share(&tree, row, 10, true);
454+ assert_eq!(shares.iter().sum::<u16>(), 10);
455+ assert!(shares[0] >= 3 && shares[1] >= 5, "{shares:?}");
456+ }
457+
458+ #[test]
459+ fn spacing_comes_off_the_room_before_it_is_shared() {
460+ let mut tree = Tree::new();
461+ let row = tree.new_node("hbox");
462+ tree.set(row, "orientation", Value::Str("horizontal".into()));
463+ tree.set(row, "spacing", Value::Num(2.0));
464+ let root = tree.root();
465+ tree.append(root, row);
466+ let a = label(&mut tree, row, "aa");
467+ let b = label(&mut tree, row, "bb");
468+ tree.set(a, "hexpand", Value::Bool(true));
469+ tree.set(b, "hexpand", Value::Bool(true));
470+ assert_eq!(share(&tree, row, 12, true), vec![5, 5]);
471+ }
472+
473+ #[test]
474+ fn a_centred_child_sits_in_the_middle_of_its_row() {
475+ assert_eq!(place(Align::Center, 4, 10), (3, 4));
476+ assert_eq!(place(Align::End, 4, 10), (6, 4));
477+ assert_eq!(place(Align::Fill, 4, 10), (0, 10));
478+ }
479+
480+ #[test]
481+ fn a_frame_spends_a_cell_a_side_on_its_border() {
482+ let mut tree = Tree::new();
483+ let frame = tree.new_node("frame");
484+ let root = tree.root();
485+ tree.append(root, frame);
486+ label(&mut tree, frame, "hi");
487+ assert_eq!(width(&tree, frame, false), 4);
488+ assert_eq!(height_for_width(&tree, frame, 4), 3);
489+ }
490+}
added crates/jolt-tui/src/lib.rs +506 -0
new file mode 100644
@@ -0,0 +1,506 @@
1+//! A terminal backend for glimmer, behind a C ABI — `libjolttui.so`.
2+//!
3+//! [`glimmer-tui`](https://github.com/jolt-lang/glimmer-tui) is the design this
4+//! follows: the same tags, the same props, the same keyboard, and the same rule
5+//! that painting goes through a grid so a test needs no terminal. What is
6+//! different is where the widget layer lives. There it is jolt over ncurses;
7+//! here it is Rust behind the same retained-tree ABI `libvidya` already
8+//! exports, so one glimmer backend on the jolt side can drive a GPU window or a
9+//! terminal by naming a different shared object.
10+//!
11+//! That split is the point. A reconciler needs widgets to patch, and a terminal
12+//! has none — so the tree lives down here, and FFI traffic tracks *edits*
13+//! rather than frames: a static screen costs no crossings per frame, and only
14+//! what the reconciler actually changed is sent.
15+//!
16+//! Rules inherited from this workspace's ABI:
17+//!
18+//! * one session per process;
19+//! * every call stays on the thread that opened it — the session lives in
20+//! thread-local storage, so a call from another thread is inert rather than
21+//! unsound;
22+//! * only integers, doubles, and UTF-8 byte strings cross, and a returned
23+//! string is borrowed until the next one of its family;
24+//! * nothing calls back. Interactions queue, and the caller polls.
25+//!
26+//! Handlers never cross the boundary: a node reports that it was clicked, and
27+//! the caller looks up whose `:on-click` that was.
28+
29+// Without the terminal feature the flush path is gone, and with it the only
30+// caller of a handful of screen and session accessors. They are the ABI's
31+// vocabulary, not dead code, so a headless build does not warn about them.
32+#![cfg_attr(not(feature = "terminal"), allow(dead_code))]
33+
34+mod keys;
35+mod layout;
36+mod paint;
37+mod screen;
38+#[cfg(feature = "terminal")]
39+mod term;
40+mod tree;
41+mod ui;
42+
43+#[cfg(test)]
44+mod tests;
45+
46+use std::cell::RefCell;
47+use std::ffi::{c_char, c_double, c_int};
48+
49+use jolt_abi::{borrowed, empty_str, guard, Scratch};
50+use tree::Value;
51+use ui::Ui;
52+
53+/// The reads: a prop, a tag, a line of the screen. One scratch, so a caller
54+/// holding a tag pointer across a prop read gets the documented lifetime and
55+/// not a surprise.
56+static READS: Scratch = Scratch::new();
57+/// Dumps are their own family: a dump is usually being printed beside the
58+/// props it mentions.
59+static DUMPS: Scratch = Scratch::new();
60+/// The event name and the event text are read one after the other by every
61+/// caller there will ever be, so they cannot share a scratch.
62+static NAMES: Scratch = Scratch::new();
63+static EVENTS: Scratch = Scratch::new();
64+
65+struct Session {
66+ ui: Ui,
67+ #[cfg(feature = "terminal")]
68+ term: Option<term::Term>,
69+}
70+
71+thread_local! {
72+ /// The process's session, owned by the thread that opened it.
73+ static SESSION: RefCell<Option<Session>> = const { RefCell::new(None) };
74+}
75+
76+fn with<R: Copy>(fallback: R, f: impl FnOnce(&mut Session) -> R) -> R {
77+ guard(fallback, || {
78+ SESSION.with_borrow_mut(|slot| match slot.as_mut() {
79+ Some(session) => f(session),
80+ None => fallback,
81+ })
82+ })
83+}
84+
85+/// Most of this ABI is a call on the tree with a session around it.
86+fn with_ui<R: Copy>(fallback: R, f: impl FnOnce(&mut Ui) -> R) -> R {
87+ with(fallback, |session| f(&mut session.ui))
88+}
89+
90+// ── the session ─────────────────────────────────────────────────────────────
91+
92+/// Take the terminal. `mouse` non-zero turns on mouse reporting. 1 on success,
93+/// 0 if a session is already open or the terminal refused raw mode.
94+#[no_mangle]
95+pub extern "C" fn tui_open(mouse: c_int) -> c_int {
96+ guard(0, || {
97+ SESSION.with_borrow_mut(|slot| {
98+ if slot.is_some() {
99+ log::error!("jolt-tui: a session is already open");
100+ return 0;
101+ }
102+ #[cfg(feature = "terminal")]
103+ {
104+ match term::Term::open(mouse != 0) {
105+ Ok(term) => {
106+ let (w, h) = term.size();
107+ *slot = Some(Session {
108+ ui: Ui::new(w, h),
109+ term: Some(term),
110+ });
111+ 1
112+ }
113+ Err(e) => {
114+ log::error!("jolt-tui: could not take the terminal: {e}");
115+ 0
116+ }
117+ }
118+ }
119+ #[cfg(not(feature = "terminal"))]
120+ {
121+ let _ = mouse;
122+ log::error!("jolt-tui: built without the terminal feature");
123+ 0
124+ }
125+ })
126+ })
127+}
128+
129+/// Open a session with no terminal at all, at a fixed size.
130+///
131+/// The whole widget layer works here — layout, painting, focus, keys fed with
132+/// `tui_feed_key` — and `tui_screen_line` reads the result back. This is what a
133+/// test suite and CI use, and it is the same code path a real session paints
134+/// through, not a second implementation of it.
135+#[no_mangle]
136+pub extern "C" fn tui_headless(width: c_int, height: c_int) -> c_int {
137+ guard(0, || {
138+ SESSION.with_borrow_mut(|slot| {
139+ if slot.is_some() {
140+ return 0;
141+ }
142+ *slot = Some(Session {
143+ ui: Ui::new(
144+ width.clamp(1, u16::MAX as c_int) as u16,
145+ height.clamp(1, u16::MAX as c_int) as u16,
146+ ),
147+ #[cfg(feature = "terminal")]
148+ term: None,
149+ });
150+ 1
151+ })
152+ })
153+}
154+
155+/// Give the terminal back and drop the tree. Safe to call twice.
156+#[no_mangle]
157+pub extern "C" fn tui_close() {
158+ guard((), || {
159+ SESSION.with_borrow_mut(|slot| {
160+ #[cfg(feature = "terminal")]
161+ if let Some(session) = slot.as_mut() {
162+ if let Some(term) = session.term.as_mut() {
163+ term.close();
164+ }
165+ }
166+ *slot = None;
167+ })
168+ })
169+}
170+
171+#[no_mangle]
172+pub extern "C" fn tui_should_close() -> c_int {
173+ with_ui(1, |ui| ui.should_close() as c_int)
174+}
175+
176+#[no_mangle]
177+pub extern "C" fn tui_quit() {
178+ with_ui((), |ui| ui.quit())
179+}
180+
181+/// Wait up to `timeout_ms` for input, then handle everything that arrived.
182+/// Answers how many things it handled, so a caller can skip a repaint when
183+/// nothing happened. Inert in a headless session, which is fed by hand.
184+#[no_mangle]
185+pub extern "C" fn tui_tick(timeout_ms: c_int) -> c_int {
186+ with(0, |session| {
187+ #[cfg(feature = "terminal")]
188+ {
189+ let Some(term) = session.term.as_mut() else {
190+ return 0;
191+ };
192+ let inputs = term.poll(timeout_ms.max(0) as u64);
193+ let mut handled = 0;
194+ for input in inputs {
195+ handled += 1;
196+ match input {
197+ term::Input::Key(name) => {
198+ session.ui.key(&name);
199+ }
200+ term::Input::Click(x, y) => {
201+ session.ui.click(x, y);
202+ }
203+ term::Input::Wheel(x, y, by) => {
204+ session.ui.wheel(x, y, by);
205+ }
206+ term::Input::Resize(w, h) => session.ui.resize(w, h),
207+ }
208+ }
209+ handled
210+ }
211+ #[cfg(not(feature = "terminal"))]
212+ {
213+ let _ = (session, timeout_ms);
214+ 0
215+ }
216+ })
217+}
218+
219+/// Lay the tree out, paint it, and send what changed. A headless session paints
220+/// and stops there.
221+#[no_mangle]
222+pub extern "C" fn tui_frame() {
223+ with((), |session| {
224+ session.ui.frame();
225+ #[cfg(feature = "terminal")]
226+ if let Some(term) = session.term.as_mut() {
227+ let cursor = session.ui.cursor();
228+ if let Err(e) = term.flush(&session.ui.screen, cursor) {
229+ log::error!("jolt-tui: could not write a frame: {e}");
230+ }
231+ }
232+ })
233+}
234+
235+#[no_mangle]
236+pub extern "C" fn tui_screen_width() -> c_int {
237+ with_ui(0, |ui| ui.screen.width() as c_int)
238+}
239+
240+#[no_mangle]
241+pub extern "C" fn tui_screen_height() -> c_int {
242+ with_ui(0, |ui| ui.screen.height() as c_int)
243+}
244+
245+/// One painted row as text, trailing blanks trimmed — what a test asserts on,
246+/// and what a bug report pastes. Borrowed until the next read.
247+#[no_mangle]
248+pub extern "C" fn tui_screen_line(y: c_int) -> *const c_char {
249+ with_ui(empty_str(), |ui| {
250+ if y < 0 {
251+ return empty_str();
252+ }
253+ READS.lend(ui.screen.line(y as u16))
254+ })
255+}
256+
257+// ── input by hand ───────────────────────────────────────────────────────────
258+
259+/// Feed one key by name — `"ctrl+u"`, `"page-down"`, `"a"` — as if the terminal
260+/// had sent it. Answers 1 when the backend acted on it and 0 when it went out
261+/// as a `key` event instead.
262+///
263+/// # Safety
264+/// `name` is null or a NUL-terminated UTF-8 string.
265+#[no_mangle]
266+pub unsafe extern "C" fn tui_feed_key(name: *const c_char) -> c_int {
267+ let name = borrowed(name);
268+ with_ui(0, |ui| ui.key(&name) as c_int)
269+}
270+
271+#[no_mangle]
272+pub extern "C" fn tui_feed_click(x: c_int, y: c_int) -> c_int {
273+ with_ui(0, |ui| {
274+ if x < 0 || y < 0 {
275+ return 0;
276+ }
277+ ui.click(x as u16, y as u16) as c_int
278+ })
279+}
280+
281+#[no_mangle]
282+pub extern "C" fn tui_feed_wheel(x: c_int, y: c_int, by: c_int) -> c_int {
283+ with_ui(0, |ui| {
284+ if x < 0 || y < 0 {
285+ return 0;
286+ }
287+ ui.wheel(x as u16, y as u16, by) as c_int
288+ })
289+}
290+
291+/// The focused node, 0 for none.
292+#[no_mangle]
293+pub extern "C" fn tui_focus() -> c_int {
294+ with_ui(0, |ui| ui.focus() as c_int)
295+}
296+
297+// ── the tree ────────────────────────────────────────────────────────────────
298+
299+#[no_mangle]
300+pub extern "C" fn tui_tree_root() -> c_int {
301+ with_ui(0, |ui| ui.tree.root() as c_int)
302+}
303+
304+/// # Safety
305+/// `tag` is null or a NUL-terminated UTF-8 string.
306+#[no_mangle]
307+pub unsafe extern "C" fn tui_node_new(tag: *const c_char) -> c_int {
308+ let tag = borrowed(tag);
309+ with_ui(0, |ui| ui.tree.new_node(&tag) as c_int)
310+}
311+
312+#[no_mangle]
313+pub extern "C" fn tui_node_free(node: c_int) {
314+ with_ui((), |ui| ui.tree.free_node(node.max(0) as u32))
315+}
316+
317+#[no_mangle]
318+pub extern "C" fn tui_node_exists(node: c_int) -> c_int {
319+ with_ui(0, |ui| ui.tree.exists(node.max(0) as u32) as c_int)
320+}
321+
322+/// # Safety
323+/// `key` and `value` are null or NUL-terminated UTF-8 strings.
324+#[no_mangle]
325+pub unsafe extern "C" fn tui_node_set_str(node: c_int, key: *const c_char, value: *const c_char) {
326+ let (key, value) = (borrowed(key), borrowed(value));
327+ with_ui((), |ui| {
328+ ui.tree.set(node.max(0) as u32, &key, Value::Str(value))
329+ })
330+}
331+
332+/// # Safety
333+/// `key` is null or a NUL-terminated UTF-8 string.
334+#[no_mangle]
335+pub unsafe extern "C" fn tui_node_set_num(node: c_int, key: *const c_char, value: c_double) {
336+ let key = borrowed(key);
337+ with_ui((), |ui| {
338+ ui.tree.set(node.max(0) as u32, &key, Value::Num(value))
339+ })
340+}
341+
342+/// # Safety
343+/// `key` is null or a NUL-terminated UTF-8 string.
344+#[no_mangle]
345+pub unsafe extern "C" fn tui_node_set_bool(node: c_int, key: *const c_char, value: c_int) {
346+ let key = borrowed(key);
347+ with_ui((), |ui| {
348+ ui.tree
349+ .set(node.max(0) as u32, &key, Value::Bool(value != 0))
350+ })
351+}
352+
353+#[no_mangle]
354+pub extern "C" fn tui_node_clear_props(node: c_int) {
355+ with_ui((), |ui| ui.tree.clear_props(node.max(0) as u32))
356+}
357+
358+/// # Safety
359+/// `key` is null or a NUL-terminated UTF-8 string.
360+#[no_mangle]
361+pub unsafe extern "C" fn tui_node_get_str(node: c_int, key: *const c_char) -> *const c_char {
362+ let key = borrowed(key);
363+ with_ui(empty_str(), |ui| {
364+ match ui.tree.get(node.max(0) as u32, &key) {
365+ Some(Value::Str(text)) => READS.lend(text.clone()),
366+ _ => empty_str(),
367+ }
368+ })
369+}
370+
371+/// # Safety
372+/// `key` is null or a NUL-terminated UTF-8 string.
373+#[no_mangle]
374+pub unsafe extern "C" fn tui_node_get_num(node: c_int, key: *const c_char) -> c_double {
375+ let key = borrowed(key);
376+ with_ui(0.0, |ui| match ui.tree.get(node.max(0) as u32, &key) {
377+ Some(Value::Num(n)) => *n,
378+ Some(Value::Bool(b)) => *b as i32 as f64,
379+ _ => 0.0,
380+ })
381+}
382+
383+/// # Safety
384+/// `key` is null or a NUL-terminated UTF-8 string.
385+#[no_mangle]
386+pub unsafe extern "C" fn tui_node_get_bool(node: c_int, key: *const c_char) -> c_int {
387+ let key = borrowed(key);
388+ with_ui(0, |ui| match ui.tree.get(node.max(0) as u32, &key) {
389+ Some(Value::Bool(b)) => *b as c_int,
390+ Some(Value::Num(n)) => (*n != 0.0) as c_int,
391+ _ => 0,
392+ })
393+}
394+
395+#[no_mangle]
396+pub extern "C" fn tui_node_tag(node: c_int) -> *const c_char {
397+ with_ui(empty_str(), |ui| {
398+ READS.lend(ui.tree.tag_name(node.max(0) as u32).to_owned())
399+ })
400+}
401+
402+/// The node this one hangs off, 0 when it is unparented or is the window.
403+#[no_mangle]
404+pub extern "C" fn tui_node_parent(node: c_int) -> c_int {
405+ with_ui(0, |ui| ui.tree.parent(node.max(0) as u32) as c_int)
406+}
407+
408+#[no_mangle]
409+pub extern "C" fn tui_node_child_count(node: c_int) -> c_int {
410+ with_ui(0, |ui| ui.tree.child_count(node.max(0) as u32) as c_int)
411+}
412+
413+#[no_mangle]
414+pub extern "C" fn tui_node_child_at(node: c_int, index: c_int) -> c_int {
415+ with_ui(0, |ui| {
416+ if index < 0 {
417+ return 0;
418+ }
419+ ui.tree.child_at(node.max(0) as u32, index as usize) as c_int
420+ })
421+}
422+
423+#[no_mangle]
424+pub extern "C" fn tui_node_append(parent: c_int, child: c_int) -> c_int {
425+ with_ui(0, |ui| {
426+ ui.tree.append(parent.max(0) as u32, child.max(0) as u32) as c_int
427+ })
428+}
429+
430+#[no_mangle]
431+pub extern "C" fn tui_node_remove(parent: c_int, child: c_int) {
432+ with_ui((), |ui| {
433+ ui.tree.remove(parent.max(0) as u32, child.max(0) as u32)
434+ })
435+}
436+
437+#[no_mangle]
438+pub extern "C" fn tui_node_insert_after(parent: c_int, child: c_int, sibling: c_int) -> c_int {
439+ with_ui(0, |ui| {
440+ ui.tree.insert_after(
441+ parent.max(0) as u32,
442+ child.max(0) as u32,
443+ sibling.max(0) as u32,
444+ ) as c_int
445+ })
446+}
447+
448+#[no_mangle]
449+pub extern "C" fn tui_node_replace(parent: c_int, old_child: c_int, new_child: c_int) -> c_int {
450+ with_ui(0, |ui| {
451+ ui.tree.replace(
452+ parent.max(0) as u32,
453+ old_child.max(0) as u32,
454+ new_child.max(0) as u32,
455+ ) as c_int
456+ })
457+}
458+
459+/// The subtree at `node` as pretty-printed hiccup; `node` 0 means the root, so
460+/// `tui_tree_dump(0)` is the whole window.
461+#[no_mangle]
462+pub extern "C" fn tui_tree_dump(node: c_int) -> *const c_char {
463+ with_ui(empty_str(), |ui| {
464+ let id = if node <= 0 {
465+ ui.tree.root()
466+ } else {
467+ node as u32
468+ };
469+ DUMPS.lend(ui.tree.dump(id))
470+ })
471+}
472+
473+// ── events ──────────────────────────────────────────────────────────────────
474+
475+#[no_mangle]
476+pub extern "C" fn tui_tree_poll_event() -> c_int {
477+ with_ui(0, |ui| ui.tree.poll() as c_int)
478+}
479+
480+#[no_mangle]
481+pub extern "C" fn tui_tree_event_node() -> c_int {
482+ with_ui(0, |ui| {
483+ ui.tree.current().map_or(0, |event| event.node as c_int)
484+ })
485+}
486+
487+#[no_mangle]
488+pub extern "C" fn tui_tree_event_name() -> *const c_char {
489+ with_ui(empty_str(), |ui| match ui.tree.current() {
490+ Some(event) => NAMES.lend(event.name),
491+ None => empty_str(),
492+ })
493+}
494+
495+#[no_mangle]
496+pub extern "C" fn tui_tree_event_text() -> *const c_char {
497+ with_ui(empty_str(), |ui| match ui.tree.current() {
498+ Some(event) => EVENTS.lend(event.text.clone()),
499+ None => empty_str(),
500+ })
501+}
502+
503+#[no_mangle]
504+pub extern "C" fn tui_tree_event_num() -> c_double {
505+ with_ui(0.0, |ui| ui.tree.current().map_or(0.0, |event| event.num))
506+}
new file mode 100644
@@ -0,0 +1,506 @@
1+//! A terminal backend for glimmer, behind a C ABI — `libjolttui.so`.
2+//!
3+//! [`glimmer-tui`](https://github.com/jolt-lang/glimmer-tui) is the design this
4+//! follows: the same tags, the same props, the same keyboard, and the same rule
5+//! that painting goes through a grid so a test needs no terminal. What is
6+//! different is where the widget layer lives. There it is jolt over ncurses;
7+//! here it is Rust behind the same retained-tree ABI `libvidya` already
8+//! exports, so one glimmer backend on the jolt side can drive a GPU window or a
9+//! terminal by naming a different shared object.
10+//!
11+//! That split is the point. A reconciler needs widgets to patch, and a terminal
12+//! has none — so the tree lives down here, and FFI traffic tracks *edits*
13+//! rather than frames: a static screen costs no crossings per frame, and only
14+//! what the reconciler actually changed is sent.
15+//!
16+//! Rules inherited from this workspace's ABI:
17+//!
18+//! * one session per process;
19+//! * every call stays on the thread that opened it — the session lives in
20+//! thread-local storage, so a call from another thread is inert rather than
21+//! unsound;
22+//! * only integers, doubles, and UTF-8 byte strings cross, and a returned
23+//! string is borrowed until the next one of its family;
24+//! * nothing calls back. Interactions queue, and the caller polls.
25+//!
26+//! Handlers never cross the boundary: a node reports that it was clicked, and
27+//! the caller looks up whose `:on-click` that was.
28+
29+// Without the terminal feature the flush path is gone, and with it the only
30+// caller of a handful of screen and session accessors. They are the ABI's
31+// vocabulary, not dead code, so a headless build does not warn about them.
32+#![cfg_attr(not(feature = "terminal"), allow(dead_code))]
33+
34+mod keys;
35+mod layout;
36+mod paint;
37+mod screen;
38+#[cfg(feature = "terminal")]
39+mod term;
40+mod tree;
41+mod ui;
42+
43+#[cfg(test)]
44+mod tests;
45+
46+use std::cell::RefCell;
47+use std::ffi::{c_char, c_double, c_int};
48+
49+use jolt_abi::{borrowed, empty_str, guard, Scratch};
50+use tree::Value;
51+use ui::Ui;
52+
53+/// The reads: a prop, a tag, a line of the screen. One scratch, so a caller
54+/// holding a tag pointer across a prop read gets the documented lifetime and
55+/// not a surprise.
56+static READS: Scratch = Scratch::new();
57+/// Dumps are their own family: a dump is usually being printed beside the
58+/// props it mentions.
59+static DUMPS: Scratch = Scratch::new();
60+/// The event name and the event text are read one after the other by every
61+/// caller there will ever be, so they cannot share a scratch.
62+static NAMES: Scratch = Scratch::new();
63+static EVENTS: Scratch = Scratch::new();
64+
65+struct Session {
66+ ui: Ui,
67+ #[cfg(feature = "terminal")]
68+ term: Option<term::Term>,
69+}
70+
71+thread_local! {
72+ /// The process's session, owned by the thread that opened it.
73+ static SESSION: RefCell<Option<Session>> = const { RefCell::new(None) };
74+}
75+
76+fn with<R: Copy>(fallback: R, f: impl FnOnce(&mut Session) -> R) -> R {
77+ guard(fallback, || {
78+ SESSION.with_borrow_mut(|slot| match slot.as_mut() {
79+ Some(session) => f(session),
80+ None => fallback,
81+ })
82+ })
83+}
84+
85+/// Most of this ABI is a call on the tree with a session around it.
86+fn with_ui<R: Copy>(fallback: R, f: impl FnOnce(&mut Ui) -> R) -> R {
87+ with(fallback, |session| f(&mut session.ui))
88+}
89+
90+// ── the session ─────────────────────────────────────────────────────────────
91+
92+/// Take the terminal. `mouse` non-zero turns on mouse reporting. 1 on success,
93+/// 0 if a session is already open or the terminal refused raw mode.
94+#[no_mangle]
95+pub extern "C" fn tui_open(mouse: c_int) -> c_int {
96+ guard(0, || {
97+ SESSION.with_borrow_mut(|slot| {
98+ if slot.is_some() {
99+ log::error!("jolt-tui: a session is already open");
100+ return 0;
101+ }
102+ #[cfg(feature = "terminal")]
103+ {
104+ match term::Term::open(mouse != 0) {
105+ Ok(term) => {
106+ let (w, h) = term.size();
107+ *slot = Some(Session {
108+ ui: Ui::new(w, h),
109+ term: Some(term),
110+ });
111+ 1
112+ }
113+ Err(e) => {
114+ log::error!("jolt-tui: could not take the terminal: {e}");
115+ 0
116+ }
117+ }
118+ }
119+ #[cfg(not(feature = "terminal"))]
120+ {
121+ let _ = mouse;
122+ log::error!("jolt-tui: built without the terminal feature");
123+ 0
124+ }
125+ })
126+ })
127+}
128+
129+/// Open a session with no terminal at all, at a fixed size.
130+///
131+/// The whole widget layer works here — layout, painting, focus, keys fed with
132+/// `tui_feed_key` — and `tui_screen_line` reads the result back. This is what a
133+/// test suite and CI use, and it is the same code path a real session paints
134+/// through, not a second implementation of it.
135+#[no_mangle]
136+pub extern "C" fn tui_headless(width: c_int, height: c_int) -> c_int {
137+ guard(0, || {
138+ SESSION.with_borrow_mut(|slot| {
139+ if slot.is_some() {
140+ return 0;
141+ }
142+ *slot = Some(Session {
143+ ui: Ui::new(
144+ width.clamp(1, u16::MAX as c_int) as u16,
145+ height.clamp(1, u16::MAX as c_int) as u16,
146+ ),
147+ #[cfg(feature = "terminal")]
148+ term: None,
149+ });
150+ 1
151+ })
152+ })
153+}
154+
155+/// Give the terminal back and drop the tree. Safe to call twice.
156+#[no_mangle]
157+pub extern "C" fn tui_close() {
158+ guard((), || {
159+ SESSION.with_borrow_mut(|slot| {
160+ #[cfg(feature = "terminal")]
161+ if let Some(session) = slot.as_mut() {
162+ if let Some(term) = session.term.as_mut() {
163+ term.close();
164+ }
165+ }
166+ *slot = None;
167+ })
168+ })
169+}
170+
171+#[no_mangle]
172+pub extern "C" fn tui_should_close() -> c_int {
173+ with_ui(1, |ui| ui.should_close() as c_int)
174+}
175+
176+#[no_mangle]
177+pub extern "C" fn tui_quit() {
178+ with_ui((), |ui| ui.quit())
179+}
180+
181+/// Wait up to `timeout_ms` for input, then handle everything that arrived.
182+/// Answers how many things it handled, so a caller can skip a repaint when
183+/// nothing happened. Inert in a headless session, which is fed by hand.
184+#[no_mangle]
185+pub extern "C" fn tui_tick(timeout_ms: c_int) -> c_int {
186+ with(0, |session| {
187+ #[cfg(feature = "terminal")]
188+ {
189+ let Some(term) = session.term.as_mut() else {
190+ return 0;
191+ };
192+ let inputs = term.poll(timeout_ms.max(0) as u64);
193+ let mut handled = 0;
194+ for input in inputs {
195+ handled += 1;
196+ match input {
197+ term::Input::Key(name) => {
198+ session.ui.key(&name);
199+ }
200+ term::Input::Click(x, y) => {
201+ session.ui.click(x, y);
202+ }
203+ term::Input::Wheel(x, y, by) => {
204+ session.ui.wheel(x, y, by);
205+ }
206+ term::Input::Resize(w, h) => session.ui.resize(w, h),
207+ }
208+ }
209+ handled
210+ }
211+ #[cfg(not(feature = "terminal"))]
212+ {
213+ let _ = (session, timeout_ms);
214+ 0
215+ }
216+ })
217+}
218+
219+/// Lay the tree out, paint it, and send what changed. A headless session paints
220+/// and stops there.
221+#[no_mangle]
222+pub extern "C" fn tui_frame() {
223+ with((), |session| {
224+ session.ui.frame();
225+ #[cfg(feature = "terminal")]
226+ if let Some(term) = session.term.as_mut() {
227+ let cursor = session.ui.cursor();
228+ if let Err(e) = term.flush(&session.ui.screen, cursor) {
229+ log::error!("jolt-tui: could not write a frame: {e}");
230+ }
231+ }
232+ })
233+}
234+
235+#[no_mangle]
236+pub extern "C" fn tui_screen_width() -> c_int {
237+ with_ui(0, |ui| ui.screen.width() as c_int)
238+}
239+
240+#[no_mangle]
241+pub extern "C" fn tui_screen_height() -> c_int {
242+ with_ui(0, |ui| ui.screen.height() as c_int)
243+}
244+
245+/// One painted row as text, trailing blanks trimmed — what a test asserts on,
246+/// and what a bug report pastes. Borrowed until the next read.
247+#[no_mangle]
248+pub extern "C" fn tui_screen_line(y: c_int) -> *const c_char {
249+ with_ui(empty_str(), |ui| {
250+ if y < 0 {
251+ return empty_str();
252+ }
253+ READS.lend(ui.screen.line(y as u16))
254+ })
255+}
256+
257+// ── input by hand ───────────────────────────────────────────────────────────
258+
259+/// Feed one key by name — `"ctrl+u"`, `"page-down"`, `"a"` — as if the terminal
260+/// had sent it. Answers 1 when the backend acted on it and 0 when it went out
261+/// as a `key` event instead.
262+///
263+/// # Safety
264+/// `name` is null or a NUL-terminated UTF-8 string.
265+#[no_mangle]
266+pub unsafe extern "C" fn tui_feed_key(name: *const c_char) -> c_int {
267+ let name = borrowed(name);
268+ with_ui(0, |ui| ui.key(&name) as c_int)
269+}
270+
271+#[no_mangle]
272+pub extern "C" fn tui_feed_click(x: c_int, y: c_int) -> c_int {
273+ with_ui(0, |ui| {
274+ if x < 0 || y < 0 {
275+ return 0;
276+ }
277+ ui.click(x as u16, y as u16) as c_int
278+ })
279+}
280+
281+#[no_mangle]
282+pub extern "C" fn tui_feed_wheel(x: c_int, y: c_int, by: c_int) -> c_int {
283+ with_ui(0, |ui| {
284+ if x < 0 || y < 0 {
285+ return 0;
286+ }
287+ ui.wheel(x as u16, y as u16, by) as c_int
288+ })
289+}
290+
291+/// The focused node, 0 for none.
292+#[no_mangle]
293+pub extern "C" fn tui_focus() -> c_int {
294+ with_ui(0, |ui| ui.focus() as c_int)
295+}
296+
297+// ── the tree ────────────────────────────────────────────────────────────────
298+
299+#[no_mangle]
300+pub extern "C" fn tui_tree_root() -> c_int {
301+ with_ui(0, |ui| ui.tree.root() as c_int)
302+}
303+
304+/// # Safety
305+/// `tag` is null or a NUL-terminated UTF-8 string.
306+#[no_mangle]
307+pub unsafe extern "C" fn tui_node_new(tag: *const c_char) -> c_int {
308+ let tag = borrowed(tag);
309+ with_ui(0, |ui| ui.tree.new_node(&tag) as c_int)
310+}
311+
312+#[no_mangle]
313+pub extern "C" fn tui_node_free(node: c_int) {
314+ with_ui((), |ui| ui.tree.free_node(node.max(0) as u32))
315+}
316+
317+#[no_mangle]
318+pub extern "C" fn tui_node_exists(node: c_int) -> c_int {
319+ with_ui(0, |ui| ui.tree.exists(node.max(0) as u32) as c_int)
320+}
321+
322+/// # Safety
323+/// `key` and `value` are null or NUL-terminated UTF-8 strings.
324+#[no_mangle]
325+pub unsafe extern "C" fn tui_node_set_str(node: c_int, key: *const c_char, value: *const c_char) {
326+ let (key, value) = (borrowed(key), borrowed(value));
327+ with_ui((), |ui| {
328+ ui.tree.set(node.max(0) as u32, &key, Value::Str(value))
329+ })
330+}
331+
332+/// # Safety
333+/// `key` is null or a NUL-terminated UTF-8 string.
334+#[no_mangle]
335+pub unsafe extern "C" fn tui_node_set_num(node: c_int, key: *const c_char, value: c_double) {
336+ let key = borrowed(key);
337+ with_ui((), |ui| {
338+ ui.tree.set(node.max(0) as u32, &key, Value::Num(value))
339+ })
340+}
341+
342+/// # Safety
343+/// `key` is null or a NUL-terminated UTF-8 string.
344+#[no_mangle]
345+pub unsafe extern "C" fn tui_node_set_bool(node: c_int, key: *const c_char, value: c_int) {
346+ let key = borrowed(key);
347+ with_ui((), |ui| {
348+ ui.tree
349+ .set(node.max(0) as u32, &key, Value::Bool(value != 0))
350+ })
351+}
352+
353+#[no_mangle]
354+pub extern "C" fn tui_node_clear_props(node: c_int) {
355+ with_ui((), |ui| ui.tree.clear_props(node.max(0) as u32))
356+}
357+
358+/// # Safety
359+/// `key` is null or a NUL-terminated UTF-8 string.
360+#[no_mangle]
361+pub unsafe extern "C" fn tui_node_get_str(node: c_int, key: *const c_char) -> *const c_char {
362+ let key = borrowed(key);
363+ with_ui(empty_str(), |ui| {
364+ match ui.tree.get(node.max(0) as u32, &key) {
365+ Some(Value::Str(text)) => READS.lend(text.clone()),
366+ _ => empty_str(),
367+ }
368+ })
369+}
370+
371+/// # Safety
372+/// `key` is null or a NUL-terminated UTF-8 string.
373+#[no_mangle]
374+pub unsafe extern "C" fn tui_node_get_num(node: c_int, key: *const c_char) -> c_double {
375+ let key = borrowed(key);
376+ with_ui(0.0, |ui| match ui.tree.get(node.max(0) as u32, &key) {
377+ Some(Value::Num(n)) => *n,
378+ Some(Value::Bool(b)) => *b as i32 as f64,
379+ _ => 0.0,
380+ })
381+}
382+
383+/// # Safety
384+/// `key` is null or a NUL-terminated UTF-8 string.
385+#[no_mangle]
386+pub unsafe extern "C" fn tui_node_get_bool(node: c_int, key: *const c_char) -> c_int {
387+ let key = borrowed(key);
388+ with_ui(0, |ui| match ui.tree.get(node.max(0) as u32, &key) {
389+ Some(Value::Bool(b)) => *b as c_int,
390+ Some(Value::Num(n)) => (*n != 0.0) as c_int,
391+ _ => 0,
392+ })
393+}
394+
395+#[no_mangle]
396+pub extern "C" fn tui_node_tag(node: c_int) -> *const c_char {
397+ with_ui(empty_str(), |ui| {
398+ READS.lend(ui.tree.tag_name(node.max(0) as u32).to_owned())
399+ })
400+}
401+
402+/// The node this one hangs off, 0 when it is unparented or is the window.
403+#[no_mangle]
404+pub extern "C" fn tui_node_parent(node: c_int) -> c_int {
405+ with_ui(0, |ui| ui.tree.parent(node.max(0) as u32) as c_int)
406+}
407+
408+#[no_mangle]
409+pub extern "C" fn tui_node_child_count(node: c_int) -> c_int {
410+ with_ui(0, |ui| ui.tree.child_count(node.max(0) as u32) as c_int)
411+}
412+
413+#[no_mangle]
414+pub extern "C" fn tui_node_child_at(node: c_int, index: c_int) -> c_int {
415+ with_ui(0, |ui| {
416+ if index < 0 {
417+ return 0;
418+ }
419+ ui.tree.child_at(node.max(0) as u32, index as usize) as c_int
420+ })
421+}
422+
423+#[no_mangle]
424+pub extern "C" fn tui_node_append(parent: c_int, child: c_int) -> c_int {
425+ with_ui(0, |ui| {
426+ ui.tree.append(parent.max(0) as u32, child.max(0) as u32) as c_int
427+ })
428+}
429+
430+#[no_mangle]
431+pub extern "C" fn tui_node_remove(parent: c_int, child: c_int) {
432+ with_ui((), |ui| {
433+ ui.tree.remove(parent.max(0) as u32, child.max(0) as u32)
434+ })
435+}
436+
437+#[no_mangle]
438+pub extern "C" fn tui_node_insert_after(parent: c_int, child: c_int, sibling: c_int) -> c_int {
439+ with_ui(0, |ui| {
440+ ui.tree.insert_after(
441+ parent.max(0) as u32,
442+ child.max(0) as u32,
443+ sibling.max(0) as u32,
444+ ) as c_int
445+ })
446+}
447+
448+#[no_mangle]
449+pub extern "C" fn tui_node_replace(parent: c_int, old_child: c_int, new_child: c_int) -> c_int {
450+ with_ui(0, |ui| {
451+ ui.tree.replace(
452+ parent.max(0) as u32,
453+ old_child.max(0) as u32,
454+ new_child.max(0) as u32,
455+ ) as c_int
456+ })
457+}
458+
459+/// The subtree at `node` as pretty-printed hiccup; `node` 0 means the root, so
460+/// `tui_tree_dump(0)` is the whole window.
461+#[no_mangle]
462+pub extern "C" fn tui_tree_dump(node: c_int) -> *const c_char {
463+ with_ui(empty_str(), |ui| {
464+ let id = if node <= 0 {
465+ ui.tree.root()
466+ } else {
467+ node as u32
468+ };
469+ DUMPS.lend(ui.tree.dump(id))
470+ })
471+}
472+
473+// ── events ──────────────────────────────────────────────────────────────────
474+
475+#[no_mangle]
476+pub extern "C" fn tui_tree_poll_event() -> c_int {
477+ with_ui(0, |ui| ui.tree.poll() as c_int)
478+}
479+
480+#[no_mangle]
481+pub extern "C" fn tui_tree_event_node() -> c_int {
482+ with_ui(0, |ui| {
483+ ui.tree.current().map_or(0, |event| event.node as c_int)
484+ })
485+}
486+
487+#[no_mangle]
488+pub extern "C" fn tui_tree_event_name() -> *const c_char {
489+ with_ui(empty_str(), |ui| match ui.tree.current() {
490+ Some(event) => NAMES.lend(event.name),
491+ None => empty_str(),
492+ })
493+}
494+
495+#[no_mangle]
496+pub extern "C" fn tui_tree_event_text() -> *const c_char {
497+ with_ui(empty_str(), |ui| match ui.tree.current() {
498+ Some(event) => EVENTS.lend(event.text.clone()),
499+ None => empty_str(),
500+ })
501+}
502+
503+#[no_mangle]
504+pub extern "C" fn tui_tree_event_num() -> c_double {
505+ with_ui(0.0, |ui| ui.tree.current().map_or(0.0, |event| event.num))
506+}
added crates/jolt-tui/src/paint.rs +438 -0
new file mode 100644
@@ -0,0 +1,438 @@
1+//! Drawing the tree into a grid of cells.
2+//!
3+//! One pass, top to bottom: each node is handed a rect by [`crate::layout`] and
4+//! paints itself into it. Two things fall out of the walk and are kept —
5+//! the focus ring, in the order the widgets were painted, and every focusable
6+//! widget's rect, so a mouse click can be turned back into a node.
7+//!
8+//! Overlays are collected rather than drawn in place: a floating panel belongs
9+//! over the whole screen, so it is painted after everything else at the size it
10+//! asked for, in the middle.
11+
12+use crate::layout::{self, wrap, Align};
13+use crate::screen::{attr, Color, Rect, Screen, Style};
14+use crate::tree::{Props, Tag, Tree};
15+
16+const SPINNER: [char; 8] = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧'];
17+
18+/// What one frame of painting learned about the tree, for the input half to
19+/// use on the next key or click.
20+#[derive(Clone, Debug, Default)]
21+pub struct Painted {
22+ /// Focusable nodes in paint order — the order Tab walks.
23+ pub ring: Vec<u32>,
24+ /// Where each of them ended up.
25+ pub hits: Vec<(u32, Rect)>,
26+ /// How far each scroll node's viewport actually was, after clamping to the
27+ /// content it had. Written back so a caller cannot scroll past the end.
28+ pub scrolled: Vec<(u32, u16)>,
29+ /// Where the cursor should sit — the focused entry's caret, if any.
30+ pub cursor: Option<(u16, u16)>,
31+}
32+
33+struct Painter<'a> {
34+ tree: &'a Tree,
35+ screen: &'a mut Screen,
36+ focus: u32,
37+ /// Where the caret sits in the focused entry's text, in characters.
38+ caret: usize,
39+ tick: u64,
40+ out: Painted,
41+ overlays: Vec<u32>,
42+}
43+
44+/// Paint the whole tree. `focus` is the node the ring is currently on and
45+/// `tick` advances the spinners.
46+pub fn frame(tree: &Tree, screen: &mut Screen, focus: u32, caret: usize, tick: u64) -> Painted {
47+ screen.clear();
48+ let mut painter = Painter {
49+ tree,
50+ screen,
51+ focus,
52+ caret,
53+ tick,
54+ out: Painted::default(),
55+ overlays: Vec::new(),
56+ };
57+ let area = painter.screen.rect();
58+ painter.node(tree.root(), area, Style::default(), true);
59+
60+ // Overlays float above the rest, so they are painted after it — and a
61+ // click landing on one must beat a click on whatever it covers, which is
62+ // what putting their hit rects first does.
63+ let overlays = std::mem::take(&mut painter.overlays);
64+ let below = std::mem::take(&mut painter.out.hits);
65+ for id in overlays {
66+ painter.overlay(id, area);
67+ }
68+ painter.out.hits.extend(below);
69+ painter.out
70+}
71+
72+impl Painter<'_> {
73+ fn style_for(&self, props: &Props, inherited: Style, enabled: bool) -> Style {
74+ let mut style = inherited;
75+ if let Some(fg) = Color::parse(props.str("color")) {
76+ style.fg = fg;
77+ }
78+ if let Some(bg) = Color::parse(props.str("bg")) {
79+ style.bg = bg;
80+ }
81+ for (key, bit) in [
82+ ("bold", attr::BOLD),
83+ ("dim", attr::DIM),
84+ ("underline", attr::UNDERLINE),
85+ ("reverse", attr::REVERSE),
86+ ("blink", attr::BLINK),
87+ ("italic", attr::ITALIC),
88+ ] {
89+ if props.bool(key, false) {
90+ style.attrs |= bit;
91+ }
92+ }
93+ if !enabled {
94+ // `:sensitive false` dims the widget *and its whole subtree*, which
95+ // is what it means in every other glimmer backend.
96+ style.attrs |= attr::DIM;
97+ }
98+ style
99+ }
100+
101+ fn node(&mut self, id: u32, area: Rect, inherited: Style, enabled: bool) {
102+ if area.is_empty() || !self.tree.exists(id) {
103+ return;
104+ }
105+ let tag = self.tree.tag(id);
106+ let props = self.tree.props(id);
107+ let enabled = enabled && props.bool("sensitive", true);
108+ let style = self.style_for(&props, inherited, enabled);
109+ if props.has("bg") {
110+ self.screen.fill(area, style);
111+ }
112+ if enabled && tag.focusable() {
113+ self.out.ring.push(id);
114+ self.out.hits.push((id, area));
115+ }
116+
117+ let pad = layout::inset(&tag, &props);
118+ let inner = area.shrink(pad);
119+ match tag {
120+ Tag::Overlay => self.overlays.push(id),
121+ Tag::Frame => {
122+ self.border(area, props.label(), style);
123+ self.children(id, inner, style, enabled);
124+ }
125+ Tag::Scroll => self.scroll(id, inner, style, enabled),
126+ Tag::Box | Tag::Window | Tag::Unknown(_) => self.children(id, inner, style, enabled),
127+ Tag::Label => self.wrapped(inner, props.label(), style),
128+ Tag::Title => self.wrapped(inner, props.label(), style.with(attr::BOLD)),
129+ Tag::DimLabel => self.wrapped(inner, props.label(), style.with(attr::DIM)),
130+ Tag::Button => self.button(id, inner, &props, style),
131+ Tag::CheckButton => self.check(id, inner, &props, style),
132+ Tag::Entry => self.entry(id, inner, &props, style),
133+ Tag::Separator => self.separator(inner, style),
134+ Tag::Progress => self.progress(inner, &props, style),
135+ Tag::Spinner => {
136+ let ch = SPINNER[(self.tick as usize) % SPINNER.len()];
137+ self.screen.set(inner.x, inner.y, ch, style);
138+ }
139+ Tag::Listbox => self.listbox(id, inner, &props, style, enabled),
140+ // A spacer is the absence of anything; the clear at the top of the
141+ // frame has already drawn it.
142+ Tag::Spacer => {}
143+ }
144+ }
145+
146+ fn children(&mut self, id: u32, area: Rect, style: Style, enabled: bool) {
147+ if area.is_empty() {
148+ return;
149+ }
150+ let rects = layout::children_rects(self.tree, id, area);
151+ for (child, rect) in self.tree.children(id).into_iter().zip(rects) {
152+ // Clip to the parent: a child asking for more rows than are left
153+ // paints what fits rather than over its neighbours.
154+ let bottom = area.y.saturating_add(area.h);
155+ let right = area.x.saturating_add(area.w);
156+ if rect.y >= bottom || rect.x >= right {
157+ continue;
158+ }
159+ let clipped = Rect::new(
160+ rect.x,
161+ rect.y,
162+ rect.w.min(right - rect.x),
163+ rect.h.min(bottom - rect.y),
164+ );
165+ self.node(child, clipped, style, enabled);
166+ }
167+ }
168+
169+ fn wrapped(&mut self, area: Rect, text: &str, style: Style) {
170+ for (i, line) in wrap(text, area.w).into_iter().enumerate() {
171+ if i as u16 >= area.h {
172+ break;
173+ }
174+ self.screen
175+ .text(area.x, area.y + i as u16, area.w, &line, style);
176+ }
177+ }
178+
179+ fn focused(&self, id: u32) -> bool {
180+ self.focus == id
181+ }
182+
183+ fn button(&mut self, id: u32, area: Rect, props: &Props, style: Style) {
184+ let mut style = match props.str("kind") {
185+ "primary" => style.with(attr::BOLD),
186+ "destructive" => style.fg(Color::parse("red").unwrap_or_default()),
187+ _ => style,
188+ };
189+ if self.focused(id) {
190+ style = style.with(attr::REVERSE);
191+ }
192+ let label = format!("[ {} ]", props.label());
193+ self.screen.text(area.x, area.y, area.w, &label, style);
194+ }
195+
196+ fn check(&mut self, id: u32, area: Rect, props: &Props, style: Style) {
197+ let style = if self.focused(id) {
198+ style.with(attr::REVERSE)
199+ } else {
200+ style
201+ };
202+ let mark = if props.bool("active", false) {
203+ 'x'
204+ } else {
205+ ' '
206+ };
207+ let label = format!("[{mark}] {}", props.label());
208+ self.screen.text(area.x, area.y, area.w, &label, style);
209+ }
210+
211+ fn entry(&mut self, id: u32, area: Rect, props: &Props, style: Style) {
212+ let focused = self.focused(id);
213+ let text = props.str("text");
214+ let showing_placeholder = text.is_empty();
215+ let shown = layout::entry_text(props);
216+ let mut style = style.with(attr::UNDERLINE);
217+ if showing_placeholder {
218+ style = style.with(attr::DIM);
219+ }
220+ if focused {
221+ style = style.with(attr::REVERSE);
222+ }
223+ // The field is its whole rect, not just the text in it: a reader needs
224+ // to see where it can type before it has typed anything.
225+ self.screen.fill(area, style);
226+ let rows = area.h.max(1);
227+ let lines = if props.cells("rows", 1) > 1 {
228+ wrap(&shown, area.w)
229+ } else {
230+ vec![shown.chars().collect::<String>()]
231+ };
232+ let caret = self.caret.min(text.chars().count());
233+ // A line longer than the field scrolls sideways to keep the caret in
234+ // view — the end of it is where someone is usually typing, but not
235+ // always, so it follows the caret rather than the end.
236+ for (i, line) in lines.iter().take(rows as usize).enumerate() {
237+ let len = line.chars().count();
238+ let last = i + 1 == lines.len().min(rows as usize);
239+ let window = area.w.saturating_sub(1).max(1) as usize;
240+ let from = if last && !showing_placeholder {
241+ caret.saturating_sub(window)
242+ } else {
243+ len.saturating_sub(window)
244+ };
245+ let visible: String = line.chars().skip(from).collect();
246+ self.screen
247+ .text(area.x, area.y + i as u16, area.w, &visible, style);
248+ if focused && last {
249+ let col = if showing_placeholder {
250+ 0
251+ } else {
252+ caret
253+ .saturating_sub(from)
254+ .min(area.w.saturating_sub(1) as usize)
255+ };
256+ self.out.cursor = Some((area.x.saturating_add(col as u16), area.y + i as u16));
257+ }
258+ }
259+ }
260+
261+ fn separator(&mut self, area: Rect, style: Style) {
262+ for x in area.x..area.x.saturating_add(area.w) {
263+ self.screen.set(x, area.y, '─', style);
264+ }
265+ }
266+
267+ fn progress(&mut self, area: Rect, props: &Props, style: Style) {
268+ let value = props.num("value", 0.0).clamp(0.0, 1.0);
269+ let filled = (value * area.w as f64).round() as u16;
270+ for x in 0..area.w {
271+ let ch = if x < filled { '█' } else { '░' };
272+ self.screen.set(area.x + x, area.y, ch, style);
273+ }
274+ let label = props.label();
275+ if !label.is_empty() {
276+ let at = area.x + (area.w.saturating_sub(label.chars().count() as u16)) / 2;
277+ self.screen.text(at, area.y, area.w, label, style);
278+ }
279+ }
280+
281+ fn listbox(&mut self, id: u32, area: Rect, props: &Props, style: Style, enabled: bool) {
282+ let items = self.tree.children(id);
283+ // No `:selected` at all means the cursor is on the first row: a list
284+ // with no cursor cannot be moved with the arrows, and a caller that
285+ // wants none says so with -1.
286+ let selected = props.num("selected", 0.0);
287+ let selected = if selected < 0.0 {
288+ None
289+ } else {
290+ Some(selected as usize)
291+ };
292+ // Keep the cursor on screen: scroll only as far as it takes.
293+ let rows = area.h as usize;
294+ let first = match selected {
295+ Some(sel) if rows > 0 && sel >= rows => sel + 1 - rows,
296+ _ => 0,
297+ };
298+ for (row, item) in items.iter().skip(first).take(rows).enumerate() {
299+ let y = area.y + row as u16;
300+ let chosen = selected == Some(first + row);
301+ let mut row_style = style;
302+ if chosen {
303+ row_style = row_style.with(if self.focused(id) {
304+ attr::REVERSE
305+ } else {
306+ attr::BOLD
307+ });
308+ self.screen.fill(Rect::new(area.x, y, area.w, 1), row_style);
309+ }
310+ let marker = if chosen { "" } else { " " };
311+ self.screen.text(area.x, y, area.w, marker, row_style);
312+ let cell = Rect::new(area.x + 2, y, area.w.saturating_sub(2), 1);
313+ self.node(*item, cell, row_style, enabled);
314+ }
315+ }
316+
317+ fn scroll(&mut self, id: u32, area: Rect, style: Style, enabled: bool) {
318+ let props = self.tree.props(id);
319+ // The content is painted at its full height into a screen of its own,
320+ // then the visible window of it is copied across. Doing it this way
321+ // means every widget inside a scroll paints exactly as it would
322+ // outside one — nothing has to know it is being clipped.
323+ let content_h = self
324+ .tree
325+ .children(id)
326+ .iter()
327+ .map(|c| layout::height_for_width(self.tree, *c, area.w))
328+ .sum::<u16>()
329+ .max(1);
330+ let max_offset = content_h.saturating_sub(area.h);
331+ let offset = props.cells("offset", 0).min(max_offset);
332+ self.out.scrolled.push((id, offset));
333+
334+ let mut buffer = Screen::new(area.w, content_h);
335+ let mut inner = Painter {
336+ tree: self.tree,
337+ screen: &mut buffer,
338+ focus: self.focus,
339+ caret: self.caret,
340+ tick: self.tick,
341+ out: Painted::default(),
342+ overlays: Vec::new(),
343+ };
344+ let full = Rect::new(0, 0, area.w, content_h);
345+ inner.children(id, full, style, enabled);
346+ let learned = inner.out;
347+
348+ for y in 0..area.h {
349+ for x in 0..area.w {
350+ if let Some(cell) = buffer.cell(x, y + offset) {
351+ self.screen.set(area.x + x, area.y + y, cell.ch, cell.style);
352+ }
353+ }
354+ }
355+ // Widgets inside keep their place in the focus ring; their rects move
356+ // by the viewport, and the ones scrolled out of sight take no clicks.
357+ self.out.ring.extend(learned.ring);
358+ for (node, rect) in learned.hits {
359+ if rect.y >= offset && rect.y < offset.saturating_add(area.h) {
360+ self.out.hits.push((
361+ node,
362+ Rect::new(
363+ area.x + rect.x,
364+ area.y + rect.y - offset,
365+ rect.w,
366+ rect.h.min(area.h),
367+ ),
368+ ));
369+ }
370+ }
371+ self.out.scrolled.extend(learned.scrolled);
372+ if let Some((cx, cy)) = learned.cursor {
373+ if cy >= offset && cy < offset.saturating_add(area.h) {
374+ self.out.cursor = Some((area.x + cx, area.y + cy - offset));
375+ }
376+ }
377+ }
378+
379+ fn overlay(&mut self, id: u32, screen: Rect) {
380+ let props = self.tree.props(id);
381+ let w = layout::width(self.tree, id, false).min(screen.w);
382+ let h = layout::height_for_width(self.tree, id, w).min(screen.h);
383+ let (x, y) = (
384+ screen.x + Align::Center.offset_pub(w, screen.w),
385+ screen.y + Align::Center.offset_pub(h, screen.h),
386+ );
387+ let area = Rect::new(x, y, w, h);
388+ let style = self.style_for(&props, Style::default(), true);
389+ // Blank what is under it: a floating panel that shows the screen
390+ // through its gaps is unreadable.
391+ for row in area.y..area.y + area.h {
392+ for col in area.x..area.x + area.w {
393+ self.screen.set(col, row, ' ', style);
394+ }
395+ }
396+ self.border(area, props.label(), style);
397+ let pad = layout::inset(&Tag::Overlay, &props);
398+ self.children(id, area.shrink(pad), style, true);
399+ }
400+
401+ /// A single-line box, with `label` set into the top edge when there is one.
402+ fn border(&mut self, area: Rect, label: &str, style: Style) {
403+ if area.w < 2 || area.h < 2 {
404+ return;
405+ }
406+ let (x1, y1) = (area.x + area.w - 1, area.y + area.h - 1);
407+ for x in area.x..=x1 {
408+ self.screen.set(x, area.y, '─', style);
409+ self.screen.set(x, y1, '─', style);
410+ }
411+ for y in area.y..=y1 {
412+ self.screen.set(area.x, y, '│', style);
413+ self.screen.set(x1, y, '│', style);
414+ }
415+ self.screen.set(area.x, area.y, '┌', style);
416+ self.screen.set(x1, area.y, '┐', style);
417+ self.screen.set(area.x, y1, '└', style);
418+ self.screen.set(x1, y1, '┘', style);
419+ if !label.is_empty() && area.w > 4 {
420+ let text = format!(" {label} ");
421+ self.screen.text(
422+ area.x + 1,
423+ area.y,
424+ area.w - 2,
425+ &text,
426+ style.with(attr::BOLD),
427+ );
428+ }
429+ }
430+}
431+
432+impl Align {
433+ /// [`Align::offset`] is private to the layout module; overlays are the one
434+ /// caller outside it that centres something by hand.
435+ fn offset_pub(self, size: u16, avail: u16) -> u16 {
436+ layout::place(self, size, avail).0
437+ }
438+}
new file mode 100644
@@ -0,0 +1,438 @@
1+//! Drawing the tree into a grid of cells.
2+//!
3+//! One pass, top to bottom: each node is handed a rect by [`crate::layout`] and
4+//! paints itself into it. Two things fall out of the walk and are kept —
5+//! the focus ring, in the order the widgets were painted, and every focusable
6+//! widget's rect, so a mouse click can be turned back into a node.
7+//!
8+//! Overlays are collected rather than drawn in place: a floating panel belongs
9+//! over the whole screen, so it is painted after everything else at the size it
10+//! asked for, in the middle.
11+
12+use crate::layout::{self, wrap, Align};
13+use crate::screen::{attr, Color, Rect, Screen, Style};
14+use crate::tree::{Props, Tag, Tree};
15+
16+const SPINNER: [char; 8] = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧'];
17+
18+/// What one frame of painting learned about the tree, for the input half to
19+/// use on the next key or click.
20+#[derive(Clone, Debug, Default)]
21+pub struct Painted {
22+ /// Focusable nodes in paint order — the order Tab walks.
23+ pub ring: Vec<u32>,
24+ /// Where each of them ended up.
25+ pub hits: Vec<(u32, Rect)>,
26+ /// How far each scroll node's viewport actually was, after clamping to the
27+ /// content it had. Written back so a caller cannot scroll past the end.
28+ pub scrolled: Vec<(u32, u16)>,
29+ /// Where the cursor should sit — the focused entry's caret, if any.
30+ pub cursor: Option<(u16, u16)>,
31+}
32+
33+struct Painter<'a> {
34+ tree: &'a Tree,
35+ screen: &'a mut Screen,
36+ focus: u32,
37+ /// Where the caret sits in the focused entry's text, in characters.
38+ caret: usize,
39+ tick: u64,
40+ out: Painted,
41+ overlays: Vec<u32>,
42+}
43+
44+/// Paint the whole tree. `focus` is the node the ring is currently on and
45+/// `tick` advances the spinners.
46+pub fn frame(tree: &Tree, screen: &mut Screen, focus: u32, caret: usize, tick: u64) -> Painted {
47+ screen.clear();
48+ let mut painter = Painter {
49+ tree,
50+ screen,
51+ focus,
52+ caret,
53+ tick,
54+ out: Painted::default(),
55+ overlays: Vec::new(),
56+ };
57+ let area = painter.screen.rect();
58+ painter.node(tree.root(), area, Style::default(), true);
59+
60+ // Overlays float above the rest, so they are painted after it — and a
61+ // click landing on one must beat a click on whatever it covers, which is
62+ // what putting their hit rects first does.
63+ let overlays = std::mem::take(&mut painter.overlays);
64+ let below = std::mem::take(&mut painter.out.hits);
65+ for id in overlays {
66+ painter.overlay(id, area);
67+ }
68+ painter.out.hits.extend(below);
69+ painter.out
70+}
71+
72+impl Painter<'_> {
73+ fn style_for(&self, props: &Props, inherited: Style, enabled: bool) -> Style {
74+ let mut style = inherited;
75+ if let Some(fg) = Color::parse(props.str("color")) {
76+ style.fg = fg;
77+ }
78+ if let Some(bg) = Color::parse(props.str("bg")) {
79+ style.bg = bg;
80+ }
81+ for (key, bit) in [
82+ ("bold", attr::BOLD),
83+ ("dim", attr::DIM),
84+ ("underline", attr::UNDERLINE),
85+ ("reverse", attr::REVERSE),
86+ ("blink", attr::BLINK),
87+ ("italic", attr::ITALIC),
88+ ] {
89+ if props.bool(key, false) {
90+ style.attrs |= bit;
91+ }
92+ }
93+ if !enabled {
94+ // `:sensitive false` dims the widget *and its whole subtree*, which
95+ // is what it means in every other glimmer backend.
96+ style.attrs |= attr::DIM;
97+ }
98+ style
99+ }
100+
101+ fn node(&mut self, id: u32, area: Rect, inherited: Style, enabled: bool) {
102+ if area.is_empty() || !self.tree.exists(id) {
103+ return;
104+ }
105+ let tag = self.tree.tag(id);
106+ let props = self.tree.props(id);
107+ let enabled = enabled && props.bool("sensitive", true);
108+ let style = self.style_for(&props, inherited, enabled);
109+ if props.has("bg") {
110+ self.screen.fill(area, style);
111+ }
112+ if enabled && tag.focusable() {
113+ self.out.ring.push(id);
114+ self.out.hits.push((id, area));
115+ }
116+
117+ let pad = layout::inset(&tag, &props);
118+ let inner = area.shrink(pad);
119+ match tag {
120+ Tag::Overlay => self.overlays.push(id),
121+ Tag::Frame => {
122+ self.border(area, props.label(), style);
123+ self.children(id, inner, style, enabled);
124+ }
125+ Tag::Scroll => self.scroll(id, inner, style, enabled),
126+ Tag::Box | Tag::Window | Tag::Unknown(_) => self.children(id, inner, style, enabled),
127+ Tag::Label => self.wrapped(inner, props.label(), style),
128+ Tag::Title => self.wrapped(inner, props.label(), style.with(attr::BOLD)),
129+ Tag::DimLabel => self.wrapped(inner, props.label(), style.with(attr::DIM)),
130+ Tag::Button => self.button(id, inner, &props, style),
131+ Tag::CheckButton => self.check(id, inner, &props, style),
132+ Tag::Entry => self.entry(id, inner, &props, style),
133+ Tag::Separator => self.separator(inner, style),
134+ Tag::Progress => self.progress(inner, &props, style),
135+ Tag::Spinner => {
136+ let ch = SPINNER[(self.tick as usize) % SPINNER.len()];
137+ self.screen.set(inner.x, inner.y, ch, style);
138+ }
139+ Tag::Listbox => self.listbox(id, inner, &props, style, enabled),
140+ // A spacer is the absence of anything; the clear at the top of the
141+ // frame has already drawn it.
142+ Tag::Spacer => {}
143+ }
144+ }
145+
146+ fn children(&mut self, id: u32, area: Rect, style: Style, enabled: bool) {
147+ if area.is_empty() {
148+ return;
149+ }
150+ let rects = layout::children_rects(self.tree, id, area);
151+ for (child, rect) in self.tree.children(id).into_iter().zip(rects) {
152+ // Clip to the parent: a child asking for more rows than are left
153+ // paints what fits rather than over its neighbours.
154+ let bottom = area.y.saturating_add(area.h);
155+ let right = area.x.saturating_add(area.w);
156+ if rect.y >= bottom || rect.x >= right {
157+ continue;
158+ }
159+ let clipped = Rect::new(
160+ rect.x,
161+ rect.y,
162+ rect.w.min(right - rect.x),
163+ rect.h.min(bottom - rect.y),
164+ );
165+ self.node(child, clipped, style, enabled);
166+ }
167+ }
168+
169+ fn wrapped(&mut self, area: Rect, text: &str, style: Style) {
170+ for (i, line) in wrap(text, area.w).into_iter().enumerate() {
171+ if i as u16 >= area.h {
172+ break;
173+ }
174+ self.screen
175+ .text(area.x, area.y + i as u16, area.w, &line, style);
176+ }
177+ }
178+
179+ fn focused(&self, id: u32) -> bool {
180+ self.focus == id
181+ }
182+
183+ fn button(&mut self, id: u32, area: Rect, props: &Props, style: Style) {
184+ let mut style = match props.str("kind") {
185+ "primary" => style.with(attr::BOLD),
186+ "destructive" => style.fg(Color::parse("red").unwrap_or_default()),
187+ _ => style,
188+ };
189+ if self.focused(id) {
190+ style = style.with(attr::REVERSE);
191+ }
192+ let label = format!("[ {} ]", props.label());
193+ self.screen.text(area.x, area.y, area.w, &label, style);
194+ }
195+
196+ fn check(&mut self, id: u32, area: Rect, props: &Props, style: Style) {
197+ let style = if self.focused(id) {
198+ style.with(attr::REVERSE)
199+ } else {
200+ style
201+ };
202+ let mark = if props.bool("active", false) {
203+ 'x'
204+ } else {
205+ ' '
206+ };
207+ let label = format!("[{mark}] {}", props.label());
208+ self.screen.text(area.x, area.y, area.w, &label, style);
209+ }
210+
211+ fn entry(&mut self, id: u32, area: Rect, props: &Props, style: Style) {
212+ let focused = self.focused(id);
213+ let text = props.str("text");
214+ let showing_placeholder = text.is_empty();
215+ let shown = layout::entry_text(props);
216+ let mut style = style.with(attr::UNDERLINE);
217+ if showing_placeholder {
218+ style = style.with(attr::DIM);
219+ }
220+ if focused {
221+ style = style.with(attr::REVERSE);
222+ }
223+ // The field is its whole rect, not just the text in it: a reader needs
224+ // to see where it can type before it has typed anything.
225+ self.screen.fill(area, style);
226+ let rows = area.h.max(1);
227+ let lines = if props.cells("rows", 1) > 1 {
228+ wrap(&shown, area.w)
229+ } else {
230+ vec![shown.chars().collect::<String>()]
231+ };
232+ let caret = self.caret.min(text.chars().count());
233+ // A line longer than the field scrolls sideways to keep the caret in
234+ // view — the end of it is where someone is usually typing, but not
235+ // always, so it follows the caret rather than the end.
236+ for (i, line) in lines.iter().take(rows as usize).enumerate() {
237+ let len = line.chars().count();
238+ let last = i + 1 == lines.len().min(rows as usize);
239+ let window = area.w.saturating_sub(1).max(1) as usize;
240+ let from = if last && !showing_placeholder {
241+ caret.saturating_sub(window)
242+ } else {
243+ len.saturating_sub(window)
244+ };
245+ let visible: String = line.chars().skip(from).collect();
246+ self.screen
247+ .text(area.x, area.y + i as u16, area.w, &visible, style);
248+ if focused && last {
249+ let col = if showing_placeholder {
250+ 0
251+ } else {
252+ caret
253+ .saturating_sub(from)
254+ .min(area.w.saturating_sub(1) as usize)
255+ };
256+ self.out.cursor = Some((area.x.saturating_add(col as u16), area.y + i as u16));
257+ }
258+ }
259+ }
260+
261+ fn separator(&mut self, area: Rect, style: Style) {
262+ for x in area.x..area.x.saturating_add(area.w) {
263+ self.screen.set(x, area.y, '─', style);
264+ }
265+ }
266+
267+ fn progress(&mut self, area: Rect, props: &Props, style: Style) {
268+ let value = props.num("value", 0.0).clamp(0.0, 1.0);
269+ let filled = (value * area.w as f64).round() as u16;
270+ for x in 0..area.w {
271+ let ch = if x < filled { '█' } else { '░' };
272+ self.screen.set(area.x + x, area.y, ch, style);
273+ }
274+ let label = props.label();
275+ if !label.is_empty() {
276+ let at = area.x + (area.w.saturating_sub(label.chars().count() as u16)) / 2;
277+ self.screen.text(at, area.y, area.w, label, style);
278+ }
279+ }
280+
281+ fn listbox(&mut self, id: u32, area: Rect, props: &Props, style: Style, enabled: bool) {
282+ let items = self.tree.children(id);
283+ // No `:selected` at all means the cursor is on the first row: a list
284+ // with no cursor cannot be moved with the arrows, and a caller that
285+ // wants none says so with -1.
286+ let selected = props.num("selected", 0.0);
287+ let selected = if selected < 0.0 {
288+ None
289+ } else {
290+ Some(selected as usize)
291+ };
292+ // Keep the cursor on screen: scroll only as far as it takes.
293+ let rows = area.h as usize;
294+ let first = match selected {
295+ Some(sel) if rows > 0 && sel >= rows => sel + 1 - rows,
296+ _ => 0,
297+ };
298+ for (row, item) in items.iter().skip(first).take(rows).enumerate() {
299+ let y = area.y + row as u16;
300+ let chosen = selected == Some(first + row);
301+ let mut row_style = style;
302+ if chosen {
303+ row_style = row_style.with(if self.focused(id) {
304+ attr::REVERSE
305+ } else {
306+ attr::BOLD
307+ });
308+ self.screen.fill(Rect::new(area.x, y, area.w, 1), row_style);
309+ }
310+ let marker = if chosen { "" } else { " " };
311+ self.screen.text(area.x, y, area.w, marker, row_style);
312+ let cell = Rect::new(area.x + 2, y, area.w.saturating_sub(2), 1);
313+ self.node(*item, cell, row_style, enabled);
314+ }
315+ }
316+
317+ fn scroll(&mut self, id: u32, area: Rect, style: Style, enabled: bool) {
318+ let props = self.tree.props(id);
319+ // The content is painted at its full height into a screen of its own,
320+ // then the visible window of it is copied across. Doing it this way
321+ // means every widget inside a scroll paints exactly as it would
322+ // outside one — nothing has to know it is being clipped.
323+ let content_h = self
324+ .tree
325+ .children(id)
326+ .iter()
327+ .map(|c| layout::height_for_width(self.tree, *c, area.w))
328+ .sum::<u16>()
329+ .max(1);
330+ let max_offset = content_h.saturating_sub(area.h);
331+ let offset = props.cells("offset", 0).min(max_offset);
332+ self.out.scrolled.push((id, offset));
333+
334+ let mut buffer = Screen::new(area.w, content_h);
335+ let mut inner = Painter {
336+ tree: self.tree,
337+ screen: &mut buffer,
338+ focus: self.focus,
339+ caret: self.caret,
340+ tick: self.tick,
341+ out: Painted::default(),
342+ overlays: Vec::new(),
343+ };
344+ let full = Rect::new(0, 0, area.w, content_h);
345+ inner.children(id, full, style, enabled);
346+ let learned = inner.out;
347+
348+ for y in 0..area.h {
349+ for x in 0..area.w {
350+ if let Some(cell) = buffer.cell(x, y + offset) {
351+ self.screen.set(area.x + x, area.y + y, cell.ch, cell.style);
352+ }
353+ }
354+ }
355+ // Widgets inside keep their place in the focus ring; their rects move
356+ // by the viewport, and the ones scrolled out of sight take no clicks.
357+ self.out.ring.extend(learned.ring);
358+ for (node, rect) in learned.hits {
359+ if rect.y >= offset && rect.y < offset.saturating_add(area.h) {
360+ self.out.hits.push((
361+ node,
362+ Rect::new(
363+ area.x + rect.x,
364+ area.y + rect.y - offset,
365+ rect.w,
366+ rect.h.min(area.h),
367+ ),
368+ ));
369+ }
370+ }
371+ self.out.scrolled.extend(learned.scrolled);
372+ if let Some((cx, cy)) = learned.cursor {
373+ if cy >= offset && cy < offset.saturating_add(area.h) {
374+ self.out.cursor = Some((area.x + cx, area.y + cy - offset));
375+ }
376+ }
377+ }
378+
379+ fn overlay(&mut self, id: u32, screen: Rect) {
380+ let props = self.tree.props(id);
381+ let w = layout::width(self.tree, id, false).min(screen.w);
382+ let h = layout::height_for_width(self.tree, id, w).min(screen.h);
383+ let (x, y) = (
384+ screen.x + Align::Center.offset_pub(w, screen.w),
385+ screen.y + Align::Center.offset_pub(h, screen.h),
386+ );
387+ let area = Rect::new(x, y, w, h);
388+ let style = self.style_for(&props, Style::default(), true);
389+ // Blank what is under it: a floating panel that shows the screen
390+ // through its gaps is unreadable.
391+ for row in area.y..area.y + area.h {
392+ for col in area.x..area.x + area.w {
393+ self.screen.set(col, row, ' ', style);
394+ }
395+ }
396+ self.border(area, props.label(), style);
397+ let pad = layout::inset(&Tag::Overlay, &props);
398+ self.children(id, area.shrink(pad), style, true);
399+ }
400+
401+ /// A single-line box, with `label` set into the top edge when there is one.
402+ fn border(&mut self, area: Rect, label: &str, style: Style) {
403+ if area.w < 2 || area.h < 2 {
404+ return;
405+ }
406+ let (x1, y1) = (area.x + area.w - 1, area.y + area.h - 1);
407+ for x in area.x..=x1 {
408+ self.screen.set(x, area.y, '─', style);
409+ self.screen.set(x, y1, '─', style);
410+ }
411+ for y in area.y..=y1 {
412+ self.screen.set(area.x, y, '│', style);
413+ self.screen.set(x1, y, '│', style);
414+ }
415+ self.screen.set(area.x, area.y, '┌', style);
416+ self.screen.set(x1, area.y, '┐', style);
417+ self.screen.set(area.x, y1, '└', style);
418+ self.screen.set(x1, y1, '┘', style);
419+ if !label.is_empty() && area.w > 4 {
420+ let text = format!(" {label} ");
421+ self.screen.text(
422+ area.x + 1,
423+ area.y,
424+ area.w - 2,
425+ &text,
426+ style.with(attr::BOLD),
427+ );
428+ }
429+ }
430+}
431+
432+impl Align {
433+ /// [`Align::offset`] is private to the layout module; overlays are the one
434+ /// caller outside it that centres something by hand.
435+ fn offset_pub(self, size: u16, avail: u16) -> u16 {
436+ layout::place(self, size, avail).0
437+ }
438+}
added crates/jolt-tui/src/screen.rs +319 -0
new file mode 100644
@@ -0,0 +1,319 @@
1+//! A grid of styled cells, and the colours that go in it.
2+//!
3+//! Everything this crate paints goes here first, and only [`crate::term`] ever
4+//! turns it into escape sequences. That split is deliberate and is the same one
5+//! glimmer-tui makes on the jolt side: painting into a grid needs no terminal,
6+//! no raw mode and no TTY, so the whole widget layer is testable in a unit test
7+//! and in CI — `tui_headless` opens a screen and nothing else.
8+
9+/// A terminal colour, in the three ways a caller can write one.
10+///
11+/// `Default` is not black: it is "whatever the terminal was using", which is
12+/// what a theme-respecting TUI wants for most of its surface.
13+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
14+pub enum Color {
15+ #[default]
16+ Default,
17+ /// An index into the xterm 256-colour palette. 0–15 are the named ones.
18+ Indexed(u8),
19+ Rgb(u8, u8, u8),
20+}
21+
22+impl Color {
23+ /// Parse a colour prop: a name (`red`, `bright-blue`, `default`), a palette
24+ /// index (`"33"`), a hex triple (`#ff6432` or `#f64`), or `r,g,b`.
25+ ///
26+ /// Answers `None` for anything else, which the caller reads as "leave the
27+ /// colour alone" rather than as an error — a typo'd colour should not take
28+ /// the widget away.
29+ pub fn parse(text: &str) -> Option<Self> {
30+ let text = text.trim();
31+ if text.is_empty() {
32+ return None;
33+ }
34+ if let Some(hex) = text.strip_prefix('#') {
35+ return Self::from_hex(hex);
36+ }
37+ if let Ok(index) = text.parse::<u8>() {
38+ return Some(Self::Indexed(index));
39+ }
40+ if text.contains(',') {
41+ let parts: Vec<&str> = text.split(',').map(str::trim).collect();
42+ if let [r, g, b] = parts[..] {
43+ return Some(Self::Rgb(r.parse().ok()?, g.parse().ok()?, b.parse().ok()?));
44+ }
45+ return None;
46+ }
47+ let (name, bright) = match text.strip_prefix("bright-") {
48+ Some(rest) => (rest, true),
49+ None => (text, false),
50+ };
51+ let base = match name {
52+ "black" => 0,
53+ "red" => 1,
54+ "green" => 2,
55+ "yellow" => 3,
56+ "blue" => 4,
57+ "magenta" => 5,
58+ "cyan" => 6,
59+ "white" => 7,
60+ "default" if !bright => return Some(Self::Default),
61+ _ => return None,
62+ };
63+ Some(Self::Indexed(base + if bright { 8 } else { 0 }))
64+ }
65+
66+ fn from_hex(hex: &str) -> Option<Self> {
67+ let bytes = hex.as_bytes();
68+ let nib = |c: u8| (c as char).to_digit(16).map(|d| d as u8);
69+ match bytes.len() {
70+ 3 => {
71+ let (r, g, b) = (nib(bytes[0])?, nib(bytes[1])?, nib(bytes[2])?);
72+ Some(Self::Rgb(r * 17, g * 17, b * 17))
73+ }
74+ 6 => {
75+ let pair = |i: usize| Some(nib(bytes[i])? * 16 + nib(bytes[i + 1])?);
76+ Some(Self::Rgb(pair(0)?, pair(2)?, pair(4)?))
77+ }
78+ _ => None,
79+ }
80+ }
81+}
82+
83+/// The attribute bits a cell can carry. A `u8` rather than a set of `bool`s
84+/// because a cell is copied a great many times a frame.
85+pub mod attr {
86+ pub const BOLD: u8 = 1 << 0;
87+ pub const DIM: u8 = 1 << 1;
88+ pub const UNDERLINE: u8 = 1 << 2;
89+ pub const REVERSE: u8 = 1 << 3;
90+ pub const BLINK: u8 = 1 << 4;
91+ pub const ITALIC: u8 = 1 << 5;
92+}
93+
94+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
95+pub struct Style {
96+ pub fg: Color,
97+ pub bg: Color,
98+ pub attrs: u8,
99+}
100+
101+impl Style {
102+ pub fn with(mut self, bits: u8) -> Self {
103+ self.attrs |= bits;
104+ self
105+ }
106+
107+ pub fn fg(mut self, color: Color) -> Self {
108+ self.fg = color;
109+ self
110+ }
111+
112+ pub fn has(&self, bits: u8) -> bool {
113+ self.attrs & bits != 0
114+ }
115+}
116+
117+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
118+pub struct Cell {
119+ pub ch: char,
120+ pub style: Style,
121+}
122+
123+impl Default for Cell {
124+ fn default() -> Self {
125+ Self {
126+ ch: ' ',
127+ style: Style::default(),
128+ }
129+ }
130+}
131+
132+/// A rectangle in cells. Columns and rows, origin top left.
133+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
134+pub struct Rect {
135+ pub x: u16,
136+ pub y: u16,
137+ pub w: u16,
138+ pub h: u16,
139+}
140+
141+impl Rect {
142+ pub fn new(x: u16, y: u16, w: u16, h: u16) -> Self {
143+ Self { x, y, w, h }
144+ }
145+
146+ pub fn is_empty(&self) -> bool {
147+ self.w == 0 || self.h == 0
148+ }
149+
150+ pub fn contains(&self, x: u16, y: u16) -> bool {
151+ x >= self.x && x < self.x + self.w && y >= self.y && y < self.y + self.h
152+ }
153+
154+ /// The rect left after taking `n` cells off every side. Saturating, so
155+ /// padding larger than the rect answers an empty one rather than wrapping.
156+ pub fn shrink(&self, n: u16) -> Self {
157+ let take = n.saturating_mul(2);
158+ Self {
159+ x: self.x.saturating_add(n),
160+ y: self.y.saturating_add(n),
161+ w: self.w.saturating_sub(take),
162+ h: self.h.saturating_sub(take),
163+ }
164+ }
165+}
166+
167+#[derive(Clone, Debug)]
168+pub struct Screen {
169+ w: u16,
170+ h: u16,
171+ cells: Vec<Cell>,
172+}
173+
174+impl Screen {
175+ pub fn new(w: u16, h: u16) -> Self {
176+ Self {
177+ w,
178+ h,
179+ cells: vec![Cell::default(); w as usize * h as usize],
180+ }
181+ }
182+
183+ pub fn width(&self) -> u16 {
184+ self.w
185+ }
186+
187+ pub fn height(&self) -> u16 {
188+ self.h
189+ }
190+
191+ pub fn rect(&self) -> Rect {
192+ Rect::new(0, 0, self.w, self.h)
193+ }
194+
195+ pub fn resize(&mut self, w: u16, h: u16) {
196+ if (w, h) != (self.w, self.h) {
197+ *self = Self::new(w, h);
198+ }
199+ }
200+
201+ pub fn clear(&mut self) {
202+ self.cells.fill(Cell::default());
203+ }
204+
205+ pub fn cell(&self, x: u16, y: u16) -> Option<&Cell> {
206+ if x < self.w && y < self.h {
207+ self.cells.get(y as usize * self.w as usize + x as usize)
208+ } else {
209+ None
210+ }
211+ }
212+
213+ pub fn set(&mut self, x: u16, y: u16, ch: char, style: Style) {
214+ if x < self.w && y < self.h {
215+ let i = y as usize * self.w as usize + x as usize;
216+ self.cells[i] = Cell { ch, style };
217+ }
218+ }
219+
220+ /// Write `text` at `x, y`, clipped to `width` columns. Answers how many
221+ /// columns were used.
222+ pub fn text(&mut self, x: u16, y: u16, width: u16, text: &str, style: Style) -> u16 {
223+ let mut col = 0u16;
224+ for ch in text.chars() {
225+ if col >= width {
226+ break;
227+ }
228+ // A control character in a label would move the cursor; show it as
229+ // a dot instead of letting it rearrange the screen.
230+ let ch = if (ch as u32) < 0x20 { '·' } else { ch };
231+ self.set(x.saturating_add(col), y, ch, style);
232+ col += 1;
233+ }
234+ col
235+ }
236+
237+ /// Paint every cell of `rect` with `style`, keeping the characters — that
238+ /// is what a background is: a colour behind whatever is already there.
239+ pub fn fill(&mut self, rect: Rect, style: Style) {
240+ for y in rect.y..rect.y.saturating_add(rect.h) {
241+ for x in rect.x..rect.x.saturating_add(rect.w) {
242+ if x < self.w && y < self.h {
243+ let i = y as usize * self.w as usize + x as usize;
244+ let ch = self.cells[i].ch;
245+ self.cells[i] = Cell { ch, style };
246+ }
247+ }
248+ }
249+ }
250+
251+ /// One row as text, trailing blanks trimmed. The whole reason the grid is
252+ /// addressable: a test asserts on lines, not on escape sequences.
253+ pub fn line(&self, y: u16) -> String {
254+ if y >= self.h {
255+ return String::new();
256+ }
257+ let start = y as usize * self.w as usize;
258+ let row: String = self.cells[start..start + self.w as usize]
259+ .iter()
260+ .map(|c| c.ch)
261+ .collect();
262+ row.trim_end().to_owned()
263+ }
264+
265+ #[cfg(test)]
266+ pub fn lines(&self) -> Vec<String> {
267+ (0..self.h).map(|y| self.line(y)).collect()
268+ }
269+
270+ pub(crate) fn cells(&self) -> &[Cell] {
271+ &self.cells
272+ }
273+}
274+
275+#[cfg(test)]
276+mod tests {
277+ use super::*;
278+
279+ #[test]
280+ fn colours_parse_in_every_shape_a_caller_writes_them() {
281+ assert_eq!(Color::parse("red"), Some(Color::Indexed(1)));
282+ assert_eq!(Color::parse("bright-blue"), Some(Color::Indexed(12)));
283+ assert_eq!(Color::parse("default"), Some(Color::Default));
284+ assert_eq!(Color::parse("33"), Some(Color::Indexed(33)));
285+ assert_eq!(Color::parse("#ff6432"), Some(Color::Rgb(255, 100, 50)));
286+ assert_eq!(Color::parse("#f64"), Some(Color::Rgb(255, 102, 68)));
287+ assert_eq!(Color::parse("255,100,50"), Some(Color::Rgb(255, 100, 50)));
288+ }
289+
290+ #[test]
291+ fn an_unreadable_colour_is_no_colour_rather_than_an_error() {
292+ assert_eq!(Color::parse("puce"), None);
293+ assert_eq!(Color::parse("#gg0000"), None);
294+ assert_eq!(Color::parse(""), None);
295+ }
296+
297+ #[test]
298+ fn text_clips_to_the_width_it_was_given() {
299+ let mut screen = Screen::new(10, 2);
300+ screen.text(0, 0, 4, "abcdefg", Style::default());
301+ assert_eq!(screen.line(0), "abcd");
302+ }
303+
304+ #[test]
305+ fn a_control_character_cannot_move_the_cursor() {
306+ let mut screen = Screen::new(6, 1);
307+ screen.text(0, 0, 6, "a\rb", Style::default());
308+ assert_eq!(screen.line(0), "a·b");
309+ }
310+
311+ #[test]
312+ fn filling_a_rect_keeps_the_characters_under_it() {
313+ let mut screen = Screen::new(4, 1);
314+ screen.text(0, 0, 4, "hi", Style::default());
315+ screen.fill(screen.rect(), Style::default().with(attr::REVERSE));
316+ assert_eq!(screen.line(0), "hi");
317+ assert!(screen.cell(0, 0).unwrap().style.has(attr::REVERSE));
318+ }
319+}
new file mode 100644
@@ -0,0 +1,319 @@
1+//! A grid of styled cells, and the colours that go in it.
2+//!
3+//! Everything this crate paints goes here first, and only [`crate::term`] ever
4+//! turns it into escape sequences. That split is deliberate and is the same one
5+//! glimmer-tui makes on the jolt side: painting into a grid needs no terminal,
6+//! no raw mode and no TTY, so the whole widget layer is testable in a unit test
7+//! and in CI — `tui_headless` opens a screen and nothing else.
8+
9+/// A terminal colour, in the three ways a caller can write one.
10+///
11+/// `Default` is not black: it is "whatever the terminal was using", which is
12+/// what a theme-respecting TUI wants for most of its surface.
13+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
14+pub enum Color {
15+ #[default]
16+ Default,
17+ /// An index into the xterm 256-colour palette. 0–15 are the named ones.
18+ Indexed(u8),
19+ Rgb(u8, u8, u8),
20+}
21+
22+impl Color {
23+ /// Parse a colour prop: a name (`red`, `bright-blue`, `default`), a palette
24+ /// index (`"33"`), a hex triple (`#ff6432` or `#f64`), or `r,g,b`.
25+ ///
26+ /// Answers `None` for anything else, which the caller reads as "leave the
27+ /// colour alone" rather than as an error — a typo'd colour should not take
28+ /// the widget away.
29+ pub fn parse(text: &str) -> Option<Self> {
30+ let text = text.trim();
31+ if text.is_empty() {
32+ return None;
33+ }
34+ if let Some(hex) = text.strip_prefix('#') {
35+ return Self::from_hex(hex);
36+ }
37+ if let Ok(index) = text.parse::<u8>() {
38+ return Some(Self::Indexed(index));
39+ }
40+ if text.contains(',') {
41+ let parts: Vec<&str> = text.split(',').map(str::trim).collect();
42+ if let [r, g, b] = parts[..] {
43+ return Some(Self::Rgb(r.parse().ok()?, g.parse().ok()?, b.parse().ok()?));
44+ }
45+ return None;
46+ }
47+ let (name, bright) = match text.strip_prefix("bright-") {
48+ Some(rest) => (rest, true),
49+ None => (text, false),
50+ };
51+ let base = match name {
52+ "black" => 0,
53+ "red" => 1,
54+ "green" => 2,
55+ "yellow" => 3,
56+ "blue" => 4,
57+ "magenta" => 5,
58+ "cyan" => 6,
59+ "white" => 7,
60+ "default" if !bright => return Some(Self::Default),
61+ _ => return None,
62+ };
63+ Some(Self::Indexed(base + if bright { 8 } else { 0 }))
64+ }
65+
66+ fn from_hex(hex: &str) -> Option<Self> {
67+ let bytes = hex.as_bytes();
68+ let nib = |c: u8| (c as char).to_digit(16).map(|d| d as u8);
69+ match bytes.len() {
70+ 3 => {
71+ let (r, g, b) = (nib(bytes[0])?, nib(bytes[1])?, nib(bytes[2])?);
72+ Some(Self::Rgb(r * 17, g * 17, b * 17))
73+ }
74+ 6 => {
75+ let pair = |i: usize| Some(nib(bytes[i])? * 16 + nib(bytes[i + 1])?);
76+ Some(Self::Rgb(pair(0)?, pair(2)?, pair(4)?))
77+ }
78+ _ => None,
79+ }
80+ }
81+}
82+
83+/// The attribute bits a cell can carry. A `u8` rather than a set of `bool`s
84+/// because a cell is copied a great many times a frame.
85+pub mod attr {
86+ pub const BOLD: u8 = 1 << 0;
87+ pub const DIM: u8 = 1 << 1;
88+ pub const UNDERLINE: u8 = 1 << 2;
89+ pub const REVERSE: u8 = 1 << 3;
90+ pub const BLINK: u8 = 1 << 4;
91+ pub const ITALIC: u8 = 1 << 5;
92+}
93+
94+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
95+pub struct Style {
96+ pub fg: Color,
97+ pub bg: Color,
98+ pub attrs: u8,
99+}
100+
101+impl Style {
102+ pub fn with(mut self, bits: u8) -> Self {
103+ self.attrs |= bits;
104+ self
105+ }
106+
107+ pub fn fg(mut self, color: Color) -> Self {
108+ self.fg = color;
109+ self
110+ }
111+
112+ pub fn has(&self, bits: u8) -> bool {
113+ self.attrs & bits != 0
114+ }
115+}
116+
117+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
118+pub struct Cell {
119+ pub ch: char,
120+ pub style: Style,
121+}
122+
123+impl Default for Cell {
124+ fn default() -> Self {
125+ Self {
126+ ch: ' ',
127+ style: Style::default(),
128+ }
129+ }
130+}
131+
132+/// A rectangle in cells. Columns and rows, origin top left.
133+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
134+pub struct Rect {
135+ pub x: u16,
136+ pub y: u16,
137+ pub w: u16,
138+ pub h: u16,
139+}
140+
141+impl Rect {
142+ pub fn new(x: u16, y: u16, w: u16, h: u16) -> Self {
143+ Self { x, y, w, h }
144+ }
145+
146+ pub fn is_empty(&self) -> bool {
147+ self.w == 0 || self.h == 0
148+ }
149+
150+ pub fn contains(&self, x: u16, y: u16) -> bool {
151+ x >= self.x && x < self.x + self.w && y >= self.y && y < self.y + self.h
152+ }
153+
154+ /// The rect left after taking `n` cells off every side. Saturating, so
155+ /// padding larger than the rect answers an empty one rather than wrapping.
156+ pub fn shrink(&self, n: u16) -> Self {
157+ let take = n.saturating_mul(2);
158+ Self {
159+ x: self.x.saturating_add(n),
160+ y: self.y.saturating_add(n),
161+ w: self.w.saturating_sub(take),
162+ h: self.h.saturating_sub(take),
163+ }
164+ }
165+}
166+
167+#[derive(Clone, Debug)]
168+pub struct Screen {
169+ w: u16,
170+ h: u16,
171+ cells: Vec<Cell>,
172+}
173+
174+impl Screen {
175+ pub fn new(w: u16, h: u16) -> Self {
176+ Self {
177+ w,
178+ h,
179+ cells: vec![Cell::default(); w as usize * h as usize],
180+ }
181+ }
182+
183+ pub fn width(&self) -> u16 {
184+ self.w
185+ }
186+
187+ pub fn height(&self) -> u16 {
188+ self.h
189+ }
190+
191+ pub fn rect(&self) -> Rect {
192+ Rect::new(0, 0, self.w, self.h)
193+ }
194+
195+ pub fn resize(&mut self, w: u16, h: u16) {
196+ if (w, h) != (self.w, self.h) {
197+ *self = Self::new(w, h);
198+ }
199+ }
200+
201+ pub fn clear(&mut self) {
202+ self.cells.fill(Cell::default());
203+ }
204+
205+ pub fn cell(&self, x: u16, y: u16) -> Option<&Cell> {
206+ if x < self.w && y < self.h {
207+ self.cells.get(y as usize * self.w as usize + x as usize)
208+ } else {
209+ None
210+ }
211+ }
212+
213+ pub fn set(&mut self, x: u16, y: u16, ch: char, style: Style) {
214+ if x < self.w && y < self.h {
215+ let i = y as usize * self.w as usize + x as usize;
216+ self.cells[i] = Cell { ch, style };
217+ }
218+ }
219+
220+ /// Write `text` at `x, y`, clipped to `width` columns. Answers how many
221+ /// columns were used.
222+ pub fn text(&mut self, x: u16, y: u16, width: u16, text: &str, style: Style) -> u16 {
223+ let mut col = 0u16;
224+ for ch in text.chars() {
225+ if col >= width {
226+ break;
227+ }
228+ // A control character in a label would move the cursor; show it as
229+ // a dot instead of letting it rearrange the screen.
230+ let ch = if (ch as u32) < 0x20 { '·' } else { ch };
231+ self.set(x.saturating_add(col), y, ch, style);
232+ col += 1;
233+ }
234+ col
235+ }
236+
237+ /// Paint every cell of `rect` with `style`, keeping the characters — that
238+ /// is what a background is: a colour behind whatever is already there.
239+ pub fn fill(&mut self, rect: Rect, style: Style) {
240+ for y in rect.y..rect.y.saturating_add(rect.h) {
241+ for x in rect.x..rect.x.saturating_add(rect.w) {
242+ if x < self.w && y < self.h {
243+ let i = y as usize * self.w as usize + x as usize;
244+ let ch = self.cells[i].ch;
245+ self.cells[i] = Cell { ch, style };
246+ }
247+ }
248+ }
249+ }
250+
251+ /// One row as text, trailing blanks trimmed. The whole reason the grid is
252+ /// addressable: a test asserts on lines, not on escape sequences.
253+ pub fn line(&self, y: u16) -> String {
254+ if y >= self.h {
255+ return String::new();
256+ }
257+ let start = y as usize * self.w as usize;
258+ let row: String = self.cells[start..start + self.w as usize]
259+ .iter()
260+ .map(|c| c.ch)
261+ .collect();
262+ row.trim_end().to_owned()
263+ }
264+
265+ #[cfg(test)]
266+ pub fn lines(&self) -> Vec<String> {
267+ (0..self.h).map(|y| self.line(y)).collect()
268+ }
269+
270+ pub(crate) fn cells(&self) -> &[Cell] {
271+ &self.cells
272+ }
273+}
274+
275+#[cfg(test)]
276+mod tests {
277+ use super::*;
278+
279+ #[test]
280+ fn colours_parse_in_every_shape_a_caller_writes_them() {
281+ assert_eq!(Color::parse("red"), Some(Color::Indexed(1)));
282+ assert_eq!(Color::parse("bright-blue"), Some(Color::Indexed(12)));
283+ assert_eq!(Color::parse("default"), Some(Color::Default));
284+ assert_eq!(Color::parse("33"), Some(Color::Indexed(33)));
285+ assert_eq!(Color::parse("#ff6432"), Some(Color::Rgb(255, 100, 50)));
286+ assert_eq!(Color::parse("#f64"), Some(Color::Rgb(255, 102, 68)));
287+ assert_eq!(Color::parse("255,100,50"), Some(Color::Rgb(255, 100, 50)));
288+ }
289+
290+ #[test]
291+ fn an_unreadable_colour_is_no_colour_rather_than_an_error() {
292+ assert_eq!(Color::parse("puce"), None);
293+ assert_eq!(Color::parse("#gg0000"), None);
294+ assert_eq!(Color::parse(""), None);
295+ }
296+
297+ #[test]
298+ fn text_clips_to_the_width_it_was_given() {
299+ let mut screen = Screen::new(10, 2);
300+ screen.text(0, 0, 4, "abcdefg", Style::default());
301+ assert_eq!(screen.line(0), "abcd");
302+ }
303+
304+ #[test]
305+ fn a_control_character_cannot_move_the_cursor() {
306+ let mut screen = Screen::new(6, 1);
307+ screen.text(0, 0, 6, "a\rb", Style::default());
308+ assert_eq!(screen.line(0), "a·b");
309+ }
310+
311+ #[test]
312+ fn filling_a_rect_keeps_the_characters_under_it() {
313+ let mut screen = Screen::new(4, 1);
314+ screen.text(0, 0, 4, "hi", Style::default());
315+ screen.fill(screen.rect(), Style::default().with(attr::REVERSE));
316+ assert_eq!(screen.line(0), "hi");
317+ assert!(screen.cell(0, 0).unwrap().style.has(attr::REVERSE));
318+ }
319+}
added crates/jolt-tui/src/term.rs +190 -0
new file mode 100644
@@ -0,0 +1,190 @@
1+//! The terminal itself: raw mode, the alternate screen, and the diff that
2+//! turns a grid of cells into the fewest escape sequences that will do.
3+//!
4+//! Everything above this file paints into a [`Screen`] and never writes a byte,
5+//! which is what makes the widget layer testable. This is the one place that
6+//! knows a TTY exists, and it is behind the `terminal` feature so a build for a
7+//! machine with no terminal crate at all still has the tree and the layout.
8+
9+use std::io::{self, Stdout, Write};
10+
11+use crossterm::event::{
12+ DisableMouseCapture, EnableMouseCapture, Event, KeyEventKind, MouseButton, MouseEventKind,
13+};
14+use crossterm::terminal::{
15+ disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
16+};
17+use crossterm::{cursor, execute, queue, style};
18+
19+use crate::keys;
20+use crate::screen::{attr, Color, Screen, Style};
21+
22+/// One thing that happened, in the vocabulary [`crate::ui::Ui`] takes.
23+#[derive(Clone, Debug, PartialEq, Eq)]
24+pub enum Input {
25+ Key(String),
26+ Click(u16, u16),
27+ Wheel(u16, u16, i32),
28+ Resize(u16, u16),
29+}
30+
31+pub struct Term {
32+ out: Stdout,
33+ /// What is on the screen now, so a frame only sends what changed.
34+ last: Screen,
35+ mouse: bool,
36+}
37+
38+impl Term {
39+ /// Take the terminal: raw mode, the alternate screen, mouse reporting, and
40+ /// no cursor until a focused entry asks for one.
41+ pub fn open(mouse: bool) -> io::Result<Self> {
42+ let (w, h) = crossterm::terminal::size().unwrap_or((80, 24));
43+ let mut out = io::stdout();
44+ enable_raw_mode()?;
45+ execute!(out, EnterAlternateScreen, cursor::Hide)?;
46+ if mouse {
47+ execute!(out, EnableMouseCapture)?;
48+ }
49+ Ok(Self {
50+ out,
51+ last: Screen::new(w, h),
52+ mouse,
53+ })
54+ }
55+
56+ pub fn size(&self) -> (u16, u16) {
57+ crossterm::terminal::size().unwrap_or((80, 24))
58+ }
59+
60+ /// Give the terminal back. Called from `tui_close`, and again from a panic
61+ /// hook — leaving a shell in raw mode with no cursor is the one failure a
62+ /// TUI must not have.
63+ pub fn close(&mut self) {
64+ if self.mouse {
65+ let _ = execute!(self.out, DisableMouseCapture);
66+ }
67+ let _ = execute!(self.out, cursor::Show, LeaveAlternateScreen);
68+ let _ = disable_raw_mode();
69+ }
70+
71+ /// Wait up to `timeout_ms` for input and answer everything that had
72+ /// arrived. A zero timeout is a poll.
73+ pub fn poll(&mut self, timeout_ms: u64) -> Vec<Input> {
74+ let mut out = Vec::new();
75+ let deadline = std::time::Duration::from_millis(timeout_ms);
76+ if !crossterm::event::poll(deadline).unwrap_or(false) {
77+ return out;
78+ }
79+ // Drain what is queued rather than one event a call: a held arrow key
80+ // or a paste arrives as a burst, and handling one per frame would make
81+ // the UI lag behind the keyboard.
82+ loop {
83+ match crossterm::event::read() {
84+ Ok(Event::Key(key)) => {
85+ // Windows reports both press and release; a release would
86+ // type every character twice.
87+ if key.kind != KeyEventKind::Release {
88+ if let Some(name) = keys::name(key) {
89+ out.push(Input::Key(name));
90+ }
91+ }
92+ }
93+ Ok(Event::Resize(w, h)) => out.push(Input::Resize(w, h)),
94+ Ok(Event::Mouse(mouse)) => match mouse.kind {
95+ MouseEventKind::Down(MouseButton::Left) => {
96+ out.push(Input::Click(mouse.column, mouse.row))
97+ }
98+ MouseEventKind::ScrollDown => {
99+ out.push(Input::Wheel(mouse.column, mouse.row, 1))
100+ }
101+ MouseEventKind::ScrollUp => out.push(Input::Wheel(mouse.column, mouse.row, -1)),
102+ _ => {}
103+ },
104+ Ok(_) => {}
105+ Err(_) => break,
106+ }
107+ if !crossterm::event::poll(std::time::Duration::ZERO).unwrap_or(false) {
108+ break;
109+ }
110+ }
111+ out
112+ }
113+
114+ /// Send whatever differs between `screen` and what is already up there.
115+ pub fn flush(&mut self, screen: &Screen, cursor: Option<(u16, u16)>) -> io::Result<()> {
116+ if (self.last.width(), self.last.height()) != (screen.width(), screen.height()) {
117+ self.last = Screen::new(screen.width(), screen.height());
118+ queue!(
119+ self.out,
120+ crossterm::terminal::Clear(crossterm::terminal::ClearType::All)
121+ )?;
122+ }
123+ let mut style = None;
124+ let mut at: Option<(u16, u16)> = None;
125+ let width = screen.width();
126+ for (i, cell) in screen.cells().iter().enumerate() {
127+ let (x, y) = ((i as u16) % width, (i as u16) / width);
128+ if self.last.cell(x, y) == Some(cell) {
129+ continue;
130+ }
131+ // Only move when the run breaks: a full-width change is one seek
132+ // and a line of text, not a seek a cell.
133+ if at != Some((x, y)) {
134+ queue!(self.out, cursor::MoveTo(x, y))?;
135+ }
136+ if style != Some(cell.style) {
137+ write_style(&mut self.out, cell.style)?;
138+ style = Some(cell.style);
139+ }
140+ queue!(self.out, style::Print(cell.ch))?;
141+ at = Some((x + 1, y));
142+ }
143+ queue!(self.out, style::ResetColor)?;
144+ match cursor {
145+ Some((x, y)) => queue!(self.out, cursor::MoveTo(x, y), cursor::Show)?,
146+ None => queue!(self.out, cursor::Hide)?,
147+ }
148+ self.out.flush()?;
149+ self.last = screen.clone();
150+ Ok(())
151+ }
152+}
153+
154+impl Drop for Term {
155+ fn drop(&mut self) {
156+ self.close();
157+ }
158+}
159+
160+fn convert(color: Color) -> style::Color {
161+ match color {
162+ Color::Default => style::Color::Reset,
163+ // The terminal downgrades the 256-colour palette itself, which is the
164+ // only place that knows how many colours it really has.
165+ Color::Indexed(i) => style::Color::AnsiValue(i),
166+ Color::Rgb(r, g, b) => style::Color::Rgb { r, g, b },
167+ }
168+}
169+
170+fn write_style(out: &mut Stdout, style: Style) -> io::Result<()> {
171+ use style::Attribute;
172+ queue!(out, style::SetAttribute(Attribute::Reset))?;
173+ for (bit, on) in [
174+ (attr::BOLD, Attribute::Bold),
175+ (attr::DIM, Attribute::Dim),
176+ (attr::UNDERLINE, Attribute::Underlined),
177+ (attr::REVERSE, Attribute::Reverse),
178+ (attr::BLINK, Attribute::SlowBlink),
179+ (attr::ITALIC, Attribute::Italic),
180+ ] {
181+ if style.has(bit) {
182+ queue!(out, style::SetAttribute(on))?;
183+ }
184+ }
185+ queue!(
186+ out,
187+ style::SetForegroundColor(convert(style.fg)),
188+ style::SetBackgroundColor(convert(style.bg))
189+ )
190+}
new file mode 100644
@@ -0,0 +1,190 @@
1+//! The terminal itself: raw mode, the alternate screen, and the diff that
2+//! turns a grid of cells into the fewest escape sequences that will do.
3+//!
4+//! Everything above this file paints into a [`Screen`] and never writes a byte,
5+//! which is what makes the widget layer testable. This is the one place that
6+//! knows a TTY exists, and it is behind the `terminal` feature so a build for a
7+//! machine with no terminal crate at all still has the tree and the layout.
8+
9+use std::io::{self, Stdout, Write};
10+
11+use crossterm::event::{
12+ DisableMouseCapture, EnableMouseCapture, Event, KeyEventKind, MouseButton, MouseEventKind,
13+};
14+use crossterm::terminal::{
15+ disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
16+};
17+use crossterm::{cursor, execute, queue, style};
18+
19+use crate::keys;
20+use crate::screen::{attr, Color, Screen, Style};
21+
22+/// One thing that happened, in the vocabulary [`crate::ui::Ui`] takes.
23+#[derive(Clone, Debug, PartialEq, Eq)]
24+pub enum Input {
25+ Key(String),
26+ Click(u16, u16),
27+ Wheel(u16, u16, i32),
28+ Resize(u16, u16),
29+}
30+
31+pub struct Term {
32+ out: Stdout,
33+ /// What is on the screen now, so a frame only sends what changed.
34+ last: Screen,
35+ mouse: bool,
36+}
37+
38+impl Term {
39+ /// Take the terminal: raw mode, the alternate screen, mouse reporting, and
40+ /// no cursor until a focused entry asks for one.
41+ pub fn open(mouse: bool) -> io::Result<Self> {
42+ let (w, h) = crossterm::terminal::size().unwrap_or((80, 24));
43+ let mut out = io::stdout();
44+ enable_raw_mode()?;
45+ execute!(out, EnterAlternateScreen, cursor::Hide)?;
46+ if mouse {
47+ execute!(out, EnableMouseCapture)?;
48+ }
49+ Ok(Self {
50+ out,
51+ last: Screen::new(w, h),
52+ mouse,
53+ })
54+ }
55+
56+ pub fn size(&self) -> (u16, u16) {
57+ crossterm::terminal::size().unwrap_or((80, 24))
58+ }
59+
60+ /// Give the terminal back. Called from `tui_close`, and again from a panic
61+ /// hook — leaving a shell in raw mode with no cursor is the one failure a
62+ /// TUI must not have.
63+ pub fn close(&mut self) {
64+ if self.mouse {
65+ let _ = execute!(self.out, DisableMouseCapture);
66+ }
67+ let _ = execute!(self.out, cursor::Show, LeaveAlternateScreen);
68+ let _ = disable_raw_mode();
69+ }
70+
71+ /// Wait up to `timeout_ms` for input and answer everything that had
72+ /// arrived. A zero timeout is a poll.
73+ pub fn poll(&mut self, timeout_ms: u64) -> Vec<Input> {
74+ let mut out = Vec::new();
75+ let deadline = std::time::Duration::from_millis(timeout_ms);
76+ if !crossterm::event::poll(deadline).unwrap_or(false) {
77+ return out;
78+ }
79+ // Drain what is queued rather than one event a call: a held arrow key
80+ // or a paste arrives as a burst, and handling one per frame would make
81+ // the UI lag behind the keyboard.
82+ loop {
83+ match crossterm::event::read() {
84+ Ok(Event::Key(key)) => {
85+ // Windows reports both press and release; a release would
86+ // type every character twice.
87+ if key.kind != KeyEventKind::Release {
88+ if let Some(name) = keys::name(key) {
89+ out.push(Input::Key(name));
90+ }
91+ }
92+ }
93+ Ok(Event::Resize(w, h)) => out.push(Input::Resize(w, h)),
94+ Ok(Event::Mouse(mouse)) => match mouse.kind {
95+ MouseEventKind::Down(MouseButton::Left) => {
96+ out.push(Input::Click(mouse.column, mouse.row))
97+ }
98+ MouseEventKind::ScrollDown => {
99+ out.push(Input::Wheel(mouse.column, mouse.row, 1))
100+ }
101+ MouseEventKind::ScrollUp => out.push(Input::Wheel(mouse.column, mouse.row, -1)),
102+ _ => {}
103+ },
104+ Ok(_) => {}
105+ Err(_) => break,
106+ }
107+ if !crossterm::event::poll(std::time::Duration::ZERO).unwrap_or(false) {
108+ break;
109+ }
110+ }
111+ out
112+ }
113+
114+ /// Send whatever differs between `screen` and what is already up there.
115+ pub fn flush(&mut self, screen: &Screen, cursor: Option<(u16, u16)>) -> io::Result<()> {
116+ if (self.last.width(), self.last.height()) != (screen.width(), screen.height()) {
117+ self.last = Screen::new(screen.width(), screen.height());
118+ queue!(
119+ self.out,
120+ crossterm::terminal::Clear(crossterm::terminal::ClearType::All)
121+ )?;
122+ }
123+ let mut style = None;
124+ let mut at: Option<(u16, u16)> = None;
125+ let width = screen.width();
126+ for (i, cell) in screen.cells().iter().enumerate() {
127+ let (x, y) = ((i as u16) % width, (i as u16) / width);
128+ if self.last.cell(x, y) == Some(cell) {
129+ continue;
130+ }
131+ // Only move when the run breaks: a full-width change is one seek
132+ // and a line of text, not a seek a cell.
133+ if at != Some((x, y)) {
134+ queue!(self.out, cursor::MoveTo(x, y))?;
135+ }
136+ if style != Some(cell.style) {
137+ write_style(&mut self.out, cell.style)?;
138+ style = Some(cell.style);
139+ }
140+ queue!(self.out, style::Print(cell.ch))?;
141+ at = Some((x + 1, y));
142+ }
143+ queue!(self.out, style::ResetColor)?;
144+ match cursor {
145+ Some((x, y)) => queue!(self.out, cursor::MoveTo(x, y), cursor::Show)?,
146+ None => queue!(self.out, cursor::Hide)?,
147+ }
148+ self.out.flush()?;
149+ self.last = screen.clone();
150+ Ok(())
151+ }
152+}
153+
154+impl Drop for Term {
155+ fn drop(&mut self) {
156+ self.close();
157+ }
158+}
159+
160+fn convert(color: Color) -> style::Color {
161+ match color {
162+ Color::Default => style::Color::Reset,
163+ // The terminal downgrades the 256-colour palette itself, which is the
164+ // only place that knows how many colours it really has.
165+ Color::Indexed(i) => style::Color::AnsiValue(i),
166+ Color::Rgb(r, g, b) => style::Color::Rgb { r, g, b },
167+ }
168+}
169+
170+fn write_style(out: &mut Stdout, style: Style) -> io::Result<()> {
171+ use style::Attribute;
172+ queue!(out, style::SetAttribute(Attribute::Reset))?;
173+ for (bit, on) in [
174+ (attr::BOLD, Attribute::Bold),
175+ (attr::DIM, Attribute::Dim),
176+ (attr::UNDERLINE, Attribute::Underlined),
177+ (attr::REVERSE, Attribute::Reverse),
178+ (attr::BLINK, Attribute::SlowBlink),
179+ (attr::ITALIC, Attribute::Italic),
180+ ] {
181+ if style.has(bit) {
182+ queue!(out, style::SetAttribute(on))?;
183+ }
184+ }
185+ queue!(
186+ out,
187+ style::SetForegroundColor(convert(style.fg)),
188+ style::SetBackgroundColor(convert(style.bg))
189+ )
190+}
added crates/jolt-tui/src/tests.rs +371 -0
new file mode 100644
@@ -0,0 +1,371 @@
1+//! The backend end to end, with no terminal.
2+//!
3+//! Every one of these mounts real nodes through the same calls the ABI makes,
4+//! paints a real frame, and asserts on the lines that came out — which is the
5+//! whole reason painting goes through a grid. Nothing here needs a TTY, a
6+//! display or raw mode, so it all runs in CI.
7+
8+use crate::screen::attr;
9+use crate::tree::Value;
10+use crate::ui::Ui;
11+
12+fn ui() -> Ui {
13+ Ui::new(24, 8)
14+}
15+
16+/// Mount `tag` under `parent` with the string props given.
17+fn node(ui: &mut Ui, parent: u32, tag: &str, props: &[(&str, &str)]) -> u32 {
18+ let id = ui.tree.new_node(tag);
19+ for (key, value) in props {
20+ ui.tree.set(id, key, Value::Str((*value).to_owned()));
21+ }
22+ ui.tree.append(parent, id);
23+ id
24+}
25+
26+fn events(ui: &mut Ui) -> Vec<(u32, String, String, f64)> {
27+ let mut out = Vec::new();
28+ while ui.tree.poll() {
29+ let event = ui.tree.current().unwrap();
30+ out.push((
31+ event.node,
32+ event.name.to_owned(),
33+ event.text.clone(),
34+ event.num,
35+ ));
36+ }
37+ out
38+}
39+
40+#[test]
41+fn a_column_paints_its_children_down_the_page() {
42+ let mut ui = ui();
43+ let root = ui.tree.root();
44+ node(&mut ui, root, "label", &[("label", "first")]);
45+ node(&mut ui, root, "label", &[("label", "second")]);
46+ ui.frame();
47+ assert_eq!(ui.screen.line(0), "first");
48+ assert_eq!(ui.screen.line(1), "second");
49+}
50+
51+#[test]
52+fn a_row_paints_its_children_across_with_its_spacing_between_them() {
53+ let mut ui = ui();
54+ let root = ui.tree.root();
55+ let row = node(&mut ui, root, "hbox", &[("orientation", "horizontal")]);
56+ ui.tree.set(row, "spacing", Value::Num(2.0));
57+ node(&mut ui, row, "label", &[("label", "aa")]);
58+ node(&mut ui, row, "label", &[("label", "bb")]);
59+ ui.frame();
60+ assert_eq!(ui.screen.line(0), "aa bb");
61+}
62+
63+#[test]
64+fn a_label_wraps_to_the_width_it_was_given() {
65+ let mut ui = Ui::new(10, 4);
66+ let root = ui.tree.root();
67+ node(&mut ui, root, "label", &[("label", "one two three four")]);
68+ ui.frame();
69+ assert_eq!(ui.screen.line(0), "one two");
70+ assert_eq!(ui.screen.line(1), "three four");
71+}
72+
73+#[test]
74+fn a_frame_draws_a_border_with_its_label_in_the_top_edge() {
75+ let mut ui = Ui::new(12, 4);
76+ let root = ui.tree.root();
77+ let frame = node(&mut ui, root, "frame", &[("label", "Task")]);
78+ node(&mut ui, frame, "label", &[("label", "hi")]);
79+ ui.frame();
80+ assert_eq!(ui.screen.line(0), "┌ Task ────┐");
81+ assert_eq!(ui.screen.line(1), "│hi │");
82+ assert_eq!(ui.screen.line(2), "└──────────┘");
83+}
84+
85+#[test]
86+fn the_first_focusable_widget_takes_focus_and_tab_walks_the_ring() {
87+ let mut ui = ui();
88+ let root = ui.tree.root();
89+ let first = node(&mut ui, root, "button", &[("label", "one")]);
90+ let second = node(&mut ui, root, "button", &[("label", "two")]);
91+ ui.frame();
92+ assert_eq!(ui.focus(), first);
93+ ui.key("tab");
94+ assert_eq!(ui.focus(), second);
95+ ui.key("tab");
96+ assert_eq!(ui.focus(), first, "the ring wraps");
97+ ui.key("shift+tab");
98+ assert_eq!(ui.focus(), second);
99+}
100+
101+#[test]
102+fn autofocus_beats_paint_order() {
103+ let mut ui = ui();
104+ let root = ui.tree.root();
105+ node(&mut ui, root, "button", &[("label", "one")]);
106+ let wanted = node(&mut ui, root, "entry", &[]);
107+ ui.tree.set(wanted, "autofocus", Value::Bool(true));
108+ ui.frame();
109+ assert_eq!(ui.focus(), wanted);
110+}
111+
112+#[test]
113+fn a_focused_button_is_drawn_in_reverse_and_activates_on_enter() {
114+ let mut ui = ui();
115+ let root = ui.tree.root();
116+ let button = node(&mut ui, root, "button", &[("label", "go")]);
117+ ui.frame();
118+ assert_eq!(ui.screen.line(0), "[ go ]");
119+ assert!(ui.screen.cell(0, 0).unwrap().style.has(attr::REVERSE));
120+ ui.key("enter");
121+ assert_eq!(
122+ events(&mut ui),
123+ vec![(button, "click".into(), String::new(), 0.0)]
124+ );
125+}
126+
127+#[test]
128+fn a_checkbutton_writes_its_new_state_back_as_well_as_reporting_it() {
129+ let mut ui = ui();
130+ let root = ui.tree.root();
131+ let check = node(&mut ui, root, "checkbutton", &[("label", "live")]);
132+ ui.frame();
133+ assert_eq!(ui.screen.line(0), "[ ] live");
134+ ui.key("space");
135+ assert_eq!(
136+ events(&mut ui),
137+ vec![(check, "toggled".into(), String::new(), 1.0)]
138+ );
139+ // A caller that ignores the event still has a working control.
140+ assert_eq!(ui.tree.props(check).bool("active", false), true);
141+ ui.frame();
142+ assert_eq!(ui.screen.line(0), "[x] live");
143+}
144+
145+#[test]
146+fn typing_in_an_entry_edits_its_text_and_reports_every_change() {
147+ let mut ui = ui();
148+ let root = ui.tree.root();
149+ let entry = node(&mut ui, root, "entry", &[("placeholder", "name")]);
150+ ui.frame();
151+ assert_eq!(
152+ ui.screen.line(0),
153+ "name",
154+ "the placeholder shows until it is typed in"
155+ );
156+ for key in ["h", "i", "space", "there"] {
157+ for name in [key] {
158+ if name.chars().count() > 1 && name != "space" {
159+ for ch in name.chars() {
160+ ui.key(&ch.to_string());
161+ }
162+ } else {
163+ ui.key(name);
164+ }
165+ }
166+ }
167+ assert_eq!(ui.tree.props(entry).str("text"), "hi there");
168+ let changes = events(&mut ui);
169+ assert_eq!(changes.len(), 8);
170+ assert_eq!(changes.last().unwrap().2, "hi there");
171+ ui.frame();
172+ assert_eq!(ui.screen.line(0), "hi there");
173+}
174+
175+#[test]
176+fn readline_keys_edit_where_the_caret_is() {
177+ let mut ui = ui();
178+ let root = ui.tree.root();
179+ let entry = node(&mut ui, root, "entry", &[("text", "one two three")]);
180+ ui.frame();
181+ ui.key("ctrl+w");
182+ assert_eq!(ui.tree.props(entry).str("text"), "one two ");
183+ ui.key("ctrl+a");
184+ ui.key("delete");
185+ assert_eq!(ui.tree.props(entry).str("text"), "ne two ");
186+ ui.key("ctrl+e");
187+ ui.key("backspace");
188+ assert_eq!(ui.tree.props(entry).str("text"), "ne two");
189+ ui.key("ctrl+u");
190+ assert_eq!(ui.tree.props(entry).str("text"), "");
191+}
192+
193+#[test]
194+fn enter_in_an_entry_activates_rather_than_typing() {
195+ let mut ui = ui();
196+ let root = ui.tree.root();
197+ let entry = node(&mut ui, root, "entry", &[("text", "search")]);
198+ ui.frame();
199+ ui.key("enter");
200+ assert_eq!(
201+ events(&mut ui),
202+ vec![(entry, "activate".into(), "search".into(), 0.0)]
203+ );
204+ assert_eq!(ui.tree.props(entry).str("text"), "search");
205+}
206+
207+#[test]
208+fn a_listbox_moves_its_cursor_with_the_arrows_and_with_j_and_k() {
209+ let mut ui = ui();
210+ let root = ui.tree.root();
211+ let list = node(&mut ui, root, "listbox", &[]);
212+ for name in ["alpha", "beta", "gamma"] {
213+ node(&mut ui, list, "label", &[("label", name)]);
214+ }
215+ ui.frame();
216+ assert_eq!(ui.screen.line(0), "› alpha");
217+ ui.key("j");
218+ assert_eq!(
219+ events(&mut ui),
220+ vec![(list, "select".into(), "beta".into(), 1.0)]
221+ );
222+ ui.key("G");
223+ assert_eq!(ui.tree.props(list).num("selected", -1.0), 2.0);
224+ ui.key("enter");
225+ assert_eq!(
226+ events(&mut ui),
227+ vec![
228+ (list, "select".into(), "gamma".into(), 2.0),
229+ (list, "activate".into(), "gamma".into(), 2.0)
230+ ]
231+ );
232+ ui.frame();
233+ assert_eq!(ui.screen.line(2), "› gamma");
234+}
235+
236+#[test]
237+fn a_key_nothing_wanted_reaches_the_caller_as_an_event() {
238+ let mut ui = ui();
239+ let root = ui.tree.root();
240+ let button = node(&mut ui, root, "button", &[("label", "go")]);
241+ ui.frame();
242+ assert!(!ui.key("f5"));
243+ assert_eq!(
244+ events(&mut ui),
245+ vec![(button, "key".into(), "f5".into(), 0.0)]
246+ );
247+}
248+
249+#[test]
250+fn ctrl_c_asks_the_loop_to_stop() {
251+ let mut ui = ui();
252+ assert!(!ui.should_close());
253+ ui.key("ctrl+c");
254+ assert!(ui.should_close());
255+}
256+
257+#[test]
258+fn an_insensitive_subtree_is_dimmed_and_out_of_the_focus_ring() {
259+ let mut ui = ui();
260+ let root = ui.tree.root();
261+ let column = node(&mut ui, root, "vbox", &[]);
262+ ui.tree.set(column, "sensitive", Value::Bool(false));
263+ node(&mut ui, column, "button", &[("label", "go")]);
264+ let live = node(&mut ui, root, "button", &[("label", "live")]);
265+ ui.frame();
266+ assert!(ui.screen.cell(0, 0).unwrap().style.has(attr::DIM));
267+ assert_eq!(ui.focus(), live);
268+}
269+
270+#[test]
271+fn a_click_focuses_and_activates_what_is_under_it() {
272+ let mut ui = ui();
273+ let root = ui.tree.root();
274+ node(&mut ui, root, "button", &[("label", "one")]);
275+ let second = node(&mut ui, root, "button", &[("label", "two")]);
276+ ui.frame();
277+ assert!(ui.click(2, 1));
278+ assert_eq!(ui.focus(), second);
279+ assert_eq!(
280+ events(&mut ui),
281+ vec![(second, "click".into(), String::new(), 0.0)]
282+ );
283+ assert!(!ui.click(20, 7), "a click on nothing is not an event");
284+}
285+
286+#[test]
287+fn a_scroll_shows_a_window_of_its_content_and_will_not_go_past_the_end() {
288+ let mut ui = Ui::new(10, 3);
289+ let root = ui.tree.root();
290+ let scroll = node(&mut ui, root, "scroll", &[]);
291+ for i in 0..6 {
292+ node(&mut ui, scroll, "label", &[("label", &format!("row {i}"))]);
293+ }
294+ ui.frame();
295+ assert_eq!(ui.screen.lines(), vec!["row 0", "row 1", "row 2"]);
296+ ui.wheel(1, 1, 2);
297+ ui.frame();
298+ assert_eq!(ui.screen.lines(), vec!["row 2", "row 3", "row 4"]);
299+ ui.wheel(1, 1, 40);
300+ ui.frame();
301+ assert_eq!(ui.screen.lines(), vec!["row 3", "row 4", "row 5"]);
302+ // The clamped viewport is written back, so the next scroll up starts from
303+ // where the reader actually is.
304+ assert_eq!(ui.tree.props(scroll).num("offset", -1.0), 3.0);
305+}
306+
307+#[test]
308+fn an_overlay_floats_in_the_middle_over_whatever_was_under_it() {
309+ let mut ui = Ui::new(14, 5);
310+ let root = ui.tree.root();
311+ node(&mut ui, root, "label", &[("label", "beneath")]);
312+ let overlay = node(&mut ui, root, "overlay", &[("label", "Sure?")]);
313+ node(&mut ui, overlay, "label", &[("label", "yes")]);
314+ ui.frame();
315+ assert_eq!(ui.screen.line(1), " ┌ Sure? ┐");
316+ assert_eq!(ui.screen.line(2), " │yes │");
317+ ui.key("esc");
318+ assert_eq!(
319+ events(&mut ui),
320+ vec![(overlay, "close".into(), String::new(), 0.0)]
321+ );
322+}
323+
324+#[test]
325+fn a_progress_bar_fills_the_share_of_its_width_it_was_given() {
326+ let mut ui = Ui::new(10, 2);
327+ let root = ui.tree.root();
328+ let bar = node(&mut ui, root, "progress", &[]);
329+ ui.tree.set(bar, "value", Value::Num(0.5));
330+ ui.frame();
331+ assert_eq!(ui.screen.line(0), "█████░░░░░");
332+}
333+
334+#[test]
335+fn colours_and_attributes_are_inherited_by_a_subtree() {
336+ let mut ui = ui();
337+ let root = ui.tree.root();
338+ let column = node(&mut ui, root, "vbox", &[("color", "red")]);
339+ ui.tree.set(column, "bold", Value::Bool(true));
340+ node(&mut ui, column, "label", &[("label", "hi")]);
341+ ui.frame();
342+ let cell = ui.screen.cell(0, 0).unwrap();
343+ assert_eq!(cell.style.fg, crate::screen::Color::Indexed(1));
344+ assert!(cell.style.has(attr::BOLD));
345+}
346+
347+#[test]
348+fn an_unknown_tag_still_shows_its_children() {
349+ let mut ui = ui();
350+ let root = ui.tree.root();
351+ let odd = node(&mut ui, root, "sparkline", &[]);
352+ node(&mut ui, odd, "label", &[("label", "inside")]);
353+ ui.frame();
354+ assert_eq!(ui.screen.line(0), "inside");
355+}
356+
357+#[test]
358+fn focus_survives_a_repaint_and_lands_somewhere_when_its_widget_is_unmounted() {
359+ let mut ui = ui();
360+ let root = ui.tree.root();
361+ let first = node(&mut ui, root, "button", &[("label", "one")]);
362+ let second = node(&mut ui, root, "button", &[("label", "two")]);
363+ ui.frame();
364+ ui.key("tab");
365+ assert_eq!(ui.focus(), second);
366+ ui.frame();
367+ assert_eq!(ui.focus(), second, "a repaint does not move the focus");
368+ ui.tree.remove(root, second);
369+ ui.frame();
370+ assert_eq!(ui.focus(), first);
371+}
new file mode 100644
@@ -0,0 +1,371 @@
1+//! The backend end to end, with no terminal.
2+//!
3+//! Every one of these mounts real nodes through the same calls the ABI makes,
4+//! paints a real frame, and asserts on the lines that came out — which is the
5+//! whole reason painting goes through a grid. Nothing here needs a TTY, a
6+//! display or raw mode, so it all runs in CI.
7+
8+use crate::screen::attr;
9+use crate::tree::Value;
10+use crate::ui::Ui;
11+
12+fn ui() -> Ui {
13+ Ui::new(24, 8)
14+}
15+
16+/// Mount `tag` under `parent` with the string props given.
17+fn node(ui: &mut Ui, parent: u32, tag: &str, props: &[(&str, &str)]) -> u32 {
18+ let id = ui.tree.new_node(tag);
19+ for (key, value) in props {
20+ ui.tree.set(id, key, Value::Str((*value).to_owned()));
21+ }
22+ ui.tree.append(parent, id);
23+ id
24+}
25+
26+fn events(ui: &mut Ui) -> Vec<(u32, String, String, f64)> {
27+ let mut out = Vec::new();
28+ while ui.tree.poll() {
29+ let event = ui.tree.current().unwrap();
30+ out.push((
31+ event.node,
32+ event.name.to_owned(),
33+ event.text.clone(),
34+ event.num,
35+ ));
36+ }
37+ out
38+}
39+
40+#[test]
41+fn a_column_paints_its_children_down_the_page() {
42+ let mut ui = ui();
43+ let root = ui.tree.root();
44+ node(&mut ui, root, "label", &[("label", "first")]);
45+ node(&mut ui, root, "label", &[("label", "second")]);
46+ ui.frame();
47+ assert_eq!(ui.screen.line(0), "first");
48+ assert_eq!(ui.screen.line(1), "second");
49+}
50+
51+#[test]
52+fn a_row_paints_its_children_across_with_its_spacing_between_them() {
53+ let mut ui = ui();
54+ let root = ui.tree.root();
55+ let row = node(&mut ui, root, "hbox", &[("orientation", "horizontal")]);
56+ ui.tree.set(row, "spacing", Value::Num(2.0));
57+ node(&mut ui, row, "label", &[("label", "aa")]);
58+ node(&mut ui, row, "label", &[("label", "bb")]);
59+ ui.frame();
60+ assert_eq!(ui.screen.line(0), "aa bb");
61+}
62+
63+#[test]
64+fn a_label_wraps_to_the_width_it_was_given() {
65+ let mut ui = Ui::new(10, 4);
66+ let root = ui.tree.root();
67+ node(&mut ui, root, "label", &[("label", "one two three four")]);
68+ ui.frame();
69+ assert_eq!(ui.screen.line(0), "one two");
70+ assert_eq!(ui.screen.line(1), "three four");
71+}
72+
73+#[test]
74+fn a_frame_draws_a_border_with_its_label_in_the_top_edge() {
75+ let mut ui = Ui::new(12, 4);
76+ let root = ui.tree.root();
77+ let frame = node(&mut ui, root, "frame", &[("label", "Task")]);
78+ node(&mut ui, frame, "label", &[("label", "hi")]);
79+ ui.frame();
80+ assert_eq!(ui.screen.line(0), "┌ Task ────┐");
81+ assert_eq!(ui.screen.line(1), "│hi │");
82+ assert_eq!(ui.screen.line(2), "└──────────┘");
83+}
84+
85+#[test]
86+fn the_first_focusable_widget_takes_focus_and_tab_walks_the_ring() {
87+ let mut ui = ui();
88+ let root = ui.tree.root();
89+ let first = node(&mut ui, root, "button", &[("label", "one")]);
90+ let second = node(&mut ui, root, "button", &[("label", "two")]);
91+ ui.frame();
92+ assert_eq!(ui.focus(), first);
93+ ui.key("tab");
94+ assert_eq!(ui.focus(), second);
95+ ui.key("tab");
96+ assert_eq!(ui.focus(), first, "the ring wraps");
97+ ui.key("shift+tab");
98+ assert_eq!(ui.focus(), second);
99+}
100+
101+#[test]
102+fn autofocus_beats_paint_order() {
103+ let mut ui = ui();
104+ let root = ui.tree.root();
105+ node(&mut ui, root, "button", &[("label", "one")]);
106+ let wanted = node(&mut ui, root, "entry", &[]);
107+ ui.tree.set(wanted, "autofocus", Value::Bool(true));
108+ ui.frame();
109+ assert_eq!(ui.focus(), wanted);
110+}
111+
112+#[test]
113+fn a_focused_button_is_drawn_in_reverse_and_activates_on_enter() {
114+ let mut ui = ui();
115+ let root = ui.tree.root();
116+ let button = node(&mut ui, root, "button", &[("label", "go")]);
117+ ui.frame();
118+ assert_eq!(ui.screen.line(0), "[ go ]");
119+ assert!(ui.screen.cell(0, 0).unwrap().style.has(attr::REVERSE));
120+ ui.key("enter");
121+ assert_eq!(
122+ events(&mut ui),
123+ vec![(button, "click".into(), String::new(), 0.0)]
124+ );
125+}
126+
127+#[test]
128+fn a_checkbutton_writes_its_new_state_back_as_well_as_reporting_it() {
129+ let mut ui = ui();
130+ let root = ui.tree.root();
131+ let check = node(&mut ui, root, "checkbutton", &[("label", "live")]);
132+ ui.frame();
133+ assert_eq!(ui.screen.line(0), "[ ] live");
134+ ui.key("space");
135+ assert_eq!(
136+ events(&mut ui),
137+ vec![(check, "toggled".into(), String::new(), 1.0)]
138+ );
139+ // A caller that ignores the event still has a working control.
140+ assert_eq!(ui.tree.props(check).bool("active", false), true);
141+ ui.frame();
142+ assert_eq!(ui.screen.line(0), "[x] live");
143+}
144+
145+#[test]
146+fn typing_in_an_entry_edits_its_text_and_reports_every_change() {
147+ let mut ui = ui();
148+ let root = ui.tree.root();
149+ let entry = node(&mut ui, root, "entry", &[("placeholder", "name")]);
150+ ui.frame();
151+ assert_eq!(
152+ ui.screen.line(0),
153+ "name",
154+ "the placeholder shows until it is typed in"
155+ );
156+ for key in ["h", "i", "space", "there"] {
157+ for name in [key] {
158+ if name.chars().count() > 1 && name != "space" {
159+ for ch in name.chars() {
160+ ui.key(&ch.to_string());
161+ }
162+ } else {
163+ ui.key(name);
164+ }
165+ }
166+ }
167+ assert_eq!(ui.tree.props(entry).str("text"), "hi there");
168+ let changes = events(&mut ui);
169+ assert_eq!(changes.len(), 8);
170+ assert_eq!(changes.last().unwrap().2, "hi there");
171+ ui.frame();
172+ assert_eq!(ui.screen.line(0), "hi there");
173+}
174+
175+#[test]
176+fn readline_keys_edit_where_the_caret_is() {
177+ let mut ui = ui();
178+ let root = ui.tree.root();
179+ let entry = node(&mut ui, root, "entry", &[("text", "one two three")]);
180+ ui.frame();
181+ ui.key("ctrl+w");
182+ assert_eq!(ui.tree.props(entry).str("text"), "one two ");
183+ ui.key("ctrl+a");
184+ ui.key("delete");
185+ assert_eq!(ui.tree.props(entry).str("text"), "ne two ");
186+ ui.key("ctrl+e");
187+ ui.key("backspace");
188+ assert_eq!(ui.tree.props(entry).str("text"), "ne two");
189+ ui.key("ctrl+u");
190+ assert_eq!(ui.tree.props(entry).str("text"), "");
191+}
192+
193+#[test]
194+fn enter_in_an_entry_activates_rather_than_typing() {
195+ let mut ui = ui();
196+ let root = ui.tree.root();
197+ let entry = node(&mut ui, root, "entry", &[("text", "search")]);
198+ ui.frame();
199+ ui.key("enter");
200+ assert_eq!(
201+ events(&mut ui),
202+ vec![(entry, "activate".into(), "search".into(), 0.0)]
203+ );
204+ assert_eq!(ui.tree.props(entry).str("text"), "search");
205+}
206+
207+#[test]
208+fn a_listbox_moves_its_cursor_with_the_arrows_and_with_j_and_k() {
209+ let mut ui = ui();
210+ let root = ui.tree.root();
211+ let list = node(&mut ui, root, "listbox", &[]);
212+ for name in ["alpha", "beta", "gamma"] {
213+ node(&mut ui, list, "label", &[("label", name)]);
214+ }
215+ ui.frame();
216+ assert_eq!(ui.screen.line(0), "› alpha");
217+ ui.key("j");
218+ assert_eq!(
219+ events(&mut ui),
220+ vec![(list, "select".into(), "beta".into(), 1.0)]
221+ );
222+ ui.key("G");
223+ assert_eq!(ui.tree.props(list).num("selected", -1.0), 2.0);
224+ ui.key("enter");
225+ assert_eq!(
226+ events(&mut ui),
227+ vec![
228+ (list, "select".into(), "gamma".into(), 2.0),
229+ (list, "activate".into(), "gamma".into(), 2.0)
230+ ]
231+ );
232+ ui.frame();
233+ assert_eq!(ui.screen.line(2), "› gamma");
234+}
235+
236+#[test]
237+fn a_key_nothing_wanted_reaches_the_caller_as_an_event() {
238+ let mut ui = ui();
239+ let root = ui.tree.root();
240+ let button = node(&mut ui, root, "button", &[("label", "go")]);
241+ ui.frame();
242+ assert!(!ui.key("f5"));
243+ assert_eq!(
244+ events(&mut ui),
245+ vec![(button, "key".into(), "f5".into(), 0.0)]
246+ );
247+}
248+
249+#[test]
250+fn ctrl_c_asks_the_loop_to_stop() {
251+ let mut ui = ui();
252+ assert!(!ui.should_close());
253+ ui.key("ctrl+c");
254+ assert!(ui.should_close());
255+}
256+
257+#[test]
258+fn an_insensitive_subtree_is_dimmed_and_out_of_the_focus_ring() {
259+ let mut ui = ui();
260+ let root = ui.tree.root();
261+ let column = node(&mut ui, root, "vbox", &[]);
262+ ui.tree.set(column, "sensitive", Value::Bool(false));
263+ node(&mut ui, column, "button", &[("label", "go")]);
264+ let live = node(&mut ui, root, "button", &[("label", "live")]);
265+ ui.frame();
266+ assert!(ui.screen.cell(0, 0).unwrap().style.has(attr::DIM));
267+ assert_eq!(ui.focus(), live);
268+}
269+
270+#[test]
271+fn a_click_focuses_and_activates_what_is_under_it() {
272+ let mut ui = ui();
273+ let root = ui.tree.root();
274+ node(&mut ui, root, "button", &[("label", "one")]);
275+ let second = node(&mut ui, root, "button", &[("label", "two")]);
276+ ui.frame();
277+ assert!(ui.click(2, 1));
278+ assert_eq!(ui.focus(), second);
279+ assert_eq!(
280+ events(&mut ui),
281+ vec![(second, "click".into(), String::new(), 0.0)]
282+ );
283+ assert!(!ui.click(20, 7), "a click on nothing is not an event");
284+}
285+
286+#[test]
287+fn a_scroll_shows_a_window_of_its_content_and_will_not_go_past_the_end() {
288+ let mut ui = Ui::new(10, 3);
289+ let root = ui.tree.root();
290+ let scroll = node(&mut ui, root, "scroll", &[]);
291+ for i in 0..6 {
292+ node(&mut ui, scroll, "label", &[("label", &format!("row {i}"))]);
293+ }
294+ ui.frame();
295+ assert_eq!(ui.screen.lines(), vec!["row 0", "row 1", "row 2"]);
296+ ui.wheel(1, 1, 2);
297+ ui.frame();
298+ assert_eq!(ui.screen.lines(), vec!["row 2", "row 3", "row 4"]);
299+ ui.wheel(1, 1, 40);
300+ ui.frame();
301+ assert_eq!(ui.screen.lines(), vec!["row 3", "row 4", "row 5"]);
302+ // The clamped viewport is written back, so the next scroll up starts from
303+ // where the reader actually is.
304+ assert_eq!(ui.tree.props(scroll).num("offset", -1.0), 3.0);
305+}
306+
307+#[test]
308+fn an_overlay_floats_in_the_middle_over_whatever_was_under_it() {
309+ let mut ui = Ui::new(14, 5);
310+ let root = ui.tree.root();
311+ node(&mut ui, root, "label", &[("label", "beneath")]);
312+ let overlay = node(&mut ui, root, "overlay", &[("label", "Sure?")]);
313+ node(&mut ui, overlay, "label", &[("label", "yes")]);
314+ ui.frame();
315+ assert_eq!(ui.screen.line(1), " ┌ Sure? ┐");
316+ assert_eq!(ui.screen.line(2), " │yes │");
317+ ui.key("esc");
318+ assert_eq!(
319+ events(&mut ui),
320+ vec![(overlay, "close".into(), String::new(), 0.0)]
321+ );
322+}
323+
324+#[test]
325+fn a_progress_bar_fills_the_share_of_its_width_it_was_given() {
326+ let mut ui = Ui::new(10, 2);
327+ let root = ui.tree.root();
328+ let bar = node(&mut ui, root, "progress", &[]);
329+ ui.tree.set(bar, "value", Value::Num(0.5));
330+ ui.frame();
331+ assert_eq!(ui.screen.line(0), "█████░░░░░");
332+}
333+
334+#[test]
335+fn colours_and_attributes_are_inherited_by_a_subtree() {
336+ let mut ui = ui();
337+ let root = ui.tree.root();
338+ let column = node(&mut ui, root, "vbox", &[("color", "red")]);
339+ ui.tree.set(column, "bold", Value::Bool(true));
340+ node(&mut ui, column, "label", &[("label", "hi")]);
341+ ui.frame();
342+ let cell = ui.screen.cell(0, 0).unwrap();
343+ assert_eq!(cell.style.fg, crate::screen::Color::Indexed(1));
344+ assert!(cell.style.has(attr::BOLD));
345+}
346+
347+#[test]
348+fn an_unknown_tag_still_shows_its_children() {
349+ let mut ui = ui();
350+ let root = ui.tree.root();
351+ let odd = node(&mut ui, root, "sparkline", &[]);
352+ node(&mut ui, odd, "label", &[("label", "inside")]);
353+ ui.frame();
354+ assert_eq!(ui.screen.line(0), "inside");
355+}
356+
357+#[test]
358+fn focus_survives_a_repaint_and_lands_somewhere_when_its_widget_is_unmounted() {
359+ let mut ui = ui();
360+ let root = ui.tree.root();
361+ let first = node(&mut ui, root, "button", &[("label", "one")]);
362+ let second = node(&mut ui, root, "button", &[("label", "two")]);
363+ ui.frame();
364+ ui.key("tab");
365+ assert_eq!(ui.focus(), second);
366+ ui.frame();
367+ assert_eq!(ui.focus(), second, "a repaint does not move the focus");
368+ ui.tree.remove(root, second);
369+ ui.frame();
370+ assert_eq!(ui.focus(), first);
371+}
added crates/jolt-tui/src/tree.rs +657 -0
new file mode 100644
@@ -0,0 +1,657 @@
1+//! A retained node tree, painted into a character grid.
2+//!
3+//! The same arena glimmer's reconciler expects everywhere else: nodes are
4+//! integer handles, `create` / `apply-props!` / `append-child!` mutate them,
5+//! and nothing is drawn until the frame call walks the whole thing at once.
6+//! Interactions come back as a queue the caller drains, because a jolt closure
7+//! cannot be a callback down here — identity crosses the boundary instead.
8+//!
9+//! This module knows nothing about terminals. It is the data; [`crate::layout`]
10+//! measures it and [`crate::paint`] draws it.
11+
12+use std::collections::{HashMap, VecDeque};
13+
14+/// A prop value: the three types the ABI can carry, which is all glimmer needs.
15+/// Keywords and colours arrive as strings, numbers as doubles, flags as ints.
16+#[derive(Clone, Debug, PartialEq)]
17+pub enum Value {
18+ Str(String),
19+ Num(f64),
20+ Bool(bool),
21+}
22+
23+/// What a node renders as.
24+///
25+/// An unknown tag is kept rather than refused — it paints as a vertical box, so
26+/// a component written against a tag this backend has not grown yet still shows
27+/// its children instead of nothing.
28+#[derive(Clone, Debug, PartialEq, Eq)]
29+pub enum Tag {
30+ Window,
31+ Box,
32+ Frame,
33+ Scroll,
34+ Overlay,
35+ Label,
36+ Title,
37+ DimLabel,
38+ Button,
39+ CheckButton,
40+ Entry,
41+ Separator,
42+ Spacer,
43+ Listbox,
44+ Progress,
45+ Spinner,
46+ Unknown(String),
47+}
48+
49+impl Default for Tag {
50+ fn default() -> Self {
51+ Self::Unknown(String::new())
52+ }
53+}
54+
55+impl Tag {
56+ fn parse(name: &str) -> Self {
57+ match name {
58+ "window" => Self::Window,
59+ "box" | "hbox" | "vbox" => Self::Box,
60+ "frame" => Self::Frame,
61+ "scroll" => Self::Scroll,
62+ "overlay" => Self::Overlay,
63+ "label" => Self::Label,
64+ "title" | "title-2" => Self::Title,
65+ "dim-label" => Self::DimLabel,
66+ "button" => Self::Button,
67+ "checkbutton" | "checkbox" => Self::CheckButton,
68+ "entry" => Self::Entry,
69+ "separator" => Self::Separator,
70+ "spacer" | "gap" => Self::Spacer,
71+ "listbox" => Self::Listbox,
72+ "progress" => Self::Progress,
73+ "spinner" => Self::Spinner,
74+ other => Self::Unknown(other.to_owned()),
75+ }
76+ }
77+
78+ /// The canonical name: `hbox` and `vbox` are one node, so both answer
79+ /// `box` and carry their orientation in a prop.
80+ pub fn name(&self) -> &str {
81+ match self {
82+ Self::Window => "window",
83+ Self::Box => "box",
84+ Self::Frame => "frame",
85+ Self::Scroll => "scroll",
86+ Self::Overlay => "overlay",
87+ Self::Label => "label",
88+ Self::Title => "title",
89+ Self::DimLabel => "dim-label",
90+ Self::Button => "button",
91+ Self::CheckButton => "checkbutton",
92+ Self::Entry => "entry",
93+ Self::Separator => "separator",
94+ Self::Spacer => "spacer",
95+ Self::Listbox => "listbox",
96+ Self::Progress => "progress",
97+ Self::Spinner => "spinner",
98+ Self::Unknown(name) => name,
99+ }
100+ }
101+
102+ /// Whether the focus ring stops here. A container never takes focus of its
103+ /// own; a control that does nothing with a key does not either.
104+ pub fn focusable(&self) -> bool {
105+ matches!(
106+ self,
107+ Self::Button | Self::CheckButton | Self::Entry | Self::Listbox
108+ )
109+ }
110+}
111+
112+/// One interaction, waiting to be drained by the caller. Names are glimmer's
113+/// handler props with the `on-` dropped.
114+#[derive(Clone, Debug, PartialEq)]
115+pub struct Event {
116+ pub node: u32,
117+ pub name: &'static str,
118+ pub text: String,
119+ pub num: f64,
120+}
121+
122+#[derive(Clone, Debug, Default)]
123+struct Node {
124+ tag: Tag,
125+ props: HashMap<String, Value>,
126+ children: Vec<u32>,
127+ /// 0 when unparented. The root's parent is 0 as well, which is what stops
128+ /// the ancestor walk in [`Tree::would_cycle`].
129+ parent: u32,
130+}
131+
132+/// A node's props, copied out for the duration of one measure or paint.
133+///
134+/// Reading them through this rather than the map means a missing prop and a
135+/// prop of the wrong type answer the same thing: the default. Nothing a caller
136+/// can write should be able to make a widget vanish.
137+#[derive(Clone, Debug, Default)]
138+pub struct Props(pub HashMap<String, Value>);
139+
140+impl Props {
141+ pub fn str(&self, key: &str) -> &str {
142+ match self.0.get(key) {
143+ Some(Value::Str(s)) => s,
144+ _ => "",
145+ }
146+ }
147+
148+ pub fn num(&self, key: &str, fallback: f64) -> f64 {
149+ match self.0.get(key) {
150+ Some(Value::Num(n)) => *n,
151+ Some(Value::Bool(b)) => {
152+ if *b {
153+ 1.0
154+ } else {
155+ 0.0
156+ }
157+ }
158+ _ => fallback,
159+ }
160+ }
161+
162+ /// A count of cells. Negative and absurd values are clamped rather than
163+ /// cast, since `as u16` on a negative double is a silent 0 or 65535.
164+ pub fn cells(&self, key: &str, fallback: u16) -> u16 {
165+ match self.0.get(key) {
166+ Some(Value::Num(n)) if n.is_finite() => n.clamp(0.0, u16::MAX as f64) as u16,
167+ _ => fallback,
168+ }
169+ }
170+
171+ pub fn bool(&self, key: &str, fallback: bool) -> bool {
172+ match self.0.get(key) {
173+ Some(Value::Bool(b)) => *b,
174+ Some(Value::Num(n)) => *n != 0.0,
175+ Some(Value::Str(s)) => s == "true",
176+ _ => fallback,
177+ }
178+ }
179+
180+ pub fn has(&self, key: &str) -> bool {
181+ self.0.contains_key(key)
182+ }
183+
184+ /// The text a widget shows. `:label` and `:text` are the same prop to every
185+ /// glimmer backend; whichever the caller wrote is the one that shows.
186+ pub fn label(&self) -> &str {
187+ if self.has("label") {
188+ self.str("label")
189+ } else {
190+ self.str("text")
191+ }
192+ }
193+}
194+
195+/// One prop value as EDN. Whole numbers print without a trailing `.0`: every
196+/// number crossed the boundary as a double, and `{:spacing 8}` reads better
197+/// than `{:spacing 8.0}`.
198+fn write_value(value: &Value, out: &mut String) {
199+ match value {
200+ Value::Bool(b) => out.push_str(if *b { "true" } else { "false" }),
201+ Value::Num(n) => {
202+ if n.is_finite() && n.fract() == 0.0 && n.abs() < 1e15 {
203+ out.push_str(&format!("{}", *n as i64));
204+ } else if n.is_finite() {
205+ out.push_str(&format!("{n}"));
206+ } else {
207+ // EDN has no infinity or NaN literal; say nil rather than emit
208+ // something no reader will take.
209+ out.push_str("nil");
210+ }
211+ }
212+ Value::Str(text) => {
213+ out.push('"');
214+ for c in text.chars() {
215+ match c {
216+ '"' => out.push_str("\\\""),
217+ '\\' => out.push_str("\\\\"),
218+ '\n' => out.push_str("\\n"),
219+ '\r' => out.push_str("\\r"),
220+ '\t' => out.push_str("\\t"),
221+ _ => out.push(c),
222+ }
223+ }
224+ out.push('"');
225+ }
226+ }
227+}
228+
229+pub struct Tree {
230+ /// Index 0 is never handed out: 0 is "no node" throughout the ABI.
231+ nodes: Vec<Option<Node>>,
232+ free: Vec<u32>,
233+ root: u32,
234+ pending: VecDeque<Event>,
235+ current: Option<Event>,
236+}
237+
238+impl Default for Tree {
239+ fn default() -> Self {
240+ Self::new()
241+ }
242+}
243+
244+impl Tree {
245+ pub fn new() -> Self {
246+ let mut tree = Self {
247+ nodes: vec![None],
248+ free: Vec::new(),
249+ root: 0,
250+ pending: VecDeque::new(),
251+ current: None,
252+ };
253+ tree.root = tree.new_node("window");
254+ tree
255+ }
256+
257+ pub fn root(&self) -> u32 {
258+ self.root
259+ }
260+
261+ fn slot(&self, id: u32) -> Option<&Node> {
262+ self.nodes.get(id as usize).and_then(|n| n.as_ref())
263+ }
264+
265+ fn slot_mut(&mut self, id: u32) -> Option<&mut Node> {
266+ self.nodes.get_mut(id as usize).and_then(|n| n.as_mut())
267+ }
268+
269+ pub fn exists(&self, id: u32) -> bool {
270+ self.slot(id).is_some()
271+ }
272+
273+ pub fn new_node(&mut self, tag: &str) -> u32 {
274+ let node = Node {
275+ tag: Tag::parse(tag),
276+ ..Node::default()
277+ };
278+ match self.free.pop() {
279+ Some(id) => {
280+ self.nodes[id as usize] = Some(node);
281+ id
282+ }
283+ None => {
284+ self.nodes.push(Some(node));
285+ (self.nodes.len() - 1) as u32
286+ }
287+ }
288+ }
289+
290+ /// Free `id` and everything under it. The root is refused: the window node
291+ /// is the one thing a caller cannot drop out from under itself.
292+ pub fn free_node(&mut self, id: u32) {
293+ if id == self.root || !self.exists(id) {
294+ return;
295+ }
296+ let parent = self.slot(id).map(|n| n.parent).unwrap_or(0);
297+ if parent != 0 {
298+ if let Some(node) = self.slot_mut(parent) {
299+ node.children.retain(|c| *c != id);
300+ }
301+ }
302+ self.free_subtree(id);
303+ }
304+
305+ fn free_subtree(&mut self, id: u32) {
306+ let children = self
307+ .slot(id)
308+ .map(|n| n.children.clone())
309+ .unwrap_or_default();
310+ for child in children {
311+ self.free_subtree(child);
312+ }
313+ if self.nodes[id as usize].take().is_some() {
314+ self.free.push(id);
315+ }
316+ // An event queued against a node that has since gone would be routed to
317+ // a handler the reconciler has already dropped. Drop it here instead.
318+ self.pending.retain(|e| e.node != id);
319+ }
320+
321+ /// Whether making `child` a child of `parent` would make a loop — `child`
322+ /// being `parent` or one of its ancestors.
323+ fn would_cycle(&self, parent: u32, child: u32) -> bool {
324+ let mut at = parent;
325+ while at != 0 {
326+ if at == child {
327+ return true;
328+ }
329+ at = match self.slot(at) {
330+ Some(node) => node.parent,
331+ None => return false,
332+ };
333+ }
334+ false
335+ }
336+
337+ fn unparent(&mut self, child: u32) {
338+ let parent = self.slot(child).map(|n| n.parent).unwrap_or(0);
339+ if parent != 0 {
340+ if let Some(node) = self.slot_mut(parent) {
341+ node.children.retain(|c| *c != child);
342+ }
343+ }
344+ if let Some(node) = self.slot_mut(child) {
345+ node.parent = 0;
346+ }
347+ }
348+
349+ pub fn append(&mut self, parent: u32, child: u32) -> bool {
350+ if !self.exists(parent) || !self.exists(child) || self.would_cycle(parent, child) {
351+ return false;
352+ }
353+ self.unparent(child);
354+ self.slot_mut(parent).unwrap().children.push(child);
355+ self.slot_mut(child).unwrap().parent = parent;
356+ true
357+ }
358+
359+ /// Unparent *and* free `child`, which is what the reconciler means by
360+ /// remove: a node it has taken out of the tree is a node it has dropped.
361+ pub fn remove(&mut self, parent: u32, child: u32) {
362+ if self.slot(child).map(|n| n.parent) == Some(parent) {
363+ self.free_node(child);
364+ }
365+ }
366+
367+ /// Move `child` after `sibling`; `sibling` 0 means the first position.
368+ pub fn insert_after(&mut self, parent: u32, child: u32, sibling: u32) -> bool {
369+ if !self.exists(parent) || !self.exists(child) || self.would_cycle(parent, child) {
370+ return false;
371+ }
372+ if sibling != 0 && self.slot(sibling).map(|n| n.parent) != Some(parent) {
373+ return false;
374+ }
375+ self.unparent(child);
376+ let at = match sibling {
377+ 0 => 0,
378+ _ => {
379+ let children = &self.slot(parent).unwrap().children;
380+ children
381+ .iter()
382+ .position(|c| *c == sibling)
383+ .map_or(0, |i| i + 1)
384+ }
385+ };
386+ self.slot_mut(parent).unwrap().children.insert(at, child);
387+ self.slot_mut(child).unwrap().parent = parent;
388+ true
389+ }
390+
391+ /// Put `new` where `old` was, and free `old`.
392+ pub fn replace(&mut self, parent: u32, old: u32, new: u32) -> bool {
393+ if self.slot(old).map(|n| n.parent) != Some(parent) || !self.exists(new) {
394+ return false;
395+ }
396+ if self.would_cycle(parent, new) {
397+ return false;
398+ }
399+ self.unparent(new);
400+ let at = self
401+ .slot(parent)
402+ .and_then(|n| n.children.iter().position(|c| *c == old));
403+ let Some(at) = at else { return false };
404+ self.slot_mut(parent).unwrap().children[at] = new;
405+ self.slot_mut(new).unwrap().parent = parent;
406+ if let Some(node) = self.slot_mut(old) {
407+ node.parent = 0;
408+ }
409+ self.free_subtree(old);
410+ true
411+ }
412+
413+ // ── reading it back ─────────────────────────────────────────────────────
414+
415+ pub fn tag(&self, id: u32) -> Tag {
416+ self.slot(id).map(|n| n.tag.clone()).unwrap_or_default()
417+ }
418+
419+ pub fn tag_name(&self, id: u32) -> &str {
420+ self.slot(id).map_or("", |n| n.tag.name())
421+ }
422+
423+ pub fn children(&self, id: u32) -> Vec<u32> {
424+ self.slot(id)
425+ .map(|n| n.children.clone())
426+ .unwrap_or_default()
427+ }
428+
429+ pub fn child_count(&self, id: u32) -> usize {
430+ self.slot(id).map_or(0, |n| n.children.len())
431+ }
432+
433+ pub fn child_at(&self, id: u32, index: usize) -> u32 {
434+ self.slot(id)
435+ .and_then(|n| n.children.get(index).copied())
436+ .unwrap_or(0)
437+ }
438+
439+ pub fn parent(&self, id: u32) -> u32 {
440+ self.slot(id).map_or(0, |n| n.parent)
441+ }
442+
443+ pub fn props(&self, id: u32) -> Props {
444+ Props(self.slot(id).map(|n| n.props.clone()).unwrap_or_default())
445+ }
446+
447+ pub fn set(&mut self, id: u32, key: &str, value: Value) {
448+ if let Some(node) = self.slot_mut(id) {
449+ node.props.insert(key.to_owned(), value);
450+ }
451+ }
452+
453+ pub fn clear_props(&mut self, id: u32) {
454+ if let Some(node) = self.slot_mut(id) {
455+ node.props.clear();
456+ }
457+ }
458+
459+ pub fn get(&self, id: u32, key: &str) -> Option<&Value> {
460+ self.slot(id).and_then(|n| n.props.get(key))
461+ }
462+
463+ /// The subtree at `id` as pretty-printed hiccup — what the tree *is*, read
464+ /// back from the arena, rather than what a component meant to build.
465+ ///
466+ /// Props are sorted, so two dumps of the same tree compare as text.
467+ pub fn dump(&self, id: u32) -> String {
468+ let mut out = String::new();
469+ self.dump_into(id, 0, &mut out);
470+ out
471+ }
472+
473+ fn dump_into(&self, id: u32, depth: usize, out: &mut String) {
474+ let Some(node) = self.slot(id) else {
475+ out.push_str("nil");
476+ return;
477+ };
478+ let indent = " ".repeat(depth);
479+ out.push_str("[:");
480+ out.push_str(node.tag.name());
481+
482+ let mut keys: Vec<&String> = node.props.keys().collect();
483+ keys.sort();
484+ out.push_str(" {");
485+ for (i, key) in keys.iter().enumerate() {
486+ if i > 0 {
487+ out.push(' ');
488+ }
489+ out.push(':');
490+ out.push_str(key);
491+ out.push(' ');
492+ write_value(&node.props[*key], out);
493+ }
494+ out.push('}');
495+
496+ for child in &node.children {
497+ out.push('\n');
498+ out.push_str(&indent);
499+ out.push_str(" ");
500+ self.dump_into(*child, depth + 1, out);
501+ }
502+ out.push(']');
503+ }
504+
505+ // ── events ──────────────────────────────────────────────────────────────
506+
507+ pub fn emit(&mut self, node: u32, name: &'static str, text: String, num: f64) {
508+ self.pending.push_back(Event {
509+ node,
510+ name,
511+ text,
512+ num,
513+ });
514+ }
515+
516+ /// Dequeue one event into the accessor slot. False when the queue is empty.
517+ pub fn poll(&mut self) -> bool {
518+ self.current = self.pending.pop_front();
519+ self.current.is_some()
520+ }
521+
522+ pub fn current(&self) -> Option<&Event> {
523+ self.current.as_ref()
524+ }
525+}
526+
527+#[cfg(test)]
528+mod tests {
529+ use super::*;
530+
531+ fn tree_with_button() -> (Tree, u32) {
532+ let mut tree = Tree::new();
533+ let button = tree.new_node("button");
534+ tree.set(button, "label", Value::Str("go".into()));
535+ let root = tree.root();
536+ tree.append(root, button);
537+ (tree, button)
538+ }
539+
540+ #[test]
541+ fn a_dump_is_the_tree_as_hiccup_with_sorted_props() {
542+ let (mut tree, button) = tree_with_button();
543+ tree.set(button, "kind", Value::Str("primary".into()));
544+ assert_eq!(
545+ tree.dump(tree.root()),
546+ "[:window {}\n [:button {:kind \"primary\" :label \"go\"}]]"
547+ );
548+ }
549+
550+ #[test]
551+ fn hbox_and_vbox_are_one_node() {
552+ let mut tree = Tree::new();
553+ let h = tree.new_node("hbox");
554+ let v = tree.new_node("vbox");
555+ assert_eq!(tree.tag_name(h), "box");
556+ assert_eq!(tree.tag_name(v), "box");
557+ }
558+
559+ #[test]
560+ fn an_unknown_tag_keeps_its_name() {
561+ let mut tree = Tree::new();
562+ let node = tree.new_node("sparkline");
563+ assert_eq!(tree.tag_name(node), "sparkline");
564+ assert_eq!(tree.tag(node), Tag::Unknown("sparkline".into()));
565+ }
566+
567+ #[test]
568+ fn removing_a_node_frees_its_subtree_and_reuses_the_handles() {
569+ let mut tree = Tree::new();
570+ let outer = tree.new_node("vbox");
571+ let inner = tree.new_node("label");
572+ tree.append(outer, inner);
573+ tree.append(tree.root(), outer);
574+ tree.remove(tree.root(), outer);
575+ assert!(!tree.exists(outer));
576+ assert!(!tree.exists(inner));
577+ assert_eq!(tree.child_count(tree.root()), 0);
578+ // The arena hands the slots back out rather than growing forever.
579+ assert!([outer, inner].contains(&tree.new_node("label")));
580+ }
581+
582+ #[test]
583+ fn a_node_cannot_become_its_own_ancestor() {
584+ let mut tree = Tree::new();
585+ let outer = tree.new_node("vbox");
586+ let inner = tree.new_node("vbox");
587+ tree.append(outer, inner);
588+ assert!(!tree.append(inner, outer));
589+ assert_eq!(tree.parent(outer), 0);
590+ }
591+
592+ #[test]
593+ fn insert_after_zero_is_the_first_position() {
594+ let mut tree = Tree::new();
595+ let (a, b, c) = (
596+ tree.new_node("label"),
597+ tree.new_node("label"),
598+ tree.new_node("label"),
599+ );
600+ let root = tree.root();
601+ tree.append(root, a);
602+ tree.append(root, b);
603+ tree.insert_after(root, c, 0);
604+ assert_eq!(tree.children(root), vec![c, a, b]);
605+ tree.insert_after(root, c, a);
606+ assert_eq!(tree.children(root), vec![a, c, b]);
607+ }
608+
609+ #[test]
610+ fn replace_keeps_the_position_and_frees_the_old_node() {
611+ let mut tree = Tree::new();
612+ let root = tree.root();
613+ let (a, b) = (tree.new_node("label"), tree.new_node("label"));
614+ tree.append(root, a);
615+ tree.append(root, b);
616+ let fresh = tree.new_node("button");
617+ assert!(tree.replace(root, a, fresh));
618+ assert_eq!(tree.children(root), vec![fresh, b]);
619+ assert!(!tree.exists(a));
620+ }
621+
622+ #[test]
623+ fn the_root_cannot_be_freed() {
624+ let mut tree = Tree::new();
625+ let root = tree.root();
626+ tree.free_node(root);
627+ assert!(tree.exists(root));
628+ }
629+
630+ #[test]
631+ fn an_event_for_a_freed_node_never_reaches_the_caller() {
632+ let (mut tree, button) = tree_with_button();
633+ tree.emit(button, "click", String::new(), 0.0);
634+ tree.remove(tree.root(), button);
635+ assert!(!tree.poll());
636+ }
637+
638+ #[test]
639+ fn props_of_the_wrong_type_read_as_the_default() {
640+ let mut tree = Tree::new();
641+ let node = tree.new_node("progress");
642+ tree.set(node, "value", Value::Str("lots".into()));
643+ let props = tree.props(node);
644+ assert_eq!(props.num("value", 0.5), 0.5);
645+ assert_eq!(props.cells("width-request", 7), 7);
646+ }
647+
648+ #[test]
649+ fn label_and_text_are_the_same_prop() {
650+ let mut tree = Tree::new();
651+ let node = tree.new_node("label");
652+ tree.set(node, "text", Value::Str("hello".into()));
653+ assert_eq!(tree.props(node).label(), "hello");
654+ tree.set(node, "label", Value::Str("hi".into()));
655+ assert_eq!(tree.props(node).label(), "hi");
656+ }
657+}
new file mode 100644
@@ -0,0 +1,657 @@
1+//! A retained node tree, painted into a character grid.
2+//!
3+//! The same arena glimmer's reconciler expects everywhere else: nodes are
4+//! integer handles, `create` / `apply-props!` / `append-child!` mutate them,
5+//! and nothing is drawn until the frame call walks the whole thing at once.
6+//! Interactions come back as a queue the caller drains, because a jolt closure
7+//! cannot be a callback down here — identity crosses the boundary instead.
8+//!
9+//! This module knows nothing about terminals. It is the data; [`crate::layout`]
10+//! measures it and [`crate::paint`] draws it.
11+
12+use std::collections::{HashMap, VecDeque};
13+
14+/// A prop value: the three types the ABI can carry, which is all glimmer needs.
15+/// Keywords and colours arrive as strings, numbers as doubles, flags as ints.
16+#[derive(Clone, Debug, PartialEq)]
17+pub enum Value {
18+ Str(String),
19+ Num(f64),
20+ Bool(bool),
21+}
22+
23+/// What a node renders as.
24+///
25+/// An unknown tag is kept rather than refused — it paints as a vertical box, so
26+/// a component written against a tag this backend has not grown yet still shows
27+/// its children instead of nothing.
28+#[derive(Clone, Debug, PartialEq, Eq)]
29+pub enum Tag {
30+ Window,
31+ Box,
32+ Frame,
33+ Scroll,
34+ Overlay,
35+ Label,
36+ Title,
37+ DimLabel,
38+ Button,
39+ CheckButton,
40+ Entry,
41+ Separator,
42+ Spacer,
43+ Listbox,
44+ Progress,
45+ Spinner,
46+ Unknown(String),
47+}
48+
49+impl Default for Tag {
50+ fn default() -> Self {
51+ Self::Unknown(String::new())
52+ }
53+}
54+
55+impl Tag {
56+ fn parse(name: &str) -> Self {
57+ match name {
58+ "window" => Self::Window,
59+ "box" | "hbox" | "vbox" => Self::Box,
60+ "frame" => Self::Frame,
61+ "scroll" => Self::Scroll,
62+ "overlay" => Self::Overlay,
63+ "label" => Self::Label,
64+ "title" | "title-2" => Self::Title,
65+ "dim-label" => Self::DimLabel,
66+ "button" => Self::Button,
67+ "checkbutton" | "checkbox" => Self::CheckButton,
68+ "entry" => Self::Entry,
69+ "separator" => Self::Separator,
70+ "spacer" | "gap" => Self::Spacer,
71+ "listbox" => Self::Listbox,
72+ "progress" => Self::Progress,
73+ "spinner" => Self::Spinner,
74+ other => Self::Unknown(other.to_owned()),
75+ }
76+ }
77+
78+ /// The canonical name: `hbox` and `vbox` are one node, so both answer
79+ /// `box` and carry their orientation in a prop.
80+ pub fn name(&self) -> &str {
81+ match self {
82+ Self::Window => "window",
83+ Self::Box => "box",
84+ Self::Frame => "frame",
85+ Self::Scroll => "scroll",
86+ Self::Overlay => "overlay",
87+ Self::Label => "label",
88+ Self::Title => "title",
89+ Self::DimLabel => "dim-label",
90+ Self::Button => "button",
91+ Self::CheckButton => "checkbutton",
92+ Self::Entry => "entry",
93+ Self::Separator => "separator",
94+ Self::Spacer => "spacer",
95+ Self::Listbox => "listbox",
96+ Self::Progress => "progress",
97+ Self::Spinner => "spinner",
98+ Self::Unknown(name) => name,
99+ }
100+ }
101+
102+ /// Whether the focus ring stops here. A container never takes focus of its
103+ /// own; a control that does nothing with a key does not either.
104+ pub fn focusable(&self) -> bool {
105+ matches!(
106+ self,
107+ Self::Button | Self::CheckButton | Self::Entry | Self::Listbox
108+ )
109+ }
110+}
111+
112+/// One interaction, waiting to be drained by the caller. Names are glimmer's
113+/// handler props with the `on-` dropped.
114+#[derive(Clone, Debug, PartialEq)]
115+pub struct Event {
116+ pub node: u32,
117+ pub name: &'static str,
118+ pub text: String,
119+ pub num: f64,
120+}
121+
122+#[derive(Clone, Debug, Default)]
123+struct Node {
124+ tag: Tag,
125+ props: HashMap<String, Value>,
126+ children: Vec<u32>,
127+ /// 0 when unparented. The root's parent is 0 as well, which is what stops
128+ /// the ancestor walk in [`Tree::would_cycle`].
129+ parent: u32,
130+}
131+
132+/// A node's props, copied out for the duration of one measure or paint.
133+///
134+/// Reading them through this rather than the map means a missing prop and a
135+/// prop of the wrong type answer the same thing: the default. Nothing a caller
136+/// can write should be able to make a widget vanish.
137+#[derive(Clone, Debug, Default)]
138+pub struct Props(pub HashMap<String, Value>);
139+
140+impl Props {
141+ pub fn str(&self, key: &str) -> &str {
142+ match self.0.get(key) {
143+ Some(Value::Str(s)) => s,
144+ _ => "",
145+ }
146+ }
147+
148+ pub fn num(&self, key: &str, fallback: f64) -> f64 {
149+ match self.0.get(key) {
150+ Some(Value::Num(n)) => *n,
151+ Some(Value::Bool(b)) => {
152+ if *b {
153+ 1.0
154+ } else {
155+ 0.0
156+ }
157+ }
158+ _ => fallback,
159+ }
160+ }
161+
162+ /// A count of cells. Negative and absurd values are clamped rather than
163+ /// cast, since `as u16` on a negative double is a silent 0 or 65535.
164+ pub fn cells(&self, key: &str, fallback: u16) -> u16 {
165+ match self.0.get(key) {
166+ Some(Value::Num(n)) if n.is_finite() => n.clamp(0.0, u16::MAX as f64) as u16,
167+ _ => fallback,
168+ }
169+ }
170+
171+ pub fn bool(&self, key: &str, fallback: bool) -> bool {
172+ match self.0.get(key) {
173+ Some(Value::Bool(b)) => *b,
174+ Some(Value::Num(n)) => *n != 0.0,
175+ Some(Value::Str(s)) => s == "true",
176+ _ => fallback,
177+ }
178+ }
179+
180+ pub fn has(&self, key: &str) -> bool {
181+ self.0.contains_key(key)
182+ }
183+
184+ /// The text a widget shows. `:label` and `:text` are the same prop to every
185+ /// glimmer backend; whichever the caller wrote is the one that shows.
186+ pub fn label(&self) -> &str {
187+ if self.has("label") {
188+ self.str("label")
189+ } else {
190+ self.str("text")
191+ }
192+ }
193+}
194+
195+/// One prop value as EDN. Whole numbers print without a trailing `.0`: every
196+/// number crossed the boundary as a double, and `{:spacing 8}` reads better
197+/// than `{:spacing 8.0}`.
198+fn write_value(value: &Value, out: &mut String) {
199+ match value {
200+ Value::Bool(b) => out.push_str(if *b { "true" } else { "false" }),
201+ Value::Num(n) => {
202+ if n.is_finite() && n.fract() == 0.0 && n.abs() < 1e15 {
203+ out.push_str(&format!("{}", *n as i64));
204+ } else if n.is_finite() {
205+ out.push_str(&format!("{n}"));
206+ } else {
207+ // EDN has no infinity or NaN literal; say nil rather than emit
208+ // something no reader will take.
209+ out.push_str("nil");
210+ }
211+ }
212+ Value::Str(text) => {
213+ out.push('"');
214+ for c in text.chars() {
215+ match c {
216+ '"' => out.push_str("\\\""),
217+ '\\' => out.push_str("\\\\"),
218+ '\n' => out.push_str("\\n"),
219+ '\r' => out.push_str("\\r"),
220+ '\t' => out.push_str("\\t"),
221+ _ => out.push(c),
222+ }
223+ }
224+ out.push('"');
225+ }
226+ }
227+}
228+
229+pub struct Tree {
230+ /// Index 0 is never handed out: 0 is "no node" throughout the ABI.
231+ nodes: Vec<Option<Node>>,
232+ free: Vec<u32>,
233+ root: u32,
234+ pending: VecDeque<Event>,
235+ current: Option<Event>,
236+}
237+
238+impl Default for Tree {
239+ fn default() -> Self {
240+ Self::new()
241+ }
242+}
243+
244+impl Tree {
245+ pub fn new() -> Self {
246+ let mut tree = Self {
247+ nodes: vec![None],
248+ free: Vec::new(),
249+ root: 0,
250+ pending: VecDeque::new(),
251+ current: None,
252+ };
253+ tree.root = tree.new_node("window");
254+ tree
255+ }
256+
257+ pub fn root(&self) -> u32 {
258+ self.root
259+ }
260+
261+ fn slot(&self, id: u32) -> Option<&Node> {
262+ self.nodes.get(id as usize).and_then(|n| n.as_ref())
263+ }
264+
265+ fn slot_mut(&mut self, id: u32) -> Option<&mut Node> {
266+ self.nodes.get_mut(id as usize).and_then(|n| n.as_mut())
267+ }
268+
269+ pub fn exists(&self, id: u32) -> bool {
270+ self.slot(id).is_some()
271+ }
272+
273+ pub fn new_node(&mut self, tag: &str) -> u32 {
274+ let node = Node {
275+ tag: Tag::parse(tag),
276+ ..Node::default()
277+ };
278+ match self.free.pop() {
279+ Some(id) => {
280+ self.nodes[id as usize] = Some(node);
281+ id
282+ }
283+ None => {
284+ self.nodes.push(Some(node));
285+ (self.nodes.len() - 1) as u32
286+ }
287+ }
288+ }
289+
290+ /// Free `id` and everything under it. The root is refused: the window node
291+ /// is the one thing a caller cannot drop out from under itself.
292+ pub fn free_node(&mut self, id: u32) {
293+ if id == self.root || !self.exists(id) {
294+ return;
295+ }
296+ let parent = self.slot(id).map(|n| n.parent).unwrap_or(0);
297+ if parent != 0 {
298+ if let Some(node) = self.slot_mut(parent) {
299+ node.children.retain(|c| *c != id);
300+ }
301+ }
302+ self.free_subtree(id);
303+ }
304+
305+ fn free_subtree(&mut self, id: u32) {
306+ let children = self
307+ .slot(id)
308+ .map(|n| n.children.clone())
309+ .unwrap_or_default();
310+ for child in children {
311+ self.free_subtree(child);
312+ }
313+ if self.nodes[id as usize].take().is_some() {
314+ self.free.push(id);
315+ }
316+ // An event queued against a node that has since gone would be routed to
317+ // a handler the reconciler has already dropped. Drop it here instead.
318+ self.pending.retain(|e| e.node != id);
319+ }
320+
321+ /// Whether making `child` a child of `parent` would make a loop — `child`
322+ /// being `parent` or one of its ancestors.
323+ fn would_cycle(&self, parent: u32, child: u32) -> bool {
324+ let mut at = parent;
325+ while at != 0 {
326+ if at == child {
327+ return true;
328+ }
329+ at = match self.slot(at) {
330+ Some(node) => node.parent,
331+ None => return false,
332+ };
333+ }
334+ false
335+ }
336+
337+ fn unparent(&mut self, child: u32) {
338+ let parent = self.slot(child).map(|n| n.parent).unwrap_or(0);
339+ if parent != 0 {
340+ if let Some(node) = self.slot_mut(parent) {
341+ node.children.retain(|c| *c != child);
342+ }
343+ }
344+ if let Some(node) = self.slot_mut(child) {
345+ node.parent = 0;
346+ }
347+ }
348+
349+ pub fn append(&mut self, parent: u32, child: u32) -> bool {
350+ if !self.exists(parent) || !self.exists(child) || self.would_cycle(parent, child) {
351+ return false;
352+ }
353+ self.unparent(child);
354+ self.slot_mut(parent).unwrap().children.push(child);
355+ self.slot_mut(child).unwrap().parent = parent;
356+ true
357+ }
358+
359+ /// Unparent *and* free `child`, which is what the reconciler means by
360+ /// remove: a node it has taken out of the tree is a node it has dropped.
361+ pub fn remove(&mut self, parent: u32, child: u32) {
362+ if self.slot(child).map(|n| n.parent) == Some(parent) {
363+ self.free_node(child);
364+ }
365+ }
366+
367+ /// Move `child` after `sibling`; `sibling` 0 means the first position.
368+ pub fn insert_after(&mut self, parent: u32, child: u32, sibling: u32) -> bool {
369+ if !self.exists(parent) || !self.exists(child) || self.would_cycle(parent, child) {
370+ return false;
371+ }
372+ if sibling != 0 && self.slot(sibling).map(|n| n.parent) != Some(parent) {
373+ return false;
374+ }
375+ self.unparent(child);
376+ let at = match sibling {
377+ 0 => 0,
378+ _ => {
379+ let children = &self.slot(parent).unwrap().children;
380+ children
381+ .iter()
382+ .position(|c| *c == sibling)
383+ .map_or(0, |i| i + 1)
384+ }
385+ };
386+ self.slot_mut(parent).unwrap().children.insert(at, child);
387+ self.slot_mut(child).unwrap().parent = parent;
388+ true
389+ }
390+
391+ /// Put `new` where `old` was, and free `old`.
392+ pub fn replace(&mut self, parent: u32, old: u32, new: u32) -> bool {
393+ if self.slot(old).map(|n| n.parent) != Some(parent) || !self.exists(new) {
394+ return false;
395+ }
396+ if self.would_cycle(parent, new) {
397+ return false;
398+ }
399+ self.unparent(new);
400+ let at = self
401+ .slot(parent)
402+ .and_then(|n| n.children.iter().position(|c| *c == old));
403+ let Some(at) = at else { return false };
404+ self.slot_mut(parent).unwrap().children[at] = new;
405+ self.slot_mut(new).unwrap().parent = parent;
406+ if let Some(node) = self.slot_mut(old) {
407+ node.parent = 0;
408+ }
409+ self.free_subtree(old);
410+ true
411+ }
412+
413+ // ── reading it back ─────────────────────────────────────────────────────
414+
415+ pub fn tag(&self, id: u32) -> Tag {
416+ self.slot(id).map(|n| n.tag.clone()).unwrap_or_default()
417+ }
418+
419+ pub fn tag_name(&self, id: u32) -> &str {
420+ self.slot(id).map_or("", |n| n.tag.name())
421+ }
422+
423+ pub fn children(&self, id: u32) -> Vec<u32> {
424+ self.slot(id)
425+ .map(|n| n.children.clone())
426+ .unwrap_or_default()
427+ }
428+
429+ pub fn child_count(&self, id: u32) -> usize {
430+ self.slot(id).map_or(0, |n| n.children.len())
431+ }
432+
433+ pub fn child_at(&self, id: u32, index: usize) -> u32 {
434+ self.slot(id)
435+ .and_then(|n| n.children.get(index).copied())
436+ .unwrap_or(0)
437+ }
438+
439+ pub fn parent(&self, id: u32) -> u32 {
440+ self.slot(id).map_or(0, |n| n.parent)
441+ }
442+
443+ pub fn props(&self, id: u32) -> Props {
444+ Props(self.slot(id).map(|n| n.props.clone()).unwrap_or_default())
445+ }
446+
447+ pub fn set(&mut self, id: u32, key: &str, value: Value) {
448+ if let Some(node) = self.slot_mut(id) {
449+ node.props.insert(key.to_owned(), value);
450+ }
451+ }
452+
453+ pub fn clear_props(&mut self, id: u32) {
454+ if let Some(node) = self.slot_mut(id) {
455+ node.props.clear();
456+ }
457+ }
458+
459+ pub fn get(&self, id: u32, key: &str) -> Option<&Value> {
460+ self.slot(id).and_then(|n| n.props.get(key))
461+ }
462+
463+ /// The subtree at `id` as pretty-printed hiccup — what the tree *is*, read
464+ /// back from the arena, rather than what a component meant to build.
465+ ///
466+ /// Props are sorted, so two dumps of the same tree compare as text.
467+ pub fn dump(&self, id: u32) -> String {
468+ let mut out = String::new();
469+ self.dump_into(id, 0, &mut out);
470+ out
471+ }
472+
473+ fn dump_into(&self, id: u32, depth: usize, out: &mut String) {
474+ let Some(node) = self.slot(id) else {
475+ out.push_str("nil");
476+ return;
477+ };
478+ let indent = " ".repeat(depth);
479+ out.push_str("[:");
480+ out.push_str(node.tag.name());
481+
482+ let mut keys: Vec<&String> = node.props.keys().collect();
483+ keys.sort();
484+ out.push_str(" {");
485+ for (i, key) in keys.iter().enumerate() {
486+ if i > 0 {
487+ out.push(' ');
488+ }
489+ out.push(':');
490+ out.push_str(key);
491+ out.push(' ');
492+ write_value(&node.props[*key], out);
493+ }
494+ out.push('}');
495+
496+ for child in &node.children {
497+ out.push('\n');
498+ out.push_str(&indent);
499+ out.push_str(" ");
500+ self.dump_into(*child, depth + 1, out);
501+ }
502+ out.push(']');
503+ }
504+
505+ // ── events ──────────────────────────────────────────────────────────────
506+
507+ pub fn emit(&mut self, node: u32, name: &'static str, text: String, num: f64) {
508+ self.pending.push_back(Event {
509+ node,
510+ name,
511+ text,
512+ num,
513+ });
514+ }
515+
516+ /// Dequeue one event into the accessor slot. False when the queue is empty.
517+ pub fn poll(&mut self) -> bool {
518+ self.current = self.pending.pop_front();
519+ self.current.is_some()
520+ }
521+
522+ pub fn current(&self) -> Option<&Event> {
523+ self.current.as_ref()
524+ }
525+}
526+
527+#[cfg(test)]
528+mod tests {
529+ use super::*;
530+
531+ fn tree_with_button() -> (Tree, u32) {
532+ let mut tree = Tree::new();
533+ let button = tree.new_node("button");
534+ tree.set(button, "label", Value::Str("go".into()));
535+ let root = tree.root();
536+ tree.append(root, button);
537+ (tree, button)
538+ }
539+
540+ #[test]
541+ fn a_dump_is_the_tree_as_hiccup_with_sorted_props() {
542+ let (mut tree, button) = tree_with_button();
543+ tree.set(button, "kind", Value::Str("primary".into()));
544+ assert_eq!(
545+ tree.dump(tree.root()),
546+ "[:window {}\n [:button {:kind \"primary\" :label \"go\"}]]"
547+ );
548+ }
549+
550+ #[test]
551+ fn hbox_and_vbox_are_one_node() {
552+ let mut tree = Tree::new();
553+ let h = tree.new_node("hbox");
554+ let v = tree.new_node("vbox");
555+ assert_eq!(tree.tag_name(h), "box");
556+ assert_eq!(tree.tag_name(v), "box");
557+ }
558+
559+ #[test]
560+ fn an_unknown_tag_keeps_its_name() {
561+ let mut tree = Tree::new();
562+ let node = tree.new_node("sparkline");
563+ assert_eq!(tree.tag_name(node), "sparkline");
564+ assert_eq!(tree.tag(node), Tag::Unknown("sparkline".into()));
565+ }
566+
567+ #[test]
568+ fn removing_a_node_frees_its_subtree_and_reuses_the_handles() {
569+ let mut tree = Tree::new();
570+ let outer = tree.new_node("vbox");
571+ let inner = tree.new_node("label");
572+ tree.append(outer, inner);
573+ tree.append(tree.root(), outer);
574+ tree.remove(tree.root(), outer);
575+ assert!(!tree.exists(outer));
576+ assert!(!tree.exists(inner));
577+ assert_eq!(tree.child_count(tree.root()), 0);
578+ // The arena hands the slots back out rather than growing forever.
579+ assert!([outer, inner].contains(&tree.new_node("label")));
580+ }
581+
582+ #[test]
583+ fn a_node_cannot_become_its_own_ancestor() {
584+ let mut tree = Tree::new();
585+ let outer = tree.new_node("vbox");
586+ let inner = tree.new_node("vbox");
587+ tree.append(outer, inner);
588+ assert!(!tree.append(inner, outer));
589+ assert_eq!(tree.parent(outer), 0);
590+ }
591+
592+ #[test]
593+ fn insert_after_zero_is_the_first_position() {
594+ let mut tree = Tree::new();
595+ let (a, b, c) = (
596+ tree.new_node("label"),
597+ tree.new_node("label"),
598+ tree.new_node("label"),
599+ );
600+ let root = tree.root();
601+ tree.append(root, a);
602+ tree.append(root, b);
603+ tree.insert_after(root, c, 0);
604+ assert_eq!(tree.children(root), vec![c, a, b]);
605+ tree.insert_after(root, c, a);
606+ assert_eq!(tree.children(root), vec![a, c, b]);
607+ }
608+
609+ #[test]
610+ fn replace_keeps_the_position_and_frees_the_old_node() {
611+ let mut tree = Tree::new();
612+ let root = tree.root();
613+ let (a, b) = (tree.new_node("label"), tree.new_node("label"));
614+ tree.append(root, a);
615+ tree.append(root, b);
616+ let fresh = tree.new_node("button");
617+ assert!(tree.replace(root, a, fresh));
618+ assert_eq!(tree.children(root), vec![fresh, b]);
619+ assert!(!tree.exists(a));
620+ }
621+
622+ #[test]
623+ fn the_root_cannot_be_freed() {
624+ let mut tree = Tree::new();
625+ let root = tree.root();
626+ tree.free_node(root);
627+ assert!(tree.exists(root));
628+ }
629+
630+ #[test]
631+ fn an_event_for_a_freed_node_never_reaches_the_caller() {
632+ let (mut tree, button) = tree_with_button();
633+ tree.emit(button, "click", String::new(), 0.0);
634+ tree.remove(tree.root(), button);
635+ assert!(!tree.poll());
636+ }
637+
638+ #[test]
639+ fn props_of_the_wrong_type_read_as_the_default() {
640+ let mut tree = Tree::new();
641+ let node = tree.new_node("progress");
642+ tree.set(node, "value", Value::Str("lots".into()));
643+ let props = tree.props(node);
644+ assert_eq!(props.num("value", 0.5), 0.5);
645+ assert_eq!(props.cells("width-request", 7), 7);
646+ }
647+
648+ #[test]
649+ fn label_and_text_are_the_same_prop() {
650+ let mut tree = Tree::new();
651+ let node = tree.new_node("label");
652+ tree.set(node, "text", Value::Str("hello".into()));
653+ assert_eq!(tree.props(node).label(), "hello");
654+ tree.set(node, "label", Value::Str("hi".into()));
655+ assert_eq!(tree.props(node).label(), "hi");
656+ }
657+}
added crates/jolt-tui/src/ui.rs +411 -0
new file mode 100644
@@ -0,0 +1,411 @@
1+//! The session: a tree, a screen, and where the focus and the caret are.
2+//!
3+//! This is the whole backend minus the terminal. It paints into a grid, takes
4+//! keys and clicks by name, and answers events — so the entire widget layer,
5+//! keyboard navigation included, runs in a test with no TTY, no raw mode and no
6+//! display. `tui_headless` opens exactly this and nothing else.
7+//!
8+//! Keys arrive already named (`"ctrl+u"`, `"page-down"`, `"a"`); turning a
9+//! terminal's bytes into those names is [`crate::keys`]'s job, and a caller
10+//! synthesising one for a test writes the name directly.
11+
12+use crate::keys;
13+use crate::paint::{self, Painted};
14+use crate::screen::Screen;
15+use crate::tree::{Tag, Tree, Value};
16+
17+pub struct Ui {
18+ pub tree: Tree,
19+ pub screen: Screen,
20+ /// The node the focus ring is on, 0 for none.
21+ focus: u32,
22+ /// The caret in the focused entry, in characters from the start.
23+ caret: usize,
24+ painted: Painted,
25+ tick: u64,
26+ quit: bool,
27+}
28+
29+impl Ui {
30+ pub fn new(width: u16, height: u16) -> Self {
31+ Self {
32+ tree: Tree::new(),
33+ screen: Screen::new(width.max(1), height.max(1)),
34+ focus: 0,
35+ caret: 0,
36+ painted: Painted::default(),
37+ tick: 0,
38+ quit: false,
39+ }
40+ }
41+
42+ pub fn resize(&mut self, width: u16, height: u16) {
43+ self.screen.resize(width.max(1), height.max(1));
44+ }
45+
46+ pub fn should_close(&self) -> bool {
47+ self.quit
48+ }
49+
50+ pub fn quit(&mut self) {
51+ self.quit = true;
52+ }
53+
54+ pub fn focus(&self) -> u32 {
55+ self.focus
56+ }
57+
58+ pub fn cursor(&self) -> Option<(u16, u16)> {
59+ self.painted.cursor
60+ }
61+
62+ /// Paint one frame, then settle the things painting decided: what is
63+ /// focusable now, and how far each scroll area really is.
64+ pub fn frame(&mut self) {
65+ self.tick = self.tick.wrapping_add(1);
66+ self.paint_once();
67+ if self.settle_focus() {
68+ // Focus is decided by what the paint found, so the frame that
69+ // gives it away has to be drawn again — otherwise the first frame
70+ // of a screen shows nothing focused and the second one does.
71+ self.paint_once();
72+ }
73+ for (node, offset) in self.painted.scrolled.clone() {
74+ // Painting clamps the viewport to the content; write the clamped
75+ // value back so the caller's next `+1` starts from the truth.
76+ if self.tree.props(node).cells("offset", 0) != offset {
77+ self.tree.set(node, "offset", Value::Num(offset as f64));
78+ }
79+ }
80+ }
81+
82+ fn paint_once(&mut self) {
83+ self.painted = paint::frame(
84+ &self.tree,
85+ &mut self.screen,
86+ self.focus,
87+ self.caret,
88+ self.tick,
89+ );
90+ }
91+
92+ /// Put the focus somewhere real. Answers whether it moved.
93+ fn settle_focus(&mut self) -> bool {
94+ let was = self.focus;
95+ // A focused widget that has since been unmounted — or dimmed — leaves
96+ // the ring, and focus lands on the first thing that is still there
97+ // rather than on nothing.
98+ if self.focus != 0 && !self.painted.ring.contains(&self.focus) {
99+ self.focus = 0;
100+ }
101+ if self.focus == 0 {
102+ let wants = self
103+ .painted
104+ .ring
105+ .iter()
106+ .find(|id| self.tree.props(**id).bool("autofocus", false))
107+ .copied();
108+ if let Some(id) = wants.or_else(|| self.painted.ring.first().copied()) {
109+ self.set_focus(id);
110+ }
111+ }
112+ self.focus != was
113+ }
114+
115+ fn set_focus(&mut self, id: u32) {
116+ if self.focus == id {
117+ return;
118+ }
119+ self.focus = id;
120+ // The caret goes to the end of whatever it just entered, which is where
121+ // someone tabbing into a field with text in it expects to type.
122+ self.caret = self.tree.props(id).str("text").chars().count();
123+ }
124+
125+ fn move_focus(&mut self, forward: bool) {
126+ if self.painted.ring.is_empty() {
127+ return;
128+ }
129+ let ring = self.painted.ring.clone();
130+ let at = ring.iter().position(|id| *id == self.focus);
131+ let next = match (at, forward) {
132+ (Some(i), true) => (i + 1) % ring.len(),
133+ (Some(i), false) => (i + ring.len() - 1) % ring.len(),
134+ (None, true) => 0,
135+ (None, false) => ring.len() - 1,
136+ };
137+ self.set_focus(ring[next]);
138+ }
139+
140+ // ── keys ────────────────────────────────────────────────────────────────
141+
142+ /// Handle one key by name. Answers false when nothing here wanted it, in
143+ /// which case it has been emitted as a `key` event for the caller to route.
144+ pub fn key(&mut self, name: &str) -> bool {
145+ if matches!(name, "ctrl+c" | "ctrl+q") {
146+ self.quit = true;
147+ return true;
148+ }
149+ match name {
150+ "tab" => {
151+ self.move_focus(true);
152+ return true;
153+ }
154+ "shift+tab" | "backtab" => {
155+ self.move_focus(false);
156+ return true;
157+ }
158+ "esc" => {
159+ // Esc belongs to the topmost overlay when there is one: that is
160+ // what closes a modal everywhere else.
161+ if let Some(overlay) = self.topmost_overlay() {
162+ self.tree.emit(overlay, "close", String::new(), 0.0);
163+ return true;
164+ }
165+ }
166+ _ => {}
167+ }
168+
169+ let focus = self.focus;
170+ let handled = match self.tree.tag(focus) {
171+ Tag::Entry => self.entry_key(focus, name),
172+ Tag::Button => self.activate_key(focus, name, "click"),
173+ Tag::CheckButton => {
174+ if matches!(name, "enter" | "space") {
175+ self.toggle(focus);
176+ true
177+ } else {
178+ false
179+ }
180+ }
181+ Tag::Listbox => self.listbox_key(focus, name),
182+ _ => false,
183+ };
184+ if !handled {
185+ // Unhandled keys go to the caller as an event on the focused node,
186+ // or on the window when nothing has focus. glimmer bubbles from
187+ // there; it holds the handlers and knows the tree.
188+ let target = if focus != 0 { focus } else { self.tree.root() };
189+ self.tree.emit(target, "key", name.to_owned(), 0.0);
190+ }
191+ handled
192+ }
193+
194+ fn topmost_overlay(&self) -> Option<u32> {
195+ fn walk(tree: &Tree, id: u32, found: &mut Option<u32>) {
196+ if matches!(tree.tag(id), Tag::Overlay) {
197+ *found = Some(id);
198+ }
199+ for child in tree.children(id) {
200+ walk(tree, child, found);
201+ }
202+ }
203+ let mut found = None;
204+ walk(&self.tree, self.tree.root(), &mut found);
205+ found
206+ }
207+
208+ fn activate_key(&mut self, node: u32, name: &str, event: &'static str) -> bool {
209+ if matches!(name, "enter" | "space") {
210+ self.tree.emit(node, event, String::new(), 0.0);
211+ true
212+ } else {
213+ false
214+ }
215+ }
216+
217+ fn toggle(&mut self, node: u32) {
218+ let now = !self.tree.props(node).bool("active", false);
219+ // The widget does not own its value, but it does keep working when the
220+ // caller ignores the event: the new state is written back here, and the
221+ // next prop write from the reconciler is what settles it.
222+ self.tree.set(node, "active", Value::Bool(now));
223+ self.tree
224+ .emit(node, "toggled", String::new(), if now { 1.0 } else { 0.0 });
225+ }
226+
227+ fn entry_key(&mut self, node: u32, name: &str) -> bool {
228+ let mut text: Vec<char> = self.tree.props(node).str("text").chars().collect();
229+ let mut at = self.caret.min(text.len());
230+ let mut changed = false;
231+ match name {
232+ "enter" => {
233+ let now: String = text.iter().collect();
234+ self.tree.emit(node, "activate", now, 0.0);
235+ return true;
236+ }
237+ "left" | "ctrl+b" => at = at.saturating_sub(1),
238+ "right" | "ctrl+f" => at = (at + 1).min(text.len()),
239+ "home" | "ctrl+a" => at = 0,
240+ "end" | "ctrl+e" => at = text.len(),
241+ "alt+b" => at = keys::word_left(&text, at),
242+ "alt+f" => at = keys::word_right(&text, at),
243+ "backspace" => {
244+ if at > 0 {
245+ text.remove(at - 1);
246+ at -= 1;
247+ changed = true;
248+ }
249+ }
250+ "delete" | "ctrl+d" => {
251+ if at < text.len() {
252+ text.remove(at);
253+ changed = true;
254+ }
255+ }
256+ "ctrl+w" | "alt+backspace" => {
257+ let from = keys::word_left(&text, at);
258+ if from < at {
259+ text.drain(from..at);
260+ at = from;
261+ changed = true;
262+ }
263+ }
264+ "ctrl+u" => {
265+ if at > 0 {
266+ text.drain(0..at);
267+ at = 0;
268+ changed = true;
269+ }
270+ }
271+ "ctrl+k" => {
272+ if at < text.len() {
273+ text.truncate(at);
274+ changed = true;
275+ }
276+ }
277+ "space" => {
278+ text.insert(at, ' ');
279+ at += 1;
280+ changed = true;
281+ }
282+ other => {
283+ // A single character with no modifier on it is text.
284+ let mut chars = other.chars();
285+ match (chars.next(), chars.next()) {
286+ (Some(ch), None) if !ch.is_control() => {
287+ text.insert(at, ch);
288+ at += 1;
289+ changed = true;
290+ }
291+ _ => return false,
292+ }
293+ }
294+ }
295+ self.caret = at;
296+ if changed {
297+ let now: String = text.iter().collect();
298+ self.tree.set(node, "text", Value::Str(now.clone()));
299+ self.tree.emit(node, "change", now, 0.0);
300+ }
301+ true
302+ }
303+
304+ fn listbox_key(&mut self, node: u32, name: &str) -> bool {
305+ let count = self.tree.child_count(node) as i64;
306+ if count == 0 {
307+ return false;
308+ }
309+ let page = self
310+ .painted
311+ .hits
312+ .iter()
313+ .find(|(id, _)| *id == node)
314+ .map_or(1, |(_, rect)| rect.h.max(1) as i64);
315+ let at = self.tree.props(node).num("selected", 0.0) as i64;
316+ let to = match name {
317+ "down" | "j" | "ctrl+n" => at + 1,
318+ "up" | "k" | "ctrl+p" => at - 1,
319+ "page-down" | "ctrl+d" => at + page,
320+ "page-up" | "ctrl+u" => at - page,
321+ "home" | "g" => 0,
322+ "end" | "G" => count - 1,
323+ "enter" | "space" => {
324+ let index = at.clamp(0, count - 1);
325+ let item = self.tree.child_at(node, index as usize);
326+ let label = self.tree.props(item).label().to_owned();
327+ self.tree.emit(node, "activate", label, index as f64);
328+ return true;
329+ }
330+ _ => return false,
331+ };
332+ self.select(node, to.clamp(0, count - 1));
333+ true
334+ }
335+
336+ fn select(&mut self, node: u32, index: i64) {
337+ if self.tree.props(node).num("selected", -1.0) as i64 == index {
338+ return;
339+ }
340+ self.tree.set(node, "selected", Value::Num(index as f64));
341+ let item = self.tree.child_at(node, index as usize);
342+ let label = self.tree.props(item).label().to_owned();
343+ self.tree.emit(node, "select", label, index as f64);
344+ }
345+
346+ // ── mouse ───────────────────────────────────────────────────────────────
347+
348+ /// A click at a cell. Focuses whatever is under it and activates it, which
349+ /// is the whole of button 1 in a terminal: there is no press and release to
350+ /// tell apart at this level.
351+ pub fn click(&mut self, x: u16, y: u16) -> bool {
352+ let Some((node, rect)) = self
353+ .painted
354+ .hits
355+ .iter()
356+ .find(|(_, rect)| rect.contains(x, y))
357+ .copied()
358+ else {
359+ return false;
360+ };
361+ self.set_focus(node);
362+ match self.tree.tag(node) {
363+ Tag::Button => self.tree.emit(node, "click", String::new(), 0.0),
364+ Tag::CheckButton => self.toggle(node),
365+ Tag::Listbox => {
366+ let row = (y - rect.y) as i64;
367+ let count = self.tree.child_count(node) as i64;
368+ if count > 0 {
369+ self.select(node, row.clamp(0, count - 1));
370+ }
371+ }
372+ Tag::Entry => {
373+ // Put the caret where it was clicked, not at the end.
374+ let text = self.tree.props(node).str("text").chars().count();
375+ self.caret = ((x - rect.x) as usize).min(text);
376+ }
377+ _ => {}
378+ }
379+ true
380+ }
381+
382+ /// The wheel, `by` rows — negative is up. It moves the innermost `:scroll`
383+ /// under the pointer, which is the one a reader means.
384+ pub fn wheel(&mut self, x: u16, y: u16, by: i32) -> bool {
385+ let Some(node) = self.scroll_at(self.tree.root(), x, y) else {
386+ return false;
387+ };
388+ let now = self.tree.props(node).cells("offset", 0) as i32;
389+ let to = (now + by).max(0) as f64;
390+ self.tree.set(node, "offset", Value::Num(to));
391+ self.tree.emit(node, "scroll", String::new(), to);
392+ true
393+ }
394+
395+ /// The innermost `:scroll` whose painted area holds this cell.
396+ fn scroll_at(&self, id: u32, x: u16, y: u16) -> Option<u32> {
397+ for child in self.tree.children(id) {
398+ if let Some(inner) = self.scroll_at(child, x, y) {
399+ return Some(inner);
400+ }
401+ }
402+ // Scroll areas take no focus, so they are not in the hit list; the
403+ // frame records the ones it painted, which is enough for a wheel.
404+ let painted = self.painted.scrolled.iter().any(|(n, _)| *n == id);
405+ if painted && matches!(self.tree.tag(id), Tag::Scroll) && self.screen.rect().contains(x, y)
406+ {
407+ return Some(id);
408+ }
409+ None
410+ }
411+}
new file mode 100644
@@ -0,0 +1,411 @@
1+//! The session: a tree, a screen, and where the focus and the caret are.
2+//!
3+//! This is the whole backend minus the terminal. It paints into a grid, takes
4+//! keys and clicks by name, and answers events — so the entire widget layer,
5+//! keyboard navigation included, runs in a test with no TTY, no raw mode and no
6+//! display. `tui_headless` opens exactly this and nothing else.
7+//!
8+//! Keys arrive already named (`"ctrl+u"`, `"page-down"`, `"a"`); turning a
9+//! terminal's bytes into those names is [`crate::keys`]'s job, and a caller
10+//! synthesising one for a test writes the name directly.
11+
12+use crate::keys;
13+use crate::paint::{self, Painted};
14+use crate::screen::Screen;
15+use crate::tree::{Tag, Tree, Value};
16+
17+pub struct Ui {
18+ pub tree: Tree,
19+ pub screen: Screen,
20+ /// The node the focus ring is on, 0 for none.
21+ focus: u32,
22+ /// The caret in the focused entry, in characters from the start.
23+ caret: usize,
24+ painted: Painted,
25+ tick: u64,
26+ quit: bool,
27+}
28+
29+impl Ui {
30+ pub fn new(width: u16, height: u16) -> Self {
31+ Self {
32+ tree: Tree::new(),
33+ screen: Screen::new(width.max(1), height.max(1)),
34+ focus: 0,
35+ caret: 0,
36+ painted: Painted::default(),
37+ tick: 0,
38+ quit: false,
39+ }
40+ }
41+
42+ pub fn resize(&mut self, width: u16, height: u16) {
43+ self.screen.resize(width.max(1), height.max(1));
44+ }
45+
46+ pub fn should_close(&self) -> bool {
47+ self.quit
48+ }
49+
50+ pub fn quit(&mut self) {
51+ self.quit = true;
52+ }
53+
54+ pub fn focus(&self) -> u32 {
55+ self.focus
56+ }
57+
58+ pub fn cursor(&self) -> Option<(u16, u16)> {
59+ self.painted.cursor
60+ }
61+
62+ /// Paint one frame, then settle the things painting decided: what is
63+ /// focusable now, and how far each scroll area really is.
64+ pub fn frame(&mut self) {
65+ self.tick = self.tick.wrapping_add(1);
66+ self.paint_once();
67+ if self.settle_focus() {
68+ // Focus is decided by what the paint found, so the frame that
69+ // gives it away has to be drawn again — otherwise the first frame
70+ // of a screen shows nothing focused and the second one does.
71+ self.paint_once();
72+ }
73+ for (node, offset) in self.painted.scrolled.clone() {
74+ // Painting clamps the viewport to the content; write the clamped
75+ // value back so the caller's next `+1` starts from the truth.
76+ if self.tree.props(node).cells("offset", 0) != offset {
77+ self.tree.set(node, "offset", Value::Num(offset as f64));
78+ }
79+ }
80+ }
81+
82+ fn paint_once(&mut self) {
83+ self.painted = paint::frame(
84+ &self.tree,
85+ &mut self.screen,
86+ self.focus,
87+ self.caret,
88+ self.tick,
89+ );
90+ }
91+
92+ /// Put the focus somewhere real. Answers whether it moved.
93+ fn settle_focus(&mut self) -> bool {
94+ let was = self.focus;
95+ // A focused widget that has since been unmounted — or dimmed — leaves
96+ // the ring, and focus lands on the first thing that is still there
97+ // rather than on nothing.
98+ if self.focus != 0 && !self.painted.ring.contains(&self.focus) {
99+ self.focus = 0;
100+ }
101+ if self.focus == 0 {
102+ let wants = self
103+ .painted
104+ .ring
105+ .iter()
106+ .find(|id| self.tree.props(**id).bool("autofocus", false))
107+ .copied();
108+ if let Some(id) = wants.or_else(|| self.painted.ring.first().copied()) {
109+ self.set_focus(id);
110+ }
111+ }
112+ self.focus != was
113+ }
114+
115+ fn set_focus(&mut self, id: u32) {
116+ if self.focus == id {
117+ return;
118+ }
119+ self.focus = id;
120+ // The caret goes to the end of whatever it just entered, which is where
121+ // someone tabbing into a field with text in it expects to type.
122+ self.caret = self.tree.props(id).str("text").chars().count();
123+ }
124+
125+ fn move_focus(&mut self, forward: bool) {
126+ if self.painted.ring.is_empty() {
127+ return;
128+ }
129+ let ring = self.painted.ring.clone();
130+ let at = ring.iter().position(|id| *id == self.focus);
131+ let next = match (at, forward) {
132+ (Some(i), true) => (i + 1) % ring.len(),
133+ (Some(i), false) => (i + ring.len() - 1) % ring.len(),
134+ (None, true) => 0,
135+ (None, false) => ring.len() - 1,
136+ };
137+ self.set_focus(ring[next]);
138+ }
139+
140+ // ── keys ────────────────────────────────────────────────────────────────
141+
142+ /// Handle one key by name. Answers false when nothing here wanted it, in
143+ /// which case it has been emitted as a `key` event for the caller to route.
144+ pub fn key(&mut self, name: &str) -> bool {
145+ if matches!(name, "ctrl+c" | "ctrl+q") {
146+ self.quit = true;
147+ return true;
148+ }
149+ match name {
150+ "tab" => {
151+ self.move_focus(true);
152+ return true;
153+ }
154+ "shift+tab" | "backtab" => {
155+ self.move_focus(false);
156+ return true;
157+ }
158+ "esc" => {
159+ // Esc belongs to the topmost overlay when there is one: that is
160+ // what closes a modal everywhere else.
161+ if let Some(overlay) = self.topmost_overlay() {
162+ self.tree.emit(overlay, "close", String::new(), 0.0);
163+ return true;
164+ }
165+ }
166+ _ => {}
167+ }
168+
169+ let focus = self.focus;
170+ let handled = match self.tree.tag(focus) {
171+ Tag::Entry => self.entry_key(focus, name),
172+ Tag::Button => self.activate_key(focus, name, "click"),
173+ Tag::CheckButton => {
174+ if matches!(name, "enter" | "space") {
175+ self.toggle(focus);
176+ true
177+ } else {
178+ false
179+ }
180+ }
181+ Tag::Listbox => self.listbox_key(focus, name),
182+ _ => false,
183+ };
184+ if !handled {
185+ // Unhandled keys go to the caller as an event on the focused node,
186+ // or on the window when nothing has focus. glimmer bubbles from
187+ // there; it holds the handlers and knows the tree.
188+ let target = if focus != 0 { focus } else { self.tree.root() };
189+ self.tree.emit(target, "key", name.to_owned(), 0.0);
190+ }
191+ handled
192+ }
193+
194+ fn topmost_overlay(&self) -> Option<u32> {
195+ fn walk(tree: &Tree, id: u32, found: &mut Option<u32>) {
196+ if matches!(tree.tag(id), Tag::Overlay) {
197+ *found = Some(id);
198+ }
199+ for child in tree.children(id) {
200+ walk(tree, child, found);
201+ }
202+ }
203+ let mut found = None;
204+ walk(&self.tree, self.tree.root(), &mut found);
205+ found
206+ }
207+
208+ fn activate_key(&mut self, node: u32, name: &str, event: &'static str) -> bool {
209+ if matches!(name, "enter" | "space") {
210+ self.tree.emit(node, event, String::new(), 0.0);
211+ true
212+ } else {
213+ false
214+ }
215+ }
216+
217+ fn toggle(&mut self, node: u32) {
218+ let now = !self.tree.props(node).bool("active", false);
219+ // The widget does not own its value, but it does keep working when the
220+ // caller ignores the event: the new state is written back here, and the
221+ // next prop write from the reconciler is what settles it.
222+ self.tree.set(node, "active", Value::Bool(now));
223+ self.tree
224+ .emit(node, "toggled", String::new(), if now { 1.0 } else { 0.0 });
225+ }
226+
227+ fn entry_key(&mut self, node: u32, name: &str) -> bool {
228+ let mut text: Vec<char> = self.tree.props(node).str("text").chars().collect();
229+ let mut at = self.caret.min(text.len());
230+ let mut changed = false;
231+ match name {
232+ "enter" => {
233+ let now: String = text.iter().collect();
234+ self.tree.emit(node, "activate", now, 0.0);
235+ return true;
236+ }
237+ "left" | "ctrl+b" => at = at.saturating_sub(1),
238+ "right" | "ctrl+f" => at = (at + 1).min(text.len()),
239+ "home" | "ctrl+a" => at = 0,
240+ "end" | "ctrl+e" => at = text.len(),
241+ "alt+b" => at = keys::word_left(&text, at),
242+ "alt+f" => at = keys::word_right(&text, at),
243+ "backspace" => {
244+ if at > 0 {
245+ text.remove(at - 1);
246+ at -= 1;
247+ changed = true;
248+ }
249+ }
250+ "delete" | "ctrl+d" => {
251+ if at < text.len() {
252+ text.remove(at);
253+ changed = true;
254+ }
255+ }
256+ "ctrl+w" | "alt+backspace" => {
257+ let from = keys::word_left(&text, at);
258+ if from < at {
259+ text.drain(from..at);
260+ at = from;
261+ changed = true;
262+ }
263+ }
264+ "ctrl+u" => {
265+ if at > 0 {
266+ text.drain(0..at);
267+ at = 0;
268+ changed = true;
269+ }
270+ }
271+ "ctrl+k" => {
272+ if at < text.len() {
273+ text.truncate(at);
274+ changed = true;
275+ }
276+ }
277+ "space" => {
278+ text.insert(at, ' ');
279+ at += 1;
280+ changed = true;
281+ }
282+ other => {
283+ // A single character with no modifier on it is text.
284+ let mut chars = other.chars();
285+ match (chars.next(), chars.next()) {
286+ (Some(ch), None) if !ch.is_control() => {
287+ text.insert(at, ch);
288+ at += 1;
289+ changed = true;
290+ }
291+ _ => return false,
292+ }
293+ }
294+ }
295+ self.caret = at;
296+ if changed {
297+ let now: String = text.iter().collect();
298+ self.tree.set(node, "text", Value::Str(now.clone()));
299+ self.tree.emit(node, "change", now, 0.0);
300+ }
301+ true
302+ }
303+
304+ fn listbox_key(&mut self, node: u32, name: &str) -> bool {
305+ let count = self.tree.child_count(node) as i64;
306+ if count == 0 {
307+ return false;
308+ }
309+ let page = self
310+ .painted
311+ .hits
312+ .iter()
313+ .find(|(id, _)| *id == node)
314+ .map_or(1, |(_, rect)| rect.h.max(1) as i64);
315+ let at = self.tree.props(node).num("selected", 0.0) as i64;
316+ let to = match name {
317+ "down" | "j" | "ctrl+n" => at + 1,
318+ "up" | "k" | "ctrl+p" => at - 1,
319+ "page-down" | "ctrl+d" => at + page,
320+ "page-up" | "ctrl+u" => at - page,
321+ "home" | "g" => 0,
322+ "end" | "G" => count - 1,
323+ "enter" | "space" => {
324+ let index = at.clamp(0, count - 1);
325+ let item = self.tree.child_at(node, index as usize);
326+ let label = self.tree.props(item).label().to_owned();
327+ self.tree.emit(node, "activate", label, index as f64);
328+ return true;
329+ }
330+ _ => return false,
331+ };
332+ self.select(node, to.clamp(0, count - 1));
333+ true
334+ }
335+
336+ fn select(&mut self, node: u32, index: i64) {
337+ if self.tree.props(node).num("selected", -1.0) as i64 == index {
338+ return;
339+ }
340+ self.tree.set(node, "selected", Value::Num(index as f64));
341+ let item = self.tree.child_at(node, index as usize);
342+ let label = self.tree.props(item).label().to_owned();
343+ self.tree.emit(node, "select", label, index as f64);
344+ }
345+
346+ // ── mouse ───────────────────────────────────────────────────────────────
347+
348+ /// A click at a cell. Focuses whatever is under it and activates it, which
349+ /// is the whole of button 1 in a terminal: there is no press and release to
350+ /// tell apart at this level.
351+ pub fn click(&mut self, x: u16, y: u16) -> bool {
352+ let Some((node, rect)) = self
353+ .painted
354+ .hits
355+ .iter()
356+ .find(|(_, rect)| rect.contains(x, y))
357+ .copied()
358+ else {
359+ return false;
360+ };
361+ self.set_focus(node);
362+ match self.tree.tag(node) {
363+ Tag::Button => self.tree.emit(node, "click", String::new(), 0.0),
364+ Tag::CheckButton => self.toggle(node),
365+ Tag::Listbox => {
366+ let row = (y - rect.y) as i64;
367+ let count = self.tree.child_count(node) as i64;
368+ if count > 0 {
369+ self.select(node, row.clamp(0, count - 1));
370+ }
371+ }
372+ Tag::Entry => {
373+ // Put the caret where it was clicked, not at the end.
374+ let text = self.tree.props(node).str("text").chars().count();
375+ self.caret = ((x - rect.x) as usize).min(text);
376+ }
377+ _ => {}
378+ }
379+ true
380+ }
381+
382+ /// The wheel, `by` rows — negative is up. It moves the innermost `:scroll`
383+ /// under the pointer, which is the one a reader means.
384+ pub fn wheel(&mut self, x: u16, y: u16, by: i32) -> bool {
385+ let Some(node) = self.scroll_at(self.tree.root(), x, y) else {
386+ return false;
387+ };
388+ let now = self.tree.props(node).cells("offset", 0) as i32;
389+ let to = (now + by).max(0) as f64;
390+ self.tree.set(node, "offset", Value::Num(to));
391+ self.tree.emit(node, "scroll", String::new(), to);
392+ true
393+ }
394+
395+ /// The innermost `:scroll` whose painted area holds this cell.
396+ fn scroll_at(&self, id: u32, x: u16, y: u16) -> Option<u32> {
397+ for child in self.tree.children(id) {
398+ if let Some(inner) = self.scroll_at(child, x, y) {
399+ return Some(inner);
400+ }
401+ }
402+ // Scroll areas take no focus, so they are not in the hit list; the
403+ // frame records the ones it painted, which is enough for a wheel.
404+ let painted = self.painted.scrolled.iter().any(|(n, _)| *n == id);
405+ if painted && matches!(self.tree.tag(id), Tag::Scroll) && self.screen.rect().contains(x, y)
406+ {
407+ return Some(id);
408+ }
409+ None
410+ }
411+}
modified justfile +1 -0
@@ -43,6 +43,7 @@ buck-build *args:
4343 mkdir -p build/lib
4444 cp "$(just buck build --show-output //:libvidya | awk '/libvidya.so/{print $2}')" build/lib/libvidya.so
4545 cp "$(just buck build --show-output //:libjoltmoq | awk '/libjoltmoq.so/{print $2}')" build/lib/libjoltmoq.so
46+ cp "$(just buck build --show-output //:libjolttui | awk '/libjolttui.so/{print $2}')" build/lib/libjolttui.so
4647 @echo "{{justfile_directory()}}/build/lib"
4748
4849 # Where a consumer points LD_LIBRARY_PATH for the buck2 build.
@@ -43,6 +43,7 @@ buck-build *args:
43 mkdir -p build/lib43 mkdir -p build/lib
44 cp "$(just buck build --show-output //:libvidya | awk '/libvidya.so/{print $2}')" build/lib/libvidya.so44 cp "$(just buck build --show-output //:libvidya | awk '/libvidya.so/{print $2}')" build/lib/libvidya.so
45 cp "$(just buck build --show-output //:libjoltmoq | awk '/libjoltmoq.so/{print $2}')" build/lib/libjoltmoq.so45 cp "$(just buck build --show-output //:libjoltmoq | awk '/libjoltmoq.so/{print $2}')" build/lib/libjoltmoq.so
46+ cp "$(just buck build --show-output //:libjolttui | awk '/libjolttui.so/{print $2}')" build/lib/libjolttui.so
46 @echo "{{justfile_directory()}}/build/lib"47 @echo "{{justfile_directory()}}/build/lib"
47 48
48 # Where a consumer points LD_LIBRARY_PATH for the buck2 build.49 # Where a consumer points LD_LIBRARY_PATH for the buck2 build.
modified third-party/rust/BUCK +276 -50
@@ -2447,20 +2447,7 @@ cargo.rust_library(
24472447 "CARGO_PKG_VERSION_PATCH": "1",
24482448 "CARGO_PKG_VERSION_PRE": "",
24492449 },
2450- platform = {
2451- "linux-arm64": dict(
2452- features = ["std"],
2453- ),
2454- "linux-x86_64": dict(
2455- features = ["std"],
2456- ),
2457- "macos-arm64": dict(
2458- features = ["std"],
2459- ),
2460- "macos-x86_64": dict(
2461- features = ["std"],
2462- ),
2463- },
2450+ features = ["std"],
24642451 visibility = [],
24652452 )
24662453
@@ -5054,6 +5041,55 @@ buildscript_run(
50545041 version = "0.8.22",
50555042 )
50565043
5044+alias(
5045+ name = "crossterm",
5046+ actual = ":crossterm-0.28",
5047+ visibility = ["PUBLIC"],
5048+)
5049+
5050+http_archive(
5051+ name = "crossterm-0.28.1.crate",
5052+ sha256 = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6",
5053+ strip_prefix = "crossterm-0.28.1",
5054+ urls = ["https://static.crates.io/crates/crossterm/0.28.1/download"],
5055+ visibility = [],
5056+)
5057+
5058+cargo.rust_library(
5059+ name = "crossterm-0.28",
5060+ srcs = [":crossterm-0.28.1.crate"],
5061+ crate = "crossterm",
5062+ crate_root = "crossterm-0.28.1.crate/src/lib.rs",
5063+ edition = "2021",
5064+ env = {
5065+ "CARGO_BIN_NAME": "crossterm",
5066+ "CARGO_CRATE_NAME": "crossterm",
5067+ "CARGO_MANIFEST_DIR": "crossterm-0.28.1.crate",
5068+ "CARGO_PKG_AUTHORS": "T. Post",
5069+ "CARGO_PKG_DESCRIPTION": "A crossplatform terminal library for manipulating terminals.",
5070+ "CARGO_PKG_HOMEPAGE": "",
5071+ "CARGO_PKG_NAME": "crossterm",
5072+ "CARGO_PKG_README": "README.md",
5073+ "CARGO_PKG_REPOSITORY": "https://github.com/crossterm-rs/crossterm",
5074+ "CARGO_PKG_RUST_VERSION": "1.63.0",
5075+ "CARGO_PKG_VERSION": "0.28.1",
5076+ "CARGO_PKG_VERSION_MAJOR": "0",
5077+ "CARGO_PKG_VERSION_MINOR": "28",
5078+ "CARGO_PKG_VERSION_PATCH": "1",
5079+ "CARGO_PKG_VERSION_PRE": "",
5080+ },
5081+ features = ["events"],
5082+ visibility = [],
5083+ deps = [
5084+ ":bitflags-2",
5085+ ":mio-1",
5086+ ":parking_lot-0.12",
5087+ ":rustix-0.38",
5088+ ":signal-hook-0.3",
5089+ ":signal-hook-mio-0.2",
5090+ ],
5091+)
5092+
50575093 http_archive(
50585094 name = "crypto-common-0.1.6.crate",
50595095 sha256 = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3",
@@ -13211,14 +13247,28 @@ cargo.rust_library(
1321113247 "CARGO_PKG_VERSION_PRE": "",
1321213248 },
1321313249 features = [
13214- "elf",
13215- "errno",
1321613250 "general",
1321713251 "ioctl",
1321813252 "no_std",
13219- "prctl",
13220- "system",
1322113253 ],
13254+ platform = {
13255+ "linux-arm64": dict(
13256+ features = [
13257+ "elf",
13258+ "errno",
13259+ "prctl",
13260+ "system",
13261+ ],
13262+ ),
13263+ "linux-x86_64": dict(
13264+ features = [
13265+ "elf",
13266+ "errno",
13267+ "prctl",
13268+ "system",
13269+ ],
13270+ ),
13271+ },
1322213272 visibility = [],
1322313273 )
1322413274
@@ -13921,12 +13971,17 @@ cargo.rust_library(
1392113971 "CARGO_PKG_VERSION_PRE": "",
1392213972 },
1392313973 features = [
13974+ "default",
13975+ "log",
1392413976 "net",
1392513977 "os-ext",
1392613978 "os-poll",
1392713979 ],
1392813980 visibility = [],
13929- deps = [":libc-0.2"],
13981+ deps = [
13982+ ":libc-0.2",
13983+ ":log-0.4",
13984+ ],
1393013985 )
1393113986
1393213987 http_archive(
@@ -21895,24 +21950,65 @@ cargo.rust_library(
2189521950 },
2189621951 features = [
2189721952 "alloc",
21898- "default",
21899- "event",
21900- "fs",
2190121953 "libc-extra-traits",
21902- "pipe",
21903- "process",
21904- "shm",
2190521954 "std",
21906- "system",
21907- "thread",
21908- "use-libc-auxv",
21955+ "stdio",
21956+ "termios",
2190921957 ],
21958+ platform = {
21959+ "android-arm64": dict(
21960+ named_deps = {
21961+ "libc_errno": ":errno-0.3",
21962+ },
21963+ deps = [
21964+ ":libc-0.2",
21965+ ":linux-raw-sys-0.4",
21966+ ],
21967+ ),
21968+ "linux-arm64": dict(
21969+ features = [
21970+ "default",
21971+ "event",
21972+ "fs",
21973+ "pipe",
21974+ "process",
21975+ "shm",
21976+ "system",
21977+ "thread",
21978+ "use-libc-auxv",
21979+ ],
21980+ deps = [":linux-raw-sys-0.4"],
21981+ ),
21982+ "linux-x86_64": dict(
21983+ features = [
21984+ "default",
21985+ "event",
21986+ "fs",
21987+ "pipe",
21988+ "process",
21989+ "shm",
21990+ "system",
21991+ "thread",
21992+ "use-libc-auxv",
21993+ ],
21994+ deps = [":linux-raw-sys-0.4"],
21995+ ),
21996+ "macos-arm64": dict(
21997+ named_deps = {
21998+ "libc_errno": ":errno-0.3",
21999+ },
22000+ deps = [":libc-0.2"],
22001+ ),
22002+ "macos-x86_64": dict(
22003+ named_deps = {
22004+ "libc_errno": ":errno-0.3",
22005+ },
22006+ deps = [":libc-0.2"],
22007+ ),
22008+ },
2191022009 rustc_flags = ["@$(location :rustix-0.38-build-script-run[rustc_flags])"],
2191122010 visibility = [],
21912- deps = [
21913- ":bitflags-2",
21914- ":linux-raw-sys-0.4",
21915- ],
22011+ deps = [":bitflags-2"],
2191622012 )
2191722013
2191822014 cargo.rust_binary(
@@ -21940,18 +22036,39 @@ cargo.rust_binary(
2194022036 },
2194122037 features = [
2194222038 "alloc",
21943- "default",
21944- "event",
21945- "fs",
2194622039 "libc-extra-traits",
21947- "pipe",
21948- "process",
21949- "shm",
2195022040 "std",
21951- "system",
21952- "thread",
21953- "use-libc-auxv",
22041+ "stdio",
22042+ "termios",
2195422043 ],
22044+ platform = {
22045+ "linux-arm64": dict(
22046+ features = [
22047+ "default",
22048+ "event",
22049+ "fs",
22050+ "pipe",
22051+ "process",
22052+ "shm",
22053+ "system",
22054+ "thread",
22055+ "use-libc-auxv",
22056+ ],
22057+ ),
22058+ "linux-x86_64": dict(
22059+ features = [
22060+ "default",
22061+ "event",
22062+ "fs",
22063+ "pipe",
22064+ "process",
22065+ "shm",
22066+ "system",
22067+ "thread",
22068+ "use-libc-auxv",
22069+ ],
22070+ ),
22071+ },
2195522072 visibility = [],
2195622073 )
2195722074
@@ -21974,18 +22091,39 @@ buildscript_run(
2197422091 },
2197522092 features = [
2197622093 "alloc",
21977- "default",
21978- "event",
21979- "fs",
2198022094 "libc-extra-traits",
21981- "pipe",
21982- "process",
21983- "shm",
2198422095 "std",
21985- "system",
21986- "thread",
21987- "use-libc-auxv",
22096+ "stdio",
22097+ "termios",
2198822098 ],
22099+ platform = {
22100+ "linux-arm64": dict(
22101+ features = [
22102+ "default",
22103+ "event",
22104+ "fs",
22105+ "pipe",
22106+ "process",
22107+ "shm",
22108+ "system",
22109+ "thread",
22110+ "use-libc-auxv",
22111+ ],
22112+ ),
22113+ "linux-x86_64": dict(
22114+ features = [
22115+ "default",
22116+ "event",
22117+ "fs",
22118+ "pipe",
22119+ "process",
22120+ "shm",
22121+ "system",
22122+ "thread",
22123+ "use-libc-auxv",
22124+ ],
22125+ ),
22126+ },
2198922127 version = "0.38.44",
2199022128 )
2199122129
@@ -24204,6 +24342,94 @@ cargo.rust_library(
2420424342 visibility = [],
2420524343 )
2420624344
24345+http_archive(
24346+ name = "signal-hook-0.3.18.crate",
24347+ sha256 = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2",
24348+ strip_prefix = "signal-hook-0.3.18",
24349+ urls = ["https://static.crates.io/crates/signal-hook/0.3.18/download"],
24350+ visibility = [],
24351+)
24352+
24353+cargo.rust_library(
24354+ name = "signal-hook-0.3",
24355+ srcs = [":signal-hook-0.3.18.crate"],
24356+ crate = "signal_hook",
24357+ crate_root = "signal-hook-0.3.18.crate/src/lib.rs",
24358+ edition = "2018",
24359+ env = {
24360+ "CARGO_BIN_NAME": "signal_hook",
24361+ "CARGO_CRATE_NAME": "signal_hook",
24362+ "CARGO_MANIFEST_DIR": "signal-hook-0.3.18.crate",
24363+ "CARGO_PKG_AUTHORS": "Michal 'vorner' Vaner <vorner@vorner.cz>:Thomas Himmelstoss <thimm@posteo.de>",
24364+ "CARGO_PKG_DESCRIPTION": "Unix signal handling",
24365+ "CARGO_PKG_HOMEPAGE": "",
24366+ "CARGO_PKG_NAME": "signal-hook",
24367+ "CARGO_PKG_README": "README.md",
24368+ "CARGO_PKG_REPOSITORY": "https://github.com/vorner/signal-hook",
24369+ "CARGO_PKG_RUST_VERSION": "",
24370+ "CARGO_PKG_VERSION": "0.3.18",
24371+ "CARGO_PKG_VERSION_MAJOR": "0",
24372+ "CARGO_PKG_VERSION_MINOR": "3",
24373+ "CARGO_PKG_VERSION_PATCH": "18",
24374+ "CARGO_PKG_VERSION_PRE": "",
24375+ },
24376+ features = [
24377+ "channel",
24378+ "default",
24379+ "iterator",
24380+ ],
24381+ visibility = [],
24382+ deps = [
24383+ ":libc-0.2",
24384+ ":signal-hook-registry-1",
24385+ ],
24386+)
24387+
24388+http_archive(
24389+ name = "signal-hook-mio-0.2.5.crate",
24390+ sha256 = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc",
24391+ strip_prefix = "signal-hook-mio-0.2.5",
24392+ urls = ["https://static.crates.io/crates/signal-hook-mio/0.2.5/download"],
24393+ visibility = [],
24394+)
24395+
24396+cargo.rust_library(
24397+ name = "signal-hook-mio-0.2",
24398+ srcs = [":signal-hook-mio-0.2.5.crate"],
24399+ crate = "signal_hook_mio",
24400+ crate_root = "signal-hook-mio-0.2.5.crate/src/lib.rs",
24401+ edition = "2018",
24402+ env = {
24403+ "CARGO_BIN_NAME": "signal_hook_mio",
24404+ "CARGO_CRATE_NAME": "signal_hook_mio",
24405+ "CARGO_MANIFEST_DIR": "signal-hook-mio-0.2.5.crate",
24406+ "CARGO_PKG_AUTHORS": "Michal 'vorner' Vaner <vorner@vorner.cz>:Thomas Himmelstoss <thimm@posteo.de>",
24407+ "CARGO_PKG_DESCRIPTION": "MIO support for signal-hook",
24408+ "CARGO_PKG_HOMEPAGE": "",
24409+ "CARGO_PKG_NAME": "signal-hook-mio",
24410+ "CARGO_PKG_README": "README.md",
24411+ "CARGO_PKG_REPOSITORY": "https://github.com/vorner/signal-hook",
24412+ "CARGO_PKG_RUST_VERSION": "",
24413+ "CARGO_PKG_VERSION": "0.2.5",
24414+ "CARGO_PKG_VERSION_MAJOR": "0",
24415+ "CARGO_PKG_VERSION_MINOR": "2",
24416+ "CARGO_PKG_VERSION_PATCH": "5",
24417+ "CARGO_PKG_VERSION_PRE": "",
24418+ },
24419+ features = [
24420+ "mio-1_0",
24421+ "support-v1_0",
24422+ ],
24423+ named_deps = {
24424+ "mio_1_0": ":mio-1",
24425+ },
24426+ visibility = [],
24427+ deps = [
24428+ ":libc-0.2",
24429+ ":signal-hook-0.3",
24430+ ],
24431+)
24432+
2420724433 http_archive(
2420824434 name = "signal-hook-registry-1.4.8.crate",
2420924435 sha256 = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b",
@@ -2447,20 +2447,7 @@ cargo.rust_library(
2447 "CARGO_PKG_VERSION_PATCH": "1",2447 "CARGO_PKG_VERSION_PATCH": "1",
2448 "CARGO_PKG_VERSION_PRE": "",2448 "CARGO_PKG_VERSION_PRE": "",
2449 },2449 },
2450- platform = {2450+ features = ["std"],
2451- "linux-arm64": dict(
2452- features = ["std"],
2453- ),
2454- "linux-x86_64": dict(
2455- features = ["std"],
2456- ),
2457- "macos-arm64": dict(
2458- features = ["std"],
2459- ),
2460- "macos-x86_64": dict(
2461- features = ["std"],
2462- ),
2463- },
2464 visibility = [],2451 visibility = [],
2465 )2452 )
2466 2453
@@ -5054,6 +5041,55 @@ buildscript_run(
5054 version = "0.8.22",5041 version = "0.8.22",
5055 )5042 )
5056 5043
5044+alias(
5045+ name = "crossterm",
5046+ actual = ":crossterm-0.28",
5047+ visibility = ["PUBLIC"],
5048+)
5049+
5050+http_archive(
5051+ name = "crossterm-0.28.1.crate",
5052+ sha256 = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6",
5053+ strip_prefix = "crossterm-0.28.1",
5054+ urls = ["https://static.crates.io/crates/crossterm/0.28.1/download"],
5055+ visibility = [],
5056+)
5057+
5058+cargo.rust_library(
5059+ name = "crossterm-0.28",
5060+ srcs = [":crossterm-0.28.1.crate"],
5061+ crate = "crossterm",
5062+ crate_root = "crossterm-0.28.1.crate/src/lib.rs",
5063+ edition = "2021",
5064+ env = {
5065+ "CARGO_BIN_NAME": "crossterm",
5066+ "CARGO_CRATE_NAME": "crossterm",
5067+ "CARGO_MANIFEST_DIR": "crossterm-0.28.1.crate",
5068+ "CARGO_PKG_AUTHORS": "T. Post",
5069+ "CARGO_PKG_DESCRIPTION": "A crossplatform terminal library for manipulating terminals.",
5070+ "CARGO_PKG_HOMEPAGE": "",
5071+ "CARGO_PKG_NAME": "crossterm",
5072+ "CARGO_PKG_README": "README.md",
5073+ "CARGO_PKG_REPOSITORY": "https://github.com/crossterm-rs/crossterm",
5074+ "CARGO_PKG_RUST_VERSION": "1.63.0",
5075+ "CARGO_PKG_VERSION": "0.28.1",
5076+ "CARGO_PKG_VERSION_MAJOR": "0",
5077+ "CARGO_PKG_VERSION_MINOR": "28",
5078+ "CARGO_PKG_VERSION_PATCH": "1",
5079+ "CARGO_PKG_VERSION_PRE": "",
5080+ },
5081+ features = ["events"],
5082+ visibility = [],
5083+ deps = [
5084+ ":bitflags-2",
5085+ ":mio-1",
5086+ ":parking_lot-0.12",
5087+ ":rustix-0.38",
5088+ ":signal-hook-0.3",
5089+ ":signal-hook-mio-0.2",
5090+ ],
5091+)
5092+
5057 http_archive(5093 http_archive(
5058 name = "crypto-common-0.1.6.crate",5094 name = "crypto-common-0.1.6.crate",
5059 sha256 = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3",5095 sha256 = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3",
@@ -13211,14 +13247,28 @@ cargo.rust_library(
13211 "CARGO_PKG_VERSION_PRE": "",13247 "CARGO_PKG_VERSION_PRE": "",
13212 },13248 },
13213 features = [13249 features = [
13214- "elf",
13215- "errno",
13216 "general",13250 "general",
13217 "ioctl",13251 "ioctl",
13218 "no_std",13252 "no_std",
13219- "prctl",
13220- "system",
13221 ],13253 ],
13254+ platform = {
13255+ "linux-arm64": dict(
13256+ features = [
13257+ "elf",
13258+ "errno",
13259+ "prctl",
13260+ "system",
13261+ ],
13262+ ),
13263+ "linux-x86_64": dict(
13264+ features = [
13265+ "elf",
13266+ "errno",
13267+ "prctl",
13268+ "system",
13269+ ],
13270+ ),
13271+ },
13222 visibility = [],13272 visibility = [],
13223 )13273 )
13224 13274
@@ -13921,12 +13971,17 @@ cargo.rust_library(
13921 "CARGO_PKG_VERSION_PRE": "",13971 "CARGO_PKG_VERSION_PRE": "",
13922 },13972 },
13923 features = [13973 features = [
13974+ "default",
13975+ "log",
13924 "net",13976 "net",
13925 "os-ext",13977 "os-ext",
13926 "os-poll",13978 "os-poll",
13927 ],13979 ],
13928 visibility = [],13980 visibility = [],
13929- deps = [":libc-0.2"],13981+ deps = [
13982+ ":libc-0.2",
13983+ ":log-0.4",
13984+ ],
13930 )13985 )
13931 13986
13932 http_archive(13987 http_archive(
@@ -21895,24 +21950,65 @@ cargo.rust_library(
21895 },21950 },
21896 features = [21951 features = [
21897 "alloc",21952 "alloc",
21898- "default",
21899- "event",
21900- "fs",
21901 "libc-extra-traits",21953 "libc-extra-traits",
21902- "pipe",
21903- "process",
21904- "shm",
21905 "std",21954 "std",
21906- "system",21955+ "stdio",
21907- "thread",21956+ "termios",
21908- "use-libc-auxv",
21909 ],21957 ],
21958+ platform = {
21959+ "android-arm64": dict(
21960+ named_deps = {
21961+ "libc_errno": ":errno-0.3",
21962+ },
21963+ deps = [
21964+ ":libc-0.2",
21965+ ":linux-raw-sys-0.4",
21966+ ],
21967+ ),
21968+ "linux-arm64": dict(
21969+ features = [
21970+ "default",
21971+ "event",
21972+ "fs",
21973+ "pipe",
21974+ "process",
21975+ "shm",
21976+ "system",
21977+ "thread",
21978+ "use-libc-auxv",
21979+ ],
21980+ deps = [":linux-raw-sys-0.4"],
21981+ ),
21982+ "linux-x86_64": dict(
21983+ features = [
21984+ "default",
21985+ "event",
21986+ "fs",
21987+ "pipe",
21988+ "process",
21989+ "shm",
21990+ "system",
21991+ "thread",
21992+ "use-libc-auxv",
21993+ ],
21994+ deps = [":linux-raw-sys-0.4"],
21995+ ),
21996+ "macos-arm64": dict(
21997+ named_deps = {
21998+ "libc_errno": ":errno-0.3",
21999+ },
22000+ deps = [":libc-0.2"],
22001+ ),
22002+ "macos-x86_64": dict(
22003+ named_deps = {
22004+ "libc_errno": ":errno-0.3",
22005+ },
22006+ deps = [":libc-0.2"],
22007+ ),
22008+ },
21910 rustc_flags = ["@$(location :rustix-0.38-build-script-run[rustc_flags])"],22009 rustc_flags = ["@$(location :rustix-0.38-build-script-run[rustc_flags])"],
21911 visibility = [],22010 visibility = [],
21912- deps = [22011+ deps = [":bitflags-2"],
21913- ":bitflags-2",
21914- ":linux-raw-sys-0.4",
21915- ],
21916 )22012 )
21917 22013
21918 cargo.rust_binary(22014 cargo.rust_binary(
@@ -21940,18 +22036,39 @@ cargo.rust_binary(
21940 },22036 },
21941 features = [22037 features = [
21942 "alloc",22038 "alloc",
21943- "default",
21944- "event",
21945- "fs",
21946 "libc-extra-traits",22039 "libc-extra-traits",
21947- "pipe",
21948- "process",
21949- "shm",
21950 "std",22040 "std",
21951- "system",22041+ "stdio",
21952- "thread",22042+ "termios",
21953- "use-libc-auxv",
21954 ],22043 ],
22044+ platform = {
22045+ "linux-arm64": dict(
22046+ features = [
22047+ "default",
22048+ "event",
22049+ "fs",
22050+ "pipe",
22051+ "process",
22052+ "shm",
22053+ "system",
22054+ "thread",
22055+ "use-libc-auxv",
22056+ ],
22057+ ),
22058+ "linux-x86_64": dict(
22059+ features = [
22060+ "default",
22061+ "event",
22062+ "fs",
22063+ "pipe",
22064+ "process",
22065+ "shm",
22066+ "system",
22067+ "thread",
22068+ "use-libc-auxv",
22069+ ],
22070+ ),
22071+ },
21955 visibility = [],22072 visibility = [],
21956 )22073 )
21957 22074
@@ -21974,18 +22091,39 @@ buildscript_run(
21974 },22091 },
21975 features = [22092 features = [
21976 "alloc",22093 "alloc",
21977- "default",
21978- "event",
21979- "fs",
21980 "libc-extra-traits",22094 "libc-extra-traits",
21981- "pipe",
21982- "process",
21983- "shm",
21984 "std",22095 "std",
21985- "system",22096+ "stdio",
21986- "thread",22097+ "termios",
21987- "use-libc-auxv",
21988 ],22098 ],
22099+ platform = {
22100+ "linux-arm64": dict(
22101+ features = [
22102+ "default",
22103+ "event",
22104+ "fs",
22105+ "pipe",
22106+ "process",
22107+ "shm",
22108+ "system",
22109+ "thread",
22110+ "use-libc-auxv",
22111+ ],
22112+ ),
22113+ "linux-x86_64": dict(
22114+ features = [
22115+ "default",
22116+ "event",
22117+ "fs",
22118+ "pipe",
22119+ "process",
22120+ "shm",
22121+ "system",
22122+ "thread",
22123+ "use-libc-auxv",
22124+ ],
22125+ ),
22126+ },
21989 version = "0.38.44",22127 version = "0.38.44",
21990 )22128 )
21991 22129
@@ -24204,6 +24342,94 @@ cargo.rust_library(
24204 visibility = [],24342 visibility = [],
24205 )24343 )
24206 24344
24345+http_archive(
24346+ name = "signal-hook-0.3.18.crate",
24347+ sha256 = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2",
24348+ strip_prefix = "signal-hook-0.3.18",
24349+ urls = ["https://static.crates.io/crates/signal-hook/0.3.18/download"],
24350+ visibility = [],
24351+)
24352+
24353+cargo.rust_library(
24354+ name = "signal-hook-0.3",
24355+ srcs = [":signal-hook-0.3.18.crate"],
24356+ crate = "signal_hook",
24357+ crate_root = "signal-hook-0.3.18.crate/src/lib.rs",
24358+ edition = "2018",
24359+ env = {
24360+ "CARGO_BIN_NAME": "signal_hook",
24361+ "CARGO_CRATE_NAME": "signal_hook",
24362+ "CARGO_MANIFEST_DIR": "signal-hook-0.3.18.crate",
24363+ "CARGO_PKG_AUTHORS": "Michal 'vorner' Vaner <vorner@vorner.cz>:Thomas Himmelstoss <thimm@posteo.de>",
24364+ "CARGO_PKG_DESCRIPTION": "Unix signal handling",
24365+ "CARGO_PKG_HOMEPAGE": "",
24366+ "CARGO_PKG_NAME": "signal-hook",
24367+ "CARGO_PKG_README": "README.md",
24368+ "CARGO_PKG_REPOSITORY": "https://github.com/vorner/signal-hook",
24369+ "CARGO_PKG_RUST_VERSION": "",
24370+ "CARGO_PKG_VERSION": "0.3.18",
24371+ "CARGO_PKG_VERSION_MAJOR": "0",
24372+ "CARGO_PKG_VERSION_MINOR": "3",
24373+ "CARGO_PKG_VERSION_PATCH": "18",
24374+ "CARGO_PKG_VERSION_PRE": "",
24375+ },
24376+ features = [
24377+ "channel",
24378+ "default",
24379+ "iterator",
24380+ ],
24381+ visibility = [],
24382+ deps = [
24383+ ":libc-0.2",
24384+ ":signal-hook-registry-1",
24385+ ],
24386+)
24387+
24388+http_archive(
24389+ name = "signal-hook-mio-0.2.5.crate",
24390+ sha256 = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc",
24391+ strip_prefix = "signal-hook-mio-0.2.5",
24392+ urls = ["https://static.crates.io/crates/signal-hook-mio/0.2.5/download"],
24393+ visibility = [],
24394+)
24395+
24396+cargo.rust_library(
24397+ name = "signal-hook-mio-0.2",
24398+ srcs = [":signal-hook-mio-0.2.5.crate"],
24399+ crate = "signal_hook_mio",
24400+ crate_root = "signal-hook-mio-0.2.5.crate/src/lib.rs",
24401+ edition = "2018",
24402+ env = {
24403+ "CARGO_BIN_NAME": "signal_hook_mio",
24404+ "CARGO_CRATE_NAME": "signal_hook_mio",
24405+ "CARGO_MANIFEST_DIR": "signal-hook-mio-0.2.5.crate",
24406+ "CARGO_PKG_AUTHORS": "Michal 'vorner' Vaner <vorner@vorner.cz>:Thomas Himmelstoss <thimm@posteo.de>",
24407+ "CARGO_PKG_DESCRIPTION": "MIO support for signal-hook",
24408+ "CARGO_PKG_HOMEPAGE": "",
24409+ "CARGO_PKG_NAME": "signal-hook-mio",
24410+ "CARGO_PKG_README": "README.md",
24411+ "CARGO_PKG_REPOSITORY": "https://github.com/vorner/signal-hook",
24412+ "CARGO_PKG_RUST_VERSION": "",
24413+ "CARGO_PKG_VERSION": "0.2.5",
24414+ "CARGO_PKG_VERSION_MAJOR": "0",
24415+ "CARGO_PKG_VERSION_MINOR": "2",
24416+ "CARGO_PKG_VERSION_PATCH": "5",
24417+ "CARGO_PKG_VERSION_PRE": "",
24418+ },
24419+ features = [
24420+ "mio-1_0",
24421+ "support-v1_0",
24422+ ],
24423+ named_deps = {
24424+ "mio_1_0": ":mio-1",
24425+ },
24426+ visibility = [],
24427+ deps = [
24428+ ":libc-0.2",
24429+ ":signal-hook-0.3",
24430+ ],
24431+)
24432+
24207 http_archive(24433 http_archive(
24208 name = "signal-hook-registry-1.4.8.crate",24434 name = "signal-hook-registry-1.4.8.crate",
24209 sha256 = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b",24435 sha256 = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b",
modified third-party/rust/Cargo.lock +36 -0
@@ -1201,6 +1201,20 @@ version = "0.8.22"
12011201 source = "registry+https://github.com/rust-lang/crates.io-index"
12021202 checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17"
12031203
1204+[[package]]
1205+name = "crossterm"
1206+version = "0.28.1"
1207+source = "registry+https://github.com/rust-lang/crates.io-index"
1208+checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6"
1209+dependencies = [
1210+ "bitflags 2.13.1",
1211+ "mio",
1212+ "parking_lot",
1213+ "rustix 0.38.44",
1214+ "signal-hook",
1215+ "signal-hook-mio",
1216+]
1217+
12041218 [[package]]
12051219 name = "crunchy"
12061220 version = "0.2.4"
@@ -3326,6 +3340,7 @@ dependencies = [
33263340 "arboard",
33273341 "coreaudio-rs",
33283342 "cpal",
3343+ "crossterm",
33293344 "dasp_sample",
33303345 "egui",
33313346 "egui-winit",
@@ -6182,6 +6197,27 @@ version = "2.0.1"
61826197 source = "registry+https://github.com/rust-lang/crates.io-index"
61836198 checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
61846199
6200+[[package]]
6201+name = "signal-hook"
6202+version = "0.3.18"
6203+source = "registry+https://github.com/rust-lang/crates.io-index"
6204+checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2"
6205+dependencies = [
6206+ "libc",
6207+ "signal-hook-registry",
6208+]
6209+
6210+[[package]]
6211+name = "signal-hook-mio"
6212+version = "0.2.5"
6213+source = "registry+https://github.com/rust-lang/crates.io-index"
6214+checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc"
6215+dependencies = [
6216+ "libc",
6217+ "mio",
6218+ "signal-hook",
6219+]
6220+
61856221 [[package]]
61866222 name = "signal-hook-registry"
61876223 version = "1.4.8"
@@ -1201,6 +1201,20 @@ version = "0.8.22"
1201 source = "registry+https://github.com/rust-lang/crates.io-index"1201 source = "registry+https://github.com/rust-lang/crates.io-index"
1202 checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17"1202 checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17"
1203 1203
1204+[[package]]
1205+name = "crossterm"
1206+version = "0.28.1"
1207+source = "registry+https://github.com/rust-lang/crates.io-index"
1208+checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6"
1209+dependencies = [
1210+ "bitflags 2.13.1",
1211+ "mio",
1212+ "parking_lot",
1213+ "rustix 0.38.44",
1214+ "signal-hook",
1215+ "signal-hook-mio",
1216+]
1217+
1204 [[package]]1218 [[package]]
1205 name = "crunchy"1219 name = "crunchy"
1206 version = "0.2.4"1220 version = "0.2.4"
@@ -3326,6 +3340,7 @@ dependencies = [
3326 "arboard",3340 "arboard",
3327 "coreaudio-rs",3341 "coreaudio-rs",
3328 "cpal",3342 "cpal",
3343+ "crossterm",
3329 "dasp_sample",3344 "dasp_sample",
3330 "egui",3345 "egui",
3331 "egui-winit",3346 "egui-winit",
@@ -6182,6 +6197,27 @@ version = "2.0.1"
6182 source = "registry+https://github.com/rust-lang/crates.io-index"6197 source = "registry+https://github.com/rust-lang/crates.io-index"
6183 checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"6198 checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
6184 6199
6200+[[package]]
6201+name = "signal-hook"
6202+version = "0.3.18"
6203+source = "registry+https://github.com/rust-lang/crates.io-index"
6204+checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2"
6205+dependencies = [
6206+ "libc",
6207+ "signal-hook-registry",
6208+]
6209+
6210+[[package]]
6211+name = "signal-hook-mio"
6212+version = "0.2.5"
6213+source = "registry+https://github.com/rust-lang/crates.io-index"
6214+checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc"
6215+dependencies = [
6216+ "libc",
6217+ "mio",
6218+ "signal-hook",
6219+]
6220+
6185 [[package]]6221 [[package]]
6186 name = "signal-hook-registry"6222 name = "signal-hook-registry"
6187 version = "1.4.8"6223 version = "1.4.8"
modified third-party/rust/Cargo.toml +4 -0
@@ -34,6 +34,10 @@ arboard = { version = "3", default-features = false, features = ["image-data"] }
3434 cpal = { path = "cpal" }
3535 # `default_fonts` because a shared library has no host app to install a font set
3636 # for it. vidya-core turns egui's defaults off; the union is what gets built.
37+# The terminal jolt-tui paints into. `events` only: the crate's default set
38+# reaches for a Windows console API and a serde derive, and neither the reader
39+# nor the writer here wants either.
40+crossterm = { version = "0.28", default-features = false, features = ["events"] }
3741 egui = { version = "0.31", features = ["default_fonts"] }
3842 egui-winit = "0.31"
3943 egui_glow = "0.31"
@@ -34,6 +34,10 @@ arboard = { version = "3", default-features = false, features = ["image-data"] }
34 cpal = { path = "cpal" }34 cpal = { path = "cpal" }
35 # `default_fonts` because a shared library has no host app to install a font set35 # `default_fonts` because a shared library has no host app to install a font set
36 # for it. vidya-core turns egui's defaults off; the union is what gets built.36 # for it. vidya-core turns egui's defaults off; the union is what gets built.
37+# The terminal jolt-tui paints into. `events` only: the crate's default set
38+# reaches for a Windows console API and a serde derive, and neither the reader
39+# nor the writer here wants either.
40+crossterm = { version = "0.28", default-features = false, features = ["events"] }
37 egui = { version = "0.31", features = ["default_fonts"] }41 egui = { version = "0.31", features = ["default_fonts"] }
38 egui-winit = "0.31"42 egui-winit = "0.31"
39 egui_glow = "0.31"43 egui_glow = "0.31"
added third-party/rust/fixups/signal-hook/fixups.toml +5 -0
new file mode 100644
@@ -0,0 +1,5 @@
1+# Nothing to run. signal-hook's build script compiles one C file, and only
2+# under `extended-siginfo-raw`, which nothing in this graph asks for — crossterm
3+# takes signal-hook for SIGWINCH alone. Without this line reindeer warns on
4+# every `just buckify` that it does not know what the script is for.
5+buildscript.run = false
new file mode 100644
@@ -0,0 +1,5 @@
1+# Nothing to run. signal-hook's build script compiles one C file, and only
2+# under `extended-siginfo-raw`, which nothing in this graph asks for — crossterm
3+# takes signal-hook for SIGWINCH alone. Without this line reindeer warns on
4+# every `just buckify` that it does not know what the script is for.
5+buildscript.run = false