nandi/jolt-nativepublic Fork 0
7703c757d45b22224a0423d7b0dc4699a8ca2f9e
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 · 613 lines · 27.8 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
Fill the window's height, and find a glyph the UI font has not got 7d907d0 nandi 9d ago73(defn- fills-height?
74 "Does any child of `n` ask to fill the height?
75
76 A row is only as tall as what is in it, and frq marks the PANES with
77 :fill-height rather than the row that holds them — egui gives a
78 horizontal layout the available height and the panes fill that, so
79 there is nothing there to mark. Here the row has to be told, and its
80 own children are what know: a row holding something that wants the
81 height wants the height.
82
83 Asked of the tree rather than inferred from the layout, because the
84 layout answers a frame too late — a row that learns it should be tall
85 from what happened last frame is a row that is the wrong height on
86 the frame anybody looks at."
87 [n]
88 (boolean (some #(:fill-height (:props (deref %))) (:children (deref n)))))
89
Write dvui's shape in jolt, on SDL3, with no shared object 109c7e4 Veronika Winters 9d ago90(defn- box-opts
91 "The container options shared by every container tag."
Fill the window's height, and find a glyph the UI font has not got 7d907d0 nandi 9d ago92 ([props key] (box-opts props key false))
93 ([props key fill-height?]
Write dvui's shape in jolt, on SDL3, with no shared object 109c7e4 Veronika Winters 9d ago94 (cond-> {:key key
Let a container fill its parent, and report "end" as the word e72d7b0 nandi 9d ago95 :dir (if (= :horizontal (:orientation props)) :horizontal :vertical)
96 ;; A CONTAINER fills its parent's cross axis by default. Without
97 ;; this every box shrink-wraps its children, and frq's chat
98 ;; column came out a couple of hundred points wide in a
99 ;; five-hundred-point window with every message wrapped to
100 ;; match — the tree is nested boxes, and each one only as wide
101 ;; as what is in it.
102 ;;
103 ;; :cross and not :horizontal: in a ROW, :horizontal means take
104 ;; a share of the slack, and a line of buttons would stretch to
105 ;; fill the window.
106 :expand :cross}
Let the pane that fills take the room, and a viewport the height it is in 5fcbb42 nandi 9d ago107 ;; :fill-height is frq's way of saying "this is the pane that takes
108 ;; what is left". It is the messages column in the row that also
109 ;; holds the people panel, and without it that column claims no slack
110 ;; at all — the backlog ends up as wide as the widest message and the
111 ;; scrollbar sits in the middle of the window.
112 ;;
113 ;; :both rather than :vertical, despite the name: in a ROW the space
114 ;; to be taken is horizontal, and a pane that fills the height of a
115 ;; row it does not fill the width of is not what anyone means by it.
116 ;; The panes that do NOT ask for it stay :cross and keep their own
117 ;; size, which is what leaves the slack to be taken.
Fill the window's height, and find a glyph the UI font has not got 7d907d0 nandi 9d ago118 (or (:fill-height props) fill-height?) (assoc :expand :both)
Let the pane that fills take the room, and a viewport the height it is in 5fcbb42 nandi 9d ago119 ;; A minimum, not a size: the messages column asks for one only while
120 ;; the people panel is beside it.
121 (:width-request props)
122 (assoc :min-size [(num (:width-request props) 0.0) 0.0])
Write dvui's shape in jolt, on SDL3, with no shared object 109c7e4 Veronika Winters 9d ago123 (:spacing props) (assoc :spacing (num (:spacing props) 0.0))
124 (:padding props) (assoc :padding (num (:padding props) 0.0))
125 (:margin props) (assoc :margin (num (:margin props) 0.0))
Fire :on-activate with nothing, wrap a row, and break a word that cannot fit 2271a91 nandi 9d ago126 (:expand props) (assoc :expand (:expand props))
127 ;; A row whose children start a new line when they run out of room —
128 ;; a line of reaction pills is the case that needs it.
129 (:wrap props) (assoc :wrap true)
130 ;; Cross-axis placement: :start :center :end, as a gravity.
131 (:align props) (assoc :gravity (case (:align props)
132 (:center "center") [0.0 0.5]
133 (:end "end") [0.0 1.0]
Fill the window's height, and find a glyph the UI font has not got 7d907d0 nandi 9d ago134 [0.0 0.0])))))
Write dvui's shape in jolt, on SDL3, with no shared object 109c7e4 Veronika Winters 9d ago135
136(defn- fire! [n k & args]
137 (when-let [f (get (:props @n) k)] (apply f args)))
138
139;; --- the walk ----------------------------------------------------------------
140
141(def ^:dynamic *record-rects?*
142 "When true, each node keeps the rectangle jvui gave it, under `:rect`.
143
144 Off in a running window, where it would be a `swap!` per node per frame for
145 nobody's benefit. On under `render-once`, so a test can click the centre of
146 a button the way a person would, rather than guessing at a coordinate and
147 re-guessing every time a padding changes."
148 false)
149
150(declare emit!)
151
152(defn- record! [n id]
153 (when *record-rects?* (swap! n assoc :rect (c/rect-of id)))
154 nil)
155
wip hover card 65272e3 nandi 9d ago156(def ^:private hovering
157 "Which widgets the pointer was on last frame.
158
159 The toolkit answers `:hover?` as a state — the pointer is over this
160 rectangle — and a component wants the two EVENTS at its edges. The
161 difference is a set, and it is kept here rather than on the node because
162 a node is replaced by the reconciler and the pointer has not moved."
163 (atom #{}))
164
165(defn- hover!
166 "Turn `over?` into on-hover and on-unhover, once each per crossing.
167
168 Only on the pass that paints: hover is derived from where the pointer is
169 rather than delivered as an event, so it is true on the settling passes
170 too, and a handler called from one of those fires two or three times for
171 one crossing."
172 [n id over?]
173 (when (c/draw-pass?)
174 (let [was (contains? @hovering id)]
175 (cond
176 (and over? (not was)) (do (swap! hovering conj id) (fire! n :on-hover))
177 (and was (not over?)) (do (swap! hovering disj id) (fire! n :on-unhover))))))
178
Walk only the rows a scroll area can show b48e404 nandi 8d ago179(def ^:private skippable
180 "The tags whose jvui box is keyed by the node's own key, so `c/skip-box!`
181 can find what that box remembered."
182 #{:vbox :box :hbox :card :frame})
183
Bring a :scroll-here row into view e0d9029 nandi 8d ago184(defn- emit-children!
185 "The body a container is walked with.
186
187 `:scroll-here` rides along on any container: while it is set, the scroll
188 area around the node is asked to bring it into view. frq sets it for the
189 moment of a jump to a message and takes it off again — left on, it would
190 pin the list there and take scrolling away from the reader."
191 [n]
192 (let [here? (:scroll-here (:props @n))]
193 (fn [_id rect]
194 (when here? (w/reveal! rect))
Walk only the rows a scroll area can show b48e404 nandi 8d ago195 (doseq [c (:children @n)]
196 (let [{:keys [tag props key]} @c]
197 ;; A container scrolled out of sight is not walked at all, only
198 ;; counted at the size it had — see `c/skip-box!`. A backlog is
199 ;; thousands of rows and a window shows twenty; walking the rest
200 ;; every frame was what made a long channel slow to answer a click.
201 ;;
202 ;; Never the one a jump is aiming at: it is off screen by
203 ;; definition, and skipping it would skip the ask to be shown.
204 (when-not (and (contains? skippable tag)
205 (not (:scroll-here props))
206 (c/skip-box! key))
207 (emit! c)))))))
Write dvui's shape in jolt, on SDL3, with no shared object 109c7e4 Veronika Winters 9d ago208
209(defn- emit!
210 "Render one node, and through it everything below it.
211
212 A widget answers what the person did to it, and that answer is turned back
213 into the callback prop the component registered — which is the whole seam
214 between an immediate-mode toolkit and a retained, callback-shaped one."
215 [n]
216 (let [{:keys [tag props key]} @n
217 s (txt props)]
218 (case tag
219 :page (w/page* (cond-> {:key key}
220 (:max-width props) (assoc :max-width (:max-width props)))
221 (emit-children! n))
222
223 (:card :frame) (w/card* (box-opts props key) (emit-children! n))
224
Make a list follow what arrives in it 95540ef nandi 9d ago225 ;; A list that follows what arrives in it. Everything here beyond
226 ;; :height is a prop frq writes and this used to drop on the floor —
227 ;; the chat did not follow new messages, and switching channels
228 ;; carried the previous one's scroll across.
Fill the window's height, and find a glyph the UI font has not got 7d907d0 nandi 9d ago229 ;; :expand is forced rather than left to box-opts, whose default is
230 ;; :cross — and a viewport that fills only the width asks its column
231 ;; for no height, is given none, and shows nothing at all. It is the
232 ;; one container that always fills both ways.
233 :scroll (w/scroll* (cond-> (assoc (box-opts props key) :expand :both)
Make a list follow what arrives in it 95540ef nandi 9d ago234 (:height props)
235 (assoc :height (num (:height props) 200.0))
236 (:reserve props)
237 (assoc :reserve (num (:reserve props) 0.0))
238 (:scroll-key props)
239 (assoc :scroll-key (str (:scroll-key props)))
240 (:stick-to-bottom props)
241 (assoc :stick-to-bottom true)
242 (:scroll-to-bottom props)
243 (assoc :scroll-to-bottom (num (:scroll-to-bottom props) 0.0))
Let a container fill its parent, and report "end" as the word e72d7b0 nandi 9d ago244 ;; "end" or "away", the STRING libvidya emits —
245 ;; frq's handler is (= "end" %) and a boolean
246 ;; makes it permanently false.
247 ;;
248 ;; :on-scroll is deliberately not fired here.
249 ;; It is the channel for backends that report an
250 ;; OFFSET rather than a place — the terminal's —
251 ;; and frq turns one into the other with
252 ;; `scrolled!`. A window that reports where it
253 ;; ended up has nothing to say on it.
254 (:on-change props)
Make a list follow what arrives in it 95540ef nandi 9d ago255 (assoc :on-at-end
256 (fn [at-end?]
Let a container fill its parent, and report "end" as the word e72d7b0 nandi 9d ago257 (fire! n :on-change (if at-end? "end" "away")))))
Write dvui's shape in jolt, on SDL3, with no shared object 109c7e4 Veronika Winters 9d ago258 (emit-children! n))
259
Fill the window's height, and find a glyph the UI font has not got 7d907d0 nandi 9d ago260 :hbox (c/box* (assoc (box-opts props key (fills-height? n)) :dir :horizontal)
Write dvui's shape in jolt, on SDL3, with no shared object 109c7e4 Veronika Winters 9d ago261 (emit-children! n))
262
Fill the window's height, and find a glyph the UI font has not got 7d907d0 nandi 9d ago263 (:vbox :box) (c/box* (box-opts props key (fills-height? n))
264 (emit-children! n))
Write dvui's shape in jolt, on SDL3, with no shared object 109c7e4 Veronika Winters 9d ago265
Take the eight tags a client still had nowhere to put d5dfd53 nandi 9d ago266 ;; ONE tag for both kinds of picture: `:feed` is live pixels pushed
267 ;; in under a name and re-uploaded as they arrive, `:src` is a file
268 ;; decoded once and kept by path. Everything downstream — the fit, the
269 ;; bounds, the click — is the same, which is why libvidya makes this a
270 ;; prop and not a second tag, and why frq writes [:image {:feed k}]
271 ;; for a call tile and [:image {:src p}] for an attachment.
272 ;;
273 ;; The pixels never go through the reconciler either way: a frame
274 ;; arrives when the network says so, and a props diff at thirty a
275 ;; second would be a re-render per frame per peer.
276 :image (let [id (c/next-id key)
277 rect (w/image {:feed (:feed props) :src (:src props)}
278 {:fit (:fit props)
279 :max-width (:max-width props)
280 :max-height (:max-height props)
281 :size (:size props)
282 :expand (:expand props)})]
283 (record! n id)
284 (when (:clicked? (c/interact! id rect)) (fire! n :on-click)))
285
286 :title-2 (w/title-2 s)
287
288 :status (w/status s (boolean (:live props)))
289
290 :spinner (w/spinner s)
291
292 :link (let [id (c/next-id key)]
293 (record! n id)
294 (when (w/link s {:key key}) (fire! n :on-click)))
295
296 :emoji (w/emoji (or (:emoji props) s) (:size props))
297
298 :avatar (w/avatar (or (:label props) s)
299 (cond-> {}
300 (:src props) (assoc :src (:src props))
301 (:size props) (assoc :size (:size props))))
302
303 :reaction (let [id (c/next-id key)
wip hover card 65272e3 nandi 9d ago304 glyph (or (:emoji props) s)
305 r (w/reaction glyph {:count (or (:count props) 0)
Take the eight tags a client still had nowhere to put d5dfd53 nandi 9d ago306 :mine? (boolean (:mine props))
307 :size (:size props)
wip hover card 65272e3 nandi 9d ago308 :key key})]
309 (record! n id)
310 (when (:clicked? r) (fire! n :on-click))
311 (hover! n id (:hover? r))
312 ;; Whatever the client hung under the pill is its hover
313 ;; card, and a card is drawn over the row rather than in
Draw a chip.s card because it has one, not because of the pointer db7ce9f nandi 9d ago314 ;; it — see c/overlay!. Just under the pill, which is
315 ;; where a pointer resting on the pill is not.
316 ;;
317 ;; On having children and not on :hover?. The client is
318 ;; the one that knows whether a card is wanted — it is
319 ;; already deciding, since it is what puts the child there
320 ;; — and a backend that asked the question a second time
321 ;; would be answering a pointer the client may not be
322 ;; tracking with a pointer of its own.
323 (when (seq (:children @n))
wip hover card 65272e3 nandi 9d ago324 (let [[x y _ h] (:rect r)]
325 (c/overlay! id [x (+ y h 4.0)]
326 #(w/card* {:expand :none :key id}
327 (emit-children! n))))))
Give jvui a picture from somewhere else, and a clock 32fae8d nandi 9d ago328
Write dvui's shape in jolt, on SDL3, with no shared object 109c7e4 Veronika Winters 9d ago329 :title (w/title s)
330
331 :label (if (:dim props) (w/dim-label s) (w/label s))
332 :dim-label (w/dim-label s)
333
334 :button (let [id (c/next-id key)
335 hit? (w/button s {:key key :kind (or (:kind props) :normal)})]
336 (record! n id)
337 (when hit? (fire! n :on-click)))
338
Take :checkbutton, which is :checkbox under another name 26defff nandi 9d ago339 ;; :checkbutton is the same widget under GTK's name for it, which is
340 ;; what libvidya calls it too — `"checkbutton" | "checkbox"` is one
341 ;; arm of its tag table. frq writes both.
Read :active, and keep a field's text inside the field c131fc6 nandi 9d ago342 ;; :active is what frq and libvidya call it — `props.bool("active")`
343 ;; in libvidya's tag table — and :checked is what this backend called
344 ;; it first. Both are read, because a client written against either
345 ;; should not render a permanently empty tick; :active wins where
346 ;; both appear.
347 ;;
348 ;; Likewise both events fire. libvidya emits "toggled"; :on-change is
349 ;; what the checkbox here answered to before.
350 (:checkbox :checkbutton)
351 (let [was (boolean (if (contains? props :active)
352 (:active props)
353 (:checked props)))
354 id (c/next-id key)
355 now (w/checkbox was s {:key key})]
356 (record! n id)
357 (when (not= now was)
358 (fire! n :on-toggled now)
359 (fire! n :on-change now)))
Write dvui's shape in jolt, on SDL3, with no shared object 109c7e4 Veronika Winters 9d ago360
361 :slider (let [was (num (:value props) 0.0)
362 id (c/next-id key)
363 now (w/slider was {:key key
364 :min (num (:min props) 0.0)
365 :max (num (:max props) 100.0)})]
366 ;; == and not not=, because a component holding a long 0 must
367 ;; not be told every frame that its slider moved to 0.0
368 (record! n id)
369 (when-not (== now was) (fire! n :on-change now)))
370
371 (:entry :text-entry)
372 (let [was (str (or (:value props) (:text props) ""))
373 id (c/next-id key)
Report Enter from a field, and take its width request c03a752 nandi 9d ago374 now (w/text-entry was {:key key
375 :placeholder (:placeholder props)
376 ;; :width-request is what frq and
377 ;; libvidya call a minimum width;
378 ;; :hexpand says take the rest of the
379 ;; row, which is this widget's default.
380 :min-width (:width-request props)
Take another line when the message stops fitting b44d525 nandi 9d ago381 ;; How tall it starts and how tall it may
382 ;; grow. :rows is what frq already writes
383 ;; for the terminal, where the field is a
384 ;; fixed block of the screen; :max-rows is
385 ;; the window's answer to the same problem
386 ;; — a compose box that gains a line when
387 ;; the message stops fitting rather than
388 ;; sliding a paragraph past one border.
389 :rows (:rows props)
390 :max-rows (:max-rows props)
Report Enter from a field, and take its width request c03a752 nandi 9d ago391 :expand (if (false? (:hexpand props))
392 :none :horizontal)})]
Write dvui's shape in jolt, on SDL3, with no shared object 109c7e4 Veronika Winters 9d ago393 (record! n id)
Report Enter from a field, and take its width request c03a752 nandi 9d ago394 (when (not= now was) (fire! n :on-change now))
Take another line when the message stops fitting b44d525 nandi 9d ago395 ;; A field that grew moved everything under it, and a client laying
396 ;; its screen out in points has no other way to hear about it: frq
397 ;; reserves the strip below its message list by hand, and a compose
398 ;; box that got taller without saying so grows down off the window.
399 (let [lines (w/entry-lines id)]
400 (when (not= lines (:lines-told (c/data id)))
401 (c/data! id {:lines-told lines})
402 (fire! n :on-rows lines)))
Report Enter from a field, and take its width request c03a752 nandi 9d ago403 ;; Enter, which a field must not swallow as input: frq sends its
404 ;; message on it, and without this the compose box accepted text
405 ;; and had no way to say it was finished.
Fire :on-activate with nothing, wrap a row, and break a word that cannot fit 2271a91 nandi 9d ago406 ;;
407 ;; NO ARGUMENT. libvidya emits activate with an empty string, and
408 ;; frq's handlers are thunks — `s/send-draft!` takes none, and
409 ;; handing it the text is an arity error the moment somebody
410 ;; presses Enter. The text is already theirs; they got it from
411 ;; :on-change.
Put the caret where the click lands, and paste into the field c7d6ea8 nandi 9d ago412 (when (w/entry-activated? id) (fire! n :on-activate))
413 ;; A paste that found no text on the clipboard. Also a thunk, and
414 ;; for frq the important one: that is how a picture is pasted — the
415 ;; same Ctrl+V as everything else, reaching `s/paste-image!` because
416 ;; the field had nothing to put in itself. libvidya's name for it.
417 (when (w/entry-paste-empty? id) (fire! n :on-paste-empty)))
Write dvui's shape in jolt, on SDL3, with no shared object 109c7e4 Veronika Winters 9d ago418
419 :progress (w/progress (num (:value props) 0.0))
420 :separator (w/separator)
421 (:spacer :gap) (w/spacer {:size (num (:size props) 8.0)
422 :expand (:expand props :none)})
423
424 ;; An unknown tag is a container rather than an error, so a tree written
425 ;; against a richer backend still shows its contents here — the same
426 ;; bargain jolt-zvui makes with the tags it does not know.
427 (c/box* (box-opts props key) (emit-children! n)))))
428
429;; --- the loop ----------------------------------------------------------------
430
431(defonce ^:private pending (atom []))
432
433(defn- schedule! [work] (swap! pending conj work) nil)
434
Give jvui a picture from somewhere else, and a clock 32fae8d nandi 9d ago435;; --- timers -----------------------------------------------------------------
436;; A client needs somewhere to run work that is not a reaction to anything:
437;; frq drives its whole media plane from `(every! 16 pump!)`, and a decoded
438;; frame arrives because a timer asked for it rather than because a person
439;; clicked. There is no other hook of the right shape — a component body runs
440;; when its state changes, which for a video feed is never.
441;;
442;; Run from the same `:before` as the reconciler's queue, and for the same
443;; reason: a callback that patches the tree must not do it mid-walk.
444
445(defonce ^:private timers (atom {}))
446(defonce ^:private next-timer (atom 0))
447
448(defn- now-ms [] (System/currentTimeMillis))
449
450(defn after!
451 "Run `f` once, at least `ms` from now. Answers a handle for `cancel!`."
452 [ms f]
453 (let [id (swap! next-timer inc)]
454 (swap! timers assoc id {:at (+ (now-ms) ms) :every nil :f f})
455 id))
456
457(defn every!
458 "Run `f` every `ms`. Answers a handle for `cancel!`.
459
460 Every `ms` AT MOST, not exactly: it fires on the first frame after the
461 deadline, so a 16ms timer on a 60Hz window runs once a frame and on a
462 slower one runs less often. That is the right failure — a timer that tried
463 to catch up would run twice in a row on a stutter, and for a pump that
464 means two frames decoded and one shown."
465 [ms f]
466 (let [id (swap! next-timer inc)]
467 (swap! timers assoc id {:at (+ (now-ms) ms) :every ms :f f})
468 id))
469
470(defn cancel!
471 "Stop a timer."
472 [id]
473 (swap! timers dissoc id)
474 nil)
475
476(defn- run-timers! []
477 (let [t (now-ms)
478 due (filter (fn [[_ v]] (<= (:at v) t)) @timers)]
479 (doseq [[id {:keys [every f]}] due]
480 (if every
481 (swap! timers assoc-in [id :at] (+ t every))
482 (swap! timers dissoc id))
483 ;; A throwing timer is cancelled rather than allowed to throw every
484 ;; frame for the rest of the session, which is unreadable and stops
485 ;; the ones behind it.
486 (try (f)
487 (catch Exception e
488 (swap! timers dissoc id)
489 (println "glimmer-jvui: timer failed, cancelled:" (ex-message e)))))))
490
Settle the layout before painting it, not after 51145df nandi 9d ago491(defn- drain-pending!
492 "Run this frame's timers and the reconciler's queued patches.
493
494 Answers whether any patch ran — which the caller turns into
495 `core/unsettle!`, and which is the whole of this backend's part in keeping
496 a changed tree from being painted at the sizes of the old one."
497 []
Give jvui a picture from somewhere else, and a clock 32fae8d nandi 9d ago498 (run-timers!)
Write dvui's shape in jolt, on SDL3, with no shared object 109c7e4 Veronika Winters 9d ago499 (let [[ws] (reset-vals! pending [])]
Settle the layout before painting it, not after 51145df nandi 9d ago500 (doseq [w ws] (w))
501 (boolean (seq ws))))
502
503(defn- before!
504 "jvui's per-frame hook: patch the tree, then say that we did.
505
506 A patch lands between two walks, where nothing jvui measures has moved yet
507 — every container still holds the size its old children asked for. Without
508 the `unsettle!` the next walk is the one that paints, and it paints the new
509 tree at those old sizes: the frame where a card is still the height of the
510 message it no longer holds and everything under it sits wherever that put
511 it. With it, that walk is a settling pass and the frame that reaches the
512 screen is the settled one."
513 [cx]
514 (when (drain-pending!) (c/unsettle! cx)))
Write dvui's shape in jolt, on SDL3, with no shared object 109c7e4 Veronika Winters 9d ago515
516(defn- run!
517 "glimmer.backend's :run. Creates the root page, mounts into it, then hands the
518 loop to jvui.
519
520 The reconciler's queued work is drained by jvui's `:before` hook rather than
521 inside the walk: a re-render patches the tree, and patching a tree while it
522 is being walked is how a frame ends up half old and half new."
523 [opts mount-root!]
524 (let [{:keys [title width height max-width theme frames auto-quit-ms shot]
525 :or {title "glimmer" width 720 height 520}} opts
526 root (create! :page (cond-> {} max-width (assoc :max-width max-width)))]
527 (mount-root! root :page)
528 (reset! b/loop-running? true)
529 (try
530 (app/run! (fn [] (emit! root))
531 {:title title :width width :height height
532 :theme (or theme theme/dark)
Settle the layout before painting it, not after 51145df nandi 9d ago533 :before before!
Write dvui's shape in jolt, on SDL3, with no shared object 109c7e4 Veronika Winters 9d ago534 :frames frames :auto-quit-ms auto-quit-ms :shot shot})
535 (finally (reset! b/loop-running? false)))))
536
537;; --- registration ------------------------------------------------------------
538
539(def backend
540 {:name :jvui
541 :create! create! :apply-props! apply-props!
542 :append-child! append-child! :remove-child! remove-child!
543 :replace-child! replace-child! :reorder-child! reorder-child!
544 :schedule schedule! :run run!})
545
546(b/register! backend)
547
548;; --- headless driving, for tests ---------------------------------------------
549
550(defn root-node
551 "A bare root page, for mounting into without a window."
552 [] (create! :page {}))
553
554(defn render-once
555 "Walk `root` through jvui with no window, no font and no display.
556
557 `cx` is a `jvui.core/context`; `evs` the events that frame. Answers the
558 context, whose `:data` is every rectangle the walk placed — which is enough
559 for a test to assert about a layout and to click on it."
560 ([root cx] (render-once root cx []))
561 ([root cx evs]
Settle the layout before painting it, not after 51145df nandi 9d ago562 (before! cx)
Write dvui's shape in jolt, on SDL3, with no shared object 109c7e4 Veronika Winters 9d ago563 (swap! cx assoc :events evs)
564 (swap! cx c/apply-input evs)
565 (binding [*record-rects?* true]
566 (c/frame! cx (fn [] (emit! root))))
567 cx))
Give jvui a picture from somewhere else, and a clock 32fae8d nandi 9d ago568
569;; --- feeds ------------------------------------------------------------------
570;; The same three calls glimmer-vidya exposes, so a client that paints a call
571;; does not care which backend is under it. They are not part of the
572;; reconciler and deliberately so: pixels arrive between frames, and the tree
573;; only ever holds the key.
574
575(defn frame-rgba!
576 "Hand feed `key` a new picture: `w` by `h` RGBA at FOREIGN pointer `px`.
577
578 The pointer is read during this call and not kept, so a caller may reuse
579 or free it immediately afterwards — which is what a decoder handing out a
580 borrowed buffer needs."
581 [key w h px]
582 (frames/put! key w h px))
583
584(defn frame-drop!
585 "Forget a feed and release its texture — someone left, or turned a camera
586 off."
587 [key]
588 (frames/drop! key))
589
590(defn feed-keys
591 "Every feed with a picture."
592 []
593 (frames/keys*))
Answer the rest of what a client asks its window 0b92f67 nandi 9d ago594
595;; --- the platform -----------------------------------------------------------
596;; The rest of what glimmer-vidya answers, so a client can ask its backend
597;; about the window it is in without knowing which backend that is. Thin on
598;; purpose: every one of these is jvui.host, and the indirection exists so
599;; the client requires one namespace rather than two.
600
601(def set-title! host/set-title!)
602(def window-width host/window-width)
603(def screen-size host/screen-size)
604(def quit! host/quit!)
605(def open-url! host/open-url!)
606(def clipboard-image-png! host/clipboard-image-png!)
607
608;; False and nil on a desktop, which is the right answer rather than a gap:
609;; the chooser exists so a phone can hand back a grant for one picture, and
610;; a caller reads the false and offers a file browser instead. glimmer-vidya
611;; says the same thing here.
612(def pick-image! host/pick-image!)
613(def picked-image! host/picked-image!)