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

core.clj · 454 lines · 17.9 KBClojure Blame HistoryRaw
Bring vidya in cfd3e36 nandi 19d ago1(ns glimmer-vidya.core
2 "The Vidya/egui backend for glimmer. Requiring this namespace installs it,
3 after which glimmer's portable reconciler renders hiccup into a GPU window:
4
5 (ns myapp
6 (:require [glimmer.ratom :refer [atom]]
7 [glimmer.core :as ui]
8 [glimmer-vidya.core])) ; installs this backend
9
10 (defn -main [& _] (ui/run my-app :title \"myapp\"))
11
12 egui is an immediate-mode toolkit: it has no widgets to hand a reconciler,
13 only calls you make every frame. So the widget tree lives one layer down, in
14 the Rust library, and this namespace is the thin part — it turns glimmer's
15 create/patch/append/remove into node mutations, runs the frame loop, and
16 routes the events that come back to the handlers the components declared.
17
18 A widget, from the reconciler's point of view, is an integer node handle. It
19 never looks inside one, which is exactly why an id is enough.
20
21 **Handlers do not cross the FFI.** A jolt closure cannot be a callback in a
22 library painting at 60fps, so identity travels instead: a node reports that
23 it was clicked, and the handler map here says whose `:on-click` that was."
24 (:require [glimmer.backend :as b]
25 [glimmer-vidya.ffi :as ffi]))
26
27;; Node id -> the :on-* props that node was last rendered with. Kept here
28;; rather than sent across because a closure has no C representation.
29;;
30;; Ids are recycled by the arena, which is safe only because every id is
31;; written here by `create!` before anything can raise an event against it — a
32;; reused id has its predecessor's handlers overwritten in the same breath.
33(defonce ^:private handlers (atom {}))
34
35;; Work posted from other threads, run on the loop thread at the top of a tick.
36(defonce ^:private pending (atom []))
37
38;; Set by quit!, read by the loop.
39(defonce ^:private quit-requested (atom false))
40
41;; --- props -------------------------------------------------------------------
42;; :hbox and :vbox are one node in the library; the tag only implies an
43;; orientation, and an explicit :orientation prop still wins.
44(def ^:private tag-orientation {:hbox "horizontal" :vbox "vertical"})
45
46(defn- handler-key?
47 "True for a prop that names an event handler rather than a value."
48 [k]
49 (let [s (name k)]
50 (and (> (count s) 3) (= "on-" (subs s 0 3)))))
51
52(defn- set-prop!
53 "Write one prop to a node, in the ABI type that fits its value. nil clears
54 nothing — the prop was already dropped by the clear that precedes a write —
55 and an unrecognized value is stringified rather than refused, so a prop this
56 backend has not learned yet still reaches the library."
57 [node k v]
58 (let [key (name k)]
59 (cond
60 (nil? v) nil
61 (true? v) (ffi/node-set-bool! node key true)
62 (false? v) (ffi/node-set-bool! node key false)
63 (number? v) (ffi/node-set-num! node key (double v))
64 (string? v) (ffi/node-set-str! node key v)
65 (keyword? v) (ffi/node-set-str! node key (name v))
66 :else (ffi/node-set-str! node key (str v)))))
67
68(defn- write-props!
69 "Replace a node's props with `props`.
70
71 Cleared first, deliberately: a re-render that stops setting `:placeholder`
72 means the placeholder is gone, and patching in place would leave the old one
73 behind. It also discards the value the library wrote back when the user typed
74 in an entry or clicked a checkbutton — which is the point. The component's
75 state is the truth, and this is the frame where it says so."
76 [node tag props]
77 (ffi/node-clear-props! node)
78 (when-let [orientation (tag-orientation tag)]
79 (when-not (contains? props :orientation)
80 (ffi/node-set-str! node "orientation" orientation)))
81 (doseq [[k v] props]
82 (when-not (handler-key? k)
83 (set-prop! node k v)))
84 (swap! handlers assoc node
85 (reduce (fn [acc [k v]]
86 (if (and (handler-key? k) (fn? v)) (assoc acc k v) acc))
87 {}
88 props))
89 nil)
90
91(defn- forget-dead-handlers!
92 "Drop handler entries for nodes the library has freed.
93
94 Removing a subtree frees every node under it, and only the library knows
95 which those were — so rather than mirror the tree here to walk it, the map is
96 filtered against what still exists. It runs once per removal, over a map the
97 size of the UI."
98 []
99 (swap! handlers
100 (fn [m]
101 (reduce (fn [acc [id hs]]
102 (if (ffi/node-exists? id) (assoc acc id hs) acc))
103 {}
104 m)))
105 nil)
106
107;; --- the backend operations --------------------------------------------------
108(defn- create!
109 "glimmer.backend's :create!. Children are appended by the reconciler, not
110 here."
111 [tag props]
112 (let [node (ffi/node-new (name tag))]
113 (when (zero? node)
114 (throw (ex-info "vidya could not allocate a node" {:tag tag})))
115 (write-props! node tag props)
116 node))
117
118(defn- apply-props! [tag node props] (write-props! node tag props))
119
120(defn- append-child! [_parent-tag parent child]
121 (ffi/node-append! parent child)
122 nil)
123
124(defn- remove-child! [_parent-tag parent child]
125 ;; The library frees the subtree; glimmer never mentions it again.
126 (ffi/node-remove! parent child)
127 (forget-dead-handlers!)
128 nil)
129
130(defn- replace-child! [_parent-tag parent old-child new-child]
131 (ffi/node-replace! parent old-child new-child)
132 (forget-dead-handlers!)
133 nil)
134
135(defn- reorder-child! [_parent-tag parent child sibling]
136 ;; nil sibling means "first"; the ABI spells that 0.
137 (ffi/node-insert-after! parent child (or sibling 0))
138 nil)
139
140;; --- the UI thread -----------------------------------------------------------
141(defn- schedule
142 "glimmer.backend's :schedule. Every node call belongs to the thread that
143 opened the window, so a ratom mutated on an nREPL worker (or any future)
144 queues its re-render here and the loop performs it on the next tick."
145 [work]
146 (swap! pending conj work)
147 nil)
148
149(defn- drain!
150 "Run everything `schedule` queued. compare-and-set! rather than reset!, so
151 work posted while the queue is being taken is not dropped."
152 []
153 (loop []
154 (let [q @pending]
155 (when (seq q)
156 (if (compare-and-set! pending q [])
157 (doseq [f q] (f))
158 (recur))))))
159
160;; --- timers ------------------------------------------------------------------
161;; A spinner or a clock has to change with nothing being pressed. The loop
162;; already wakes every frame, so a timer is a due time and a thunk. Both
163;; entry points are safe to call from another thread, and both run their thunk
164;; ON the loop thread, the only one allowed to touch nodes.
165(defonce ^:private timers (atom {:next-id 0 :entries {}}))
166
167(defn- now-ms [] (System/currentTimeMillis))
168
169(defn- add-timer! [ms every? f]
170 (let [id (:next-id (swap! timers update :next-id inc))]
171 (swap! timers assoc-in [:entries id]
172 {:due (+ (now-ms) ms) :every (when every? ms) :f f})
173 id))
174
175(defn after!
176 "Run `f` on the loop thread in about `ms` milliseconds. Returns an id for
177 `cancel!`. Resolution is one frame."
178 [ms f] (add-timer! ms false f))
179
180(defn every!
181 "Run `f` on the loop thread about every `ms` milliseconds until cancelled:
182
183 (every! 80 #(swap! tick inc))"
184 [ms f] (add-timer! ms true f))
185
186(defn cancel!
187 "Stop the timer `id`."
188 [id] (swap! timers update :entries dissoc id) nil)
189
190(defn cancel-all!
191 "Stop every timer, so a repeating one does not outlive the UI it animated."
192 [] (swap! timers assoc :entries {}) nil)
193
194(defn- pump-timers! []
195 (let [t (now-ms)
196 due (reduce (fn [acc [id e]] (if (<= (:due e) t) (conj acc [id e]) acc))
197 []
198 (:entries @timers))]
199 (doseq [[id e] due]
200 (if-let [period (:every e)]
201 (swap! timers assoc-in [:entries id :due] (+ t period))
202 (swap! timers update :entries dissoc id))
203 ((:f e)))
204 nil))
205
206;; --- events ------------------------------------------------------------------
207(defn- dispatch-events!
208 "Drain the frame's interactions and call the handlers they belong to.
209
210 An event whose node has no handler for it is dropped, which is what makes a
211 control that ignores its own event still work: the library wrote the new
212 state into the node, and the next render either confirms it or overwrites it."
213 []
214 (loop []
215 (when (ffi/poll-event!)
216 (let [node (ffi/event-node)
217 kind (ffi/event-name)
218 hs (get @handlers node)]
219 (case kind
220 "click" (when-let [f (:on-click hs)] (f))
221 "toggled" (when-let [f (:on-toggled hs)] (f))
222 ;; The text is read before anything else can overwrite the library's
223 ;; one scratch buffer — jolt copies it as it crosses.
224 "change" (when-let [f (:on-change hs)] (f (ffi/event-text)))
225 "activate" (when-let [f (:on-activate hs)] (f))
Let a face answer the pointer fa4ecdf nandi 19d ago226 ;; The two edges of a pointer hover, not the middle of one: the
227 ;; backend says so when it starts and again when it ends, so a
228 ;; handler can put something on screen and take it away again.
229 "hover" (when-let [f (:on-hover hs)] (f))
230 "unhover" (when-let [f (:on-unhover hs)] (f))
Bring vidya in cfd3e36 nandi 19d ago231 ;; Ctrl+V on a clipboard with no text on it. What is on it instead is
232 ;; the caller's to find out — `clipboard-image-png!` is the only
233 ;; question the backend answers about it.
234 "paste-empty" (when-let [f (:on-paste-empty hs)] (f))
235 nil))
236 (recur))))
237
238;; --- the event loop ----------------------------------------------------------
239(defn- clear-children!
240 "Drop everything under `node`. Removing a child frees it, so this walks the
241 first slot until there is nothing left rather than iterating an index."
242 [node]
243 (loop []
244 (when (pos? (ffi/node-child-count node))
245 (ffi/node-remove! node (ffi/node-child-at node 0))
246 (recur)))
247 (forget-dead-handlers!)
248 nil)
249
Let the window be renamed after it opens 5adddc7 nandi 19d ago250(defn set-title!
251 "Rename the open window. `run` names it once at startup; this is for a title
252 that has something to say later — who is signed in, which document is open,
253 what a second window of the same app is for.
254
255 A no-op before the window exists and after it closes."
256 [title]
257 (ffi/set-title! (str title))
258 nil)
259
Bring vidya in cfd3e36 nandi 19d ago260(defn window-width
261 "The width in points of the window's content area, as the last painted frame
262 measured it. 0 before the first frame.
263
264 This is how a layout asks how much room it has: nothing else here reports a
265 size, and the window is not resizable from this side either. Read it from a
266 timer — `every!` — rather than per render, and hold it in a ratom, so that
267 the components which switch on it re-render only when it actually changes."
268 []
269 (ffi/node-get-num (ffi/tree-root) "window-width"))
270
271(defn clipboard-image-png!
272 "Write the picture on the system clipboard to `path` as a PNG. True when
273 there was one; false for an empty clipboard, text on it, an unwritable path,
274 or a platform with no clipboard of images (Android).
275
276 A paste of a picture reaches no handler of its own — the backend delivers
277 clipboard text only — so a caller asks for it, from whatever gesture it means
278 paste by. An `:entry`'s `:on-paste-empty` is that gesture where the reader
279 expects it: a Ctrl+V the field had no text to answer with."
280 [path]
281 (ffi/clipboard-image-png! path))
282
283(defn open-url!
284 "Hand `url` to whatever shows web pages here — xdg-open or `open` on a
285 desktop, an ACTION_VIEW intent on Android. True when something took it;
286 false leaves the caller to show the URL and let the reader carry it across.
287
288 A sign-in that goes through a browser is the reason this exists: the app
289 leaves for a page and the page comes back to it."
290 [url]
291 (ffi/open-url! url))
292
Catch up with vidya c90f8af nandi 19d ago293(defn pick-image!
294 "Open the platform's own picture chooser. True when one opened, false where
295 there is none — a desktop, or an Android activity that does not offer it —
296 and a false is the caller's cue to browse the filesystem itself.
297
298 It does not answer with the picture. The reader is in another screen by then,
299 so what they chose arrives at `picked-image!`, which the caller polls."
300 []
301 (ffi/pick-image!))
302
303(defn picked-image!
304 "Move the picture chosen since the last call to `path`; true when there was
305 one. The answer is handed over once, so a poll still running does not take
306 the same picture twice."
307 [path]
308 (ffi/picked-image! path))
309
Tell a caller how big the window is 42dabb0 nandi 19d ago310(defn screen-size
311 "The window's size in points, as `[width height]`. `[0 0]` before the first
312 frame has been painted.
313
314 For laying something out as a share of the window — a row of tiles that
315 should divide the width between them — where how many there are and how much
316 gap goes between them is the caller's arithmetic, and not something one
317 widget can work out from the space it was handed.
318
319 Follows the window as it is dragged, so a component that reads it wants to be
320 re-rendered when it changes: keep it in a ratom rather than asking here at
321 render time, or the layout will be whatever it was on the first frame."
322 []
323 [(ffi/screen-width) (ffi/screen-height)])
324
Bring vidya in cfd3e36 nandi 19d ago325(defn frame-rgba!
326 "Hand the backend a frame of live pixels under `key`. An `:image` node with
327 `:feed key` paints the latest one.
328
329 `rgba` is a foreign pointer — `width * height * 4` un-premultiplied bytes,
330 row-major — and is copied before this returns, so whoever owns it may reuse
331 it immediately. False when the length does not match the dimensions.
332
333 This is the source an `:image` `:src` cannot be: a `:src` decodes a file and
334 caches it by path for the life of the process, which is right for a picture
335 in a message and wrong for one that is new thirty times a second. Frames
336 coalesce rather than queue, so a source faster than the window costs nothing.
337
338 On the window's thread, like everything else here: a frame produced on a
339 decoder thread is the caller's to hand across."
340 [key width height rgba]
341 (ffi/frame-rgba! key width height rgba))
342
343(defn frame-drop!
344 "Forget the feed named `key` and release its texture; true when there was
345 one. A source that has stopped keeps painting its last frame otherwise."
346 [key]
347 (ffi/frame-drop! key))
348
349(defn quit!
350 "Stop the running loop and close the window."
351 []
352 (reset! quit-requested true)
353 nil)
354
355(defn- run!
356 "glimmer.backend's :run. Opens a window, mounts the root component into the
357 library's root node, and paints until the window closes or `quit!` is called.
358 Blocks, like every UI main loop.
359
360 Options (on top of glimmer's own):
361 :title :width :height the window
362 :fps frame rate cap (default 60)
363 :mode :dark (default) or :light
364 :font path to a TTF/OTF to use for UI text
365 :auto-quit-ms stop after roughly this long — for smoke tests,
366 which have nobody to close the window
367
368 The window is closed in a finally, so a handler that throws does not leave a
369 GPU surface and an event loop behind."
370 [opts mount-root!]
371 (let [{:keys [title width height fps mode font auto-quit-ms]
372 :or {title "glimmer" width 900 height 640 fps 60}} opts]
373 (when-not (ffi/open! width height title)
374 (throw (ex-info "vidya could not open a window"
375 {:title title :width width :height height})))
376 (reset! quit-requested false)
377 (ffi/set-target-fps! fps)
378 (ffi/set-mode! (if (= mode :light) ffi/light-mode ffi/dark-mode))
379 (when font (ffi/load-font! font))
380 (let [started (now-ms)
381 root (ffi/tree-root)]
382 (try
383 ;; The library's root outlives a run — it is process-wide, not per
384 ;; window — so a second `ui/run` in one session (a REPL, a test) would
385 ;; otherwise mount its tree alongside the last one's.
386 (clear-children! root)
387 (mount-root! root :window)
388 (reset! b/loop-running? true)
389 (loop []
390 (drain!)
391 (pump-timers!)
392 ;; One call paints the whole tree; the events it produced are read
393 ;; straight after, while the frame that caused them is still the
394 ;; frame the components rendered.
395 (ffi/tree-frame!)
396 (dispatch-events!)
397 (when-not (or @quit-requested
398 (ffi/should-close?)
399 (and auto-quit-ms (>= (- (now-ms) started) auto-quit-ms)))
400 (recur)))
401 (finally
402 (reset! b/loop-running? false)
403 (cancel-all!)
404 (ffi/close!)
405 (reset! handlers {}))))))
406
407;; --- looking at what was rendered --------------------------------------------
408(defn dump-str
409 "The rendered tree as hiccup text, read back out of the library.
410
411 With no argument, the whole window; with a node handle, that subtree. This is
412 the tree as it *is* after the reconciler has run, not what a component
413 returned, so paste it into a bug report and the two can be compared.
414
415 Two things to know when reading one. `:hbox` and `:vbox` are one node down
416 there and both dump as `:box`, with the orientation in the props; and no
417 `:on-*` appears, because handlers are held on this side and never sent."
418 ([] (dump-str 0))
419 ([node] (ffi/tree-dump node)))
420
421(defn dump
422 "`dump-str`, read back as hiccup data — vectors, keywords and maps — for a
423 test that wants to assert on a subtree rather than on a string."
424 ([] (dump 0))
425 ([node] (read-string (dump-str node))))
426
427(defn dump!
428 "Print `dump-str` to stdout. The one you want from a handler or the REPL."
429 ([] (dump! 0))
430 ([node] (println (dump-str node)) nil))
431
432;; --- the backend -------------------------------------------------------------
433(def backend
434 "The Vidya backend map handed to glimmer.backend/register!. See that
435 namespace for the contract each key satisfies."
436 {:name :vidya
437 :create! create!
438 :apply-props! apply-props!
439 :append-child! append-child!
440 :remove-child! remove-child!
441 :replace-child! replace-child!
442 :reorder-child! reorder-child!
443 :schedule schedule
444 :run run!})
445
446(defn install!
447 "Make Vidya/egui the backend glimmer renders with. Called on load, so
448 requiring this namespace is enough; exposed for code that wants to be
449 explicit, or to switch back after another backend was installed."
450 []
451 (b/register! backend)
452 nil)
453
454(defonce ^:private installed (do (install!) true))