nandi/jolt-nativepublic Fork 0
7289263
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 tree ABI a third way: Zig and dvui

jolt-vidya exports the retained-tree ABI on egui and jolt-tui exports it
again over terminal cells. This is a third painter under the same shape —
zig/jolt-zvui, built on dvui, which is to Zig roughly what egui is to Rust.

The prefix is zvui_ and the object is libjoltzvui.so, following jolt-tui's
convention rather than trying to be a drop-in libvidya.so: a consumer names
one object in :jolt/native and binds one set of symbols.

Two things worth calling out.

Ids come back; identities do not. The free list hands a node id out again
after a free, but widget identity is (generation, index) — so a node freed
and reallocated in the same frame cannot inherit the cursor, scroll position
or animation of the node that used to live in that slot. That is the bug
class frq's deps.edn describes from the reconciler's side, closed here at
the backend rather than only upstream. There is a test pinning it.

Zig cannot keep jolt-abi's unwinding rule. There is no catch_unwind, so
guard's bargain — a panicking decoder is a black tile rather than a dead
process — is not available, and a Zig panic aborts. The answer is to not
panic: every entry point is total, a bad node id is a miss rather than an
index, and allocation failure answers the fallback a caught panic would.
The build is ReleaseSafe by default for the same reason. The other two
rules are kept exactly — one scratch slot for strings out, and nothing
calls back.

Thirty-two symbols against jolt-vidya's fifty-seven: the tree, the window,
the event queue, and enough widgets to paint a real one. No images,
avatars, video frames, clipboard, file picking, font loading or Android.

