| Bring vidya in cfd3e36 nandi 19d ago | 1 | # glimmer-vidya |
| 2 | |
| 3 | The **Vidya/egui** backend for [glimmer](https://github.com/jolt-lang/glimmer), |
| 4 | the reactive GUI toolkit for [jolt](https://github.com/jolt-lang/jolt). |
| 5 | |
| 6 | glimmer owns the portable half — reactive cells, the component model, the |
| 7 | reconciler — and knows nothing about any toolkit. This project supplies the |
| 8 | other half for a GPU window: Vidya's widgets and theme, painted by egui through |
| 9 | [`../ffi`](../ffi/README.md). Requiring `glimmer-vidya.core` registers it, and |
| 10 | components that render as GTK widgets under |
| 11 | [glimmer-gtk](https://github.com/jolt-lang/glimmer-gtk), and as text under |
| 12 | [glimmer-tui](https://github.com/jolt-lang/glimmer-tui), render here as Vidya. |
| 13 | |
| 14 | ```clojure |
| 15 | (ns myapp |
| 16 | (:require [glimmer.ratom :as r :refer [atom]] |
| 17 | [glimmer.core :as ui] |
| 18 | [glimmer-vidya.core])) ; installs this backend |
| 19 | |
| 20 | (defn counter [] |
| 21 | (let [count (atom 0)] |
| 22 | (fn [] |
| 23 | [:page {:max-width 420} |
| 24 | [:card {} |
| 25 | [:title {:label "Counter"}] |
| 26 | [:label {:label (str "Count: " @count)}] |
| 27 | [:hbox {:spacing 8} |
| 28 | [:button {:label "- 1" :on-click #(swap! count dec)}] |
| 29 | [:button {:label "+ 1" :kind :primary :on-click #(swap! count inc)}] |
| 30 | [:button {:label "reset" :on-click #(reset! count 0)}]]]]))) |
| 31 | |
| 32 | (defn -main [& _] (ui/run counter :title "counter" :width 480 :height 320)) |
| 33 | ``` |
| 34 | |
| 35 | Components, reactive state and reconciliation are documented in glimmer's |
| 36 | README. What follows is the Vidya-specific part. |
| 37 | |
| 38 | ## How an immediate-mode toolkit holds still |
| 39 | |
| 40 | egui has no widgets. It has calls you make every frame, and a reconciler has |
| 41 | nothing to reconcile against — no pointer to patch, nothing to append a child |
| 42 | to. GTK hands glimmer a `GtkButton`; egui hands it nothing at all. |
| 43 | |
| 44 | So the widget tree lives one layer down, in Rust. `libvidya` keeps a node arena |
| 45 | behind a second C ABI ([`../ffi/include/vidya_tree.h`](../ffi/include/vidya_tree.h)): |
| 46 | nodes are integer handles, and this backend's `create!` / `apply-props!` / |
| 47 | `append-child!` mutate them. Nothing is painted by those calls. Once a frame, |
| 48 | `vidya_tree_frame` walks the whole tree and emits the egui calls it describes. |
| 49 | |
| 50 | Two things follow from putting the tree there rather than here: |
| 51 | |
| 52 | * **FFI traffic tracks edits, not frames.** A static UI costs no crossings per |
| 53 | frame; only what the reconciler actually changed is sent. The alternative — |
| 54 | keeping the tree in jolt and walking it over the FFI 60 times a second — |
| 55 | would put the frame rate at the mercy of the reconciler's thread. |
| 56 | * **The closure-shaped parts of egui work.** `ScrollArea` and `Frame` take an |
| 57 | `FnOnce(&mut Ui)` and keep their `begin`/`end` private, which is why Vidya's |
| 58 | original push/pop ABI could not scroll a page. Painting from a tree already |
| 59 | in hand means the recursion *is* the closure. |
| 60 | |
| 61 | **Handlers do not cross the boundary.** A jolt closure cannot be a callback in |
| 62 | a library painting at 60fps, so identity travels instead: a node reports that it |
| 63 | was clicked, `glimmer-vidya.core` looks up whose `:on-click` that was, and calls |
| 64 | it on the loop thread. Handlers are held on the jolt side and never sent. |
| 65 | |
| 66 | ## Requirements |
| 67 | |
| 68 | `libvidya` built from [`../ffi`](../ffi), the Rust/egui implementation: |
| 69 | |
| 70 | ```sh |
| 71 | just ffi # buck2 → ../build/libvidya.so |
| 72 | ``` |
| 73 | |
| 74 | Then put it on the search path when running anything here: |
| 75 | |
| 76 | ```sh |
| 77 | LD_LIBRARY_PATH=../build jolt counter |
| 78 | ``` |
| 79 | |
| 80 | `just ffi-android` cross-compiles the same library to |
| 81 | `../build/android/arm64-v8a/libvidya.so` for a 64-bit device — both ABIs this |
| 82 | backend binds are exported there too. |
| 83 | |
| 84 | On macOS use `DYLD_LIBRARY_PATH`. Note that this is the one place in the repo |
| 85 | where the two `libvidya` builds are **not** interchangeable: `../raylib` |
| 86 | implements `vidya.h` only, and this backend binds the tree ABI, which is the |
| 87 | Rust build's alone. |
| 88 | |
| 89 | ## Running |
| 90 | |
| 91 | ```sh |
| 92 | jolt test # the suite, headless: no window, display or GPU needed |
| 93 | jolt counter # the counter above |
| 94 | jolt showcase # every tag, a keyed task list, an entry, a disabled subtree |
| 95 | jolt smoke # non-interactive: reconciles under paint, then quits |
| 96 | ``` |
| 97 | |
| 98 | ## Seeing what was rendered |
| 99 | |
| 100 | `dump` reads the tree back out of the library as hiccup — what is actually |
| 101 | mounted, after the reconciler has had its way with it, rather than what a |
| 102 | component returned: |
| 103 | |
| 104 | ```clojure |
| 105 | (require '[glimmer-vidya.core :as backend]) |
| 106 | |
| 107 | (backend/dump!) ; print the whole window |
| 108 | (backend/dump! node) ; or one subtree |
| 109 | (backend/dump) ; the same as hiccup data, for a test |
| 110 | (backend/dump-str) ; as text, to paste into a bug report |
| 111 | ``` |
| 112 | |
| 113 | ```clojure |
| 114 | [:window {} |
| 115 | [:card {} |
| 116 | [:title {:label "Counter"}] |
| 117 | [:label {:label "Count: 3"}] |
| 118 | [:box {:orientation "horizontal" :spacing 8} |
| 119 | [:button {:label "- 1"}] |
| 120 | [:button {:kind "primary" :label "+ 1"}]]]] |
| 121 | ``` |
| 122 | |
| 123 | Two things to expect when reading one, both of them the boundary showing |
| 124 | through. `:hbox` and `:vbox` are one node down there, so both dump as `:box` |
| 125 | with the orientation in the props; and no `:on-*` appears, because handlers are |
| 126 | held on this side and never sent. Props are sorted, so two dumps of the same |
| 127 | tree compare as text. |
| 128 | |
| 129 | It needs no window — the tree is only painted by `vidya_tree_frame` — so a |
| 130 | headless test can mount a component and assert on `(dump root)` directly. |
| 131 | |
| 132 | ## Hiccup reference |
| 133 | |
| 134 | Elements are `[:tag props? & children]`, as everywhere in glimmer. Strings and |
| 135 | numbers become labels, `nil` children are skipped, seqs are spliced. |
| 136 | |
| 137 | **Containers** |
| 138 | |
| 139 | | tag | holds | notes | |
| 140 | |---|---|---| |
| 141 | | `:window` | many | the root; you rarely name it | |
| 142 | | `:box` / `:hbox` / `:vbox` | many | `:orientation :horizontal\|:vertical`, implied by the tag | |
| 143 | | `:page` | many | scrolling column with page padding; `:max-width` centres it | |
| 144 | | `:scroll` | many | `:orientation :vertical\|:horizontal\|:both`, `:scroll-key` | |
| 145 | | `:card` | many | Vidya's raised surface | |
| 146 | | `:frame` | many | a card with a `:label` as its heading | |
| 147 | |
| 148 | **Widgets** |
| 149 | |
| 150 | | tag | shows | |
| 151 | |---|---| |
| 152 | | `:label` | body text | |
| 153 | | `:title` / `:title-2` / `:dim-label` | the type scale's other roles | |
| 154 | | `:button` | `:kind :default\|:primary\|:destructive` | |
| 155 | | `:checkbutton` | Vidya's themed checkbox (`:checkbox` is an alias) | |
| 156 | | `:entry` | a text field; `:multiline true` with `:rows` for a box | |
| 157 | | `:separator` | a rule | |
| 158 | | `:spacer` | blank space of `:size` points | |
| 159 | | `:progress` | a bar, `:value` 0.0–1.0, with an optional `:label` | |
| 160 | | `:spinner` | an indeterminate spinner | |
| 161 | | `:status` | a live/offline dot beside a label | |
| 162 | | `: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) | |
| 163 | |
| 164 | **Common props** |
| 165 | |
| 166 | - `:sensitive false` — dims the widget *and its whole subtree*, and takes it out |
| 167 | of egui's interaction |
| 168 | - `:width-request` — a fixed width. Worth more here than it sounds: immediate |
| 169 | mode has no natural width for a field, so an `:entry` asks for whatever is |
| 170 | left and takes the row it shares with a button. This is how you say otherwise. |
| 171 | - `:margin`, `:spacing` — on containers |
| 172 | |
| 173 | **Events** |
| 174 | |
| 175 | - `:on-click` — button pressed. No args. |
| 176 | - `:on-toggled` — checkbutton clicked. No args. |
| 177 | - `:on-change` — entry text changed. Receives the new text. |
| 178 | - `:on-activate` — Enter pressed in an entry. No args. |
| 179 | - `:on-paste-empty` — Ctrl+V in an entry with no text on the clipboard, which |
| 180 | is what a copied picture looks like from there. No args; ask |
| 181 | `clipboard-image-png!` what is actually on it. |
| 182 | |
| 183 | As in the other backends, a handler owns the state: `:on-toggled` flips the cell |
| 184 | the component reads, and `:active` comes back down as a prop. A control that |
| 185 | ignores its own event still works — the library wrote the new state into the |
| 186 | node, and the next render either confirms it or overwrites it. |
| 187 | |
| 188 | An unrecognized tag is kept rather than refused: it paints as a vertical box, so |
| 189 | a component written against a tag this backend has not grown yet still shows its |
| 190 | children. |
| 191 | |
| 192 | ## Options for `ui/run` |
| 193 | |
| 194 | On top of glimmer's own `:title`, `:width`, `:height` and `:auto-quit-ms`: |
| 195 | |
| 196 | | option | | |
| 197 | |---|---| |
| 198 | | `:fps` | frame rate cap, default 60 | |
| 199 | | `:mode` | `:dark` (the default) or `:light` | |
| 200 | | `:font` | path to a TTF/OTF for UI text | |
| 201 | |
| 202 | ## Timers |
| 203 | |
| 204 | The loop wakes every frame anyway, so a timer is a due time and a thunk. Both |
| 205 | run on the loop thread, the only one allowed to touch nodes: |
| 206 | |
| 207 | ```clojure |
| 208 | (vidya/every! 80 #(swap! tick inc)) ; a spinner, a clock, a progress bar |
| 209 | (vidya/after! 500 #(reset! ready true)) |
| 210 | (vidya/cancel! id) |
| 211 | ``` |
| 212 | |
| 213 | `(vidya/quit!)` stops the loop and closes the window. |
| 214 | |
| 215 | ## Threads |
| 216 | |
| 217 | Every node call belongs to the thread that opened the window — the library |
| 218 | enforces it, keeping its state in thread-local storage. glimmer already knows |
| 219 | this: while the loop runs it marshals each component's re-render through the |
| 220 | backend's `schedule`, which queues the work for the next tick. So a `swap!` from |
| 221 | an nREPL worker is safe, and `glimmer.core/on-gui` is there for code that wants |
| 222 | to touch the tree directly. |
| 223 | |
| 224 | ## Testing |
| 225 | |
| 226 | The suite is headless. Node calls need no GL surface — only `vidya_tree_frame` |
| 227 | does — so `jolt test` mounts real components, reconciles them, and reads the |
| 228 | resulting tree back through the same ABI the backend writes it with. It asserts |
| 229 | what was created, what was patched in place, what was replaced, and that a keyed |
| 230 | list reorders its widgets rather than rebuilding them. No window is opened and |
| 231 | no display is required, which is also what makes it run in CI. |
| 232 | |
| 233 | Painting is checked separately, by `jolt smoke` under `VIDYA_CAPTURE`: |
| 234 | |
| 235 | ```sh |
| 236 | VIDYA_CAPTURE=/tmp/frame.ppm LD_LIBRARY_PATH=../build jolt smoke |
| 237 | ``` |
| 238 | |
| 239 | ## Limits |
| 240 | |
| 241 | * **X11 is preferred on Linux**, for the reason in |
| 242 | [`../ffi/README.md`](../ffi/README.md): the caller owns the frame loop, and a |
| 243 | native Wayland surface driven that way stops receiving frame callbacks. Under |
| 244 | XWayland everything works; fractional scaling follows XWayland's rules. |
| 245 | * **No focus or keyboard navigation of your own.** egui owns focus, tabbing and |
| 246 | hit testing, so there is nothing here like glimmer-tui's `:keys`, `:on-key` or |
| 247 | `:autofocus` — and nothing to configure. |
| 248 | * **No `:listbox`, `:table`, `:overlay` or `:paginator` yet.** A list is a |
| 249 | keyed `:vbox` for now. These are widget-layer work in `../ffi/src/tree.rs` |
| 250 | plus a tag; nothing in the design is in the way. |