nandi/jolt-nativepublic Fork 0
c03a75281355a14d95e6e810f66f52d4ac6d4977
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 · 412 lines · 17.0 KBClojure Blame HistoryRaw
Write dvui's shape in jolt, on SDL3, with no shared object 109c7e4 Veronika Winters 9d ago1(ns glimmer-jvui.core
2 "A glimmer backend that renders through [jvui](../../../jvui).
3
4 glimmer owns the reactive core — ratoms, components, the reconciler — and
5 knows nothing about any toolkit. glimmer-gtk fills that in with GtkWidgets,
6 glimmer-vidya with a Rust node arena painted by egui, glimmer-gfx with a
7 software rasterizer it writes itself. This fills it in with jvui, and so is
8 the smallest of the four: everything a backend usually has to supply — the
9 measuring, the placing, the hit testing, the painting — is already a toolkit
10 one directory over.
11
12 What is left is the half an immediate-mode library does not have: a tree to
13 hold still between frames. The reconciler needs somewhere to put a widget it
14 created and to append a child to, and jvui's widgets draw and return within
15 one call. So a node here is an atom of {:tag :props :children :key}, about
16 thirty lines of it, and once a frame `emit!` walks that tree and calls the
17 jvui widget each node names.
18
19 # The walk is the closure
20
21 glimmer-vidya's README explains why its tree lives in Rust: `ScrollArea` and
22 `Frame` take an `FnOnce(&mut Ui)` and keep their begin/end private, so a
23 push/pop ABI cannot scroll a page. jvui's containers take a body function
24 for the same reason, and here the recursion *is* that function — `emit!` on
25 a container passes `emit-children!` as the body, and the nesting takes care
26 of itself.
27
28 # Why every node carries a key
29
30 jvui identifies a widget by its parent and its index among its siblings,
31 unless it is given a `:key`, which replaces the index. A reconciler reorders
32 children; an identity built on the index would hand each widget after the
33 moved one the caret, the scroll offset and the drag of whichever widget used
34 to sit at its index. So every node gets a serial number at creation and
35 passes it as its key, and the identity follows the node rather than its
36 position. That is the bug class zvui's README describes from the backend
37 side, closed here at the other end."
38 (:require [glimmer.backend :as b]
39 [jvui.app :as app]
40 [jvui.core :as c]
41 [jvui.theme :as theme]
Give jvui a picture from somewhere else, and a clock 32fae8d nandi 9d ago42 [jvui.widgets :as w]
Answer the rest of what a client asks its window 0b92f67 nandi 9d ago43 [jvui.frames :as frames]
44 [jvui.host :as host]))
Write dvui's shape in jolt, on SDL3, with no shared object 109c7e4 Veronika Winters 9d ago45
46;; --- the retained tree -------------------------------------------------------
47
48(defonce ^:private serial (atom 0))
49
50(defn- create! [tag props]
51 (atom {:tag tag :props props :children [] :key (swap! serial inc)}))
52
53(defn- apply-props! [_tag n props] (swap! n assoc :props props) nil)
54(defn- append-child! [_t parent child] (swap! parent update :children conj child) nil)
55(defn- remove-child! [_t parent child]
56 (swap! parent update :children #(vec (remove #{child} %))) nil)
57(defn- replace-child! [_t parent old new]
58 (swap! parent update :children #(mapv (fn [c] (if (= c old) new c)) %)) nil)
59(defn- reorder-child! [_t parent child sibling]
60 (swap! parent update :children
61 (fn [cs]
62 (let [cs (vec (remove #{child} cs))
63 i (if (nil? sibling) 0 (inc (.indexOf cs sibling)))]
64 (vec (concat (subvec cs 0 i) [child] (subvec cs i))))))
65 nil)
66
67;; --- props -------------------------------------------------------------------
68
69(defn- txt [props] (str (or (:label props) (:text props) "")))
70
71(defn- num [v default] (if (number? v) (double v) default))
72
73(defn- box-opts
74 "The container options shared by every container tag."
75 [props key]
76 (cond-> {:key key
77 :dir (if (= :horizontal (:orientation props)) :horizontal :vertical)}
78 (:spacing props) (assoc :spacing (num (:spacing props) 0.0))
79 (:padding props) (assoc :padding (num (:padding props) 0.0))
80 (:margin props) (assoc :margin (num (:margin props) 0.0))
81 (:expand props) (assoc :expand (:expand props))))
82
83(defn- fire! [n k & args]
84 (when-let [f (get (:props @n) k)] (apply f args)))
85
86;; --- the walk ----------------------------------------------------------------
87
88(def ^:dynamic *record-rects?*
89 "When true, each node keeps the rectangle jvui gave it, under `:rect`.
90
91 Off in a running window, where it would be a `swap!` per node per frame for
92 nobody's benefit. On under `render-once`, so a test can click the centre of
93 a button the way a person would, rather than guessing at a coordinate and
94 re-guessing every time a padding changes."
95 false)
96
97(declare emit!)
98
99(defn- record! [n id]
100 (when *record-rects?* (swap! n assoc :rect (c/rect-of id)))
101 nil)
102
103(defn- emit-children! [n]
104 (fn [_id _rect] (doseq [c (:children @n)] (emit! c))))
105
106(defn- emit!
107 "Render one node, and through it everything below it.
108
109 A widget answers what the person did to it, and that answer is turned back
110 into the callback prop the component registered — which is the whole seam
111 between an immediate-mode toolkit and a retained, callback-shaped one."
112 [n]
113 (let [{:keys [tag props key]} @n
114 s (txt props)]
115 (case tag
116 :page (w/page* (cond-> {:key key}
117 (:max-width props) (assoc :max-width (:max-width props)))
118 (emit-children! n))
119
120 (:card :frame) (w/card* (box-opts props key) (emit-children! n))
121
122 :scroll (w/scroll* (assoc (box-opts props key)
123 :height (num (:height props) 200.0))
124 (emit-children! n))
125
126 :hbox (c/box* (assoc (box-opts props key) :dir :horizontal)
127 (emit-children! n))
128
129 (:vbox :box) (c/box* (box-opts props key) (emit-children! n))
130
Take the eight tags a client still had nowhere to put d5dfd53 nandi 9d ago131 ;; ONE tag for both kinds of picture: `:feed` is live pixels pushed
132 ;; in under a name and re-uploaded as they arrive, `:src` is a file
133 ;; decoded once and kept by path. Everything downstream — the fit, the
134 ;; bounds, the click — is the same, which is why libvidya makes this a
135 ;; prop and not a second tag, and why frq writes [:image {:feed k}]
136 ;; for a call tile and [:image {:src p}] for an attachment.
137 ;;
138 ;; The pixels never go through the reconciler either way: a frame
139 ;; arrives when the network says so, and a props diff at thirty a
140 ;; second would be a re-render per frame per peer.
141 :image (let [id (c/next-id key)
142 rect (w/image {:feed (:feed props) :src (:src props)}
143 {:fit (:fit props)
144 :max-width (:max-width props)
145 :max-height (:max-height props)
146 :size (:size props)
147 :expand (:expand props)})]
148 (record! n id)
149 (when (:clicked? (c/interact! id rect)) (fire! n :on-click)))
150
151 :title-2 (w/title-2 s)
152
153 :status (w/status s (boolean (:live props)))
154
155 :spinner (w/spinner s)
156
157 :link (let [id (c/next-id key)]
158 (record! n id)
159 (when (w/link s {:key key}) (fire! n :on-click)))
160
161 :emoji (w/emoji (or (:emoji props) s) (:size props))
162
163 :avatar (w/avatar (or (:label props) s)
164 (cond-> {}
165 (:src props) (assoc :src (:src props))
166 (:size props) (assoc :size (:size props))))
167
168 :reaction (let [id (c/next-id key)
169 glyph (or (:emoji props) s)]
170 (record! n id)
171 (when (w/reaction glyph {:count (or (:count props) 0)
172 :mine? (boolean (:mine props))
173 :size (:size props)
174 :key key})
175 (fire! n :on-click)))
Give jvui a picture from somewhere else, and a clock 32fae8d nandi 9d ago176
Write dvui's shape in jolt, on SDL3, with no shared object 109c7e4 Veronika Winters 9d ago177 :title (w/title s)
178
179 :label (if (:dim props) (w/dim-label s) (w/label s))
180 :dim-label (w/dim-label s)
181
182 :button (let [id (c/next-id key)
183 hit? (w/button s {:key key :kind (or (:kind props) :normal)})]
184 (record! n id)
185 (when hit? (fire! n :on-click)))
186
Take :checkbutton, which is :checkbox under another name 26defff nandi 9d ago187 ;; :checkbutton is the same widget under GTK's name for it, which is
188 ;; what libvidya calls it too — `"checkbutton" | "checkbox"` is one
189 ;; arm of its tag table. frq writes both.
Read :active, and keep a field's text inside the field c131fc6 nandi 9d ago190 ;; :active is what frq and libvidya call it — `props.bool("active")`
191 ;; in libvidya's tag table — and :checked is what this backend called
192 ;; it first. Both are read, because a client written against either
193 ;; should not render a permanently empty tick; :active wins where
194 ;; both appear.
195 ;;
196 ;; Likewise both events fire. libvidya emits "toggled"; :on-change is
197 ;; what the checkbox here answered to before.
198 (:checkbox :checkbutton)
199 (let [was (boolean (if (contains? props :active)
200 (:active props)
201 (:checked props)))
202 id (c/next-id key)
203 now (w/checkbox was s {:key key})]
204 (record! n id)
205 (when (not= now was)
206 (fire! n :on-toggled now)
207 (fire! n :on-change now)))
Write dvui's shape in jolt, on SDL3, with no shared object 109c7e4 Veronika Winters 9d ago208
209 :slider (let [was (num (:value props) 0.0)
210 id (c/next-id key)
211 now (w/slider was {:key key
212 :min (num (:min props) 0.0)
213 :max (num (:max props) 100.0)})]
214 ;; == and not not=, because a component holding a long 0 must
215 ;; not be told every frame that its slider moved to 0.0
216 (record! n id)
217 (when-not (== now was) (fire! n :on-change now)))
218
219 (:entry :text-entry)
220 (let [was (str (or (:value props) (:text props) ""))
221 id (c/next-id key)
Report Enter from a field, and take its width request c03a752 nandi 9d ago222 now (w/text-entry was {:key key
223 :placeholder (:placeholder props)
224 ;; :width-request is what frq and
225 ;; libvidya call a minimum width;
226 ;; :hexpand says take the rest of the
227 ;; row, which is this widget's default.
228 :min-width (:width-request props)
229 :expand (if (false? (:hexpand props))
230 :none :horizontal)})]
Write dvui's shape in jolt, on SDL3, with no shared object 109c7e4 Veronika Winters 9d ago231 (record! n id)
Report Enter from a field, and take its width request c03a752 nandi 9d ago232 (when (not= now was) (fire! n :on-change now))
233 ;; Enter, which a field must not swallow as input: frq sends its
234 ;; message on it, and without this the compose box accepted text
235 ;; and had no way to say it was finished.
236 (when (w/entry-activated? id) (fire! n :on-activate now)))
Write dvui's shape in jolt, on SDL3, with no shared object 109c7e4 Veronika Winters 9d ago237
238 :progress (w/progress (num (:value props) 0.0))
239 :separator (w/separator)
240 (:spacer :gap) (w/spacer {:size (num (:size props) 8.0)
241 :expand (:expand props :none)})
242
243 ;; An unknown tag is a container rather than an error, so a tree written
244 ;; against a richer backend still shows its contents here — the same
245 ;; bargain jolt-zvui makes with the tags it does not know.
246 (c/box* (box-opts props key) (emit-children! n)))))
247
248;; --- the loop ----------------------------------------------------------------
249
250(defonce ^:private pending (atom []))
251
252(defn- schedule! [work] (swap! pending conj work) nil)
253
Give jvui a picture from somewhere else, and a clock 32fae8d nandi 9d ago254;; --- timers -----------------------------------------------------------------
255;; A client needs somewhere to run work that is not a reaction to anything:
256;; frq drives its whole media plane from `(every! 16 pump!)`, and a decoded
257;; frame arrives because a timer asked for it rather than because a person
258;; clicked. There is no other hook of the right shape — a component body runs
259;; when its state changes, which for a video feed is never.
260;;
261;; Run from the same `:before` as the reconciler's queue, and for the same
262;; reason: a callback that patches the tree must not do it mid-walk.
263
264(defonce ^:private timers (atom {}))
265(defonce ^:private next-timer (atom 0))
266
267(defn- now-ms [] (System/currentTimeMillis))
268
269(defn after!
270 "Run `f` once, at least `ms` from now. Answers a handle for `cancel!`."
271 [ms f]
272 (let [id (swap! next-timer inc)]
273 (swap! timers assoc id {:at (+ (now-ms) ms) :every nil :f f})
274 id))
275
276(defn every!
277 "Run `f` every `ms`. Answers a handle for `cancel!`.
278
279 Every `ms` AT MOST, not exactly: it fires on the first frame after the
280 deadline, so a 16ms timer on a 60Hz window runs once a frame and on a
281 slower one runs less often. That is the right failure — a timer that tried
282 to catch up would run twice in a row on a stutter, and for a pump that
283 means two frames decoded and one shown."
284 [ms f]
285 (let [id (swap! next-timer inc)]
286 (swap! timers assoc id {:at (+ (now-ms) ms) :every ms :f f})
287 id))
288
289(defn cancel!
290 "Stop a timer."
291 [id]
292 (swap! timers dissoc id)
293 nil)
294
295(defn- run-timers! []
296 (let [t (now-ms)
297 due (filter (fn [[_ v]] (<= (:at v) t)) @timers)]
298 (doseq [[id {:keys [every f]}] due]
299 (if every
300 (swap! timers assoc-in [id :at] (+ t every))
301 (swap! timers dissoc id))
302 ;; A throwing timer is cancelled rather than allowed to throw every
303 ;; frame for the rest of the session, which is unreadable and stops
304 ;; the ones behind it.
305 (try (f)
306 (catch Exception e
307 (swap! timers dissoc id)
308 (println "glimmer-jvui: timer failed, cancelled:" (ex-message e)))))))
309
Write dvui's shape in jolt, on SDL3, with no shared object 109c7e4 Veronika Winters 9d ago310(defn- drain-pending! []
Give jvui a picture from somewhere else, and a clock 32fae8d nandi 9d ago311 (run-timers!)
Write dvui's shape in jolt, on SDL3, with no shared object 109c7e4 Veronika Winters 9d ago312 (let [[ws] (reset-vals! pending [])]
313 (doseq [w ws] (w))))
314
315(defn- run!
316 "glimmer.backend's :run. Creates the root page, mounts into it, then hands the
317 loop to jvui.
318
319 The reconciler's queued work is drained by jvui's `:before` hook rather than
320 inside the walk: a re-render patches the tree, and patching a tree while it
321 is being walked is how a frame ends up half old and half new."
322 [opts mount-root!]
323 (let [{:keys [title width height max-width theme frames auto-quit-ms shot]
324 :or {title "glimmer" width 720 height 520}} opts
325 root (create! :page (cond-> {} max-width (assoc :max-width max-width)))]
326 (mount-root! root :page)
327 (reset! b/loop-running? true)
328 (try
329 (app/run! (fn [] (emit! root))
330 {:title title :width width :height height
331 :theme (or theme theme/dark)
332 :before drain-pending!
333 :frames frames :auto-quit-ms auto-quit-ms :shot shot})
334 (finally (reset! b/loop-running? false)))))
335
336;; --- registration ------------------------------------------------------------
337
338(def backend
339 {:name :jvui
340 :create! create! :apply-props! apply-props!
341 :append-child! append-child! :remove-child! remove-child!
342 :replace-child! replace-child! :reorder-child! reorder-child!
343 :schedule schedule! :run run!})
344
345(b/register! backend)
346
347;; --- headless driving, for tests ---------------------------------------------
348
349(defn root-node
350 "A bare root page, for mounting into without a window."
351 [] (create! :page {}))
352
353(defn render-once
354 "Walk `root` through jvui with no window, no font and no display.
355
356 `cx` is a `jvui.core/context`; `evs` the events that frame. Answers the
357 context, whose `:data` is every rectangle the walk placed — which is enough
358 for a test to assert about a layout and to click on it."
359 ([root cx] (render-once root cx []))
360 ([root cx evs]
361 (drain-pending!)
362 (swap! cx assoc :events evs)
363 (swap! cx c/apply-input evs)
364 (binding [*record-rects?* true]
365 (c/frame! cx (fn [] (emit! root))))
366 cx))
Give jvui a picture from somewhere else, and a clock 32fae8d nandi 9d ago367
368;; --- feeds ------------------------------------------------------------------
369;; The same three calls glimmer-vidya exposes, so a client that paints a call
370;; does not care which backend is under it. They are not part of the
371;; reconciler and deliberately so: pixels arrive between frames, and the tree
372;; only ever holds the key.
373
374(defn frame-rgba!
375 "Hand feed `key` a new picture: `w` by `h` RGBA at FOREIGN pointer `px`.
376
377 The pointer is read during this call and not kept, so a caller may reuse
378 or free it immediately afterwards — which is what a decoder handing out a
379 borrowed buffer needs."
380 [key w h px]
381 (frames/put! key w h px))
382
383(defn frame-drop!
384 "Forget a feed and release its texture — someone left, or turned a camera
385 off."
386 [key]
387 (frames/drop! key))
388
389(defn feed-keys
390 "Every feed with a picture."
391 []
392 (frames/keys*))
Answer the rest of what a client asks its window 0b92f67 nandi 9d ago393
394;; --- the platform -----------------------------------------------------------
395;; The rest of what glimmer-vidya answers, so a client can ask its backend
396;; about the window it is in without knowing which backend that is. Thin on
397;; purpose: every one of these is jvui.host, and the indirection exists so
398;; the client requires one namespace rather than two.
399
400(def set-title! host/set-title!)
401(def window-width host/window-width)
402(def screen-size host/screen-size)
403(def quit! host/quit!)
404(def open-url! host/open-url!)
405(def clipboard-image-png! host/clipboard-image-png!)
406
407;; False and nil on a desktop, which is the right answer rather than a gap:
408;; the chooser exists so a phone can hand back a grant for one picture, and
409;; a caller reads the false and offers a file browser instead. glimmer-vidya
410;; says the same thing here.
411(def pick-image! host/pick-image!)
412(def picked-image! host/picked-image!)