Merge the Zig/dvui tree-ABI backend
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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 | +} | ||