| 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 |
| Point the docs at the flake d682bfa nandi 13d ago | 71 | just ffi-android # nix build .#android → ../result/lib/arm64-v8a/libvidya.so |
| Bring vidya in cfd3e36 nandi 19d ago | 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 |
| Hand the window to a REPL, so the UI is something to poke at 757d8e7 nandi 17d ago | 96 | jolt repl-ui # a window you edit from a prompt, live |
| Bring vidya in cfd3e36 nandi 19d ago | 97 | ``` |
| 98 | |
| Hand the window to a REPL, so the UI is something to poke at 757d8e7 nandi 17d ago | 99 | ## Live from a REPL |
| 100 | |
| 101 | `examples/glimmer_vidya/repl.jolt` is a window whose whole contents are one |
| 102 | ratom, and a REPL that writes to it. Nothing in it is special to REPLs — it is |
| 103 | the ordinary arrangement with the cell driven by what you type rather than by a |
| 104 | handler — but it turns the toolkit into something you poke at rather than |
| 105 | restart: |
| 106 | |
| 107 | ```sh |
| 108 | LD_LIBRARY_PATH=../build jolt repl-ui |
| 109 | ``` |
| 110 | |
| 111 | ```clojure |
| 112 | ui=> (show! [:card {} [:title {:label "live"}] [:label {:label "typed just now"}]]) |
| 113 | ui=> (show! (fn [] [:label {:label (str "hits " @hits)}])) ; a component, not a picture |
| 114 | ui=> (swap! hits inc) ; …so this re-renders it |
| 115 | ui=> (dump!) |
| 116 | ui=> :quit |
| 117 | ``` |
| 118 | |
| 119 | Hiccup is fixed at the moment you type it; a zero-arg function is mounted as a |
| 120 | component and re-runs whenever a cell it derefs changes. Either way the |
| 121 | reconciler patches what changed, so the window does not blink and an `:entry` |
| 122 | keeps its text and cursor across a `show!`. |
| 123 | |
| 124 | Two boundary rules shape the example, and both are the library's rather than |
| 125 | glimmer's: |
| 126 | |
| 127 | * **The frame loop must own the main thread.** winit refuses to create an event |
| 128 | loop anywhere else, so `-main` paints on the main thread and reads stdin on a |
| 129 | second one. From an editor it is the other way around — `jolt nrepl-server` |
| 130 | parks the main thread in a pump, and `start!` posts the loop to it through |
| 131 | `jolt.host/call-on-main-thread`, so eval stays free: |
| 132 | |
| 133 | ```clojure |
| 134 | (require '[glimmer-vidya.repl :as live]) |
| 135 | (live/start!) |
| 136 | (live/show! [:label {:label "from the editor"}]) |
| 137 | ``` |
| 138 | |
| 139 | An event loop cannot be recreated either, so `stop!` ends the window for the |
| 140 | life of that process and `start!` says so rather than opening an invisible |
| 141 | one. Editing the UI never needs a restart; that is the point. |
| 142 | |
| 143 | * **Nodes belong to the loop thread.** A `show!` is safe from anywhere because |
| 144 | a ratom is not a node — glimmer marshals the re-render through the backend's |
| 145 | `schedule`. Anything that reads or writes the tree itself has to hop, which |
| 146 | is what the example's `gui` helper does with `glimmer.core/on-gui` and why |
| 147 | its `dump!` returns the tree rather than an empty root. |
| 148 | |
| Bring vidya in cfd3e36 nandi 19d ago | 149 | ## Seeing what was rendered |
| 150 | |
| 151 | `dump` reads the tree back out of the library as hiccup — what is actually |
| 152 | mounted, after the reconciler has had its way with it, rather than what a |
| 153 | component returned: |
| 154 | |
| 155 | ```clojure |
| 156 | (require '[glimmer-vidya.core :as backend]) |
| 157 | |
| 158 | (backend/dump!) ; print the whole window |
| 159 | (backend/dump! node) ; or one subtree |
| 160 | (backend/dump) ; the same as hiccup data, for a test |
| 161 | (backend/dump-str) ; as text, to paste into a bug report |
| 162 | ``` |
| 163 | |
| 164 | ```clojure |
| 165 | [:window {} |
| 166 | [:card {} |
| 167 | [:title {:label "Counter"}] |
| 168 | [:label {:label "Count: 3"}] |
| 169 | [:box {:orientation "horizontal" :spacing 8} |
| 170 | [:button {:label "- 1"}] |
| 171 | [:button {:kind "primary" :label "+ 1"}]]]] |
| 172 | ``` |
| 173 | |
| 174 | Two things to expect when reading one, both of them the boundary showing |
| 175 | through. `:hbox` and `:vbox` are one node down there, so both dump as `:box` |
| 176 | with the orientation in the props; and no `:on-*` appears, because handlers are |
| 177 | held on this side and never sent. Props are sorted, so two dumps of the same |
| 178 | tree compare as text. |
| 179 | |
| 180 | It needs no window — the tree is only painted by `vidya_tree_frame` — so a |
| 181 | headless test can mount a component and assert on `(dump root)` directly. |
| 182 | |
| 183 | ## Hiccup reference |
| 184 | |
| 185 | Elements are `[:tag props? & children]`, as everywhere in glimmer. Strings and |
| 186 | numbers become labels, `nil` children are skipped, seqs are spliced. |
| 187 | |
| 188 | **Containers** |
| 189 | |
| 190 | | tag | holds | notes | |
| 191 | |---|---|---| |
| 192 | | `:window` | many | the root; you rarely name it | |
| 193 | | `:box` / `:hbox` / `:vbox` | many | `:orientation :horizontal\|:vertical`, implied by the tag | |
| 194 | | `:page` | many | scrolling column with page padding; `:max-width` centres it | |
| 195 | | `:scroll` | many | `:orientation :vertical\|:horizontal\|:both`, `:scroll-key` | |
| 196 | | `:card` | many | Vidya's raised surface | |
| 197 | | `:frame` | many | a card with a `:label` as its heading | |
| 198 | |
| 199 | **Widgets** |
| 200 | |
| 201 | | tag | shows | |
| 202 | |---|---| |
| 203 | | `:label` | body text | |
| 204 | | `:title` / `:title-2` / `:dim-label` | the type scale's other roles | |
| 205 | | `:button` | `:kind :default\|:primary\|:destructive` | |
| 206 | | `:checkbutton` | Vidya's themed checkbox (`:checkbox` is an alias) | |
| 207 | | `:entry` | a text field; `:multiline true` with `:rows` for a box | |
| 208 | | `:separator` | a rule | |
| 209 | | `:spacer` | blank space of `:size` points | |
| 210 | | `:progress` | a bar, `:value` 0.0–1.0, with an optional `:label` | |
| 211 | | `:spinner` | an indeterminate spinner | |
| 212 | | `:status` | a live/offline dot beside a label | |
| 213 | | `: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) | |
| 214 | |
| 215 | **Common props** |
| 216 | |
| 217 | - `:sensitive false` — dims the widget *and its whole subtree*, and takes it out |
| 218 | of egui's interaction |
| 219 | - `:width-request` — a fixed width. Worth more here than it sounds: immediate |
| 220 | mode has no natural width for a field, so an `:entry` asks for whatever is |
| 221 | left and takes the row it shares with a button. This is how you say otherwise. |
| 222 | - `:margin`, `:spacing` — on containers |
| 223 | |
| 224 | **Events** |
| 225 | |
| 226 | - `:on-click` — button pressed. No args. |
| 227 | - `:on-toggled` — checkbutton clicked. No args. |
| 228 | - `:on-change` — entry text changed. Receives the new text. |
| 229 | - `:on-activate` — Enter pressed in an entry. No args. |
| 230 | - `:on-paste-empty` — Ctrl+V in an entry with no text on the clipboard, which |
| 231 | is what a copied picture looks like from there. No args; ask |
| 232 | `clipboard-image-png!` what is actually on it. |
| 233 | |
| 234 | As in the other backends, a handler owns the state: `:on-toggled` flips the cell |
| 235 | the component reads, and `:active` comes back down as a prop. A control that |
| 236 | ignores its own event still works — the library wrote the new state into the |
| 237 | node, and the next render either confirms it or overwrites it. |
| 238 | |
| 239 | An unrecognized tag is kept rather than refused: it paints as a vertical box, so |
| 240 | a component written against a tag this backend has not grown yet still shows its |
| 241 | children. |
| 242 | |
| 243 | ## Options for `ui/run` |
| 244 | |
| 245 | On top of glimmer's own `:title`, `:width`, `:height` and `:auto-quit-ms`: |
| 246 | |
| 247 | | option | | |
| 248 | |---|---| |
| 249 | | `:fps` | frame rate cap, default 60 | |
| 250 | | `:mode` | `:dark` (the default) or `:light` | |
| 251 | | `:font` | path to a TTF/OTF for UI text | |
| 252 | |
| 253 | ## Timers |
| 254 | |
| 255 | The loop wakes every frame anyway, so a timer is a due time and a thunk. Both |
| 256 | run on the loop thread, the only one allowed to touch nodes: |
| 257 | |
| 258 | ```clojure |
| 259 | (vidya/every! 80 #(swap! tick inc)) ; a spinner, a clock, a progress bar |
| 260 | (vidya/after! 500 #(reset! ready true)) |
| 261 | (vidya/cancel! id) |
| 262 | ``` |
| 263 | |
| 264 | `(vidya/quit!)` stops the loop and closes the window. |
| 265 | |
| 266 | ## Threads |
| 267 | |
| 268 | Every node call belongs to the thread that opened the window — the library |
| 269 | enforces it, keeping its state in thread-local storage. glimmer already knows |
| 270 | this: while the loop runs it marshals each component's re-render through the |
| 271 | backend's `schedule`, which queues the work for the next tick. So a `swap!` from |
| 272 | an nREPL worker is safe, and `glimmer.core/on-gui` is there for code that wants |
| 273 | to touch the tree directly. |
| 274 | |
| 275 | ## Testing |
| 276 | |
| 277 | The suite is headless. Node calls need no GL surface — only `vidya_tree_frame` |
| 278 | does — so `jolt test` mounts real components, reconciles them, and reads the |
| 279 | resulting tree back through the same ABI the backend writes it with. It asserts |
| 280 | what was created, what was patched in place, what was replaced, and that a keyed |
| 281 | list reorders its widgets rather than rebuilding them. No window is opened and |
| 282 | no display is required, which is also what makes it run in CI. |
| 283 | |
| 284 | Painting is checked separately, by `jolt smoke` under `VIDYA_CAPTURE`: |
| 285 | |
| 286 | ```sh |
| 287 | VIDYA_CAPTURE=/tmp/frame.ppm LD_LIBRARY_PATH=../build jolt smoke |
| 288 | ``` |
| 289 | |
| 290 | ## Limits |
| 291 | |
| 292 | * **X11 is preferred on Linux**, for the reason in |
| 293 | [`../ffi/README.md`](../ffi/README.md): the caller owns the frame loop, and a |
| 294 | native Wayland surface driven that way stops receiving frame callbacks. Under |
| 295 | XWayland everything works; fractional scaling follows XWayland's rules. |
| 296 | * **No focus or keyboard navigation of your own.** egui owns focus, tabbing and |
| 297 | hit testing, so there is nothing here like glimmer-tui's `:keys`, `:on-key` or |
| 298 | `:autofocus` — and nothing to configure. |
| 299 | * **No `:listbox`, `:table`, `:overlay` or `:paginator` yet.** A list is a |
| 300 | keyed `:vbox` for now. These are widget-layer work in `../ffi/src/tree.rs` |
| 301 | plus a tag; nothing in the design is in the way. |