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