nandi/jolt-nativepublic Fork 0
c90f8af80ae93ec3b20ea6adbac32ddb64736940
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.jolt · 424 lines · 16.6 KBGDScript3 Blame HistoryRaw
Bring vidya in cfd3e36 nandi 20d 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))
226 ;; Ctrl+V on a clipboard with no text on it. What is on it instead is
227 ;; the caller's to find out — `clipboard-image-png!` is the only
228 ;; question the backend answers about it.
229 "paste-empty" (when-let [f (:on-paste-empty hs)] (f))
230 nil))
231 (recur))))
232
233;; --- the event loop ----------------------------------------------------------
234(defn- clear-children!
235 "Drop everything under `node`. Removing a child frees it, so this walks the
236 first slot until there is nothing left rather than iterating an index."
237 [node]
238 (loop []
239 (when (pos? (ffi/node-child-count node))
240 (ffi/node-remove! node (ffi/node-child-at node 0))
241 (recur)))
242 (forget-dead-handlers!)
243 nil)
244
245(defn window-width
246 "The width in points of the window's content area, as the last painted frame
247 measured it. 0 before the first frame.
248
249 This is how a layout asks how much room it has: nothing else here reports a
250 size, and the window is not resizable from this side either. Read it from a
251 timer `every!` rather than per render, and hold it in a ratom, so that
252 the components which switch on it re-render only when it actually changes."
253 []
254 (ffi/node-get-num (ffi/tree-root) "window-width"))
255
256(defn clipboard-image-png!
257 "Write the picture on the system clipboard to `path` as a PNG. True when
258 there was one; false for an empty clipboard, text on it, an unwritable path,
259 or a platform with no clipboard of images (Android).
260
261 A paste of a picture reaches no handler of its own the backend delivers
262 clipboard text only so a caller asks for it, from whatever gesture it means
263 paste by. An `:entry`'s `:on-paste-empty` is that gesture where the reader
264 expects it: a Ctrl+V the field had no text to answer with."
265 [path]
266 (ffi/clipboard-image-png! path))
267
268(defn open-url!
269 "Hand `url` to whatever shows web pages here — xdg-open or `open` on a
270 desktop, an ACTION_VIEW intent on Android. True when something took it;
271 false leaves the caller to show the URL and let the reader carry it across.
272
273 A sign-in that goes through a browser is the reason this exists: the app
274 leaves for a page and the page comes back to it."
275 [url]
276 (ffi/open-url! url))
277
Catch up with vidya c90f8af nandi 20d ago278(defn pick-image!
279 "Open the platform's own picture chooser. True when one opened, false where
280 there is none a desktop, or an Android activity that does not offer it
281 and a false is the caller's cue to browse the filesystem itself.
282
283 It does not answer with the picture. The reader is in another screen by then,
284 so what they chose arrives at `picked-image!`, which the caller polls."
285 []
286 (ffi/pick-image!))
287
288(defn picked-image!
289 "Move the picture chosen since the last call to `path`; true when there was
290 one. The answer is handed over once, so a poll still running does not take
291 the same picture twice."
292 [path]
293 (ffi/picked-image! path))
294
Bring vidya in cfd3e36 nandi 20d ago295(defn frame-rgba!
296 "Hand the backend a frame of live pixels under `key`. An `:image` node with
297 `:feed key` paints the latest one.
298
299 `rgba` is a foreign pointer `width * height * 4` un-premultiplied bytes,
300 row-major and is copied before this returns, so whoever owns it may reuse
301 it immediately. False when the length does not match the dimensions.
302
303 This is the source an `:image` `:src` cannot be: a `:src` decodes a file and
304 caches it by path for the life of the process, which is right for a picture
305 in a message and wrong for one that is new thirty times a second. Frames
306 coalesce rather than queue, so a source faster than the window costs nothing.
307
308 On the window's thread, like everything else here: a frame produced on a
309 decoder thread is the caller's to hand across."
310 [key width height rgba]
311 (ffi/frame-rgba! key width height rgba))
312
313(defn frame-drop!
314 "Forget the feed named `key` and release its texture; true when there was
315 one. A source that has stopped keeps painting its last frame otherwise."
316 [key]
317 (ffi/frame-drop! key))
318
319(defn quit!
320 "Stop the running loop and close the window."
321 []
322 (reset! quit-requested true)
323 nil)
324
325(defn- run!
326 "glimmer.backend's :run. Opens a window, mounts the root component into the
327 library's root node, and paints until the window closes or `quit!` is called.
328 Blocks, like every UI main loop.
329
330 Options (on top of glimmer's own):
331 :title :width :height the window
332 :fps frame rate cap (default 60)
333 :mode :dark (default) or :light
334 :font path to a TTF/OTF to use for UI text
335 :auto-quit-ms stop after roughly this long for smoke tests,
336 which have nobody to close the window
337
338 The window is closed in a finally, so a handler that throws does not leave a
339 GPU surface and an event loop behind."
340 [opts mount-root!]
341 (let [{:keys [title width height fps mode font auto-quit-ms]
342 :or {title "glimmer" width 900 height 640 fps 60}} opts]
343 (when-not (ffi/open! width height title)
344 (throw (ex-info "vidya could not open a window"
345 {:title title :width width :height height})))
346 (reset! quit-requested false)
347 (ffi/set-target-fps! fps)
348 (ffi/set-mode! (if (= mode :light) ffi/light-mode ffi/dark-mode))
349 (when font (ffi/load-font! font))
350 (let [started (now-ms)
351 root (ffi/tree-root)]
352 (try
353 ;; The library's root outlives a run — it is process-wide, not per
354 ;; window so a second `ui/run` in one session (a REPL, a test) would
355 ;; otherwise mount its tree alongside the last one's.
356 (clear-children! root)
357 (mount-root! root :window)
358 (reset! b/loop-running? true)
359 (loop []
360 (drain!)
361 (pump-timers!)
362 ;; One call paints the whole tree; the events it produced are read
363 ;; straight after, while the frame that caused them is still the
364 ;; frame the components rendered.
365 (ffi/tree-frame!)
366 (dispatch-events!)
367 (when-not (or @quit-requested
368 (ffi/should-close?)
369 (and auto-quit-ms (>= (- (now-ms) started) auto-quit-ms)))
370 (recur)))
371 (finally
372 (reset! b/loop-running? false)
373 (cancel-all!)
374 (ffi/close!)
375 (reset! handlers {}))))))
376
377;; --- looking at what was rendered --------------------------------------------
378(defn dump-str
379 "The rendered tree as hiccup text, read back out of the library.
380
381 With no argument, the whole window; with a node handle, that subtree. This is
382 the tree as it *is* after the reconciler has run, not what a component
383 returned, so paste it into a bug report and the two can be compared.
384
385 Two things to know when reading one. `:hbox` and `:vbox` are one node down
386 there and both dump as `:box`, with the orientation in the props; and no
387 `:on-*` appears, because handlers are held on this side and never sent."
388 ([] (dump-str 0))
389 ([node] (ffi/tree-dump node)))
390
391(defn dump
392 "`dump-str`, read back as hiccup data — vectors, keywords and maps — for a
393 test that wants to assert on a subtree rather than on a string."
394 ([] (dump 0))
395 ([node] (read-string (dump-str node))))
396
397(defn dump!
398 "Print `dump-str` to stdout. The one you want from a handler or the REPL."
399 ([] (dump! 0))
400 ([node] (println (dump-str node)) nil))
401
402;; --- the backend -------------------------------------------------------------
403(def backend
404 "The Vidya backend map handed to glimmer.backend/register!. See that
405 namespace for the contract each key satisfies."
406 {:name :vidya
407 :create! create!
408 :apply-props! apply-props!
409 :append-child! append-child!
410 :remove-child! remove-child!
411 :replace-child! replace-child!
412 :reorder-child! reorder-child!
413 :schedule schedule
414 :run run!})
415
416(defn install!
417 "Make Vidya/egui the backend glimmer renders with. Called on load, so
418 requiring this namespace is enough; exposed for code that wants to be
419 explicit, or to switch back after another backend was installed."
420 []
421 (b/register! backend)
422 nil)
423
424(defonce ^:private installed (do (install!) true))