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

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

Move the jolt libraries under glimmer-backends 19df0d8 · on e0d90292c3d0c00acad1fb5cdc100d728938759f · nandi · 12d ago
README.md · 301 lines · 12.3 KBmarkdown
Blame HistoryOpen raw

glimmer-vidya

The Vidya/egui backend for glimmer,
the reactive GUI toolkit for jolt.

glimmer owns the portable half — reactive cells, the component model, the
reconciler — and knows nothing about any toolkit. This project supplies the
other half for a GPU window: Vidya's widgets and theme, painted by egui through
../ffi. Requiring glimmer-vidya.core registers it, and
components that render as GTK widgets under
glimmer-gtk, and as text under
glimmer-tui, render here as Vidya.

(ns myapp
  (:require [glimmer.ratom :as r :refer [atom]]
            [glimmer.core :as ui]
            [glimmer-vidya.core]))          ; installs this backend

(defn counter []
  (let [count (atom 0)]
    (fn []
      [:page {:max-width 420}
       [:card {}
        [:title {:label "Counter"}]
        [:label {:label (str "Count: " @count)}]
        [:hbox {:spacing 8}
         [:button {:label "- 1" :on-click #(swap! count dec)}]
         [:button {:label "+ 1" :kind :primary :on-click #(swap! count inc)}]
         [:button {:label "reset" :on-click #(reset! count 0)}]]]])))

(defn -main [& _] (ui/run counter :title "counter" :width 480 :height 320))

Components, reactive state and reconciliation are documented in glimmer's
README. What follows is the Vidya-specific part.

How an immediate-mode toolkit holds still

egui has no widgets. It has calls you make every frame, and a reconciler has
nothing to reconcile against — no pointer to patch, nothing to append a child
to. GTK hands glimmer a GtkButton; egui hands it nothing at all.

So the widget tree lives one layer down, in Rust. libvidya keeps a node arena
behind a second C ABI (../ffi/include/vidya_tree.h):
nodes are integer handles, and this backend's create! / apply-props! /
append-child! mutate them. Nothing is painted by those calls. Once a frame,
vidya_tree_frame walks the whole tree and emits the egui calls it describes.

Two things follow from putting the tree there rather than here:

  • FFI traffic tracks edits, not frames. A static UI costs no crossings per
    frame; only what the reconciler actually changed is sent. The alternative —
    keeping the tree in jolt and walking it over the FFI 60 times a second —
    would put the frame rate at the mercy of the reconciler's thread.
  • The closure-shaped parts of egui work. ScrollArea and Frame take an
    FnOnce(&mut Ui) and keep their begin/end private, which is why Vidya's
    original push/pop ABI could not scroll a page. Painting from a tree already
    in hand means the recursion is the closure.

Handlers do not cross the boundary. A jolt closure cannot be a callback in
a library painting at 60fps, so identity travels instead: a node reports that it
was clicked, glimmer-vidya.core looks up whose :on-click that was, and calls
it on the loop thread. Handlers are held on the jolt side and never sent.

Requirements

libvidya built from ../ffi, the Rust/egui implementation:

just ffi-android    # nix build .#android → ../result/lib/arm64-v8a/libvidya.so

Then put it on the search path when running anything here:

LD_LIBRARY_PATH=../build jolt counter

just ffi-android cross-compiles the same library to
../build/android/arm64-v8a/libvidya.so for a 64-bit device — both ABIs this
backend binds are exported there too.

On macOS use DYLD_LIBRARY_PATH. Note that this is the one place in the repo
where the two libvidya builds are not interchangeable: ../raylib
implements vidya.h only, and this backend binds the tree ABI, which is the
Rust build's alone.

Running

jolt test       # the suite, headless: no window, display or GPU needed
jolt counter    # the counter above
jolt showcase   # every tag, a keyed task list, an entry, a disabled subtree
jolt smoke      # non-interactive: reconciles under paint, then quits
jolt repl-ui    # a window you edit from a prompt, live

Live from a REPL

examples/glimmer_vidya/repl.clj is a window whose whole contents are one
ratom, and a REPL that writes to it. Nothing in it is special to REPLs — it is
the ordinary arrangement with the cell driven by what you type rather than by a
handler — but it turns the toolkit into something you poke at rather than
restart:

LD_LIBRARY_PATH=../build jolt repl-ui
ui=> (show! [:card {} [:title {:label "live"}] [:label {:label "typed just now"}]])
ui=> (show! (fn [] [:label {:label (str "hits " @hits)}]))   ; a component, not a picture
ui=> (swap! hits inc)                                        ; …so this re-renders it
ui=> (dump!)
ui=> :quit

Hiccup is fixed at the moment you type it; a zero-arg function is mounted as a
component and re-runs whenever a cell it derefs changes. Either way the
reconciler patches what changed, so the window does not blink and an :entry
keeps its text and cursor across a show!.

Two boundary rules shape the example, and both are the library's rather than
glimmer's:

  • The frame loop must own the main thread. winit refuses to create an event
    loop anywhere else, so -main paints on the main thread and reads stdin on a
    second one. From an editor it is the other way around — jolt nrepl-server
    parks the main thread in a pump, and start! posts the loop to it through
    jolt.host/call-on-main-thread, so eval stays free:

    (require '[glimmer-vidya.repl :as live])
    (live/start!)
    (live/show! [:label {:label "from the editor"}])
    

    An event loop cannot be recreated either, so stop! ends the window for the
    life of that process and start! says so rather than opening an invisible
    one. Editing the UI never needs a restart; that is the point.

  • Nodes belong to the loop thread. A show! is safe from anywhere because
    a ratom is not a node — glimmer marshals the re-render through the backend's
    schedule. Anything that reads or writes the tree itself has to hop, which
    is what the example's gui helper does with glimmer.core/on-gui and why
    its dump! returns the tree rather than an empty root.

Seeing what was rendered

dump reads the tree back out of the library as hiccup — what is actually
mounted, after the reconciler has had its way with it, rather than what a
component returned:

(require '[glimmer-vidya.core :as backend])

(backend/dump!)          ; print the whole window
(backend/dump! node)     ; or one subtree
(backend/dump)           ; the same as hiccup data, for a test
(backend/dump-str)       ; as text, to paste into a bug report
[:window {}
  [:card {}
    [:title {:label "Counter"}]
    [:label {:label "Count: 3"}]
    [:box {:orientation "horizontal" :spacing 8}
      [:button {:label "- 1"}]
      [:button {:kind "primary" :label "+ 1"}]]]]

Two things to expect when reading one, both of them the boundary showing
through. :hbox and :vbox are one node down there, so both dump as :box
with the orientation in the props; and no :on-* appears, because handlers are
held on this side and never sent. Props are sorted, so two dumps of the same
tree compare as text.

It needs no window — the tree is only painted by vidya_tree_frame — so a
headless test can mount a component and assert on (dump root) directly.

Hiccup reference

Elements are [:tag props? & children], as everywhere in glimmer. Strings and
numbers become labels, nil children are skipped, seqs are spliced.

Containers

tag holds notes
:window many the root; you rarely name it
:box / :hbox / :vbox many :orientation :horizontal|:vertical, implied by the tag
:page many scrolling column with page padding; :max-width centres it
:scroll many :orientation :vertical|:horizontal|:both, :scroll-key
:card many Vidya's raised surface
:frame many a card with a :label as its heading

Widgets

tag shows
:label body text
:title / :title-2 / :dim-label the type scale's other roles
:button :kind :default|:primary|:destructive
:checkbutton Vidya's themed checkbox (:checkbox is an alias)
:entry a text field; :multiline true with :rows for a box
:separator a rule
:spacer blank space of :size points
:progress a bar, :value 0.0–1.0, with an optional :label
:spinner an indeterminate spinner
:status a live/offline dot beside a label
:image a picture from a file; :max-height bounds it, :fit true fills and centres it in the space it is given (the one case that scales up)

Common props

  • :sensitive false — dims the widget and its whole subtree, and takes it out
    of egui's interaction
  • :width-request — a fixed width. Worth more here than it sounds: immediate
    mode has no natural width for a field, so an :entry asks for whatever is
    left and takes the row it shares with a button. This is how you say otherwise.
  • :margin, :spacing — on containers

Events

  • :on-click — button pressed. No args.
  • :on-toggled — checkbutton clicked. No args.
  • :on-change — entry text changed. Receives the new text.
  • :on-activate — Enter pressed in an entry. No args.
  • :on-paste-empty — Ctrl+V in an entry with no text on the clipboard, which
    is what a copied picture looks like from there. No args; ask
    clipboard-image-png! what is actually on it.

As in the other backends, a handler owns the state: :on-toggled flips the cell
the component reads, and :active comes back down as a prop. A control that
ignores its own event still works — the library wrote the new state into the
node, and the next render either confirms it or overwrites it.

An unrecognized tag is kept rather than refused: it paints as a vertical box, so
a component written against a tag this backend has not grown yet still shows its
children.

Options for ui/run

On top of glimmer's own :title, :width, :height and :auto-quit-ms:

option
:fps frame rate cap, default 60
:mode :dark (the default) or :light
:font path to a TTF/OTF for UI text

Timers

The loop wakes every frame anyway, so a timer is a due time and a thunk. Both
run on the loop thread, the only one allowed to touch nodes:

(vidya/every! 80 #(swap! tick inc))    ; a spinner, a clock, a progress bar
(vidya/after! 500 #(reset! ready true))
(vidya/cancel! id)

(vidya/quit!) stops the loop and closes the window.

Threads

Every node call belongs to the thread that opened the window — the library
enforces it, keeping its state in thread-local storage. glimmer already knows
this: while the loop runs it marshals each component's re-render through the
backend's schedule, which queues the work for the next tick. So a swap! from
an nREPL worker is safe, and glimmer.core/on-gui is there for code that wants
to touch the tree directly.

Testing

The suite is headless. Node calls need no GL surface — only vidya_tree_frame
does — so jolt test mounts real components, reconciles them, and reads the
resulting tree back through the same ABI the backend writes it with. It asserts
what was created, what was patched in place, what was replaced, and that a keyed
list reorders its widgets rather than rebuilding them. No window is opened and
no display is required, which is also what makes it run in CI.

Painting is checked separately, by jolt smoke under VIDYA_CAPTURE:

VIDYA_CAPTURE=/tmp/frame.ppm LD_LIBRARY_PATH=../build jolt smoke

Limits

  • X11 is preferred on Linux, for the reason in
    ../ffi/README.md: the caller owns the frame loop, and a
    native Wayland surface driven that way stops receiving frame callbacks. Under
    XWayland everything works; fractional scaling follows XWayland's rules.
  • No focus or keyboard navigation of your own. egui owns focus, tabbing and
    hit testing, so there is nothing here like glimmer-tui's :keys, :on-key or
    :autofocus — and nothing to configure.
  • No :listbox, :table, :overlay or :paginator yet. A list is a
    keyed :vbox for now. These are widget-layer work in ../ffi/src/tree.rs
    plus a tag; nothing in the design is in the way.
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
# glimmer-vidya

The **Vidya/egui** backend for [glimmer](https://github.com/jolt-lang/glimmer),
the reactive GUI toolkit for [jolt](https://github.com/jolt-lang/jolt).

glimmer owns the portable half — reactive cells, the component model, the
reconciler — and knows nothing about any toolkit. This project supplies the
other half for a GPU window: Vidya's widgets and theme, painted by egui through
[`../ffi`](../ffi/README.md). Requiring `glimmer-vidya.core` registers it, and
components that render as GTK widgets under
[glimmer-gtk](https://github.com/jolt-lang/glimmer-gtk), and as text under
[glimmer-tui](https://github.com/jolt-lang/glimmer-tui), render here as Vidya.

```clojure
(ns myapp
  (:require [glimmer.ratom :as r :refer [atom]]
            [glimmer.core :as ui]
            [glimmer-vidya.core]))          ; installs this backend

(defn counter []
  (let [count (atom 0)]
    (fn []
      [:page {:max-width 420}
       [:card {}
        [:title {:label "Counter"}]
        [:label {:label (str "Count: " @count)}]
        [:hbox {:spacing 8}
         [:button {:label "- 1" :on-click #(swap! count dec)}]
         [:button {:label "+ 1" :kind :primary :on-click #(swap! count inc)}]
         [:button {:label "reset" :on-click #(reset! count 0)}]]]])))

(defn -main [& _] (ui/run counter :title "counter" :width 480 :height 320))
```

Components, reactive state and reconciliation are documented in glimmer's
README. What follows is the Vidya-specific part.

## How an immediate-mode toolkit holds still

egui has no widgets. It has calls you make every frame, and a reconciler has
nothing to reconcile against — no pointer to patch, nothing to append a child
to. GTK hands glimmer a `GtkButton`; egui hands it nothing at all.

So the widget tree lives one layer down, in Rust. `libvidya` keeps a node arena
behind a second C ABI ([`../ffi/include/vidya_tree.h`](../ffi/include/vidya_tree.h)):
nodes are integer handles, and this backend's `create!` / `apply-props!` /
`append-child!` mutate them. Nothing is painted by those calls. Once a frame,
`vidya_tree_frame` walks the whole tree and emits the egui calls it describes.

Two things follow from putting the tree there rather than here:

* **FFI traffic tracks edits, not frames.** A static UI costs no crossings per
  frame; only what the reconciler actually changed is sent. The alternative —
  keeping the tree in jolt and walking it over the FFI 60 times a second —
  would put the frame rate at the mercy of the reconciler's thread.
* **The closure-shaped parts of egui work.** `ScrollArea` and `Frame` take an
  `FnOnce(&mut Ui)` and keep their `begin`/`end` private, which is why Vidya's
  original push/pop ABI could not scroll a page. Painting from a tree already
  in hand means the recursion *is* the closure.

**Handlers do not cross the boundary.** A jolt closure cannot be a callback in
a library painting at 60fps, so identity travels instead: a node reports that it
was clicked, `glimmer-vidya.core` looks up whose `:on-click` that was, and calls
it on the loop thread. Handlers are held on the jolt side and never sent.

## Requirements

`libvidya` built from [`../ffi`](../ffi), the Rust/egui implementation:

```sh
just ffi-android    # nix build .#android → ../result/lib/arm64-v8a/libvidya.so
```

Then put it on the search path when running anything here:

```sh
LD_LIBRARY_PATH=../build jolt counter
```

`just ffi-android` cross-compiles the same library to
`../build/android/arm64-v8a/libvidya.so` for a 64-bit device — both ABIs this
backend binds are exported there too.

On macOS use `DYLD_LIBRARY_PATH`. Note that this is the one place in the repo
where the two `libvidya` builds are **not** interchangeable: `../raylib`
implements `vidya.h` only, and this backend binds the tree ABI, which is the
Rust build's alone.

## Running

```sh
jolt test       # the suite, headless: no window, display or GPU needed
jolt counter    # the counter above
jolt showcase   # every tag, a keyed task list, an entry, a disabled subtree
jolt smoke      # non-interactive: reconciles under paint, then quits
jolt repl-ui    # a window you edit from a prompt, live
```

## Live from a REPL

`examples/glimmer_vidya/repl.clj` is a window whose whole contents are one
ratom, and a REPL that writes to it. Nothing in it is special to REPLs — it is
the ordinary arrangement with the cell driven by what you type rather than by a
handler — but it turns the toolkit into something you poke at rather than
restart:

```sh
LD_LIBRARY_PATH=../build jolt repl-ui
```

```clojure
ui=> (show! [:card {} [:title {:label "live"}] [:label {:label "typed just now"}]])
ui=> (show! (fn [] [:label {:label (str "hits " @hits)}]))   ; a component, not a picture
ui=> (swap! hits inc)                                        ; …so this re-renders it
ui=> (dump!)
ui=> :quit
```

Hiccup is fixed at the moment you type it; a zero-arg function is mounted as a
component and re-runs whenever a cell it derefs changes. Either way the
reconciler patches what changed, so the window does not blink and an `:entry`
keeps its text and cursor across a `show!`.

Two boundary rules shape the example, and both are the library's rather than
glimmer's:

* **The frame loop must own the main thread.** winit refuses to create an event
  loop anywhere else, so `-main` paints on the main thread and reads stdin on a
  second one. From an editor it is the other way around — `jolt nrepl-server`
  parks the main thread in a pump, and `start!` posts the loop to it through
  `jolt.host/call-on-main-thread`, so eval stays free:

  ```clojure
  (require '[glimmer-vidya.repl :as live])
  (live/start!)
  (live/show! [:label {:label "from the editor"}])
  ```

  An event loop cannot be recreated either, so `stop!` ends the window for the
  life of that process and `start!` says so rather than opening an invisible
  one. Editing the UI never needs a restart; that is the point.

* **Nodes belong to the loop thread.** A `show!` is safe from anywhere because
  a ratom is not a node — glimmer marshals the re-render through the backend's
  `schedule`. Anything that reads or writes the tree itself has to hop, which
  is what the example's `gui` helper does with `glimmer.core/on-gui` and why
  its `dump!` returns the tree rather than an empty root.

## Seeing what was rendered

`dump` reads the tree back out of the library as hiccup — what is actually
mounted, after the reconciler has had its way with it, rather than what a
component returned:

```clojure
(require '[glimmer-vidya.core :as backend])

(backend/dump!)          ; print the whole window
(backend/dump! node)     ; or one subtree
(backend/dump)           ; the same as hiccup data, for a test
(backend/dump-str)       ; as text, to paste into a bug report
```

```clojure
[:window {}
  [:card {}
    [:title {:label "Counter"}]
    [:label {:label "Count: 3"}]
    [:box {:orientation "horizontal" :spacing 8}
      [:button {:label "- 1"}]
      [:button {:kind "primary" :label "+ 1"}]]]]
```

Two things to expect when reading one, both of them the boundary showing
through. `:hbox` and `:vbox` are one node down there, so both dump as `:box`
with the orientation in the props; and no `:on-*` appears, because handlers are
held on this side and never sent. Props are sorted, so two dumps of the same
tree compare as text.

It needs no window — the tree is only painted by `vidya_tree_frame` — so a
headless test can mount a component and assert on `(dump root)` directly.

## Hiccup reference

Elements are `[:tag props? & children]`, as everywhere in glimmer. Strings and
numbers become labels, `nil` children are skipped, seqs are spliced.

**Containers**

| tag | holds | notes |
|---|---|---|
| `:window` | many | the root; you rarely name it |
| `:box` / `:hbox` / `:vbox` | many | `:orientation :horizontal\|:vertical`, implied by the tag |
| `:page` | many | scrolling column with page padding; `:max-width` centres it |
| `:scroll` | many | `:orientation :vertical\|:horizontal\|:both`, `:scroll-key` |
| `:card` | many | Vidya's raised surface |
| `:frame` | many | a card with a `:label` as its heading |

**Widgets**

| tag | shows |
|---|---|
| `:label` | body text |
| `:title` / `:title-2` / `:dim-label` | the type scale's other roles |
| `:button` | `:kind :default\|:primary\|:destructive` |
| `:checkbutton` | Vidya's themed checkbox (`:checkbox` is an alias) |
| `:entry` | a text field; `:multiline true` with `:rows` for a box |
| `:separator` | a rule |
| `:spacer` | blank space of `:size` points |
| `:progress` | a bar, `:value` 0.0–1.0, with an optional `:label` |
| `:spinner` | an indeterminate spinner |
| `:status` | a live/offline dot beside a label |
| `:image` | a picture from a file; `:max-height` bounds it, `:fit true` fills and centres it in the space it is given (the one case that scales up) |

**Common props**

- `:sensitive false` — dims the widget *and its whole subtree*, and takes it out
  of egui's interaction
- `:width-request` — a fixed width. Worth more here than it sounds: immediate
  mode has no natural width for a field, so an `:entry` asks for whatever is
  left and takes the row it shares with a button. This is how you say otherwise.
- `:margin`, `:spacing` — on containers

**Events**

- `:on-click` — button pressed. No args.
- `:on-toggled` — checkbutton clicked. No args.
- `:on-change` — entry text changed. Receives the new text.
- `:on-activate` — Enter pressed in an entry. No args.
- `:on-paste-empty` — Ctrl+V in an entry with no text on the clipboard, which
  is what a copied picture looks like from there. No args; ask
  `clipboard-image-png!` what is actually on it.

As in the other backends, a handler owns the state: `:on-toggled` flips the cell
the component reads, and `:active` comes back down as a prop. A control that
ignores its own event still works — the library wrote the new state into the
node, and the next render either confirms it or overwrites it.

An unrecognized tag is kept rather than refused: it paints as a vertical box, so
a component written against a tag this backend has not grown yet still shows its
children.

## Options for `ui/run`

On top of glimmer's own `:title`, `:width`, `:height` and `:auto-quit-ms`:

| option | |
|---|---|
| `:fps` | frame rate cap, default 60 |
| `:mode` | `:dark` (the default) or `:light` |
| `:font` | path to a TTF/OTF for UI text |

## Timers

The loop wakes every frame anyway, so a timer is a due time and a thunk. Both
run on the loop thread, the only one allowed to touch nodes:

```clojure
(vidya/every! 80 #(swap! tick inc))    ; a spinner, a clock, a progress bar
(vidya/after! 500 #(reset! ready true))
(vidya/cancel! id)
```

`(vidya/quit!)` stops the loop and closes the window.

## Threads

Every node call belongs to the thread that opened the window — the library
enforces it, keeping its state in thread-local storage. glimmer already knows
this: while the loop runs it marshals each component's re-render through the
backend's `schedule`, which queues the work for the next tick. So a `swap!` from
an nREPL worker is safe, and `glimmer.core/on-gui` is there for code that wants
to touch the tree directly.

## Testing

The suite is headless. Node calls need no GL surface — only `vidya_tree_frame`
does — so `jolt test` mounts real components, reconciles them, and reads the
resulting tree back through the same ABI the backend writes it with. It asserts
what was created, what was patched in place, what was replaced, and that a keyed
list reorders its widgets rather than rebuilding them. No window is opened and
no display is required, which is also what makes it run in CI.

Painting is checked separately, by `jolt smoke` under `VIDYA_CAPTURE`:

```sh
VIDYA_CAPTURE=/tmp/frame.ppm LD_LIBRARY_PATH=../build jolt smoke
```

## Limits

* **X11 is preferred on Linux**, for the reason in
  [`../ffi/README.md`](../ffi/README.md): the caller owns the frame loop, and a
  native Wayland surface driven that way stops receiving frame callbacks. Under
  XWayland everything works; fractional scaling follows XWayland's rules.
* **No focus or keyboard navigation of your own.** egui owns focus, tabbing and
  hit testing, so there is nothing here like glimmer-tui's `:keys`, `:on-key` or
  `:autofocus` — and nothing to configure.
* **No `:listbox`, `:table`, `:overlay` or `:paginator` yet.** A list is a
  keyed `:vbox` for now. These are widget-layer work in `../ffi/src/tree.rs`
  plus a tag; nothing in the design is in the way.