nandi/jolt-nativepublic Fork 0
32fae8d36cadee8d58ff342979aea79722e69784
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 · 328 lines · 13.0 KBClojure Blame HistoryRaw
Write dvui's shape in jolt, on SDL3, with no shared object 109c7e4 Veronika Winters 10d 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]
43 [jvui.frames :as frames]))
Write dvui's shape in jolt, on SDL3, with no shared object 109c7e4 Veronika Winters 10d ago44
45;; --- the retained tree -------------------------------------------------------
46
47(defonce ^:private serial (atom 0))
48
49(defn- create! [tag props]
50 (atom {:tag tag :props props :children [] :key (swap! serial inc)}))
51
52(defn- apply-props! [_tag n props] (swap! n assoc :props props) nil)
53(defn- append-child! [_t parent child] (swap! parent update :children conj child) nil)
54(defn- remove-child! [_t parent child]
55 (swap! parent update :children #(vec (remove #{child} %))) nil)
56(defn- replace-child! [_t parent old new]
57 (swap! parent update :children #(mapv (fn [c] (if (= c old) new c)) %)) nil)
58(defn- reorder-child! [_t parent child sibling]
59 (swap! parent update :children
60 (fn [cs]
61 (let [cs (vec (remove #{child} cs))
62 i (if (nil? sibling) 0 (inc (.indexOf cs sibling)))]
63 (vec (concat (subvec cs 0 i) [child] (subvec cs i))))))
64 nil)
65
66;; --- props -------------------------------------------------------------------
67
68(defn- txt [props] (str (or (:label props) (:text props) "")))
69
70(defn- num [v default] (if (number? v) (double v) default))
71
72(defn- box-opts
73 "The container options shared by every container tag."
74 [props key]
75 (cond-> {:key key
76 :dir (if (= :horizontal (:orientation props)) :horizontal :vertical)}
77 (:spacing props) (assoc :spacing (num (:spacing props) 0.0))
78 (:padding props) (assoc :padding (num (:padding props) 0.0))
79 (:margin props) (assoc :margin (num (:margin props) 0.0))
80 (:expand props) (assoc :expand (:expand props))))
81
82(defn- fire! [n k & args]
83 (when-let [f (get (:props @n) k)] (apply f args)))
84
85;; --- the walk ----------------------------------------------------------------
86
87(def ^:dynamic *record-rects?*
88 "When true, each node keeps the rectangle jvui gave it, under `:rect`.
89
90 Off in a running window, where it would be a `swap!` per node per frame for
91 nobody's benefit. On under `render-once`, so a test can click the centre of
92 a button the way a person would, rather than guessing at a coordinate and
93 re-guessing every time a padding changes."
94 false)
95
96(declare emit!)
97
98(defn- record! [n id]
99 (when *record-rects?* (swap! n assoc :rect (c/rect-of id)))
100 nil)
101
102(defn- emit-children! [n]
103 (fn [_id _rect] (doseq [c (:children @n)] (emit! c))))
104
105(defn- emit!
106 "Render one node, and through it everything below it.
107
108 A widget answers what the person did to it, and that answer is turned back
109 into the callback prop the component registered — which is the whole seam
110 between an immediate-mode toolkit and a retained, callback-shaped one."
111 [n]
112 (let [{:keys [tag props key]} @n
113 s (txt props)]
114 (case tag
115 :page (w/page* (cond-> {:key key}
116 (:max-width props) (assoc :max-width (:max-width props)))
117 (emit-children! n))
118
119 (:card :frame) (w/card* (box-opts props key) (emit-children! n))
120
121 :scroll (w/scroll* (assoc (box-opts props key)
122 :height (num (:height props) 200.0))
123 (emit-children! n))
124
125 :hbox (c/box* (assoc (box-opts props key) :dir :horizontal)
126 (emit-children! n))
127
128 (:vbox :box) (c/box* (box-opts props key) (emit-children! n))
129
Give jvui a picture from somewhere else, and a clock 32fae8d nandi 9d ago130 ;; A live picture: a camera, a call, anything a decoder is filling in
131 ;; between frames. The node carries only the KEY — the pixels never go
132 ;; through the reconciler, because a video frame arrives when the
133 ;; network says so and a props diff at thirty a second would be a
134 ;; re-render per frame per peer.
135 :video (w/video (or (:feed props) (:key props) (str key))
136 (cond-> {}
137 (:size props) (assoc :size (:size props))
138 (:expand props) (assoc :expand (:expand props))
139 (:gravity props) (assoc :gravity (:gravity props))))
140
Write dvui's shape in jolt, on SDL3, with no shared object 109c7e4 Veronika Winters 10d ago141 :title (w/title s)
142
143 :label (if (:dim props) (w/dim-label s) (w/label s))
144 :dim-label (w/dim-label s)
145
146 :button (let [id (c/next-id key)
147 hit? (w/button s {:key key :kind (or (:kind props) :normal)})]
148 (record! n id)
149 (when hit? (fire! n :on-click)))
150
151 :checkbox (let [was (boolean (:checked props))
152 id (c/next-id key)
153 now (w/checkbox was s {:key key})]
154 (record! n id)
155 (when (not= now was) (fire! n :on-change now)))
156
157 :slider (let [was (num (:value props) 0.0)
158 id (c/next-id key)
159 now (w/slider was {:key key
160 :min (num (:min props) 0.0)
161 :max (num (:max props) 100.0)})]
162 ;; == and not not=, because a component holding a long 0 must
163 ;; not be told every frame that its slider moved to 0.0
164 (record! n id)
165 (when-not (== now was) (fire! n :on-change now)))
166
167 (:entry :text-entry)
168 (let [was (str (or (:value props) (:text props) ""))
169 id (c/next-id key)
170 now (w/text-entry was {:key key :placeholder (:placeholder props)})]
171 (record! n id)
172 (when (not= now was) (fire! n :on-change now)))
173
174 :progress (w/progress (num (:value props) 0.0))
175 :separator (w/separator)
176 (:spacer :gap) (w/spacer {:size (num (:size props) 8.0)
177 :expand (:expand props :none)})
178
179 ;; An unknown tag is a container rather than an error, so a tree written
180 ;; against a richer backend still shows its contents here — the same
181 ;; bargain jolt-zvui makes with the tags it does not know.
182 (c/box* (box-opts props key) (emit-children! n)))))
183
184;; --- the loop ----------------------------------------------------------------
185
186(defonce ^:private pending (atom []))
187
188(defn- schedule! [work] (swap! pending conj work) nil)
189
Give jvui a picture from somewhere else, and a clock 32fae8d nandi 9d ago190;; --- timers -----------------------------------------------------------------
191;; A client needs somewhere to run work that is not a reaction to anything:
192;; frq drives its whole media plane from `(every! 16 pump!)`, and a decoded
193;; frame arrives because a timer asked for it rather than because a person
194;; clicked. There is no other hook of the right shape — a component body runs
195;; when its state changes, which for a video feed is never.
196;;
197;; Run from the same `:before` as the reconciler's queue, and for the same
198;; reason: a callback that patches the tree must not do it mid-walk.
199
200(defonce ^:private timers (atom {}))
201(defonce ^:private next-timer (atom 0))
202
203(defn- now-ms [] (System/currentTimeMillis))
204
205(defn after!
206 "Run `f` once, at least `ms` from now. Answers a handle for `cancel!`."
207 [ms f]
208 (let [id (swap! next-timer inc)]
209 (swap! timers assoc id {:at (+ (now-ms) ms) :every nil :f f})
210 id))
211
212(defn every!
213 "Run `f` every `ms`. Answers a handle for `cancel!`.
214
215 Every `ms` AT MOST, not exactly: it fires on the first frame after the
216 deadline, so a 16ms timer on a 60Hz window runs once a frame and on a
217 slower one runs less often. That is the right failure — a timer that tried
218 to catch up would run twice in a row on a stutter, and for a pump that
219 means two frames decoded and one shown."
220 [ms f]
221 (let [id (swap! next-timer inc)]
222 (swap! timers assoc id {:at (+ (now-ms) ms) :every ms :f f})
223 id))
224
225(defn cancel!
226 "Stop a timer."
227 [id]
228 (swap! timers dissoc id)
229 nil)
230
231(defn- run-timers! []
232 (let [t (now-ms)
233 due (filter (fn [[_ v]] (<= (:at v) t)) @timers)]
234 (doseq [[id {:keys [every f]}] due]
235 (if every
236 (swap! timers assoc-in [id :at] (+ t every))
237 (swap! timers dissoc id))
238 ;; A throwing timer is cancelled rather than allowed to throw every
239 ;; frame for the rest of the session, which is unreadable and stops
240 ;; the ones behind it.
241 (try (f)
242 (catch Exception e
243 (swap! timers dissoc id)
244 (println "glimmer-jvui: timer failed, cancelled:" (ex-message e)))))))
245
Write dvui's shape in jolt, on SDL3, with no shared object 109c7e4 Veronika Winters 10d ago246(defn- drain-pending! []
Give jvui a picture from somewhere else, and a clock 32fae8d nandi 9d ago247 (run-timers!)
Write dvui's shape in jolt, on SDL3, with no shared object 109c7e4 Veronika Winters 10d ago248 (let [[ws] (reset-vals! pending [])]
249 (doseq [w ws] (w))))
250
251(defn- run!
252 "glimmer.backend's :run. Creates the root page, mounts into it, then hands the
253 loop to jvui.
254
255 The reconciler's queued work is drained by jvui's `:before` hook rather than
256 inside the walk: a re-render patches the tree, and patching a tree while it
257 is being walked is how a frame ends up half old and half new."
258 [opts mount-root!]
259 (let [{:keys [title width height max-width theme frames auto-quit-ms shot]
260 :or {title "glimmer" width 720 height 520}} opts
261 root (create! :page (cond-> {} max-width (assoc :max-width max-width)))]
262 (mount-root! root :page)
263 (reset! b/loop-running? true)
264 (try
265 (app/run! (fn [] (emit! root))
266 {:title title :width width :height height
267 :theme (or theme theme/dark)
268 :before drain-pending!
269 :frames frames :auto-quit-ms auto-quit-ms :shot shot})
270 (finally (reset! b/loop-running? false)))))
271
272;; --- registration ------------------------------------------------------------
273
274(def backend
275 {:name :jvui
276 :create! create! :apply-props! apply-props!
277 :append-child! append-child! :remove-child! remove-child!
278 :replace-child! replace-child! :reorder-child! reorder-child!
279 :schedule schedule! :run run!})
280
281(b/register! backend)
282
283;; --- headless driving, for tests ---------------------------------------------
284
285(defn root-node
286 "A bare root page, for mounting into without a window."
287 [] (create! :page {}))
288
289(defn render-once
290 "Walk `root` through jvui with no window, no font and no display.
291
292 `cx` is a `jvui.core/context`; `evs` the events that frame. Answers the
293 context, whose `:data` is every rectangle the walk placed — which is enough
294 for a test to assert about a layout and to click on it."
295 ([root cx] (render-once root cx []))
296 ([root cx evs]
297 (drain-pending!)
298 (swap! cx assoc :events evs)
299 (swap! cx c/apply-input evs)
300 (binding [*record-rects?* true]
301 (c/frame! cx (fn [] (emit! root))))
302 cx))
Give jvui a picture from somewhere else, and a clock 32fae8d nandi 9d ago303
304;; --- feeds ------------------------------------------------------------------
305;; The same three calls glimmer-vidya exposes, so a client that paints a call
306;; does not care which backend is under it. They are not part of the
307;; reconciler and deliberately so: pixels arrive between frames, and the tree
308;; only ever holds the key.
309
310(defn frame-rgba!
311 "Hand feed `key` a new picture: `w` by `h` RGBA at FOREIGN pointer `px`.
312
313 The pointer is read during this call and not kept, so a caller may reuse
314 or free it immediately afterwards — which is what a decoder handing out a
315 borrowed buffer needs."
316 [key w h px]
317 (frames/put! key w h px))
318
319(defn frame-drop!
320 "Forget a feed and release its texture — someone left, or turned a camera
321 off."
322 [key]
323 (frames/drop! key))
324
325(defn feed-keys
326 "Every feed with a picture."
327 []
328 (frames/keys*))