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