nandi/jolt-nativepublic Fork 0
main
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.

README.md · 301 lines · 12.3 KBmarkdown Blame HistoryRaw
Bring vidya in cfd3e36 nandi 19d ago1# glimmer-vidya
2
3The **Vidya/egui** backend for [glimmer](https://github.com/jolt-lang/glimmer),
4the reactive GUI toolkit for [jolt](https://github.com/jolt-lang/jolt).
5
6glimmer owns the portable half — reactive cells, the component model, the
7reconciler — and knows nothing about any toolkit. This project supplies the
8other 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
10components 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
35Components, reactive state and reconciliation are documented in glimmer's
36README. What follows is the Vidya-specific part.
37
38## How an immediate-mode toolkit holds still
39
40egui has no widgets. It has calls you make every frame, and a reconciler has
41nothing to reconcile against — no pointer to patch, nothing to append a child
42to. GTK hands glimmer a `GtkButton`; egui hands it nothing at all.
43
44So the widget tree lives one layer down, in Rust. `libvidya` keeps a node arena
45behind a second C ABI ([`../ffi/include/vidya_tree.h`](../ffi/include/vidya_tree.h)):
46nodes 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
50Two 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
62a library painting at 60fps, so identity travels instead: a node reports that it
63was clicked, `glimmer-vidya.core` looks up whose `:on-click` that was, and calls
64it 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 ago71just ffi-android # nix build .#android → ../result/lib/arm64-v8a/libvidya.so
Bring vidya in cfd3e36 nandi 19d ago72```
73
74Then put it on the search path when running anything here:
75
76```sh
77LD_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
82backend binds are exported there too.
83
84On macOS use `DYLD_LIBRARY_PATH`. Note that this is the one place in the repo
85where the two `libvidya` builds are **not** interchangeable: `../raylib`
86implements `vidya.h` only, and this backend binds the tree ABI, which is the
87Rust build's alone.
88
89## Running
90
91```sh
92jolt test # the suite, headless: no window, display or GPU needed
93jolt counter # the counter above
94jolt showcase # every tag, a keyed task list, an entry, a disabled subtree
95jolt 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 ago96jolt repl-ui # a window you edit from a prompt, live
Bring vidya in cfd3e36 nandi 19d ago97```
98
Hand the window to a REPL, so the UI is something to poke at 757d8e7 nandi 17d ago99## Live from a REPL
100
Name a jolt source .clj 6f016e7 nandi 12d ago101`examples/glimmer_vidya/repl.clj` is a window whose whole contents are one
Hand the window to a REPL, so the UI is something to poke at 757d8e7 nandi 17d ago102ratom, and a REPL that writes to it. Nothing in it is special to REPLs — it is
103the ordinary arrangement with the cell driven by what you type rather than by a
104handler — but it turns the toolkit into something you poke at rather than
105restart:
106
107```sh
108LD_LIBRARY_PATH=../build jolt repl-ui
109```
110
111```clojure
112ui=> (show! [:card {} [:title {:label "live"}] [:label {:label "typed just now"}]])
113ui=> (show! (fn [] [:label {:label (str "hits " @hits)}])) ; a component, not a picture
114ui=> (swap! hits inc) ; …so this re-renders it
115ui=> (dump!)
116ui=> :quit
117```
118
119Hiccup is fixed at the moment you type it; a zero-arg function is mounted as a
120component and re-runs whenever a cell it derefs changes. Either way the
121reconciler patches what changed, so the window does not blink and an `:entry`
122keeps its text and cursor across a `show!`.
123
124Two boundary rules shape the example, and both are the library's rather than
125glimmer'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 ago149## Seeing what was rendered
150
151`dump` reads the tree back out of the library as hiccup — what is actually
152mounted, after the reconciler has had its way with it, rather than what a
153component 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
174Two things to expect when reading one, both of them the boundary showing
175through. `:hbox` and `:vbox` are one node down there, so both dump as `:box`
176with the orientation in the props; and no `:on-*` appears, because handlers are
177held on this side and never sent. Props are sorted, so two dumps of the same
178tree compare as text.
179
180It needs no window — the tree is only painted by `vidya_tree_frame` — so a
181headless test can mount a component and assert on `(dump root)` directly.
182
183## Hiccup reference
184
185Elements are `[:tag props? & children]`, as everywhere in glimmer. Strings and
186numbers 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
234As in the other backends, a handler owns the state: `:on-toggled` flips the cell
235the component reads, and `:active` comes back down as a prop. A control that
236ignores its own event still works — the library wrote the new state into the
237node, and the next render either confirms it or overwrites it.
238
239An unrecognized tag is kept rather than refused: it paints as a vertical box, so
240a component written against a tag this backend has not grown yet still shows its
241children.
242
243## Options for `ui/run`
244
245On 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
255The loop wakes every frame anyway, so a timer is a due time and a thunk. Both
256run 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
268Every node call belongs to the thread that opened the window — the library
269enforces it, keeping its state in thread-local storage. glimmer already knows
270this: while the loop runs it marshals each component's re-render through the
271backend's `schedule`, which queues the work for the next tick. So a `swap!` from
272an nREPL worker is safe, and `glimmer.core/on-gui` is there for code that wants
273to touch the tree directly.
274
275## Testing
276
277The suite is headless. Node calls need no GL surface — only `vidya_tree_frame`
278does — so `jolt test` mounts real components, reconciles them, and reads the
279resulting tree back through the same ABI the backend writes it with. It asserts
280what was created, what was patched in place, what was replaced, and that a keyed
281list reorders its widgets rather than rebuilding them. No window is opened and
282no display is required, which is also what makes it run in CI.
283
284Painting is checked separately, by `jolt smoke` under `VIDYA_CAPTURE`:
285
286```sh
287VIDYA_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.