nandi/jolt-nativepublic Fork 0
cfd3e3677bed92e80cfe447469a249cfdc0b1401
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 · 407 lines · 15.9 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
278(defn frame-rgba!
279 "Hand the backend a frame of live pixels under `key`. An `:image` node with
280 `:feed key` paints the latest one.
281
282 `rgba` is a foreign pointer `width * height * 4` un-premultiplied bytes,
283 row-major and is copied before this returns, so whoever owns it may reuse
284 it immediately. False when the length does not match the dimensions.
285
286 This is the source an `:image` `:src` cannot be: a `:src` decodes a file and
287 caches it by path for the life of the process, which is right for a picture
288 in a message and wrong for one that is new thirty times a second. Frames
289 coalesce rather than queue, so a source faster than the window costs nothing.
290
291 On the window's thread, like everything else here: a frame produced on a
292 decoder thread is the caller's to hand across."
293 [key width height rgba]
294 (ffi/frame-rgba! key width height rgba))
295
296(defn frame-drop!
297 "Forget the feed named `key` and release its texture; true when there was
298 one. A source that has stopped keeps painting its last frame otherwise."
299 [key]
300 (ffi/frame-drop! key))
301
302(defn quit!
303 "Stop the running loop and close the window."
304 []
305 (reset! quit-requested true)
306 nil)
307
308(defn- run!
309 "glimmer.backend's :run. Opens a window, mounts the root component into the
310 library's root node, and paints until the window closes or `quit!` is called.
311 Blocks, like every UI main loop.
312
313 Options (on top of glimmer's own):
314 :title :width :height the window
315 :fps frame rate cap (default 60)
316 :mode :dark (default) or :light
317 :font path to a TTF/OTF to use for UI text
318 :auto-quit-ms stop after roughly this long for smoke tests,
319 which have nobody to close the window
320
321 The window is closed in a finally, so a handler that throws does not leave a
322 GPU surface and an event loop behind."
323 [opts mount-root!]
324 (let [{:keys [title width height fps mode font auto-quit-ms]
325 :or {title "glimmer" width 900 height 640 fps 60}} opts]
326 (when-not (ffi/open! width height title)
327 (throw (ex-info "vidya could not open a window"
328 {:title title :width width :height height})))
329 (reset! quit-requested false)
330 (ffi/set-target-fps! fps)
331 (ffi/set-mode! (if (= mode :light) ffi/light-mode ffi/dark-mode))
332 (when font (ffi/load-font! font))
333 (let [started (now-ms)
334 root (ffi/tree-root)]
335 (try
336 ;; The library's root outlives a run — it is process-wide, not per
337 ;; window so a second `ui/run` in one session (a REPL, a test) would
338 ;; otherwise mount its tree alongside the last one's.
339 (clear-children! root)
340 (mount-root! root :window)
341 (reset! b/loop-running? true)
342 (loop []
343 (drain!)
344 (pump-timers!)
345 ;; One call paints the whole tree; the events it produced are read
346 ;; straight after, while the frame that caused them is still the
347 ;; frame the components rendered.
348 (ffi/tree-frame!)
349 (dispatch-events!)
350 (when-not (or @quit-requested
351 (ffi/should-close?)
352 (and auto-quit-ms (>= (- (now-ms) started) auto-quit-ms)))
353 (recur)))
354 (finally
355 (reset! b/loop-running? false)
356 (cancel-all!)
357 (ffi/close!)
358 (reset! handlers {}))))))
359
360;; --- looking at what was rendered --------------------------------------------
361(defn dump-str
362 "The rendered tree as hiccup text, read back out of the library.
363
364 With no argument, the whole window; with a node handle, that subtree. This is
365 the tree as it *is* after the reconciler has run, not what a component
366 returned, so paste it into a bug report and the two can be compared.
367
368 Two things to know when reading one. `:hbox` and `:vbox` are one node down
369 there and both dump as `:box`, with the orientation in the props; and no
370 `:on-*` appears, because handlers are held on this side and never sent."
371 ([] (dump-str 0))
372 ([node] (ffi/tree-dump node)))
373
374(defn dump
375 "`dump-str`, read back as hiccup data — vectors, keywords and maps — for a
376 test that wants to assert on a subtree rather than on a string."
377 ([] (dump 0))
378 ([node] (read-string (dump-str node))))
379
380(defn dump!
381 "Print `dump-str` to stdout. The one you want from a handler or the REPL."
382 ([] (dump! 0))
383 ([node] (println (dump-str node)) nil))
384
385;; --- the backend -------------------------------------------------------------
386(def backend
387 "The Vidya backend map handed to glimmer.backend/register!. See that
388 namespace for the contract each key satisfies."
389 {:name :vidya
390 :create! create!
391 :apply-props! apply-props!
392 :append-child! append-child!
393 :remove-child! remove-child!
394 :replace-child! replace-child!
395 :reorder-child! reorder-child!
396 :schedule schedule
397 :run run!})
398
399(defn install!
400 "Make Vidya/egui the backend glimmer renders with. Called on load, so
401 requiring this namespace is enough; exposed for code that wants to be
402 explicit, or to switch back after another backend was installed."
403 []
404 (b/register! backend)
405 nil)
406
407(defonce ^:private installed (do (install!) true))