| Write dvui's shape in jolt, on SDL3, with no shared object 109c7e4 Veronika Winters 10d ago | 1 | (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 ago | 42 | [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 ago | 44 | |
| 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 ago | 130 | ;; 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 ago | 141 | :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 ago | 190 | ;; --- 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 ago | 246 | (defn- drain-pending! [] |
| Give jvui a picture from somewhere else, and a clock 32fae8d nandi 9d ago | 247 | (run-timers!) |
| Write dvui's shape in jolt, on SDL3, with no shared object 109c7e4 Veronika Winters 10d ago | 248 | (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 ago | 303 | |
| 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*)) |