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

Merge the Zig/dvui tree-ABI backend

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
nandi committed 2026-09-09T21:13:43-07:00 Browse files
38e196f parents: 67d1728 7289263
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+}