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.
ScrollAreaandFrametake an
FnOnce(&mut Ui)and keep theirbegin/endprivate, 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-mainpaints 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, andstart!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 andstart!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'sguihelper does withglimmer.core/on-guiand why
itsdump!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:entryasks 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-keyor
:autofocus— and nothing to configure. - No
:listbox,:table,:overlayor:paginatoryet. A list is a
keyed:vboxfor 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 |
|