The cargo workspace is untouched; this builds with zig build, not cargo.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
nandi committed 2026-09-09T21:13:37-07:00 Browse files
7289263 parent: 67d1728
added zig/jolt-zvui/.gitignore +4 -0
new file mode 100644
@@ -0,0 +1,4 @@
1+.zig-cache/
2+zig-out/
3+zig-pkg/
4+demo
new file mode 100644
@@ -0,0 +1,4 @@
1+.zig-cache/
2+zig-out/
3+zig-pkg/
4+demo
added zig/jolt-zvui/README.md +83 -0
new file mode 100644
@@ -0,0 +1,83 @@
1+# jolt-zvui
2+
3+The retained-tree C ABI, in Zig, painted by [dvui](https://github.com/david-vanderson/dvui).
4+
5+`crates/jolt-vidya` exports this shape on egui and `crates/jolt-tui` exports it
6+again over terminal cells. This is a third painter under the same shape: a tree
7+the caller mutates between frames, one call that walks it, and a queue of what
8+the person did. Nothing in the tree knows what paints it, which is the property
9+that makes a third backend possible at all.
10+
11+The prefix is `zvui_` and the object is `libjoltzvui.so`, for the same reason
12+jolt-tui's are `tui_` and `libjolttui.so`: a consumer names one object in
13+`:jolt/native` and binds one set of symbols.
14+
15+```
16+src/tree.zig nodes, props, children, the event queue — no dvui in it
17+src/paint.zig one walk per frame, in dvui widgets
18+src/lib.zig the 32 exported symbols
19+include/zvui.h the header a C caller (or a jolt.ffi/defcfn) reads
20+demo.c a caller: builds a tree, paints it, drains events
21+```
22+
23+## Building
24+
25+Zig 0.16 and nothing else installed — dvui, SDL3, FreeType and tree-sitter are
26+fetched and built by the Zig package manager, at the revs `build.zig.zon` pins.
27+
28+```bash
29+zig build --release # zig-out/lib/libjoltzvui.so
30+zig build test # the tree, without a window
31+```
32+
33+`--release` rather than `-Doptimize=`: this library sets a preferred optimize
34+mode (ReleaseSafe), and Zig spells that choice `--release`.
35+
36+## The demo
37+
38+```bash
39+gcc demo.c ./zig-out/lib/libjoltzvui.so -Wl,-rpath,'$ORIGIN/zig-out/lib' -o demo
40+./demo # a window
41+./demo --dump # the tree on stdout, no window
42+./demo --frames 3 # paint three frames and leave
43+```
44+
45+## The vocabulary
46+
47+Tags: `window` `box` `hbox` `vbox` `page` `card` `frame` `scroll` `label`
48+`title` `dim-label` `button` `checkbox` `entry` `separator` `spacer`/`gap`
49+`progress`. An unknown tag is a container rather than an error, so a tree
50+written against a richer painter still shows its contents here.
51+
52+Props: `text` `label` `orientation` `spacing` `margin` `align` `max-width`
53+`width-request` `placeholder` `value` `active` `sensitive` `kind` `multiline`
54+`size` `fill-height`.
55+
56+Events: `click` `change` `toggled` `activate`.
57+
58+## Two things worth knowing
59+
60+**Ids come back; identities do not.** The free list hands a node id out again
61+after a free — but widget identity is `(generation, index)`, so a node freed and
62+reallocated in the same frame does not inherit the cursor, scroll position or
63+animation of the node that used to live in that slot. That is the bug class
64+frq's `deps.edn` describes from the reconciler's side, closed here at the
65+backend rather than only upstream.
66+
67+**Zig cannot keep jolt-abi's unwinding rule.** There is no `catch_unwind`, so
68+`jolt_abi::guard`'s bargain — a panicking decoder is a black tile rather than a
69+dead process — is not available. A Zig panic aborts. The answer here is to not
70+panic: every entry point is total, a bad node id is a miss rather than an index,
71+and allocation failure answers the fallback a caught panic would have. The build
72+is ReleaseSafe by default for the same reason — a trap inside this library beats
73+quiet corruption of the caller's heap.
74+
75+The other two rules are kept exactly. Strings out live in one scratch slot until
76+the next string-returning call, and nothing here calls back: events are queued
77+and polled.
78+
79+## Not done
80+
81+No `page`/`card` push-pop immediate API (the tree is the only interface), no
82+images, avatars, video frames, clipboard, file picking, font loading, or Android
83+entry points. `jolt-vidya` exports 57 symbols; this exports 32.
new file mode 100644
@@ -0,0 +1,83 @@
1+# jolt-zvui
2+
3+The retained-tree C ABI, in Zig, painted by [dvui](https://github.com/david-vanderson/dvui).
4+
5+`crates/jolt-vidya` exports this shape on egui and `crates/jolt-tui` exports it
6+again over terminal cells. This is a third painter under the same shape: a tree
7+the caller mutates between frames, one call that walks it, and a queue of what
8+the person did. Nothing in the tree knows what paints it, which is the property
9+that makes a third backend possible at all.
10+
11+The prefix is `zvui_` and the object is `libjoltzvui.so`, for the same reason
12+jolt-tui's are `tui_` and `libjolttui.so`: a consumer names one object in
13+`:jolt/native` and binds one set of symbols.
14+
15+```
16+src/tree.zig nodes, props, children, the event queue — no dvui in it
17+src/paint.zig one walk per frame, in dvui widgets
18+src/lib.zig the 32 exported symbols
19+include/zvui.h the header a C caller (or a jolt.ffi/defcfn) reads
20+demo.c a caller: builds a tree, paints it, drains events
21+```
22+
23+## Building
24+
25+Zig 0.16 and nothing else installed — dvui, SDL3, FreeType and tree-sitter are
26+fetched and built by the Zig package manager, at the revs `build.zig.zon` pins.
27+
28+```bash
29+zig build --release # zig-out/lib/libjoltzvui.so
30+zig build test # the tree, without a window
31+```
32+
33+`--release` rather than `-Doptimize=`: this library sets a preferred optimize
34+mode (ReleaseSafe), and Zig spells that choice `--release`.
35+
36+## The demo
37+
38+```bash
39+gcc demo.c ./zig-out/lib/libjoltzvui.so -Wl,-rpath,'$ORIGIN/zig-out/lib' -o demo
40+./demo # a window
41+./demo --dump # the tree on stdout, no window
42+./demo --frames 3 # paint three frames and leave
43+```
44+
45+## The vocabulary
46+
47+Tags: `window` `box` `hbox` `vbox` `page` `card` `frame` `scroll` `label`
48+`title` `dim-label` `button` `checkbox` `entry` `separator` `spacer`/`gap`
49+`progress`. An unknown tag is a container rather than an error, so a tree
50+written against a richer painter still shows its contents here.
51+
52+Props: `text` `label` `orientation` `spacing` `margin` `align` `max-width`
53+`width-request` `placeholder` `value` `active` `sensitive` `kind` `multiline`
54+`size` `fill-height`.
55+
56+Events: `click` `change` `toggled` `activate`.
57+
58+## Two things worth knowing
59+
60+**Ids come back; identities do not.** The free list hands a node id out again
61+after a free — but widget identity is `(generation, index)`, so a node freed and
62+reallocated in the same frame does not inherit the cursor, scroll position or
63+animation of the node that used to live in that slot. That is the bug class
64+frq's `deps.edn` describes from the reconciler's side, closed here at the
65+backend rather than only upstream.
66+
67+**Zig cannot keep jolt-abi's unwinding rule.** There is no `catch_unwind`, so
68+`jolt_abi::guard`'s bargain — a panicking decoder is a black tile rather than a
69+dead process — is not available. A Zig panic aborts. The answer here is to not
70+panic: every entry point is total, a bad node id is a miss rather than an index,
71+and allocation failure answers the fallback a caught panic would have. The build
72+is ReleaseSafe by default for the same reason — a trap inside this library beats
73+quiet corruption of the caller's heap.
74+
75+The other two rules are kept exactly. Strings out live in one scratch slot until
76+the next string-returning call, and nothing here calls back: events are queued
77+and polled.
78+
79+## Not done
80+
81+No `page`/`card` push-pop immediate API (the tree is the only interface), no
82+images, avatars, video frames, clipboard, file picking, font loading, or Android
83+entry points. `jolt-vidya` exports 57 symbols; this exports 32.
added zig/jolt-zvui/build.zig +51 -0
new file mode 100644
@@ -0,0 +1,51 @@
1+//! Builds `libjoltzvui.so`: the retained-tree ABI with dvui under it.
2+//!
3+//! dvui's own build.zig picks a backend from `-Dbackend=`, and exposes the
4+//! painted module as `dvui_sdl3` plus the raw SDL backend as `sdl3`. Both are
5+//! needed here — the ABI drives the loop itself (open / frame / close), which
6+//! is dvui's "standalone" shape rather than its App shape, because the caller
7+//! on the other side of this boundary owns the loop.
8+
9+const std = @import("std");
10+
11+pub fn build(b: *std.Build) void {
12+ const target = b.standardTargetOptions(.{});
13+ // ReleaseSafe by default: this library is loaded into a foreign runtime
14+ // that cannot survive a panic, so the checks are worth more than the
15+ // last few percent. See the note at the top of src/lib.zig.
16+ const optimize = b.standardOptimizeOption(.{ .preferred_optimize_mode = .ReleaseSafe });
17+
18+ const dvui_dep = b.dependency("dvui", .{
19+ .target = target,
20+ .optimize = optimize,
21+ .backend = .sdl3,
22+ });
23+
24+ const mod = b.createModule(.{
25+ .root_source_file = b.path("src/lib.zig"),
26+ .target = target,
27+ .optimize = optimize,
28+ .link_libc = true,
29+ });
30+ mod.addImport("dvui", dvui_dep.module("dvui_sdl3"));
31+ mod.addImport("sdl-backend", dvui_dep.module("sdl3"));
32+
33+ const lib = b.addLibrary(.{
34+ .name = "joltzvui",
35+ .linkage = .dynamic,
36+ .root_module = mod,
37+ });
38+ b.installArtifact(lib);
39+ b.installFile("include/zvui.h", "include/zvui.h");
40+
41+ // The tree is the half worth testing without a window: `zig build test`.
42+ const tests = b.addTest(.{
43+ .root_module = b.createModule(.{
44+ .root_source_file = b.path("src/tree.zig"),
45+ .target = target,
46+ .optimize = optimize,
47+ }),
48+ });
49+ const run_tests = b.addRunArtifact(tests);
50+ b.step("test", "Run the tree tests").dependOn(&run_tests.step);
51+}
new file mode 100644
@@ -0,0 +1,51 @@
1+//! Builds `libjoltzvui.so`: the retained-tree ABI with dvui under it.
2+//!
3+//! dvui's own build.zig picks a backend from `-Dbackend=`, and exposes the
4+//! painted module as `dvui_sdl3` plus the raw SDL backend as `sdl3`. Both are
5+//! needed here — the ABI drives the loop itself (open / frame / close), which
6+//! is dvui's "standalone" shape rather than its App shape, because the caller
7+//! on the other side of this boundary owns the loop.
8+
9+const std = @import("std");
10+
11+pub fn build(b: *std.Build) void {
12+ const target = b.standardTargetOptions(.{});
13+ // ReleaseSafe by default: this library is loaded into a foreign runtime
14+ // that cannot survive a panic, so the checks are worth more than the
15+ // last few percent. See the note at the top of src/lib.zig.
16+ const optimize = b.standardOptimizeOption(.{ .preferred_optimize_mode = .ReleaseSafe });
17+
18+ const dvui_dep = b.dependency("dvui", .{
19+ .target = target,
20+ .optimize = optimize,
21+ .backend = .sdl3,
22+ });
23+
24+ const mod = b.createModule(.{
25+ .root_source_file = b.path("src/lib.zig"),
26+ .target = target,
27+ .optimize = optimize,
28+ .link_libc = true,
29+ });
30+ mod.addImport("dvui", dvui_dep.module("dvui_sdl3"));
31+ mod.addImport("sdl-backend", dvui_dep.module("sdl3"));
32+
33+ const lib = b.addLibrary(.{
34+ .name = "joltzvui",
35+ .linkage = .dynamic,
36+ .root_module = mod,
37+ });
38+ b.installArtifact(lib);
39+ b.installFile("include/zvui.h", "include/zvui.h");
40+
41+ // The tree is the half worth testing without a window: `zig build test`.
42+ const tests = b.addTest(.{
43+ .root_module = b.createModule(.{
44+ .root_source_file = b.path("src/tree.zig"),
45+ .target = target,
46+ .optimize = optimize,
47+ }),
48+ });
49+ const run_tests = b.addRunArtifact(tests);
50+ b.step("test", "Run the tree tests").dependOn(&run_tests.step);
51+}
added zig/jolt-zvui/build.zig.zon +49 -0
new file mode 100644
@@ -0,0 +1,49 @@
1+.{
2+ // This is the default name used by packages depending on this one. For
3+ // example, when a user runs `zig fetch --save <url>`, this field is used
4+ // as the key in the `dependencies` table. Although the user can choose a
5+ // different name, most users will stick with this provided value.
6+ //
7+ // It is redundant to include "zig" in this name because it is already
8+ // within the Zig package namespace.
9+ .name = .jolt_zvui,
10+ // This is a [Semantic Version](https://semver.org/).
11+ // In a future version of Zig it will be used for package deduplication.
12+ .version = "0.0.0",
13+ // Together with name, this represents a globally unique package
14+ // identifier. This field is generated by the Zig toolchain when the
15+ // package is first created, and then *never changes*. This allows
16+ // unambiguous detection of one package being an updated version of
17+ // another.
18+ //
19+ // When forking a Zig project, this id should be regenerated (delete the
20+ // field and run `zig build`) if the upstream project is still maintained.
21+ // Otherwise, the fork is *hostile*, attempting to take control over the
22+ // original project's identity. Thus it is recommended to leave the comment
23+ // on the following line intact, so that it shows up in code reviews that
24+ // modify the field.
25+ .fingerprint = 0x72b19c8a8c410f6b, // Changing this has security and trust implications.
26+ // Tracks the earliest Zig version that the package considers to be a
27+ // supported use case.
28+ .minimum_zig_version = "0.16.0",
29+ // This field is optional.
30+ // Each dependency must either provide a `url` and `hash`, or a `path`.
31+ // `zig build --fetch` can be used to fetch all dependencies of a package, recursively.
32+ // Once all dependencies are fetched, `zig build` no longer requires
33+ // internet connectivity.
34+ .dependencies = .{
35+ .dvui = .{
36+ .url = "git+https://github.com/david-vanderson/dvui?ref=main#535518f7f7d3e6ccf7a2f77c55c2b34981b40073",
37+ .hash = "dvui-0.5.0-dev-AQFJmYJkAwHc4v0Q_mAmJF9BfPveyolJEVEJWEA9b1Qz",
38+ },
39+ },
40+ .paths = .{
41+ "build.zig",
42+ "build.zig.zon",
43+ "src",
44+ "include",
45+ // For example...
46+ //"LICENSE",
47+ //"README.md",
48+ },
49+}
new file mode 100644
@@ -0,0 +1,49 @@
1+.{
2+ // This is the default name used by packages depending on this one. For
3+ // example, when a user runs `zig fetch --save <url>`, this field is used
4+ // as the key in the `dependencies` table. Although the user can choose a
5+ // different name, most users will stick with this provided value.
6+ //
7+ // It is redundant to include "zig" in this name because it is already
8+ // within the Zig package namespace.
9+ .name = .jolt_zvui,
10+ // This is a [Semantic Version](https://semver.org/).
11+ // In a future version of Zig it will be used for package deduplication.
12+ .version = "0.0.0",
13+ // Together with name, this represents a globally unique package
14+ // identifier. This field is generated by the Zig toolchain when the
15+ // package is first created, and then *never changes*. This allows
16+ // unambiguous detection of one package being an updated version of
17+ // another.
18+ //
19+ // When forking a Zig project, this id should be regenerated (delete the
20+ // field and run `zig build`) if the upstream project is still maintained.
21+ // Otherwise, the fork is *hostile*, attempting to take control over the
22+ // original project's identity. Thus it is recommended to leave the comment
23+ // on the following line intact, so that it shows up in code reviews that
24+ // modify the field.
25+ .fingerprint = 0x72b19c8a8c410f6b, // Changing this has security and trust implications.
26+ // Tracks the earliest Zig version that the package considers to be a
27+ // supported use case.
28+ .minimum_zig_version = "0.16.0",
29+ // This field is optional.
30+ // Each dependency must either provide a `url` and `hash`, or a `path`.
31+ // `zig build --fetch` can be used to fetch all dependencies of a package, recursively.
32+ // Once all dependencies are fetched, `zig build` no longer requires
33+ // internet connectivity.
34+ .dependencies = .{
35+ .dvui = .{
36+ .url = "git+https://github.com/david-vanderson/dvui?ref=main#535518f7f7d3e6ccf7a2f77c55c2b34981b40073",
37+ .hash = "dvui-0.5.0-dev-AQFJmYJkAwHc4v0Q_mAmJF9BfPveyolJEVEJWEA9b1Qz",
38+ },
39+ },
40+ .paths = .{
41+ "build.zig",
42+ "build.zig.zon",
43+ "src",
44+ "include",
45+ // For example...
46+ //"LICENSE",
47+ //"README.md",
48+ },
49+}
added zig/jolt-zvui/demo.c +89 -0
new file mode 100644
@@ -0,0 +1,89 @@
1+/* A caller of libjoltzvui: builds a tree, paints it, drains events.
2+ * This is the shape glimmer's reconciler drives the library in, in C. */
3+#include <stdio.h>
4+#include <string.h>
5+#include <stdlib.h>
6+#include "include/zvui.h"
7+
8+static int mk(const char *tag, int parent) {
9+ int n = zvui_node_new(tag);
10+ zvui_node_append(parent, n);
11+ return n;
12+}
13+
14+int main(int argc, char **argv) {
15+ int headless = (argc > 1 && strcmp(argv[1], "--dump") == 0);
16+ /* --frames N paints N frames and leaves: a smoke test that ends by itself. */
17+ int limit = 0;
18+ for (int i = 1; i < argc - 1; i++)
19+ if (strcmp(argv[i], "--frames") == 0) limit = atoi(argv[i + 1]);
20+
21+ int root = zvui_tree_root();
22+ zvui_node_set_num(root, "spacing", 8);
23+
24+ int card = mk("card", root);
25+ int title = mk("title", card);
26+ zvui_node_set_str(title, "text", "jolt-zvui");
27+
28+ int sub = mk("dim-label", card);
29+ zvui_node_set_str(sub, "text", "the vidya tree ABI, painted by dvui");
30+
31+ mk("separator", card);
32+
33+ int name = mk("entry", card);
34+ zvui_node_set_str(name, "placeholder", "your handle");
35+
36+ int tls = mk("checkbox", card);
37+ zvui_node_set_str(tls, "label", "Use TLS");
38+ zvui_node_set_bool(tls, "value", 1);
39+
40+ int row = mk("hbox", card);
41+ zvui_node_set_num(row, "spacing", 6);
42+ int connect = mk("button", row);
43+ zvui_node_set_str(connect, "text", "Connect");
44+ zvui_node_set_str(connect, "kind", "primary");
45+ int quit = mk("button", row);
46+ zvui_node_set_str(quit, "text", "Quit");
47+
48+ int status = mk("label", card);
49+ zvui_node_set_str(status, "text", "idle");
50+
51+ if (headless) {
52+ printf("%s", zvui_tree_dump(root));
53+ printf("child_count(root)=%d tag(card)=%s\n",
54+ zvui_node_child_count(root), zvui_node_tag(card));
55+ return 0;
56+ }
57+
58+ if (!zvui_open(520, 420, "jolt-zvui demo")) {
59+ fprintf(stderr, "could not open a window\n");
60+ return 1;
61+ }
62+
63+ int painted = 0;
64+ while (!zvui_should_close()) {
65+ zvui_frame();
66+ if (limit && ++painted >= limit) {
67+ printf("painted %d frames, %.0fx%.0f points\n",
68+ painted, zvui_screen_width(), zvui_screen_height());
69+ break;
70+ }
71+ while (zvui_tree_poll_event()) {
72+ int node = zvui_tree_event_node();
73+ const char *ev = zvui_tree_event_name();
74+ printf("event %s on #%d text=\"%s\" num=%f\n",
75+ ev, node, zvui_tree_event_text(), zvui_tree_event_num());
76+ if (node == quit && strcmp(ev, "click") == 0) goto done;
77+ if (node == connect && strcmp(ev, "click") == 0)
78+ zvui_node_set_str(status, "text", "connecting...");
79+ if (node == name && strcmp(ev, "change") == 0) {
80+ static char buf[256];
81+ snprintf(buf, sizeof buf, "hello, %s", zvui_tree_event_text());
82+ zvui_node_set_str(status, "text", buf);
83+ }
84+ }
85+ }
86+done:
87+ zvui_close();
88+ return 0;
89+}
new file mode 100644
@@ -0,0 +1,89 @@
1+/* A caller of libjoltzvui: builds a tree, paints it, drains events.
2+ * This is the shape glimmer's reconciler drives the library in, in C. */
3+#include <stdio.h>
4+#include <string.h>
5+#include <stdlib.h>
6+#include "include/zvui.h"
7+
8+static int mk(const char *tag, int parent) {
9+ int n = zvui_node_new(tag);
10+ zvui_node_append(parent, n);
11+ return n;
12+}
13+
14+int main(int argc, char **argv) {
15+ int headless = (argc > 1 && strcmp(argv[1], "--dump") == 0);
16+ /* --frames N paints N frames and leaves: a smoke test that ends by itself. */
17+ int limit = 0;
18+ for (int i = 1; i < argc - 1; i++)
19+ if (strcmp(argv[i], "--frames") == 0) limit = atoi(argv[i + 1]);
20+
21+ int root = zvui_tree_root();
22+ zvui_node_set_num(root, "spacing", 8);
23+
24+ int card = mk("card", root);
25+ int title = mk("title", card);
26+ zvui_node_set_str(title, "text", "jolt-zvui");
27+
28+ int sub = mk("dim-label", card);
29+ zvui_node_set_str(sub, "text", "the vidya tree ABI, painted by dvui");
30+
31+ mk("separator", card);
32+
33+ int name = mk("entry", card);
34+ zvui_node_set_str(name, "placeholder", "your handle");
35+
36+ int tls = mk("checkbox", card);
37+ zvui_node_set_str(tls, "label", "Use TLS");
38+ zvui_node_set_bool(tls, "value", 1);
39+
40+ int row = mk("hbox", card);
41+ zvui_node_set_num(row, "spacing", 6);
42+ int connect = mk("button", row);
43+ zvui_node_set_str(connect, "text", "Connect");
44+ zvui_node_set_str(connect, "kind", "primary");
45+ int quit = mk("button", row);
46+ zvui_node_set_str(quit, "text", "Quit");
47+
48+ int status = mk("label", card);
49+ zvui_node_set_str(status, "text", "idle");
50+
51+ if (headless) {
52+ printf("%s", zvui_tree_dump(root));
53+ printf("child_count(root)=%d tag(card)=%s\n",
54+ zvui_node_child_count(root), zvui_node_tag(card));
55+ return 0;
56+ }
57+
58+ if (!zvui_open(520, 420, "jolt-zvui demo")) {
59+ fprintf(stderr, "could not open a window\n");
60+ return 1;
61+ }
62+
63+ int painted = 0;
64+ while (!zvui_should_close()) {
65+ zvui_frame();
66+ if (limit && ++painted >= limit) {
67+ printf("painted %d frames, %.0fx%.0f points\n",
68+ painted, zvui_screen_width(), zvui_screen_height());
69+ break;
70+ }
71+ while (zvui_tree_poll_event()) {
72+ int node = zvui_tree_event_node();
73+ const char *ev = zvui_tree_event_name();
74+ printf("event %s on #%d text=\"%s\" num=%f\n",
75+ ev, node, zvui_tree_event_text(), zvui_tree_event_num());
76+ if (node == quit && strcmp(ev, "click") == 0) goto done;
77+ if (node == connect && strcmp(ev, "click") == 0)
78+ zvui_node_set_str(status, "text", "connecting...");
79+ if (node == name && strcmp(ev, "change") == 0) {
80+ static char buf[256];
81+ snprintf(buf, sizeof buf, "hello, %s", zvui_tree_event_text());
82+ zvui_node_set_str(status, "text", buf);
83+ }
84+ }
85+ }
86+done:
87+ zvui_close();
88+ return 0;
89+}
added zig/jolt-zvui/include/zvui.h +65 -0
new file mode 100644
@@ -0,0 +1,65 @@
1+/* libjoltzvui — the retained-tree C ABI, painted by dvui.
2+ *
3+ * The shape crates/jolt-vidya exports on egui and crates/jolt-tui exports over
4+ * terminal cells. A caller builds a tree of nodes between frames, calls
5+ * zvui_frame() to paint it, and drains what the person did with
6+ * zvui_tree_poll_event().
7+ *
8+ * Node ids are small positive ints; 0 is "no node". Ids are reused after a
9+ * free, but widget identity is not — see src/tree.zig.
10+ *
11+ * Every `const char *` returned here points into one scratch slot and is valid
12+ * only until the next string-returning call. Copy it if you need it longer.
13+ * Nothing here calls back: events are queued and polled.
14+ */
15+#ifndef JOLTZVUI_H
16+#define JOLTZVUI_H
17+
18+#ifdef __cplusplus
19+extern "C" {
20+#endif
21+
22+/* The window. */
23+int zvui_open(int width, int height, const char *title);
24+void zvui_close(void);
25+int zvui_should_close(void); /* 1 once asked to quit, or with no window */
26+void zvui_set_title(const char *title);
27+float zvui_screen_width(void); /* points, not pixels; 0 before frame one */
28+float zvui_screen_height(void);
29+void zvui_frame(void); /* paint the whole tree once, and present */
30+
31+/* The tree. */
32+int zvui_tree_root(void);
33+int zvui_node_new(const char *tag);
34+void zvui_node_free(int node); /* and everything under it */
35+int zvui_node_exists(int node);
36+const char *zvui_node_tag(int node);
37+int zvui_node_parent(int node);
38+
39+void zvui_node_set_str(int node, const char *key, const char *value);
40+void zvui_node_set_num(int node, const char *key, double value);
41+void zvui_node_set_bool(int node, const char *key, int value);
42+void zvui_node_clear_props(int node);
43+const char *zvui_node_get_str(int node, const char *key);
44+double zvui_node_get_num(int node, const char *key);
45+int zvui_node_get_bool(int node, const char *key);
46+
47+int zvui_node_child_count(int node);
48+int zvui_node_child_at(int node, int index);
49+int zvui_node_append(int parent, int child);
50+void zvui_node_remove(int parent, int child);
51+int zvui_node_insert_after(int parent, int child, int sibling);
52+int zvui_node_replace(int parent, int old_child, int new_child);
53+const char *zvui_tree_dump(int node);
54+
55+/* Events: poll until it answers 0, reading each with the accessors. */
56+int zvui_tree_poll_event(void);
57+int zvui_tree_event_node(void);
58+const char *zvui_tree_event_name(void); /* click | change | toggled | activate */
59+const char *zvui_tree_event_text(void);
60+double zvui_tree_event_num(void);
61+
62+#ifdef __cplusplus
63+}
64+#endif
65+#endif /* JOLTZVUI_H */
new file mode 100644
@@ -0,0 +1,65 @@
1+/* libjoltzvui — the retained-tree C ABI, painted by dvui.
2+ *
3+ * The shape crates/jolt-vidya exports on egui and crates/jolt-tui exports over
4+ * terminal cells. A caller builds a tree of nodes between frames, calls
5+ * zvui_frame() to paint it, and drains what the person did with
6+ * zvui_tree_poll_event().
7+ *
8+ * Node ids are small positive ints; 0 is "no node". Ids are reused after a
9+ * free, but widget identity is not — see src/tree.zig.
10+ *
11+ * Every `const char *` returned here points into one scratch slot and is valid
12+ * only until the next string-returning call. Copy it if you need it longer.
13+ * Nothing here calls back: events are queued and polled.
14+ */
15+#ifndef JOLTZVUI_H
16+#define JOLTZVUI_H
17+
18+#ifdef __cplusplus
19+extern "C" {
20+#endif
21+
22+/* The window. */
23+int zvui_open(int width, int height, const char *title);
24+void zvui_close(void);
25+int zvui_should_close(void); /* 1 once asked to quit, or with no window */
26+void zvui_set_title(const char *title);
27+float zvui_screen_width(void); /* points, not pixels; 0 before frame one */
28+float zvui_screen_height(void);
29+void zvui_frame(void); /* paint the whole tree once, and present */
30+
31+/* The tree. */
32+int zvui_tree_root(void);
33+int zvui_node_new(const char *tag);
34+void zvui_node_free(int node); /* and everything under it */
35+int zvui_node_exists(int node);
36+const char *zvui_node_tag(int node);
37+int zvui_node_parent(int node);
38+
39+void zvui_node_set_str(int node, const char *key, const char *value);
40+void zvui_node_set_num(int node, const char *key, double value);
41+void zvui_node_set_bool(int node, const char *key, int value);
42+void zvui_node_clear_props(int node);
43+const char *zvui_node_get_str(int node, const char *key);
44+double zvui_node_get_num(int node, const char *key);
45+int zvui_node_get_bool(int node, const char *key);
46+
47+int zvui_node_child_count(int node);
48+int zvui_node_child_at(int node, int index);
49+int zvui_node_append(int parent, int child);
50+void zvui_node_remove(int parent, int child);
51+int zvui_node_insert_after(int parent, int child, int sibling);
52+int zvui_node_replace(int parent, int old_child, int new_child);
53+const char *zvui_tree_dump(int node);
54+
55+/* Events: poll until it answers 0, reading each with the accessors. */
56+int zvui_tree_poll_event(void);
57+int zvui_tree_event_node(void);
58+const char *zvui_tree_event_name(void); /* click | change | toggled | activate */
59+const char *zvui_tree_event_text(void);
60+double zvui_tree_event_num(void);
61+
62+#ifdef __cplusplus
63+}
64+#endif
65+#endif /* JOLTZVUI_H */
added zig/jolt-zvui/src/lib.zig +339 -0
new file mode 100644
@@ -0,0 +1,339 @@
1+//! `libjoltzvui.so` — the retained-tree C ABI, painted by dvui.
2+//!
3+//! The same tree ABI `crates/jolt-vidya` exports on egui and `crates/jolt-tui`
4+//! exports over terminal cells, with a third painter under it. The prefix is
5+//! `zvui_` for the same reason jolt-tui's is `tui_`: a consumer names one
6+//! object in `:jolt/native` and binds one set of symbols.
7+//!
8+//! # What this keeps of jolt-abi's rules, and what it cannot
9+//!
10+//! *Strings out* and *asking, not telling* are kept exactly: a returned string
11+//! lives in one scratch slot until the next string-returning call, and nothing
12+//! here calls back — events are queued and polled.
13+//!
14+//! *Unwinding* is the one rule Zig cannot keep the way `jolt_abi::guard` does.
15+//! There is no `catch_unwind`: a Zig panic aborts the process, so a bug here is
16+//! a dead client rather than a black tile. The answer is to not panic — every
17+//! entry point below is total, allocation failure answers the same fallback a
18+//! caught panic would, and an out-of-range node id is a miss rather than an
19+//! index. Build ReleaseSafe (the default here) so an overflow traps loudly in
20+//! this library instead of quietly corrupting the caller's heap.
21+
22+const std = @import("std");
23+const dvui = @import("dvui");
24+const SDLBackend = @import("sdl-backend");
25+
26+const tree_mod = @import("tree.zig");
27+const paint = @import("paint.zig");
28+const Tree = tree_mod.Tree;
29+
30+const log = std.log.scoped(.zvui);
31+
32+var gpa_instance: std.heap.DebugAllocator(.{}) = .init;
33+const gpa = gpa_instance.allocator();
34+
35+var tree: ?Tree = null;
36+
37+var threaded: ?std.Io.Threaded = null;
38+var backend: ?SDLBackend = null;
39+var win: ?dvui.Window = null;
40+var window_open: bool = false;
41+var interrupted: bool = false;
42+
43+/// Backing store for every `const char *` this library returns. One slot,
44+/// overwritten by the next call — the contract jolt-abi's `Scratch` states, and
45+/// the one `vidya_tree_event_text` and friends already keep.
46+var scratch: std.ArrayList(u8) = .empty;
47+
48+/// Copy `value` into the scratch slot and answer a pointer good until the next
49+/// string-returning call. An interior NUL truncates rather than failing: these
50+/// strings are shown to someone, not parsed.
51+fn lend(value: []const u8) [*:0]const u8 {
52+ const cut = std.mem.indexOfScalar(u8, value, 0) orelse value.len;
53+ scratch.clearRetainingCapacity();
54+ scratch.appendSlice(gpa, value[0..cut]) catch {
55+ scratch.clearRetainingCapacity();
56+ };
57+ scratch.append(gpa, 0) catch {
58+ // Out of memory for one byte. An empty C string still terminates.
59+ return "";
60+ };
61+ return @ptrCast(scratch.items.ptr);
62+}
63+
64+/// Read a caller's string. Null is the empty string, as in `jolt_abi::borrowed`.
65+fn borrowed(ptr: ?[*:0]const u8) []const u8 {
66+ const p = ptr orelse return "";
67+ return std.mem.span(p);
68+}
69+
70+/// The tree exists from first use, so a caller that only pushes nodes never
71+/// pays to open a window.
72+fn theTree() ?*Tree {
73+ if (tree == null) {
74+ tree = Tree.init(gpa) catch |e| {
75+ log.err("tree init failed: {t}", .{e});
76+ return null;
77+ };
78+ }
79+ return &tree.?;
80+}
81+
82+// ── The window ──────────────────────────────────────────────────────────────
83+
84+export fn zvui_open(width: c_int, height: c_int, title: ?[*:0]const u8) c_int {
85+ if (win != null) return 1; // already open; opening twice is a no-op, not an error
86+
87+ threaded = std.Io.Threaded.init(gpa, .{});
88+ const io = threaded.?.io();
89+
90+ var buf: [256]u8 = undefined;
91+ const name = borrowed(title);
92+ const t = std.fmt.bufPrintSentinel(&buf, "{s}", .{name}, 0) catch "zvui";
93+
94+ backend = SDLBackend.initWindow(.{
95+ .io = io,
96+ .size = .{ .w = @floatFromInt(width), .h = @floatFromInt(height) },
97+ .vsync = true,
98+ .title = t,
99+ }) catch |e| {
100+ log.err("SDL window failed: {t}", .{e});
101+ threaded.?.deinit();
102+ threaded = null;
103+ backend = null;
104+ return 0;
105+ };
106+
107+ window_open = true;
108+ win = dvui.Window.init(@src(), gpa, backend.?.backend(), .{
109+ .theme = switch (backend.?.preferredColorScheme() orelse .dark) {
110+ .light => dvui.Theme.builtin.adwaita_light,
111+ .dark => dvui.Theme.builtin.adwaita_dark,
112+ },
113+ .open_flag = &window_open,
114+ }) catch |e| {
115+ log.err("dvui window failed: {t}", .{e});
116+ backend.?.deinit();
117+ backend = null;
118+ threaded.?.deinit();
119+ threaded = null;
120+ window_open = false;
121+ return 0;
122+ };
123+ return 1;
124+}
125+
126+export fn zvui_close() void {
127+ if (win) |*w| w.deinit();
128+ win = null;
129+ if (backend) |*b| b.deinit();
130+ backend = null;
131+ if (threaded) |*t| t.deinit();
132+ threaded = null;
133+ window_open = false;
134+}
135+
136+/// 1 once the person has asked for the window to go away. Answers 1 with no
137+/// window open, so a caller's loop terminates rather than spinning on nothing.
138+export fn zvui_should_close() c_int {
139+ if (win == null) return 1;
140+ return if (window_open) 0 else 1;
141+}
142+
143+export fn zvui_set_title(title: ?[*:0]const u8) void {
144+ const b = &(backend orelse return);
145+ const w = &(win orelse return);
146+ b.title(w, borrowed(title));
147+}
148+
149+/// The window's width in points, or 0 before the first frame. Points, not
150+/// pixels: whoever asks is about to lay something out.
151+export fn zvui_screen_width() f32 {
152+ const w = &(win orelse return 0);
153+ return w.data().rect.w;
154+}
155+
156+export fn zvui_screen_height() f32 {
157+ const w = &(win orelse return 0);
158+ return w.data().rect.h;
159+}
160+
161+/// Paint the whole tree as one frame and present it. Inert with no window open.
162+export fn zvui_frame() void {
163+ const b = &(backend orelse return);
164+ const w = &(win orelse return);
165+ const t = theTree() orelse return;
166+
167+ const nstime = w.beginWait(interrupted);
168+ w.begin(nstime) catch |e| {
169+ log.err("begin: {t}", .{e});
170+ return;
171+ };
172+ b.addAllEvents(w) catch |e| {
173+ log.err("events: {t}", .{e});
174+ };
175+
176+ paint.walk(t, t.root());
177+
178+ const end_micros = w.end(.{}) catch |e| blk: {
179+ log.err("end: {t}", .{e});
180+ break :blk null;
181+ };
182+ const wait_micros = w.waitTime(end_micros);
183+ interrupted = b.waitEventTimeout(wait_micros) catch false;
184+}
185+
186+// ── The tree ────────────────────────────────────────────────────────────────
187+
188+export fn zvui_tree_root() c_int {
189+ const t = theTree() orelse return 0;
190+ return @intCast(t.root());
191+}
192+
193+export fn zvui_node_new(tag: ?[*:0]const u8) c_int {
194+ const t = theTree() orelse return 0;
195+ return @intCast(t.new(borrowed(tag)));
196+}
197+
198+export fn zvui_node_free(node: c_int) void {
199+ const t = theTree() orelse return;
200+ t.free_node(idOf(node));
201+}
202+
203+export fn zvui_node_exists(node: c_int) c_int {
204+ const t = theTree() orelse return 0;
205+ return if (t.get(idOf(node)) != null) 1 else 0;
206+}
207+
208+export fn zvui_node_tag(node: c_int) [*:0]const u8 {
209+ const t = theTree() orelse return lend("");
210+ const n = t.get(idOf(node)) orelse return lend("");
211+ return lend(n.tag);
212+}
213+
214+export fn zvui_node_parent(node: c_int) c_int {
215+ const t = theTree() orelse return 0;
216+ const n = t.get(idOf(node)) orelse return 0;
217+ return @intCast(n.parent);
218+}
219+
220+export fn zvui_node_set_str(node: c_int, key: ?[*:0]const u8, value: ?[*:0]const u8) void {
221+ const t = theTree() orelse return;
222+ t.setStr(idOf(node), borrowed(key), borrowed(value));
223+}
224+
225+export fn zvui_node_set_num(node: c_int, key: ?[*:0]const u8, value: f64) void {
226+ const t = theTree() orelse return;
227+ t.setNum(idOf(node), borrowed(key), value);
228+}
229+
230+export fn zvui_node_set_bool(node: c_int, key: ?[*:0]const u8, value: c_int) void {
231+ const t = theTree() orelse return;
232+ t.setBool(idOf(node), borrowed(key), value != 0);
233+}
234+
235+export fn zvui_node_clear_props(node: c_int) void {
236+ const t = theTree() orelse return;
237+ t.clear(idOf(node));
238+}
239+
240+export fn zvui_node_get_str(node: c_int, key: ?[*:0]const u8) [*:0]const u8 {
241+ const t = theTree() orelse return lend("");
242+ return lend(t.getStr(idOf(node), borrowed(key)));
243+}
244+
245+export fn zvui_node_get_num(node: c_int, key: ?[*:0]const u8) f64 {
246+ const t = theTree() orelse return 0;
247+ return t.getNum(idOf(node), borrowed(key));
248+}
249+
250+export fn zvui_node_get_bool(node: c_int, key: ?[*:0]const u8) c_int {
251+ const t = theTree() orelse return 0;
252+ return if (t.getBool(idOf(node), borrowed(key))) 1 else 0;
253+}
254+
255+export fn zvui_node_child_count(node: c_int) c_int {
256+ const t = theTree() orelse return 0;
257+ return @intCast(t.childCount(idOf(node)));
258+}
259+
260+export fn zvui_node_child_at(node: c_int, index: c_int) c_int {
261+ const t = theTree() orelse return 0;
262+ if (index < 0) return 0;
263+ return @intCast(t.childAt(idOf(node), @intCast(index)));
264+}
265+
266+export fn zvui_node_append(parent: c_int, child: c_int) c_int {
267+ const t = theTree() orelse return 0;
268+ return if (t.append(idOf(parent), idOf(child))) 1 else 0;
269+}
270+
271+export fn zvui_node_remove(parent: c_int, child: c_int) void {
272+ const t = theTree() orelse return;
273+ t.remove(idOf(parent), idOf(child));
274+}
275+
276+export fn zvui_node_insert_after(parent: c_int, child: c_int, sibling: c_int) c_int {
277+ const t = theTree() orelse return 0;
278+ return if (t.insertAfter(idOf(parent), idOf(child), idOf(sibling))) 1 else 0;
279+}
280+
281+export fn zvui_node_replace(parent: c_int, old_child: c_int, new_child: c_int) c_int {
282+ const t = theTree() orelse return 0;
283+ return if (t.replace(idOf(parent), idOf(old_child), idOf(new_child))) 1 else 0;
284+}
285+
286+/// The subtree under `node` as indented text. For a bug report or a test, the
287+/// same way `vidya_tree_dump` is.
288+export fn zvui_tree_dump(node: c_int) [*:0]const u8 {
289+ const t = theTree() orelse return lend("");
290+ var out: std.ArrayList(u8) = .empty;
291+ defer out.deinit(gpa);
292+ t.dump(idOf(node), &out, 0);
293+ return lend(out.items);
294+}
295+
296+// ── Events ──────────────────────────────────────────────────────────────────
297+
298+/// Dequeue one event, answering 1 while there was one. Its fields are read with
299+/// the accessors below, which describe the most recently dequeued event.
300+export fn zvui_tree_poll_event() c_int {
301+ const t = theTree() orelse return 0;
302+ return if (t.poll()) 1 else 0;
303+}
304+
305+export fn zvui_tree_event_node() c_int {
306+ const t = theTree() orelse return 0;
307+ const e = t.current orelse return 0;
308+ return @intCast(e.node);
309+}
310+
311+/// `click`, `change`, `toggled`, `activate` — or the empty string when nothing
312+/// has been dequeued.
313+export fn zvui_tree_event_name() [*:0]const u8 {
314+ const t = theTree() orelse return lend("");
315+ const e = t.current orelse return lend("");
316+ return lend(e.name);
317+}
318+
319+export fn zvui_tree_event_text() [*:0]const u8 {
320+ const t = theTree() orelse return lend("");
321+ const e = t.current orelse return lend("");
322+ return lend(e.text);
323+}
324+
325+export fn zvui_tree_event_num() f64 {
326+ const t = theTree() orelse return 0;
327+ const e = t.current orelse return 0;
328+ return e.num;
329+}
330+
331+/// A negative id from C is not a node. Fold it to 0 — the null node — rather
332+/// than let it become a huge index.
333+fn idOf(node: c_int) u32 {
334+ return if (node <= 0) 0 else @intCast(node);
335+}
336+
337+test {
338+ _ = tree_mod;
339+}
new file mode 100644
@@ -0,0 +1,339 @@
1+//! `libjoltzvui.so` — the retained-tree C ABI, painted by dvui.
2+//!
3+//! The same tree ABI `crates/jolt-vidya` exports on egui and `crates/jolt-tui`
4+//! exports over terminal cells, with a third painter under it. The prefix is
5+//! `zvui_` for the same reason jolt-tui's is `tui_`: a consumer names one
6+//! object in `:jolt/native` and binds one set of symbols.
7+//!
8+//! # What this keeps of jolt-abi's rules, and what it cannot
9+//!
10+//! *Strings out* and *asking, not telling* are kept exactly: a returned string
11+//! lives in one scratch slot until the next string-returning call, and nothing
12+//! here calls back — events are queued and polled.
13+//!
14+//! *Unwinding* is the one rule Zig cannot keep the way `jolt_abi::guard` does.
15+//! There is no `catch_unwind`: a Zig panic aborts the process, so a bug here is
16+//! a dead client rather than a black tile. The answer is to not panic — every
17+//! entry point below is total, allocation failure answers the same fallback a
18+//! caught panic would, and an out-of-range node id is a miss rather than an
19+//! index. Build ReleaseSafe (the default here) so an overflow traps loudly in
20+//! this library instead of quietly corrupting the caller's heap.
21+
22+const std = @import("std");
23+const dvui = @import("dvui");
24+const SDLBackend = @import("sdl-backend");
25+
26+const tree_mod = @import("tree.zig");
27+const paint = @import("paint.zig");
28+const Tree = tree_mod.Tree;
29+
30+const log = std.log.scoped(.zvui);
31+
32+var gpa_instance: std.heap.DebugAllocator(.{}) = .init;
33+const gpa = gpa_instance.allocator();
34+
35+var tree: ?Tree = null;
36+
37+var threaded: ?std.Io.Threaded = null;
38+var backend: ?SDLBackend = null;
39+var win: ?dvui.Window = null;
40+var window_open: bool = false;
41+var interrupted: bool = false;
42+
43+/// Backing store for every `const char *` this library returns. One slot,
44+/// overwritten by the next call — the contract jolt-abi's `Scratch` states, and
45+/// the one `vidya_tree_event_text` and friends already keep.
46+var scratch: std.ArrayList(u8) = .empty;
47+
48+/// Copy `value` into the scratch slot and answer a pointer good until the next
49+/// string-returning call. An interior NUL truncates rather than failing: these
50+/// strings are shown to someone, not parsed.
51+fn lend(value: []const u8) [*:0]const u8 {
52+ const cut = std.mem.indexOfScalar(u8, value, 0) orelse value.len;
53+ scratch.clearRetainingCapacity();
54+ scratch.appendSlice(gpa, value[0..cut]) catch {
55+ scratch.clearRetainingCapacity();
56+ };
57+ scratch.append(gpa, 0) catch {
58+ // Out of memory for one byte. An empty C string still terminates.
59+ return "";
60+ };
61+ return @ptrCast(scratch.items.ptr);
62+}
63+
64+/// Read a caller's string. Null is the empty string, as in `jolt_abi::borrowed`.
65+fn borrowed(ptr: ?[*:0]const u8) []const u8 {
66+ const p = ptr orelse return "";
67+ return std.mem.span(p);
68+}
69+
70+/// The tree exists from first use, so a caller that only pushes nodes never
71+/// pays to open a window.
72+fn theTree() ?*Tree {
73+ if (tree == null) {
74+ tree = Tree.init(gpa) catch |e| {
75+ log.err("tree init failed: {t}", .{e});
76+ return null;
77+ };
78+ }
79+ return &tree.?;
80+}
81+
82+// ── The window ──────────────────────────────────────────────────────────────
83+
84+export fn zvui_open(width: c_int, height: c_int, title: ?[*:0]const u8) c_int {
85+ if (win != null) return 1; // already open; opening twice is a no-op, not an error
86+
87+ threaded = std.Io.Threaded.init(gpa, .{});
88+ const io = threaded.?.io();
89+
90+ var buf: [256]u8 = undefined;
91+ const name = borrowed(title);
92+ const t = std.fmt.bufPrintSentinel(&buf, "{s}", .{name}, 0) catch "zvui";
93+
94+ backend = SDLBackend.initWindow(.{
95+ .io = io,
96+ .size = .{ .w = @floatFromInt(width), .h = @floatFromInt(height) },
97+ .vsync = true,
98+ .title = t,
99+ }) catch |e| {
100+ log.err("SDL window failed: {t}", .{e});
101+ threaded.?.deinit();
102+ threaded = null;
103+ backend = null;
104+ return 0;
105+ };
106+
107+ window_open = true;
108+ win = dvui.Window.init(@src(), gpa, backend.?.backend(), .{
109+ .theme = switch (backend.?.preferredColorScheme() orelse .dark) {
110+ .light => dvui.Theme.builtin.adwaita_light,
111+ .dark => dvui.Theme.builtin.adwaita_dark,
112+ },
113+ .open_flag = &window_open,
114+ }) catch |e| {
115+ log.err("dvui window failed: {t}", .{e});
116+ backend.?.deinit();
117+ backend = null;
118+ threaded.?.deinit();
119+ threaded = null;
120+ window_open = false;
121+ return 0;
122+ };
123+ return 1;
124+}
125+
126+export fn zvui_close() void {
127+ if (win) |*w| w.deinit();
128+ win = null;
129+ if (backend) |*b| b.deinit();
130+ backend = null;
131+ if (threaded) |*t| t.deinit();
132+ threaded = null;
133+ window_open = false;
134+}
135+
136+/// 1 once the person has asked for the window to go away. Answers 1 with no
137+/// window open, so a caller's loop terminates rather than spinning on nothing.
138+export fn zvui_should_close() c_int {
139+ if (win == null) return 1;
140+ return if (window_open) 0 else 1;
141+}
142+
143+export fn zvui_set_title(title: ?[*:0]const u8) void {
144+ const b = &(backend orelse return);
145+ const w = &(win orelse return);
146+ b.title(w, borrowed(title));
147+}
148+
149+/// The window's width in points, or 0 before the first frame. Points, not
150+/// pixels: whoever asks is about to lay something out.
151+export fn zvui_screen_width() f32 {
152+ const w = &(win orelse return 0);
153+ return w.data().rect.w;
154+}
155+
156+export fn zvui_screen_height() f32 {
157+ const w = &(win orelse return 0);
158+ return w.data().rect.h;
159+}
160+
161+/// Paint the whole tree as one frame and present it. Inert with no window open.
162+export fn zvui_frame() void {
163+ const b = &(backend orelse return);
164+ const w = &(win orelse return);
165+ const t = theTree() orelse return;
166+
167+ const nstime = w.beginWait(interrupted);
168+ w.begin(nstime) catch |e| {
169+ log.err("begin: {t}", .{e});
170+ return;
171+ };
172+ b.addAllEvents(w) catch |e| {
173+ log.err("events: {t}", .{e});
174+ };
175+
176+ paint.walk(t, t.root());
177+
178+ const end_micros = w.end(.{}) catch |e| blk: {
179+ log.err("end: {t}", .{e});
180+ break :blk null;
181+ };
182+ const wait_micros = w.waitTime(end_micros);
183+ interrupted = b.waitEventTimeout(wait_micros) catch false;
184+}
185+
186+// ── The tree ────────────────────────────────────────────────────────────────
187+
188+export fn zvui_tree_root() c_int {
189+ const t = theTree() orelse return 0;
190+ return @intCast(t.root());
191+}
192+
193+export fn zvui_node_new(tag: ?[*:0]const u8) c_int {
194+ const t = theTree() orelse return 0;
195+ return @intCast(t.new(borrowed(tag)));
196+}
197+
198+export fn zvui_node_free(node: c_int) void {
199+ const t = theTree() orelse return;
200+ t.free_node(idOf(node));
201+}
202+
203+export fn zvui_node_exists(node: c_int) c_int {
204+ const t = theTree() orelse return 0;
205+ return if (t.get(idOf(node)) != null) 1 else 0;
206+}
207+
208+export fn zvui_node_tag(node: c_int) [*:0]const u8 {
209+ const t = theTree() orelse return lend("");
210+ const n = t.get(idOf(node)) orelse return lend("");
211+ return lend(n.tag);
212+}
213+
214+export fn zvui_node_parent(node: c_int) c_int {
215+ const t = theTree() orelse return 0;
216+ const n = t.get(idOf(node)) orelse return 0;
217+ return @intCast(n.parent);
218+}
219+
220+export fn zvui_node_set_str(node: c_int, key: ?[*:0]const u8, value: ?[*:0]const u8) void {
221+ const t = theTree() orelse return;
222+ t.setStr(idOf(node), borrowed(key), borrowed(value));
223+}
224+
225+export fn zvui_node_set_num(node: c_int, key: ?[*:0]const u8, value: f64) void {
226+ const t = theTree() orelse return;
227+ t.setNum(idOf(node), borrowed(key), value);
228+}
229+
230+export fn zvui_node_set_bool(node: c_int, key: ?[*:0]const u8, value: c_int) void {
231+ const t = theTree() orelse return;
232+ t.setBool(idOf(node), borrowed(key), value != 0);
233+}
234+
235+export fn zvui_node_clear_props(node: c_int) void {
236+ const t = theTree() orelse return;
237+ t.clear(idOf(node));
238+}
239+
240+export fn zvui_node_get_str(node: c_int, key: ?[*:0]const u8) [*:0]const u8 {
241+ const t = theTree() orelse return lend("");
242+ return lend(t.getStr(idOf(node), borrowed(key)));
243+}
244+
245+export fn zvui_node_get_num(node: c_int, key: ?[*:0]const u8) f64 {
246+ const t = theTree() orelse return 0;
247+ return t.getNum(idOf(node), borrowed(key));
248+}
249+
250+export fn zvui_node_get_bool(node: c_int, key: ?[*:0]const u8) c_int {
251+ const t = theTree() orelse return 0;
252+ return if (t.getBool(idOf(node), borrowed(key))) 1 else 0;
253+}
254+
255+export fn zvui_node_child_count(node: c_int) c_int {
256+ const t = theTree() orelse return 0;
257+ return @intCast(t.childCount(idOf(node)));
258+}
259+
260+export fn zvui_node_child_at(node: c_int, index: c_int) c_int {
261+ const t = theTree() orelse return 0;
262+ if (index < 0) return 0;
263+ return @intCast(t.childAt(idOf(node), @intCast(index)));
264+}
265+
266+export fn zvui_node_append(parent: c_int, child: c_int) c_int {
267+ const t = theTree() orelse return 0;
268+ return if (t.append(idOf(parent), idOf(child))) 1 else 0;
269+}
270+
271+export fn zvui_node_remove(parent: c_int, child: c_int) void {
272+ const t = theTree() orelse return;
273+ t.remove(idOf(parent), idOf(child));
274+}
275+
276+export fn zvui_node_insert_after(parent: c_int, child: c_int, sibling: c_int) c_int {
277+ const t = theTree() orelse return 0;
278+ return if (t.insertAfter(idOf(parent), idOf(child), idOf(sibling))) 1 else 0;
279+}
280+
281+export fn zvui_node_replace(parent: c_int, old_child: c_int, new_child: c_int) c_int {
282+ const t = theTree() orelse return 0;
283+ return if (t.replace(idOf(parent), idOf(old_child), idOf(new_child))) 1 else 0;
284+}
285+
286+/// The subtree under `node` as indented text. For a bug report or a test, the
287+/// same way `vidya_tree_dump` is.
288+export fn zvui_tree_dump(node: c_int) [*:0]const u8 {
289+ const t = theTree() orelse return lend("");
290+ var out: std.ArrayList(u8) = .empty;
291+ defer out.deinit(gpa);
292+ t.dump(idOf(node), &out, 0);
293+ return lend(out.items);
294+}
295+
296+// ── Events ──────────────────────────────────────────────────────────────────
297+
298+/// Dequeue one event, answering 1 while there was one. Its fields are read with
299+/// the accessors below, which describe the most recently dequeued event.
300+export fn zvui_tree_poll_event() c_int {
301+ const t = theTree() orelse return 0;
302+ return if (t.poll()) 1 else 0;
303+}
304+
305+export fn zvui_tree_event_node() c_int {
306+ const t = theTree() orelse return 0;
307+ const e = t.current orelse return 0;
308+ return @intCast(e.node);
309+}
310+
311+/// `click`, `change`, `toggled`, `activate` — or the empty string when nothing
312+/// has been dequeued.
313+export fn zvui_tree_event_name() [*:0]const u8 {
314+ const t = theTree() orelse return lend("");
315+ const e = t.current orelse return lend("");
316+ return lend(e.name);
317+}
318+
319+export fn zvui_tree_event_text() [*:0]const u8 {
320+ const t = theTree() orelse return lend("");
321+ const e = t.current orelse return lend("");
322+ return lend(e.text);
323+}
324+
325+export fn zvui_tree_event_num() f64 {
326+ const t = theTree() orelse return 0;
327+ const e = t.current orelse return 0;
328+ return e.num;
329+}
330+
331+/// A negative id from C is not a node. Fold it to 0 — the null node — rather
332+/// than let it become a huge index.
333+fn idOf(node: c_int) u32 {
334+ return if (node <= 0) 0 else @intCast(node);
335+}
336+
337+test {
338+ _ = tree_mod;
339+}
added zig/jolt-zvui/src/paint.zig +251 -0
new file mode 100644
@@ -0,0 +1,251 @@
1+//! The painter: one walk of the retained tree per frame, in dvui widgets.
2+//!
3+//! The tree carries no sizes and no widget state of its own. Everything here
4+//! reads props and answers events back into the tree, so the same tree could
5+//! be walked by a different painter — which is the whole point of the shape
6+//! `jolt-tui` already proves from the other side.
7+
8+const std = @import("std");
9+const dvui = @import("dvui");
10+
11+const tree_mod = @import("tree.zig");
12+const Tree = tree_mod.Tree;
13+
14+/// Every widget here is keyed by the tree's `(generation, index)` identity, so
15+/// one `@src()` per tag is enough to keep dvui's ids apart.
16+fn opts(t: *Tree, id: u32) dvui.Options {
17+ var o: dvui.Options = .{ .id_extra = t.widgetId(id) };
18+ if (t.getNum(id, "width-request") > 0) {
19+ o.min_size_content = .{ .w = @floatCast(t.getNum(id, "width-request")), .h = 0 };
20+ }
21+ if (t.getNum(id, "margin") > 0) {
22+ const m: f32 = @floatCast(t.getNum(id, "margin"));
23+ o.margin = .{ .x = m, .y = m, .w = m, .h = m };
24+ }
25+ // `align` is the cross-axis pull a box gives a child: 0 start, 1 end.
26+ const a = t.getStr(id, "align");
27+ if (a.len != 0) {
28+ if (std.mem.eql(u8, a, "center")) {
29+ o.gravity_x = 0.5;
30+ } else if (std.mem.eql(u8, a, "end")) {
31+ o.gravity_x = 1.0;
32+ } else if (std.mem.eql(u8, a, "fill")) {
33+ o.expand = .horizontal;
34+ }
35+ }
36+ if (t.getBool(id, "fill-height")) o.expand = .both;
37+ return o;
38+}
39+
40+fn direction(t: *Tree, id: u32) dvui.enums.Direction {
41+ const tag = (t.get(id) orelse return .vertical).tag;
42+ if (std.mem.eql(u8, tag, "hbox")) return .horizontal;
43+ if (std.mem.eql(u8, tag, "vbox")) return .vertical;
44+ const o = t.getStr(id, "orientation");
45+ if (std.mem.eql(u8, o, "horizontal")) return .horizontal;
46+ return .vertical;
47+}
48+
49+/// The label a widget shows: `text` first, `label` second. Two names because
50+/// the tags disagree about which one reads better, not because they differ.
51+fn caption(t: *Tree, id: u32) []const u8 {
52+ const text = t.getStr(id, "text");
53+ if (text.len != 0) return text;
54+ return t.getStr(id, "label");
55+}
56+
57+pub fn walk(t: *Tree, id: u32) void {
58+ const n = t.get(id) orelse return;
59+ const tag = n.tag;
60+
61+ if (std.mem.eql(u8, tag, "window") or
62+ std.mem.eql(u8, tag, "box") or
63+ std.mem.eql(u8, tag, "hbox") or
64+ std.mem.eql(u8, tag, "vbox"))
65+ {
66+ var o = opts(t, id);
67+ if (o.expand == null) o.expand = .horizontal;
68+ var b = dvui.box(@src(), .{ .dir = direction(t, id) }, o);
69+ defer b.deinit();
70+ children(t, id, @floatCast(t.getNum(id, "spacing")));
71+ return;
72+ }
73+
74+ if (std.mem.eql(u8, tag, "page")) {
75+ // A page is a column with a reading width, centred in whatever it got.
76+ var o = opts(t, id);
77+ o.expand = .both;
78+ var outer = dvui.box(@src(), .{ .dir = .horizontal }, o);
79+ defer outer.deinit();
80+ const max = t.getNum(id, "max-width");
81+ var inner = dvui.box(@src(), .{ .dir = .vertical }, .{
82+ .id_extra = t.widgetId(id),
83+ .expand = .vertical,
84+ .gravity_x = 0.5,
85+ .min_size_content = .{ .w = if (max > 0) @floatCast(max) else 0, .h = 0 },
86+ .max_size_content = if (max > 0) .width(@floatCast(max)) else null,
87+ });
88+ defer inner.deinit();
89+ children(t, id, @floatCast(t.getNum(id, "spacing")));
90+ return;
91+ }
92+
93+ if (std.mem.eql(u8, tag, "card") or std.mem.eql(u8, tag, "frame")) {
94+ var o = opts(t, id);
95+ if (o.expand == null) o.expand = .horizontal;
96+ o.background = true;
97+ o.corners = dvui.CornerRect.all(6);
98+ o.border = dvui.Rect.all(1);
99+ o.padding = dvui.Rect.all(8);
100+ var b = dvui.box(@src(), .{ .dir = direction(t, id) }, o);
101+ defer b.deinit();
102+ children(t, id, @floatCast(t.getNum(id, "spacing")));
103+ return;
104+ }
105+
106+ if (std.mem.eql(u8, tag, "scroll")) {
107+ var o = opts(t, id);
108+ o.expand = .both;
109+ var s = dvui.scrollArea(@src(), .{}, o);
110+ defer s.deinit();
111+ children(t, id, @floatCast(t.getNum(id, "spacing")));
112+ return;
113+ }
114+
115+ if (std.mem.eql(u8, tag, "label") or
116+ std.mem.eql(u8, tag, "title") or
117+ std.mem.eql(u8, tag, "dim-label"))
118+ {
119+ var o = opts(t, id);
120+ if (std.mem.eql(u8, tag, "title")) {
121+ o.font = dvui.themeGet().font_title;
122+ } else if (std.mem.eql(u8, tag, "dim-label")) {
123+ o.color_text = .{ .color = dvui.themeGet().color(.window, .text).opacity(0.6) };
124+ }
125+ dvui.labelNoFmt(@src(), caption(t, id), .{ .align_x = o.gravity_x orelse 0 }, o);
126+ return;
127+ }
128+
129+ if (std.mem.eql(u8, tag, "separator")) {
130+ var o = opts(t, id);
131+ if (o.expand == null) o.expand = .horizontal;
132+ o.min_size_content = .{ .w = 0, .h = 1 };
133+ _ = dvui.separator(@src(), o);
134+ return;
135+ }
136+
137+ if (std.mem.eql(u8, tag, "spacer") or std.mem.eql(u8, tag, "gap")) {
138+ var o = opts(t, id);
139+ const size: f32 = @floatCast(t.getNum(id, "size"));
140+ o.min_size_content = .{ .w = size, .h = size };
141+ _ = dvui.spacer(@src(), o);
142+ return;
143+ }
144+
145+ if (std.mem.eql(u8, tag, "button")) {
146+ var o = opts(t, id);
147+ // `sensitive` is absent by default, and absent means enabled — an
148+ // unset prop must not read as a dead button.
149+ const enabled = t.get(id).?.props.contains("sensitive") == false or t.getBool(id, "sensitive");
150+ if (!enabled) o.color_text = .{ .color = dvui.themeGet().color(.window, .text).opacity(0.4) };
151+ const kind = t.getStr(id, "kind");
152+ if (std.mem.eql(u8, kind, "primary")) {
153+ o.color_fill = .{ .color = dvui.themeGet().color(.highlight, .fill) };
154+ } else if (std.mem.eql(u8, kind, "destructive")) {
155+ o.color_fill = .{ .color = dvui.themeGet().color(.err, .fill) };
156+ }
157+ if (dvui.button(@src(), caption(t, id), .{}, o) and enabled) {
158+ t.emit(id, "click", "", 0);
159+ }
160+ return;
161+ }
162+
163+ if (std.mem.eql(u8, tag, "checkbox") or std.mem.eql(u8, tag, "checkbutton")) {
164+ // dvui writes through the target, so the prop is the store: read it in,
165+ // let the widget edit it, write back what changed and say so.
166+ var checked = t.getBool(id, "value") or t.getBool(id, "active");
167+ const before = checked;
168+ _ = dvui.checkbox(@src(), &checked, caption(t, id), opts(t, id));
169+ if (checked != before) {
170+ t.setBool(id, "value", checked);
171+ t.setBool(id, "active", checked);
172+ t.emit(id, "toggled", "", if (checked) 1 else 0);
173+ }
174+ return;
175+ }
176+
177+ if (std.mem.eql(u8, tag, "entry")) {
178+ entry(t, id);
179+ return;
180+ }
181+
182+ if (std.mem.eql(u8, tag, "progress")) {
183+ var o = opts(t, id);
184+ if (o.expand == null) o.expand = .horizontal;
185+ o.min_size_content = .{ .w = 0, .h = 10 };
186+ dvui.progress(@src(), .{ .percent = @floatCast(t.getNum(id, "value")) }, o);
187+ return;
188+ }
189+
190+ // An unknown tag is a container, not an error: a tree written against a
191+ // richer painter should still show its contents here rather than vanish.
192+ var b = dvui.box(@src(), .{ .dir = .vertical }, opts(t, id));
193+ defer b.deinit();
194+ children(t, id, @floatCast(t.getNum(id, "spacing")));
195+}
196+
197+fn entry(t: *Tree, id: u32) void {
198+ const n = t.get(id) orelse return;
199+ if (n.entry == null) n.entry = .empty;
200+ if (!n.entry_seeded) {
201+ // The caller's `text` wins until the person typing changes it.
202+ const want = t.getStr(id, "text");
203+ n.entry.?.clearRetainingCapacity();
204+ n.entry.?.appendSlice(t.gpa, want) catch {};
205+ n.entry_seeded = true;
206+ }
207+
208+ const placeholder = t.getStr(id, "placeholder");
209+ var o = opts(t, id);
210+ if (o.expand == null) o.expand = .horizontal;
211+
212+ var te = dvui.textEntry(@src(), .{
213+ .text = .{ .array_list = .{ .backing = &n.entry.?, .allocator = t.gpa } },
214+ .placeholder = if (placeholder.len != 0) placeholder else null,
215+ .multiline = t.getBool(id, "multiline"),
216+ }, o);
217+ const changed = te.text_changed;
218+ const entered = te.enter_pressed;
219+ te.deinit();
220+
221+ // Read the buffer back only after deinit: the widget updates the list there.
222+ const now = if (t.get(id)) |m| (if (m.entry) |buf| buf.items else "") else "";
223+ if (changed) {
224+ // Mirror into the prop so `zvui_node_get_str(node, "text")` answers what
225+ // is on screen, then emit — without disturbing `entry_seeded`, which
226+ // `setStr` would clear and so wipe the cursor position every keystroke.
227+ const owned = t.gpa.dupe(u8, now) catch return;
228+ defer t.gpa.free(owned);
229+ t.setStr(id, "text", owned);
230+ if (t.get(id)) |m| m.entry_seeded = true;
231+ t.emit(id, "change", owned, 0);
232+ }
233+ if (entered) t.emit(id, "activate", now, 0);
234+}
235+
236+fn children(t: *Tree, id: u32, spacing: f32) void {
237+ const n = t.get(id) orelse return;
238+ // Copy the child list before walking it: painting emits events, and a
239+ // caller is free to restructure the tree between frames on the same thread.
240+ const kids = t.gpa.dupe(u32, n.children.items) catch return;
241+ defer t.gpa.free(kids);
242+ for (kids, 0..) |c, i| {
243+ if (spacing > 0 and i > 0) {
244+ _ = dvui.spacer(@src(), .{
245+ .id_extra = t.widgetId(c),
246+ .min_size_content = .{ .w = spacing, .h = spacing },
247+ });
248+ }
249+ walk(t, c);
250+ }
251+}
new file mode 100644
@@ -0,0 +1,251 @@
1+//! The painter: one walk of the retained tree per frame, in dvui widgets.
2+//!
3+//! The tree carries no sizes and no widget state of its own. Everything here
4+//! reads props and answers events back into the tree, so the same tree could
5+//! be walked by a different painter — which is the whole point of the shape
6+//! `jolt-tui` already proves from the other side.
7+
8+const std = @import("std");
9+const dvui = @import("dvui");
10+
11+const tree_mod = @import("tree.zig");
12+const Tree = tree_mod.Tree;
13+
14+/// Every widget here is keyed by the tree's `(generation, index)` identity, so
15+/// one `@src()` per tag is enough to keep dvui's ids apart.
16+fn opts(t: *Tree, id: u32) dvui.Options {
17+ var o: dvui.Options = .{ .id_extra = t.widgetId(id) };
18+ if (t.getNum(id, "width-request") > 0) {
19+ o.min_size_content = .{ .w = @floatCast(t.getNum(id, "width-request")), .h = 0 };
20+ }
21+ if (t.getNum(id, "margin") > 0) {
22+ const m: f32 = @floatCast(t.getNum(id, "margin"));
23+ o.margin = .{ .x = m, .y = m, .w = m, .h = m };
24+ }
25+ // `align` is the cross-axis pull a box gives a child: 0 start, 1 end.
26+ const a = t.getStr(id, "align");
27+ if (a.len != 0) {
28+ if (std.mem.eql(u8, a, "center")) {
29+ o.gravity_x = 0.5;
30+ } else if (std.mem.eql(u8, a, "end")) {
31+ o.gravity_x = 1.0;
32+ } else if (std.mem.eql(u8, a, "fill")) {
33+ o.expand = .horizontal;
34+ }
35+ }
36+ if (t.getBool(id, "fill-height")) o.expand = .both;
37+ return o;
38+}
39+
40+fn direction(t: *Tree, id: u32) dvui.enums.Direction {
41+ const tag = (t.get(id) orelse return .vertical).tag;
42+ if (std.mem.eql(u8, tag, "hbox")) return .horizontal;
43+ if (std.mem.eql(u8, tag, "vbox")) return .vertical;
44+ const o = t.getStr(id, "orientation");
45+ if (std.mem.eql(u8, o, "horizontal")) return .horizontal;
46+ return .vertical;
47+}
48+
49+/// The label a widget shows: `text` first, `label` second. Two names because
50+/// the tags disagree about which one reads better, not because they differ.
51+fn caption(t: *Tree, id: u32) []const u8 {
52+ const text = t.getStr(id, "text");
53+ if (text.len != 0) return text;
54+ return t.getStr(id, "label");
55+}
56+
57+pub fn walk(t: *Tree, id: u32) void {
58+ const n = t.get(id) orelse return;
59+ const tag = n.tag;
60+
61+ if (std.mem.eql(u8, tag, "window") or
62+ std.mem.eql(u8, tag, "box") or
63+ std.mem.eql(u8, tag, "hbox") or
64+ std.mem.eql(u8, tag, "vbox"))
65+ {
66+ var o = opts(t, id);
67+ if (o.expand == null) o.expand = .horizontal;
68+ var b = dvui.box(@src(), .{ .dir = direction(t, id) }, o);
69+ defer b.deinit();
70+ children(t, id, @floatCast(t.getNum(id, "spacing")));
71+ return;
72+ }
73+
74+ if (std.mem.eql(u8, tag, "page")) {
75+ // A page is a column with a reading width, centred in whatever it got.
76+ var o = opts(t, id);
77+ o.expand = .both;
78+ var outer = dvui.box(@src(), .{ .dir = .horizontal }, o);
79+ defer outer.deinit();
80+ const max = t.getNum(id, "max-width");
81+ var inner = dvui.box(@src(), .{ .dir = .vertical }, .{
82+ .id_extra = t.widgetId(id),
83+ .expand = .vertical,
84+ .gravity_x = 0.5,
85+ .min_size_content = .{ .w = if (max > 0) @floatCast(max) else 0, .h = 0 },
86+ .max_size_content = if (max > 0) .width(@floatCast(max)) else null,
87+ });
88+ defer inner.deinit();
89+ children(t, id, @floatCast(t.getNum(id, "spacing")));
90+ return;
91+ }
92+
93+ if (std.mem.eql(u8, tag, "card") or std.mem.eql(u8, tag, "frame")) {
94+ var o = opts(t, id);
95+ if (o.expand == null) o.expand = .horizontal;
96+ o.background = true;
97+ o.corners = dvui.CornerRect.all(6);
98+ o.border = dvui.Rect.all(1);
99+ o.padding = dvui.Rect.all(8);
100+ var b = dvui.box(@src(), .{ .dir = direction(t, id) }, o);
101+ defer b.deinit();
102+ children(t, id, @floatCast(t.getNum(id, "spacing")));
103+ return;
104+ }
105+
106+ if (std.mem.eql(u8, tag, "scroll")) {
107+ var o = opts(t, id);
108+ o.expand = .both;
109+ var s = dvui.scrollArea(@src(), .{}, o);
110+ defer s.deinit();
111+ children(t, id, @floatCast(t.getNum(id, "spacing")));
112+ return;
113+ }
114+
115+ if (std.mem.eql(u8, tag, "label") or
116+ std.mem.eql(u8, tag, "title") or
117+ std.mem.eql(u8, tag, "dim-label"))
118+ {
119+ var o = opts(t, id);
120+ if (std.mem.eql(u8, tag, "title")) {
121+ o.font = dvui.themeGet().font_title;
122+ } else if (std.mem.eql(u8, tag, "dim-label")) {
123+ o.color_text = .{ .color = dvui.themeGet().color(.window, .text).opacity(0.6) };
124+ }
125+ dvui.labelNoFmt(@src(), caption(t, id), .{ .align_x = o.gravity_x orelse 0 }, o);
126+ return;
127+ }
128+
129+ if (std.mem.eql(u8, tag, "separator")) {
130+ var o = opts(t, id);
131+ if (o.expand == null) o.expand = .horizontal;
132+ o.min_size_content = .{ .w = 0, .h = 1 };
133+ _ = dvui.separator(@src(), o);
134+ return;
135+ }
136+
137+ if (std.mem.eql(u8, tag, "spacer") or std.mem.eql(u8, tag, "gap")) {
138+ var o = opts(t, id);
139+ const size: f32 = @floatCast(t.getNum(id, "size"));
140+ o.min_size_content = .{ .w = size, .h = size };
141+ _ = dvui.spacer(@src(), o);
142+ return;
143+ }
144+
145+ if (std.mem.eql(u8, tag, "button")) {
146+ var o = opts(t, id);
147+ // `sensitive` is absent by default, and absent means enabled — an
148+ // unset prop must not read as a dead button.
149+ const enabled = t.get(id).?.props.contains("sensitive") == false or t.getBool(id, "sensitive");
150+ if (!enabled) o.color_text = .{ .color = dvui.themeGet().color(.window, .text).opacity(0.4) };
151+ const kind = t.getStr(id, "kind");
152+ if (std.mem.eql(u8, kind, "primary")) {
153+ o.color_fill = .{ .color = dvui.themeGet().color(.highlight, .fill) };
154+ } else if (std.mem.eql(u8, kind, "destructive")) {
155+ o.color_fill = .{ .color = dvui.themeGet().color(.err, .fill) };
156+ }
157+ if (dvui.button(@src(), caption(t, id), .{}, o) and enabled) {
158+ t.emit(id, "click", "", 0);
159+ }
160+ return;
161+ }
162+
163+ if (std.mem.eql(u8, tag, "checkbox") or std.mem.eql(u8, tag, "checkbutton")) {
164+ // dvui writes through the target, so the prop is the store: read it in,
165+ // let the widget edit it, write back what changed and say so.
166+ var checked = t.getBool(id, "value") or t.getBool(id, "active");
167+ const before = checked;
168+ _ = dvui.checkbox(@src(), &checked, caption(t, id), opts(t, id));
169+ if (checked != before) {
170+ t.setBool(id, "value", checked);
171+ t.setBool(id, "active", checked);
172+ t.emit(id, "toggled", "", if (checked) 1 else 0);
173+ }
174+ return;
175+ }
176+
177+ if (std.mem.eql(u8, tag, "entry")) {
178+ entry(t, id);
179+ return;
180+ }
181+
182+ if (std.mem.eql(u8, tag, "progress")) {
183+ var o = opts(t, id);
184+ if (o.expand == null) o.expand = .horizontal;
185+ o.min_size_content = .{ .w = 0, .h = 10 };
186+ dvui.progress(@src(), .{ .percent = @floatCast(t.getNum(id, "value")) }, o);
187+ return;
188+ }
189+
190+ // An unknown tag is a container, not an error: a tree written against a
191+ // richer painter should still show its contents here rather than vanish.
192+ var b = dvui.box(@src(), .{ .dir = .vertical }, opts(t, id));
193+ defer b.deinit();
194+ children(t, id, @floatCast(t.getNum(id, "spacing")));
195+}
196+
197+fn entry(t: *Tree, id: u32) void {
198+ const n = t.get(id) orelse return;
199+ if (n.entry == null) n.entry = .empty;
200+ if (!n.entry_seeded) {
201+ // The caller's `text` wins until the person typing changes it.
202+ const want = t.getStr(id, "text");
203+ n.entry.?.clearRetainingCapacity();
204+ n.entry.?.appendSlice(t.gpa, want) catch {};
205+ n.entry_seeded = true;
206+ }
207+
208+ const placeholder = t.getStr(id, "placeholder");
209+ var o = opts(t, id);
210+ if (o.expand == null) o.expand = .horizontal;
211+
212+ var te = dvui.textEntry(@src(), .{
213+ .text = .{ .array_list = .{ .backing = &n.entry.?, .allocator = t.gpa } },
214+ .placeholder = if (placeholder.len != 0) placeholder else null,
215+ .multiline = t.getBool(id, "multiline"),
216+ }, o);
217+ const changed = te.text_changed;
218+ const entered = te.enter_pressed;
219+ te.deinit();
220+
221+ // Read the buffer back only after deinit: the widget updates the list there.
222+ const now = if (t.get(id)) |m| (if (m.entry) |buf| buf.items else "") else "";
223+ if (changed) {
224+ // Mirror into the prop so `zvui_node_get_str(node, "text")` answers what
225+ // is on screen, then emit — without disturbing `entry_seeded`, which
226+ // `setStr` would clear and so wipe the cursor position every keystroke.
227+ const owned = t.gpa.dupe(u8, now) catch return;
228+ defer t.gpa.free(owned);
229+ t.setStr(id, "text", owned);
230+ if (t.get(id)) |m| m.entry_seeded = true;
231+ t.emit(id, "change", owned, 0);
232+ }
233+ if (entered) t.emit(id, "activate", now, 0);
234+}
235+
236+fn children(t: *Tree, id: u32, spacing: f32) void {
237+ const n = t.get(id) orelse return;
238+ // Copy the child list before walking it: painting emits events, and a
239+ // caller is free to restructure the tree between frames on the same thread.
240+ const kids = t.gpa.dupe(u32, n.children.items) catch return;
241+ defer t.gpa.free(kids);
242+ for (kids, 0..) |c, i| {
243+ if (spacing > 0 and i > 0) {
244+ _ = dvui.spacer(@src(), .{
245+ .id_extra = t.widgetId(c),
246+ .min_size_content = .{ .w = spacing, .h = spacing },
247+ });
248+ }
249+ walk(t, c);
250+ }
251+}
added zig/jolt-zvui/src/tree.zig +555 -0
new file mode 100644
@@ -0,0 +1,555 @@
1+//! The retained tree: nodes, props, children, and the event queue jolt polls.
2+//!
3+//! This is the same shape `crates/jolt-vidya/src/tree.rs` holds and
4+//! `crates/jolt-tui` holds again — a tree the caller mutates between frames and
5+//! a painter walks during one. Nothing here knows what paints it.
6+
7+const std = @import("std");
8+
9+const Allocator = std.mem.Allocator;
10+
11+/// A prop value. The three types jolt's FFI vocabulary can carry.
12+pub const Value = union(enum) {
13+ str: []u8,
14+ num: f64,
15+ boolean: bool,
16+};
17+
18+/// One queued event, drained by `poll`.
19+pub const Event = struct {
20+ node: u32,
21+ /// Static: `click`, `change`, `toggled`, `activate`.
22+ name: []const u8,
23+ text: []u8,
24+ num: f64,
25+};
26+
27+pub const Node = struct {
28+ tag: []u8 = &.{},
29+ props: std.StringHashMapUnmanaged(Value) = .empty,
30+ children: std.ArrayList(u32) = .empty,
31+ parent: u32 = 0,
32+ alive: bool = false,
33+ /// Bumped every time this slot is handed out again. Widget identity is
34+ /// `(generation, index)` rather than the index alone, so a node freed and
35+ /// reallocated in the same frame does not inherit the widget state of the
36+ /// node that used to live here — which is the bug class the reconciler
37+ /// notes in frq's deps.edn describe from the other side.
38+ generation: u32 = 0,
39+ /// Persistent edit buffer for `entry` nodes, kept across frames because a
40+ /// text field owns its contents between the caller's writes.
41+ entry: ?std.ArrayList(u8) = null,
42+ /// Whether `entry` has been seeded from the `text` prop yet.
43+ entry_seeded: bool = false,
44+};
45+
46+pub const Tree = struct {
47+ gpa: Allocator,
48+ /// Slot 0 is never handed out — 0 is how this ABI spells "no node", the
49+ /// same way `vidya_node_new` answers 0 on failure.
50+ nodes: std.ArrayList(Node) = .empty,
51+ free: std.ArrayList(u32) = .empty,
52+ events: std.ArrayList(Event) = .empty,
53+ /// Read position in `events`; the queue compacts when it drains.
54+ head: usize = 0,
55+ current: ?Event = null,
56+
57+ pub fn init(gpa: Allocator) !Tree {
58+ var self: Tree = .{ .gpa = gpa };
59+ // Slot 0: the null node. Slot 1: the root, which is never freed.
60+ try self.nodes.append(gpa, .{});
61+ const root_id = try self.alloc("window");
62+ std.debug.assert(root_id == 1);
63+ return self;
64+ }
65+
66+ pub fn deinit(self: *Tree) void {
67+ for (self.nodes.items) |*n| self.release(n);
68+ self.nodes.deinit(self.gpa);
69+ self.free.deinit(self.gpa);
70+ for (self.events.items) |e| self.gpa.free(e.text);
71+ self.events.deinit(self.gpa);
72+ if (self.current) |e| self.gpa.free(e.text);
73+ self.current = null;
74+ }
75+
76+ fn release(self: *Tree, n: *Node) void {
77+ if (n.tag.len != 0) self.gpa.free(n.tag);
78+ n.tag = &.{};
79+ self.clearProps(n);
80+ n.props.deinit(self.gpa);
81+ n.props = .empty;
82+ n.children.deinit(self.gpa);
83+ n.children = .empty;
84+ if (n.entry) |*buf| buf.deinit(self.gpa);
85+ n.entry = null;
86+ n.entry_seeded = false;
87+ }
88+
89+ fn clearProps(self: *Tree, n: *Node) void {
90+ var it = n.props.iterator();
91+ while (it.next()) |kv| {
92+ self.gpa.free(kv.key_ptr.*);
93+ if (kv.value_ptr.* == .str) self.gpa.free(kv.value_ptr.str);
94+ }
95+ n.props.clearRetainingCapacity();
96+ }
97+
98+ pub fn root(self: *Tree) u32 {
99+ _ = self;
100+ return 1;
101+ }
102+
103+ pub fn get(self: *Tree, id: u32) ?*Node {
104+ if (id == 0 or id >= self.nodes.items.len) return null;
105+ const n = &self.nodes.items[id];
106+ return if (n.alive) n else null;
107+ }
108+
109+ fn alloc(self: *Tree, tag: []const u8) !u32 {
110+ const owned = try self.gpa.dupe(u8, tag);
111+ errdefer self.gpa.free(owned);
112+ if (self.free.pop()) |id| {
113+ const n = &self.nodes.items[id];
114+ n.* = .{
115+ .tag = owned,
116+ .alive = true,
117+ // Reuse never repeats an identity.
118+ .generation = n.generation +% 1,
119+ };
120+ return id;
121+ }
122+ const id: u32 = @intCast(self.nodes.items.len);
123+ try self.nodes.append(self.gpa, .{ .tag = owned, .alive = true });
124+ return id;
125+ }
126+
127+ pub fn new(self: *Tree, tag: []const u8) u32 {
128+ return self.alloc(tag) catch 0;
129+ }
130+
131+ /// Free a node and everything under it. Detaches from its parent first, so
132+ /// a caller that frees a subtree still holds a consistent tree.
133+ pub fn free_node(self: *Tree, id: u32) void {
134+ if (id <= 1) return; // never free the null slot or the root
135+ const n = self.get(id) orelse return;
136+ const parent = n.parent;
137+ if (self.get(parent)) |p| {
138+ if (std.mem.indexOfScalar(u32, p.children.items, id)) |i| {
139+ _ = p.children.orderedRemove(i);
140+ }
141+ }
142+ self.freeSubtree(id);
143+ }
144+
145+ fn freeSubtree(self: *Tree, id: u32) void {
146+ const n = self.get(id) orelse return;
147+ // Copy the child list: releasing the node frees the backing array.
148+ const kids = self.gpa.dupe(u32, n.children.items) catch &.{};
149+ defer if (kids.len != 0) self.gpa.free(kids);
150+ for (kids) |c| self.freeSubtree(c);
151+ const node = &self.nodes.items[id];
152+ self.release(node);
153+ node.alive = false;
154+ node.parent = 0;
155+ self.free.append(self.gpa, id) catch {};
156+ }
157+
158+ /// The identity a painter keys widget state by: never repeated after reuse.
159+ pub fn widgetId(self: *Tree, id: u32) usize {
160+ const gen: usize = if (id < self.nodes.items.len) self.nodes.items[id].generation else 0;
161+ return (gen << 32) | @as(usize, id);
162+ }
163+
164+ // ── Props ───────────────────────────────────────────────────────────────
165+
166+ fn put(self: *Tree, n: *Node, key: []const u8, value: Value) !void {
167+ const gop = try n.props.getOrPut(self.gpa, key);
168+ if (gop.found_existing) {
169+ if (gop.value_ptr.* == .str) self.gpa.free(gop.value_ptr.str);
170+ } else {
171+ gop.key_ptr.* = self.gpa.dupe(u8, key) catch |e| {
172+ _ = n.props.remove(key);
173+ return e;
174+ };
175+ }
176+ gop.value_ptr.* = value;
177+ }
178+
179+ pub fn setStr(self: *Tree, id: u32, key: []const u8, value: []const u8) void {
180+ const n = self.get(id) orelse return;
181+ const owned = self.gpa.dupe(u8, value) catch return;
182+ self.put(n, key, .{ .str = owned }) catch self.gpa.free(owned);
183+ // A caller writing `text` is authoritative over an entry's contents.
184+ if (std.mem.eql(u8, key, "text")) n.entry_seeded = false;
185+ }
186+
187+ pub fn setNum(self: *Tree, id: u32, key: []const u8, value: f64) void {
188+ const n = self.get(id) orelse return;
189+ self.put(n, key, .{ .num = value }) catch {};
190+ }
191+
192+ pub fn setBool(self: *Tree, id: u32, key: []const u8, value: bool) void {
193+ const n = self.get(id) orelse return;
194+ self.put(n, key, .{ .boolean = value }) catch {};
195+ }
196+
197+ pub fn clear(self: *Tree, id: u32) void {
198+ const n = self.get(id) orelse return;
199+ self.clearProps(n);
200+ }
201+
202+ pub fn getStr(self: *Tree, id: u32, key: []const u8) []const u8 {
203+ const n = self.get(id) orelse return "";
204+ const v = n.props.get(key) orelse return "";
205+ return switch (v) {
206+ .str => |s| s,
207+ else => "",
208+ };
209+ }
210+
211+ pub fn getNum(self: *Tree, id: u32, key: []const u8) f64 {
212+ const n = self.get(id) orelse return 0;
213+ const v = n.props.get(key) orelse return 0;
214+ return switch (v) {
215+ .num => |x| x,
216+ .boolean => |b| if (b) 1 else 0,
217+ .str => 0,
218+ };
219+ }
220+
221+ pub fn getBool(self: *Tree, id: u32, key: []const u8) bool {
222+ const n = self.get(id) orelse return false;
223+ const v = n.props.get(key) orelse return false;
224+ return switch (v) {
225+ .boolean => |b| b,
226+ .num => |x| x != 0,
227+ .str => |s| s.len != 0,
228+ };
229+ }
230+
231+ // ── Children ────────────────────────────────────────────────────────────
232+
233+ /// Detach `child` from whatever parent currently holds it.
234+ fn detach(self: *Tree, child: u32) void {
235+ const c = self.get(child) orelse return;
236+ if (self.get(c.parent)) |p| {
237+ if (std.mem.indexOfScalar(u32, p.children.items, child)) |i| {
238+ _ = p.children.orderedRemove(i);
239+ }
240+ }
241+ c.parent = 0;
242+ }
243+
244+ /// True when `ancestor` is `node` or is above it — a cycle check, because
245+ /// appending a node into its own subtree makes a walk that never returns.
246+ fn contains(self: *Tree, ancestor: u32, node: u32) bool {
247+ var walk = node;
248+ var guard_count: usize = 0;
249+ while (walk != 0) : (guard_count += 1) {
250+ if (walk == ancestor) return true;
251+ if (guard_count > self.nodes.items.len) return true; // already cyclic
252+ const n = self.get(walk) orelse return false;
253+ walk = n.parent;
254+ }
255+ return false;
256+ }
257+
258+ pub fn append(self: *Tree, parent: u32, child: u32) bool {
259+ if (parent == child) return false;
260+ _ = self.get(child) orelse return false;
261+ const p = self.get(parent) orelse return false;
262+ if (self.contains(child, parent)) return false;
263+ self.detach(child);
264+ const pp = self.get(parent).?;
265+ pp.children.append(self.gpa, child) catch return false;
266+ self.get(child).?.parent = parent;
267+ _ = p;
268+ return true;
269+ }
270+
271+ pub fn remove(self: *Tree, parent: u32, child: u32) void {
272+ const p = self.get(parent) orelse return;
273+ if (std.mem.indexOfScalar(u32, p.children.items, child)) |i| {
274+ _ = p.children.orderedRemove(i);
275+ if (self.get(child)) |c| c.parent = 0;
276+ }
277+ }
278+
279+ pub fn insertAfter(self: *Tree, parent: u32, child: u32, sibling: u32) bool {
280+ if (parent == child) return false;
281+ _ = self.get(child) orelse return false;
282+ _ = self.get(parent) orelse return false;
283+ if (self.contains(child, parent)) return false;
284+ self.detach(child);
285+ const p = self.get(parent).?;
286+ // A sibling of 0, or one that is not here, means the front — which is
287+ // what "insert after nothing" means to a reconciler walking a list.
288+ const at: usize = if (sibling == 0)
289+ 0
290+ else if (std.mem.indexOfScalar(u32, p.children.items, sibling)) |i|
291+ i + 1
292+ else
293+ p.children.items.len;
294+ p.children.insert(self.gpa, at, child) catch return false;
295+ self.get(child).?.parent = parent;
296+ return true;
297+ }
298+
299+ pub fn replace(self: *Tree, parent: u32, old_child: u32, new_child: u32) bool {
300+ if (old_child == new_child) return true;
301+ _ = self.get(new_child) orelse return false;
302+ const p = self.get(parent) orelse return false;
303+ const at = std.mem.indexOfScalar(u32, p.children.items, old_child) orelse return false;
304+ if (self.contains(new_child, parent)) return false;
305+ self.detach(new_child);
306+ const pp = self.get(parent).?;
307+ // `detach` may have shifted the list if new_child was a sibling.
308+ const idx = std.mem.indexOfScalar(u32, pp.children.items, old_child) orelse at;
309+ pp.children.items[idx] = new_child;
310+ self.get(new_child).?.parent = parent;
311+ if (self.get(old_child)) |o| o.parent = 0;
312+ return true;
313+ }
314+
315+ pub fn childCount(self: *Tree, id: u32) u32 {
316+ const n = self.get(id) orelse return 0;
317+ return @intCast(n.children.items.len);
318+ }
319+
320+ pub fn childAt(self: *Tree, id: u32, index: u32) u32 {
321+ const n = self.get(id) orelse return 0;
322+ if (index >= n.children.items.len) return 0;
323+ return n.children.items[index];
324+ }
325+
326+ // ── Events ──────────────────────────────────────────────────────────────
327+
328+ pub fn emit(self: *Tree, node: u32, name: []const u8, text: []const u8, num: f64) void {
329+ const owned = self.gpa.dupe(u8, text) catch return;
330+ self.events.append(self.gpa, .{
331+ .node = node,
332+ .name = name,
333+ .text = owned,
334+ .num = num,
335+ }) catch self.gpa.free(owned);
336+ }
337+
338+ /// Dequeue one event. True while there was one; the accessors below then
339+ /// describe it until the next poll.
340+ pub fn poll(self: *Tree) bool {
341+ if (self.current) |e| {
342+ self.gpa.free(e.text);
343+ self.current = null;
344+ }
345+ if (self.head >= self.events.items.len) {
346+ // Drained: reset rather than grow the backing array forever.
347+ self.events.clearRetainingCapacity();
348+ self.head = 0;
349+ return false;
350+ }
351+ self.current = self.events.items[self.head];
352+ self.head += 1;
353+ return true;
354+ }
355+
356+ // ── Debugging ───────────────────────────────────────────────────────────
357+
358+ pub fn dump(self: *Tree, id: u32, out: *std.ArrayList(u8), depth: usize) void {
359+ const n = self.get(id) orelse return;
360+ out.appendNTimes(self.gpa, ' ', depth * 2) catch return;
361+ out.print(self.gpa, "<{s} #{d}", .{ n.tag, id }) catch return;
362+ var it = n.props.iterator();
363+ while (it.next()) |kv| {
364+ switch (kv.value_ptr.*) {
365+ .str => |s| out.print(self.gpa, " {s}=\"{s}\"", .{ kv.key_ptr.*, s }) catch return,
366+ .num => |x| out.print(self.gpa, " {s}={d}", .{ kv.key_ptr.*, x }) catch return,
367+ .boolean => |b| out.print(self.gpa, " {s}={}", .{ kv.key_ptr.*, b }) catch return,
368+ }
369+ }
370+ out.appendSlice(self.gpa, ">\n") catch return;
371+ const kids = self.gpa.dupe(u32, n.children.items) catch return;
372+ defer self.gpa.free(kids);
373+ for (kids) |c| self.dump(c, out, depth + 1);
374+ }
375+};
376+
377+// ── Tests ───────────────────────────────────────────────────────────────────
378+//
379+// The tree is the half worth testing without a window: everything here is what
380+// a reconciler does to it between frames.
381+
382+const testing = std.testing;
383+
384+fn childrenOf(t: *Tree, id: u32) []const u32 {
385+ return (t.get(id) orelse unreachable).children.items;
386+}
387+
388+test "root exists and is not freeable" {
389+ var t = try Tree.init(testing.allocator);
390+ defer t.deinit();
391+ try testing.expectEqual(@as(u32, 1), t.root());
392+ t.free_node(t.root());
393+ try testing.expect(t.get(t.root()) != null);
394+}
395+
396+test "props round-trip and coerce" {
397+ var t = try Tree.init(testing.allocator);
398+ defer t.deinit();
399+ const n = t.new("label");
400+ t.setStr(n, "text", "hello");
401+ t.setNum(n, "size", 12.5);
402+ t.setBool(n, "active", true);
403+ try testing.expectEqualStrings("hello", t.getStr(n, "text"));
404+ try testing.expectEqual(@as(f64, 12.5), t.getNum(n, "size"));
405+ try testing.expect(t.getBool(n, "active"));
406+ // A bool reads as a number and a number as a bool; a missing prop is zero.
407+ try testing.expectEqual(@as(f64, 1), t.getNum(n, "active"));
408+ try testing.expectEqual(@as(f64, 0), t.getNum(n, "absent"));
409+ try testing.expectEqualStrings("", t.getStr(n, "absent"));
410+ // Overwriting a string frees the old one rather than leaking it.
411+ t.setStr(n, "text", "goodbye");
412+ try testing.expectEqualStrings("goodbye", t.getStr(n, "text"));
413+ t.clear(n);
414+ try testing.expectEqualStrings("", t.getStr(n, "text"));
415+}
416+
417+test "append moves a child rather than duplicating it" {
418+ var t = try Tree.init(testing.allocator);
419+ defer t.deinit();
420+ const a = t.new("box");
421+ const b = t.new("box");
422+ const c = t.new("label");
423+ try testing.expect(t.append(t.root(), a));
424+ try testing.expect(t.append(t.root(), b));
425+ try testing.expect(t.append(a, c));
426+ try testing.expectEqualSlices(u32, &.{c}, childrenOf(&t, a));
427+ // Re-appending elsewhere detaches from the old parent.
428+ try testing.expect(t.append(b, c));
429+ try testing.expectEqualSlices(u32, &.{}, childrenOf(&t, a));
430+ try testing.expectEqualSlices(u32, &.{c}, childrenOf(&t, b));
431+ try testing.expectEqual(b, t.get(c).?.parent);
432+}
433+
434+test "a node cannot be appended into its own subtree" {
435+ var t = try Tree.init(testing.allocator);
436+ defer t.deinit();
437+ const a = t.new("box");
438+ const b = t.new("box");
439+ try testing.expect(t.append(t.root(), a));
440+ try testing.expect(t.append(a, b));
441+ // Both the direct cycle and the deeper one are refused; a walk that never
442+ // returns is a hang in the painter, not an error the caller would see.
443+ try testing.expect(!t.append(a, a));
444+ try testing.expect(!t.append(b, a));
445+ try testing.expectEqualSlices(u32, &.{b}, childrenOf(&t, a));
446+}
447+
448+test "insert_after places by sibling, and 0 means the front" {
449+ var t = try Tree.init(testing.allocator);
450+ defer t.deinit();
451+ const p = t.new("box");
452+ _ = t.append(t.root(), p);
453+ const a = t.new("label");
454+ const b = t.new("label");
455+ const c = t.new("label");
456+ _ = t.append(p, a);
457+ _ = t.append(p, b);
458+ try testing.expect(t.insertAfter(p, c, a));
459+ try testing.expectEqualSlices(u32, &.{ a, c, b }, childrenOf(&t, p));
460+ const d = t.new("label");
461+ try testing.expect(t.insertAfter(p, d, 0));
462+ try testing.expectEqualSlices(u32, &.{ d, a, c, b }, childrenOf(&t, p));
463+}
464+
465+test "replace keeps position, including when the new child is a sibling" {
466+ var t = try Tree.init(testing.allocator);
467+ defer t.deinit();
468+ const p = t.new("box");
469+ _ = t.append(t.root(), p);
470+ const a = t.new("label");
471+ const b = t.new("label");
472+ const c = t.new("label");
473+ _ = t.append(p, a);
474+ _ = t.append(p, b);
475+ _ = t.append(p, c);
476+ // Replacing with a node already in the list must not leave it twice, and
477+ // must not shift the slot out from under the index it was found at.
478+ try testing.expect(t.replace(p, a, c));
479+ try testing.expectEqualSlices(u32, &.{ c, b }, childrenOf(&t, p));
480+ try testing.expectEqual(@as(u32, 0), t.get(a).?.parent);
481+}
482+
483+test "free takes the subtree and detaches from the parent" {
484+ var t = try Tree.init(testing.allocator);
485+ defer t.deinit();
486+ const p = t.new("box");
487+ _ = t.append(t.root(), p);
488+ const a = t.new("box");
489+ const b = t.new("label");
490+ _ = t.append(p, a);
491+ _ = t.append(a, b);
492+ t.free_node(a);
493+ try testing.expect(t.get(a) == null);
494+ try testing.expect(t.get(b) == null);
495+ try testing.expectEqualSlices(u32, &.{}, childrenOf(&t, p));
496+}
497+
498+test "a reused id is never the same widget identity" {
499+ var t = try Tree.init(testing.allocator);
500+ defer t.deinit();
501+ const a = t.new("button");
502+ _ = t.append(t.root(), a);
503+ const first = t.widgetId(a);
504+ t.free_node(a);
505+ const b = t.new("button");
506+ // The id comes back — that is the point of the free list — but the identity
507+ // a painter keys widget state by must not, or the new node inherits the old
508+ // one's cursor, scroll position and animation.
509+ try testing.expectEqual(a, b);
510+ try testing.expect(first != t.widgetId(b));
511+}
512+
513+test "events queue in order and drain once" {
514+ var t = try Tree.init(testing.allocator);
515+ defer t.deinit();
516+ const a = t.new("button");
517+ t.emit(a, "click", "", 0);
518+ t.emit(a, "change", "typed", 3);
519+ try testing.expect(t.poll());
520+ try testing.expectEqualStrings("click", t.current.?.name);
521+ try testing.expect(t.poll());
522+ try testing.expectEqualStrings("change", t.current.?.name);
523+ try testing.expectEqualStrings("typed", t.current.?.text);
524+ try testing.expectEqual(@as(f64, 3), t.current.?.num);
525+ try testing.expect(!t.poll());
526+ try testing.expect(t.current == null);
527+}
528+
529+test "dump shows the shape" {
530+ var t = try Tree.init(testing.allocator);
531+ defer t.deinit();
532+ const l = t.new("label");
533+ t.setStr(l, "text", "hi");
534+ _ = t.append(t.root(), l);
535+ var out: std.ArrayList(u8) = .empty;
536+ defer out.deinit(testing.allocator);
537+ t.dump(t.root(), &out, 0);
538+ try testing.expectEqualStrings(
539+ \\<window #1>
540+ \\ <label #2 text="hi">
541+ \\
542+ , out.items);
543+}
544+
545+test "a bad id is a miss, not an index" {
546+ var t = try Tree.init(testing.allocator);
547+ defer t.deinit();
548+ try testing.expect(t.get(0) == null);
549+ try testing.expect(t.get(9999) == null);
550+ try testing.expectEqual(@as(u32, 0), t.childCount(9999));
551+ try testing.expectEqual(@as(u32, 0), t.childAt(9999, 0));
552+ try testing.expect(!t.append(9999, 1));
553+ t.setStr(9999, "text", "ignored");
554+ t.free_node(9999);
555+}
new file mode 100644
@@ -0,0 +1,555 @@
1+//! The retained tree: nodes, props, children, and the event queue jolt polls.
2+//!
3+//! This is the same shape `crates/jolt-vidya/src/tree.rs` holds and
4+//! `crates/jolt-tui` holds again — a tree the caller mutates between frames and
5+//! a painter walks during one. Nothing here knows what paints it.
6+
7+const std = @import("std");
8+
9+const Allocator = std.mem.Allocator;
10+
11+/// A prop value. The three types jolt's FFI vocabulary can carry.
12+pub const Value = union(enum) {
13+ str: []u8,
14+ num: f64,
15+ boolean: bool,
16+};
17+
18+/// One queued event, drained by `poll`.
19+pub const Event = struct {
20+ node: u32,
21+ /// Static: `click`, `change`, `toggled`, `activate`.
22+ name: []const u8,
23+ text: []u8,
24+ num: f64,
25+};
26+
27+pub const Node = struct {
28+ tag: []u8 = &.{},
29+ props: std.StringHashMapUnmanaged(Value) = .empty,
30+ children: std.ArrayList(u32) = .empty,
31+ parent: u32 = 0,
32+ alive: bool = false,
33+ /// Bumped every time this slot is handed out again. Widget identity is
34+ /// `(generation, index)` rather than the index alone, so a node freed and
35+ /// reallocated in the same frame does not inherit the widget state of the
36+ /// node that used to live here — which is the bug class the reconciler
37+ /// notes in frq's deps.edn describe from the other side.
38+ generation: u32 = 0,
39+ /// Persistent edit buffer for `entry` nodes, kept across frames because a
40+ /// text field owns its contents between the caller's writes.
41+ entry: ?std.ArrayList(u8) = null,
42+ /// Whether `entry` has been seeded from the `text` prop yet.
43+ entry_seeded: bool = false,
44+};
45+
46+pub const Tree = struct {
47+ gpa: Allocator,
48+ /// Slot 0 is never handed out — 0 is how this ABI spells "no node", the
49+ /// same way `vidya_node_new` answers 0 on failure.
50+ nodes: std.ArrayList(Node) = .empty,
51+ free: std.ArrayList(u32) = .empty,
52+ events: std.ArrayList(Event) = .empty,
53+ /// Read position in `events`; the queue compacts when it drains.
54+ head: usize = 0,
55+ current: ?Event = null,
56+
57+ pub fn init(gpa: Allocator) !Tree {
58+ var self: Tree = .{ .gpa = gpa };
59+ // Slot 0: the null node. Slot 1: the root, which is never freed.
60+ try self.nodes.append(gpa, .{});
61+ const root_id = try self.alloc("window");
62+ std.debug.assert(root_id == 1);
63+ return self;
64+ }
65+
66+ pub fn deinit(self: *Tree) void {
67+ for (self.nodes.items) |*n| self.release(n);
68+ self.nodes.deinit(self.gpa);
69+ self.free.deinit(self.gpa);
70+ for (self.events.items) |e| self.gpa.free(e.text);
71+ self.events.deinit(self.gpa);
72+ if (self.current) |e| self.gpa.free(e.text);
73+ self.current = null;
74+ }
75+
76+ fn release(self: *Tree, n: *Node) void {
77+ if (n.tag.len != 0) self.gpa.free(n.tag);
78+ n.tag = &.{};
79+ self.clearProps(n);
80+ n.props.deinit(self.gpa);
81+ n.props = .empty;
82+ n.children.deinit(self.gpa);
83+ n.children = .empty;
84+ if (n.entry) |*buf| buf.deinit(self.gpa);
85+ n.entry = null;
86+ n.entry_seeded = false;
87+ }
88+
89+ fn clearProps(self: *Tree, n: *Node) void {
90+ var it = n.props.iterator();
91+ while (it.next()) |kv| {
92+ self.gpa.free(kv.key_ptr.*);
93+ if (kv.value_ptr.* == .str) self.gpa.free(kv.value_ptr.str);
94+ }
95+ n.props.clearRetainingCapacity();
96+ }
97+
98+ pub fn root(self: *Tree) u32 {
99+ _ = self;
100+ return 1;
101+ }
102+
103+ pub fn get(self: *Tree, id: u32) ?*Node {
104+ if (id == 0 or id >= self.nodes.items.len) return null;
105+ const n = &self.nodes.items[id];
106+ return if (n.alive) n else null;
107+ }
108+
109+ fn alloc(self: *Tree, tag: []const u8) !u32 {
110+ const owned = try self.gpa.dupe(u8, tag);
111+ errdefer self.gpa.free(owned);
112+ if (self.free.pop()) |id| {
113+ const n = &self.nodes.items[id];
114+ n.* = .{
115+ .tag = owned,
116+ .alive = true,
117+ // Reuse never repeats an identity.
118+ .generation = n.generation +% 1,
119+ };
120+ return id;
121+ }
122+ const id: u32 = @intCast(self.nodes.items.len);
123+ try self.nodes.append(self.gpa, .{ .tag = owned, .alive = true });
124+ return id;
125+ }
126+
127+ pub fn new(self: *Tree, tag: []const u8) u32 {
128+ return self.alloc(tag) catch 0;
129+ }
130+
131+ /// Free a node and everything under it. Detaches from its parent first, so
132+ /// a caller that frees a subtree still holds a consistent tree.
133+ pub fn free_node(self: *Tree, id: u32) void {
134+ if (id <= 1) return; // never free the null slot or the root
135+ const n = self.get(id) orelse return;
136+ const parent = n.parent;
137+ if (self.get(parent)) |p| {
138+ if (std.mem.indexOfScalar(u32, p.children.items, id)) |i| {
139+ _ = p.children.orderedRemove(i);
140+ }
141+ }
142+ self.freeSubtree(id);
143+ }
144+
145+ fn freeSubtree(self: *Tree, id: u32) void {
146+ const n = self.get(id) orelse return;
147+ // Copy the child list: releasing the node frees the backing array.
148+ const kids = self.gpa.dupe(u32, n.children.items) catch &.{};
149+ defer if (kids.len != 0) self.gpa.free(kids);
150+ for (kids) |c| self.freeSubtree(c);
151+ const node = &self.nodes.items[id];
152+ self.release(node);
153+ node.alive = false;
154+ node.parent = 0;
155+ self.free.append(self.gpa, id) catch {};
156+ }
157+
158+ /// The identity a painter keys widget state by: never repeated after reuse.
159+ pub fn widgetId(self: *Tree, id: u32) usize {
160+ const gen: usize = if (id < self.nodes.items.len) self.nodes.items[id].generation else 0;
161+ return (gen << 32) | @as(usize, id);
162+ }
163+
164+ // ── Props ───────────────────────────────────────────────────────────────
165+
166+ fn put(self: *Tree, n: *Node, key: []const u8, value: Value) !void {
167+ const gop = try n.props.getOrPut(self.gpa, key);
168+ if (gop.found_existing) {
169+ if (gop.value_ptr.* == .str) self.gpa.free(gop.value_ptr.str);
170+ } else {
171+ gop.key_ptr.* = self.gpa.dupe(u8, key) catch |e| {
172+ _ = n.props.remove(key);
173+ return e;
174+ };
175+ }
176+ gop.value_ptr.* = value;
177+ }
178+
179+ pub fn setStr(self: *Tree, id: u32, key: []const u8, value: []const u8) void {
180+ const n = self.get(id) orelse return;
181+ const owned = self.gpa.dupe(u8, value) catch return;
182+ self.put(n, key, .{ .str = owned }) catch self.gpa.free(owned);
183+ // A caller writing `text` is authoritative over an entry's contents.
184+ if (std.mem.eql(u8, key, "text")) n.entry_seeded = false;
185+ }
186+
187+ pub fn setNum(self: *Tree, id: u32, key: []const u8, value: f64) void {
188+ const n = self.get(id) orelse return;
189+ self.put(n, key, .{ .num = value }) catch {};
190+ }
191+
192+ pub fn setBool(self: *Tree, id: u32, key: []const u8, value: bool) void {
193+ const n = self.get(id) orelse return;
194+ self.put(n, key, .{ .boolean = value }) catch {};
195+ }
196+
197+ pub fn clear(self: *Tree, id: u32) void {
198+ const n = self.get(id) orelse return;
199+ self.clearProps(n);
200+ }
201+
202+ pub fn getStr(self: *Tree, id: u32, key: []const u8) []const u8 {
203+ const n = self.get(id) orelse return "";
204+ const v = n.props.get(key) orelse return "";
205+ return switch (v) {
206+ .str => |s| s,
207+ else => "",
208+ };
209+ }
210+
211+ pub fn getNum(self: *Tree, id: u32, key: []const u8) f64 {
212+ const n = self.get(id) orelse return 0;
213+ const v = n.props.get(key) orelse return 0;
214+ return switch (v) {
215+ .num => |x| x,
216+ .boolean => |b| if (b) 1 else 0,
217+ .str => 0,
218+ };
219+ }
220+
221+ pub fn getBool(self: *Tree, id: u32, key: []const u8) bool {
222+ const n = self.get(id) orelse return false;
223+ const v = n.props.get(key) orelse return false;
224+ return switch (v) {
225+ .boolean => |b| b,
226+ .num => |x| x != 0,
227+ .str => |s| s.len != 0,
228+ };
229+ }
230+
231+ // ── Children ────────────────────────────────────────────────────────────
232+
233+ /// Detach `child` from whatever parent currently holds it.
234+ fn detach(self: *Tree, child: u32) void {
235+ const c = self.get(child) orelse return;
236+ if (self.get(c.parent)) |p| {
237+ if (std.mem.indexOfScalar(u32, p.children.items, child)) |i| {
238+ _ = p.children.orderedRemove(i);
239+ }
240+ }
241+ c.parent = 0;
242+ }
243+
244+ /// True when `ancestor` is `node` or is above it — a cycle check, because
245+ /// appending a node into its own subtree makes a walk that never returns.
246+ fn contains(self: *Tree, ancestor: u32, node: u32) bool {
247+ var walk = node;
248+ var guard_count: usize = 0;
249+ while (walk != 0) : (guard_count += 1) {
250+ if (walk == ancestor) return true;
251+ if (guard_count > self.nodes.items.len) return true; // already cyclic
252+ const n = self.get(walk) orelse return false;
253+ walk = n.parent;
254+ }
255+ return false;
256+ }
257+
258+ pub fn append(self: *Tree, parent: u32, child: u32) bool {
259+ if (parent == child) return false;
260+ _ = self.get(child) orelse return false;
261+ const p = self.get(parent) orelse return false;
262+ if (self.contains(child, parent)) return false;
263+ self.detach(child);
264+ const pp = self.get(parent).?;
265+ pp.children.append(self.gpa, child) catch return false;
266+ self.get(child).?.parent = parent;
267+ _ = p;
268+ return true;
269+ }
270+
271+ pub fn remove(self: *Tree, parent: u32, child: u32) void {
272+ const p = self.get(parent) orelse return;
273+ if (std.mem.indexOfScalar(u32, p.children.items, child)) |i| {
274+ _ = p.children.orderedRemove(i);
275+ if (self.get(child)) |c| c.parent = 0;
276+ }
277+ }
278+
279+ pub fn insertAfter(self: *Tree, parent: u32, child: u32, sibling: u32) bool {
280+ if (parent == child) return false;
281+ _ = self.get(child) orelse return false;
282+ _ = self.get(parent) orelse return false;
283+ if (self.contains(child, parent)) return false;
284+ self.detach(child);
285+ const p = self.get(parent).?;
286+ // A sibling of 0, or one that is not here, means the front — which is
287+ // what "insert after nothing" means to a reconciler walking a list.
288+ const at: usize = if (sibling == 0)
289+ 0
290+ else if (std.mem.indexOfScalar(u32, p.children.items, sibling)) |i|
291+ i + 1
292+ else
293+ p.children.items.len;
294+ p.children.insert(self.gpa, at, child) catch return false;
295+ self.get(child).?.parent = parent;
296+ return true;
297+ }
298+
299+ pub fn replace(self: *Tree, parent: u32, old_child: u32, new_child: u32) bool {
300+ if (old_child == new_child) return true;
301+ _ = self.get(new_child) orelse return false;
302+ const p = self.get(parent) orelse return false;
303+ const at = std.mem.indexOfScalar(u32, p.children.items, old_child) orelse return false;
304+ if (self.contains(new_child, parent)) return false;
305+ self.detach(new_child);
306+ const pp = self.get(parent).?;
307+ // `detach` may have shifted the list if new_child was a sibling.
308+ const idx = std.mem.indexOfScalar(u32, pp.children.items, old_child) orelse at;
309+ pp.children.items[idx] = new_child;
310+ self.get(new_child).?.parent = parent;
311+ if (self.get(old_child)) |o| o.parent = 0;
312+ return true;
313+ }
314+
315+ pub fn childCount(self: *Tree, id: u32) u32 {
316+ const n = self.get(id) orelse return 0;
317+ return @intCast(n.children.items.len);
318+ }
319+
320+ pub fn childAt(self: *Tree, id: u32, index: u32) u32 {
321+ const n = self.get(id) orelse return 0;
322+ if (index >= n.children.items.len) return 0;
323+ return n.children.items[index];
324+ }
325+
326+ // ── Events ──────────────────────────────────────────────────────────────
327+
328+ pub fn emit(self: *Tree, node: u32, name: []const u8, text: []const u8, num: f64) void {
329+ const owned = self.gpa.dupe(u8, text) catch return;
330+ self.events.append(self.gpa, .{
331+ .node = node,
332+ .name = name,
333+ .text = owned,
334+ .num = num,
335+ }) catch self.gpa.free(owned);
336+ }
337+
338+ /// Dequeue one event. True while there was one; the accessors below then
339+ /// describe it until the next poll.
340+ pub fn poll(self: *Tree) bool {
341+ if (self.current) |e| {
342+ self.gpa.free(e.text);
343+ self.current = null;
344+ }
345+ if (self.head >= self.events.items.len) {
346+ // Drained: reset rather than grow the backing array forever.
347+ self.events.clearRetainingCapacity();
348+ self.head = 0;
349+ return false;
350+ }
351+ self.current = self.events.items[self.head];
352+ self.head += 1;
353+ return true;
354+ }
355+
356+ // ── Debugging ───────────────────────────────────────────────────────────
357+
358+ pub fn dump(self: *Tree, id: u32, out: *std.ArrayList(u8), depth: usize) void {
359+ const n = self.get(id) orelse return;
360+ out.appendNTimes(self.gpa, ' ', depth * 2) catch return;
361+ out.print(self.gpa, "<{s} #{d}", .{ n.tag, id }) catch return;
362+ var it = n.props.iterator();
363+ while (it.next()) |kv| {
364+ switch (kv.value_ptr.*) {
365+ .str => |s| out.print(self.gpa, " {s}=\"{s}\"", .{ kv.key_ptr.*, s }) catch return,
366+ .num => |x| out.print(self.gpa, " {s}={d}", .{ kv.key_ptr.*, x }) catch return,
367+ .boolean => |b| out.print(self.gpa, " {s}={}", .{ kv.key_ptr.*, b }) catch return,
368+ }
369+ }
370+ out.appendSlice(self.gpa, ">\n") catch return;
371+ const kids = self.gpa.dupe(u32, n.children.items) catch return;
372+ defer self.gpa.free(kids);
373+ for (kids) |c| self.dump(c, out, depth + 1);
374+ }
375+};
376+
377+// ── Tests ───────────────────────────────────────────────────────────────────
378+//
379+// The tree is the half worth testing without a window: everything here is what
380+// a reconciler does to it between frames.
381+
382+const testing = std.testing;
383+
384+fn childrenOf(t: *Tree, id: u32) []const u32 {
385+ return (t.get(id) orelse unreachable).children.items;
386+}
387+
388+test "root exists and is not freeable" {
389+ var t = try Tree.init(testing.allocator);
390+ defer t.deinit();
391+ try testing.expectEqual(@as(u32, 1), t.root());
392+ t.free_node(t.root());
393+ try testing.expect(t.get(t.root()) != null);
394+}
395+
396+test "props round-trip and coerce" {
397+ var t = try Tree.init(testing.allocator);
398+ defer t.deinit();
399+ const n = t.new("label");
400+ t.setStr(n, "text", "hello");
401+ t.setNum(n, "size", 12.5);
402+ t.setBool(n, "active", true);
403+ try testing.expectEqualStrings("hello", t.getStr(n, "text"));
404+ try testing.expectEqual(@as(f64, 12.5), t.getNum(n, "size"));
405+ try testing.expect(t.getBool(n, "active"));
406+ // A bool reads as a number and a number as a bool; a missing prop is zero.
407+ try testing.expectEqual(@as(f64, 1), t.getNum(n, "active"));
408+ try testing.expectEqual(@as(f64, 0), t.getNum(n, "absent"));
409+ try testing.expectEqualStrings("", t.getStr(n, "absent"));
410+ // Overwriting a string frees the old one rather than leaking it.
411+ t.setStr(n, "text", "goodbye");
412+ try testing.expectEqualStrings("goodbye", t.getStr(n, "text"));
413+ t.clear(n);
414+ try testing.expectEqualStrings("", t.getStr(n, "text"));
415+}
416+
417+test "append moves a child rather than duplicating it" {
418+ var t = try Tree.init(testing.allocator);
419+ defer t.deinit();
420+ const a = t.new("box");
421+ const b = t.new("box");
422+ const c = t.new("label");
423+ try testing.expect(t.append(t.root(), a));
424+ try testing.expect(t.append(t.root(), b));
425+ try testing.expect(t.append(a, c));
426+ try testing.expectEqualSlices(u32, &.{c}, childrenOf(&t, a));
427+ // Re-appending elsewhere detaches from the old parent.
428+ try testing.expect(t.append(b, c));
429+ try testing.expectEqualSlices(u32, &.{}, childrenOf(&t, a));
430+ try testing.expectEqualSlices(u32, &.{c}, childrenOf(&t, b));
431+ try testing.expectEqual(b, t.get(c).?.parent);
432+}
433+
434+test "a node cannot be appended into its own subtree" {
435+ var t = try Tree.init(testing.allocator);
436+ defer t.deinit();
437+ const a = t.new("box");
438+ const b = t.new("box");
439+ try testing.expect(t.append(t.root(), a));
440+ try testing.expect(t.append(a, b));
441+ // Both the direct cycle and the deeper one are refused; a walk that never
442+ // returns is a hang in the painter, not an error the caller would see.
443+ try testing.expect(!t.append(a, a));
444+ try testing.expect(!t.append(b, a));
445+ try testing.expectEqualSlices(u32, &.{b}, childrenOf(&t, a));
446+}
447+
448+test "insert_after places by sibling, and 0 means the front" {
449+ var t = try Tree.init(testing.allocator);
450+ defer t.deinit();
451+ const p = t.new("box");
452+ _ = t.append(t.root(), p);
453+ const a = t.new("label");
454+ const b = t.new("label");
455+ const c = t.new("label");
456+ _ = t.append(p, a);
457+ _ = t.append(p, b);
458+ try testing.expect(t.insertAfter(p, c, a));
459+ try testing.expectEqualSlices(u32, &.{ a, c, b }, childrenOf(&t, p));
460+ const d = t.new("label");
461+ try testing.expect(t.insertAfter(p, d, 0));
462+ try testing.expectEqualSlices(u32, &.{ d, a, c, b }, childrenOf(&t, p));
463+}
464+
465+test "replace keeps position, including when the new child is a sibling" {
466+ var t = try Tree.init(testing.allocator);
467+ defer t.deinit();
468+ const p = t.new("box");
469+ _ = t.append(t.root(), p);
470+ const a = t.new("label");
471+ const b = t.new("label");
472+ const c = t.new("label");
473+ _ = t.append(p, a);
474+ _ = t.append(p, b);
475+ _ = t.append(p, c);
476+ // Replacing with a node already in the list must not leave it twice, and
477+ // must not shift the slot out from under the index it was found at.
478+ try testing.expect(t.replace(p, a, c));
479+ try testing.expectEqualSlices(u32, &.{ c, b }, childrenOf(&t, p));
480+ try testing.expectEqual(@as(u32, 0), t.get(a).?.parent);
481+}
482+
483+test "free takes the subtree and detaches from the parent" {
484+ var t = try Tree.init(testing.allocator);
485+ defer t.deinit();
486+ const p = t.new("box");
487+ _ = t.append(t.root(), p);
488+ const a = t.new("box");
489+ const b = t.new("label");
490+ _ = t.append(p, a);
491+ _ = t.append(a, b);
492+ t.free_node(a);
493+ try testing.expect(t.get(a) == null);
494+ try testing.expect(t.get(b) == null);
495+ try testing.expectEqualSlices(u32, &.{}, childrenOf(&t, p));
496+}
497+
498+test "a reused id is never the same widget identity" {
499+ var t = try Tree.init(testing.allocator);
500+ defer t.deinit();
501+ const a = t.new("button");
502+ _ = t.append(t.root(), a);
503+ const first = t.widgetId(a);
504+ t.free_node(a);
505+ const b = t.new("button");
506+ // The id comes back — that is the point of the free list — but the identity
507+ // a painter keys widget state by must not, or the new node inherits the old
508+ // one's cursor, scroll position and animation.
509+ try testing.expectEqual(a, b);
510+ try testing.expect(first != t.widgetId(b));
511+}
512+
513+test "events queue in order and drain once" {
514+ var t = try Tree.init(testing.allocator);
515+ defer t.deinit();
516+ const a = t.new("button");
517+ t.emit(a, "click", "", 0);
518+ t.emit(a, "change", "typed", 3);
519+ try testing.expect(t.poll());
520+ try testing.expectEqualStrings("click", t.current.?.name);
521+ try testing.expect(t.poll());
522+ try testing.expectEqualStrings("change", t.current.?.name);
523+ try testing.expectEqualStrings("typed", t.current.?.text);
524+ try testing.expectEqual(@as(f64, 3), t.current.?.num);
525+ try testing.expect(!t.poll());
526+ try testing.expect(t.current == null);
527+}
528+
529+test "dump shows the shape" {
530+ var t = try Tree.init(testing.allocator);
531+ defer t.deinit();
532+ const l = t.new("label");
533+ t.setStr(l, "text", "hi");
534+ _ = t.append(t.root(), l);
535+ var out: std.ArrayList(u8) = .empty;
536+ defer out.deinit(testing.allocator);
537+ t.dump(t.root(), &out, 0);
538+ try testing.expectEqualStrings(
539+ \\<window #1>
540+ \\ <label #2 text="hi">
541+ \\
542+ , out.items);
543+}
544+
545+test "a bad id is a miss, not an index" {
546+ var t = try Tree.init(testing.allocator);
547+ defer t.deinit();
548+ try testing.expect(t.get(0) == null);
549+ try testing.expect(t.get(9999) == null);
550+ try testing.expectEqual(@as(u32, 0), t.childCount(9999));
551+ try testing.expectEqual(@as(u32, 0), t.childAt(9999, 0));
552+ try testing.expect(!t.append(9999, 1));
553+ t.setStr(9999, "text", "ignored");
554+ t.free_node(9999);
555+}