| Write dvui's shape in jolt, on SDL3, with no shared object 109c7e4 Veronika Winters 9d 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] |
| Answer the rest of what a client asks its window 0b92f67 nandi 9d ago | 43 | [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 ago | 45 | |
| 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 ago | 131 | ;; 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 ago | 176 | |
| Write dvui's shape in jolt, on SDL3, with no shared object 109c7e4 Veronika Winters 9d ago | 177 | :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 ago | 187 | ;; :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 ago | 190 | ;; :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 ago | 208 | |
| 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) |
| 222 | now (w/text-entry was {:key key :placeholder (:placeholder props)})] |
| 223 | (record! n id) |
| 224 | (when (not= now was) (fire! n :on-change now))) |
| 225 | |
| 226 | :progress (w/progress (num (:value props) 0.0)) |
| 227 | :separator (w/separator) |
| 228 | (:spacer :gap) (w/spacer {:size (num (:size props) 8.0) |
| 229 | :expand (:expand props :none)}) |
| 230 | |
| 231 | ;; An unknown tag is a container rather than an error, so a tree written |
| 232 | ;; against a richer backend still shows its contents here — the same |
| 233 | ;; bargain jolt-zvui makes with the tags it does not know. |
| 234 | (c/box* (box-opts props key) (emit-children! n))))) |
| 235 | |
| 236 | ;; --- the loop ---------------------------------------------------------------- |
| 237 | |
| 238 | (defonce ^:private pending (atom [])) |
| 239 | |
| 240 | (defn- schedule! [work] (swap! pending conj work) nil) |
| 241 | |
| Give jvui a picture from somewhere else, and a clock 32fae8d nandi 9d ago | 242 | ;; --- timers ----------------------------------------------------------------- |
| 243 | ;; A client needs somewhere to run work that is not a reaction to anything: |
| 244 | ;; frq drives its whole media plane from `(every! 16 pump!)`, and a decoded |
| 245 | ;; frame arrives because a timer asked for it rather than because a person |
| 246 | ;; clicked. There is no other hook of the right shape — a component body runs |
| 247 | ;; when its state changes, which for a video feed is never. |
| 248 | ;; |
| 249 | ;; Run from the same `:before` as the reconciler's queue, and for the same |
| 250 | ;; reason: a callback that patches the tree must not do it mid-walk. |
| 251 | |
| 252 | (defonce ^:private timers (atom {})) |
| 253 | (defonce ^:private next-timer (atom 0)) |
| 254 | |
| 255 | (defn- now-ms [] (System/currentTimeMillis)) |
| 256 | |
| 257 | (defn after! |
| 258 | "Run `f` once, at least `ms` from now. Answers a handle for `cancel!`." |
| 259 | [ms f] |
| 260 | (let [id (swap! next-timer inc)] |
| 261 | (swap! timers assoc id {:at (+ (now-ms) ms) :every nil :f f}) |
| 262 | id)) |
| 263 | |
| 264 | (defn every! |
| 265 | "Run `f` every `ms`. Answers a handle for `cancel!`. |
| 266 | |
| 267 | Every `ms` AT MOST, not exactly: it fires on the first frame after the |
| 268 | deadline, so a 16ms timer on a 60Hz window runs once a frame and on a |
| 269 | slower one runs less often. That is the right failure — a timer that tried |
| 270 | to catch up would run twice in a row on a stutter, and for a pump that |
| 271 | means two frames decoded and one shown." |
| 272 | [ms f] |
| 273 | (let [id (swap! next-timer inc)] |
| 274 | (swap! timers assoc id {:at (+ (now-ms) ms) :every ms :f f}) |
| 275 | id)) |
| 276 | |
| 277 | (defn cancel! |
| 278 | "Stop a timer." |
| 279 | [id] |
| 280 | (swap! timers dissoc id) |
| 281 | nil) |
| 282 | |
| 283 | (defn- run-timers! [] |
| 284 | (let [t (now-ms) |
| 285 | due (filter (fn [[_ v]] (<= (:at v) t)) @timers)] |
| 286 | (doseq [[id {:keys [every f]}] due] |
| 287 | (if every |
| 288 | (swap! timers assoc-in [id :at] (+ t every)) |
| 289 | (swap! timers dissoc id)) |
| 290 | ;; A throwing timer is cancelled rather than allowed to throw every |
| 291 | ;; frame for the rest of the session, which is unreadable and stops |
| 292 | ;; the ones behind it. |
| 293 | (try (f) |
| 294 | (catch Exception e |
| 295 | (swap! timers dissoc id) |
| 296 | (println "glimmer-jvui: timer failed, cancelled:" (ex-message e))))))) |
| 297 | |
| Write dvui's shape in jolt, on SDL3, with no shared object 109c7e4 Veronika Winters 9d ago | 298 | (defn- drain-pending! [] |
| Give jvui a picture from somewhere else, and a clock 32fae8d nandi 9d ago | 299 | (run-timers!) |
| Write dvui's shape in jolt, on SDL3, with no shared object 109c7e4 Veronika Winters 9d ago | 300 | (let [[ws] (reset-vals! pending [])] |
| 301 | (doseq [w ws] (w)))) |
| 302 | |
| 303 | (defn- run! |
| 304 | "glimmer.backend's :run. Creates the root page, mounts into it, then hands the |
| 305 | loop to jvui. |
| 306 | |
| 307 | The reconciler's queued work is drained by jvui's `:before` hook rather than |
| 308 | inside the walk: a re-render patches the tree, and patching a tree while it |
| 309 | is being walked is how a frame ends up half old and half new." |
| 310 | [opts mount-root!] |
| 311 | (let [{:keys [title width height max-width theme frames auto-quit-ms shot] |
| 312 | :or {title "glimmer" width 720 height 520}} opts |
| 313 | root (create! :page (cond-> {} max-width (assoc :max-width max-width)))] |
| 314 | (mount-root! root :page) |
| 315 | (reset! b/loop-running? true) |
| 316 | (try |
| 317 | (app/run! (fn [] (emit! root)) |
| 318 | {:title title :width width :height height |
| 319 | :theme (or theme theme/dark) |
| 320 | :before drain-pending! |
| 321 | :frames frames :auto-quit-ms auto-quit-ms :shot shot}) |
| 322 | (finally (reset! b/loop-running? false))))) |
| 323 | |
| 324 | ;; --- registration ------------------------------------------------------------ |
| 325 | |
| 326 | (def backend |
| 327 | {:name :jvui |
| 328 | :create! create! :apply-props! apply-props! |
| 329 | :append-child! append-child! :remove-child! remove-child! |
| 330 | :replace-child! replace-child! :reorder-child! reorder-child! |
| 331 | :schedule schedule! :run run!}) |
| 332 | |
| 333 | (b/register! backend) |
| 334 | |
| 335 | ;; --- headless driving, for tests --------------------------------------------- |
| 336 | |
| 337 | (defn root-node |
| 338 | "A bare root page, for mounting into without a window." |
| 339 | [] (create! :page {})) |
| 340 | |
| 341 | (defn render-once |
| 342 | "Walk `root` through jvui with no window, no font and no display. |
| 343 | |
| 344 | `cx` is a `jvui.core/context`; `evs` the events that frame. Answers the |
| 345 | context, whose `:data` is every rectangle the walk placed — which is enough |
| 346 | for a test to assert about a layout and to click on it." |
| 347 | ([root cx] (render-once root cx [])) |
| 348 | ([root cx evs] |
| 349 | (drain-pending!) |
| 350 | (swap! cx assoc :events evs) |
| 351 | (swap! cx c/apply-input evs) |
| 352 | (binding [*record-rects?* true] |
| 353 | (c/frame! cx (fn [] (emit! root)))) |
| 354 | cx)) |
| Give jvui a picture from somewhere else, and a clock 32fae8d nandi 9d ago | 355 | |
| 356 | ;; --- feeds ------------------------------------------------------------------ |
| 357 | ;; The same three calls glimmer-vidya exposes, so a client that paints a call |
| 358 | ;; does not care which backend is under it. They are not part of the |
| 359 | ;; reconciler and deliberately so: pixels arrive between frames, and the tree |
| 360 | ;; only ever holds the key. |
| 361 | |
| 362 | (defn frame-rgba! |
| 363 | "Hand feed `key` a new picture: `w` by `h` RGBA at FOREIGN pointer `px`. |
| 364 | |
| 365 | The pointer is read during this call and not kept, so a caller may reuse |
| 366 | or free it immediately afterwards — which is what a decoder handing out a |
| 367 | borrowed buffer needs." |
| 368 | [key w h px] |
| 369 | (frames/put! key w h px)) |
| 370 | |
| 371 | (defn frame-drop! |
| 372 | "Forget a feed and release its texture — someone left, or turned a camera |
| 373 | off." |
| 374 | [key] |
| 375 | (frames/drop! key)) |
| 376 | |
| 377 | (defn feed-keys |
| 378 | "Every feed with a picture." |
| 379 | [] |
| 380 | (frames/keys*)) |
| Answer the rest of what a client asks its window 0b92f67 nandi 9d ago | 381 | |
| 382 | ;; --- the platform ----------------------------------------------------------- |
| 383 | ;; The rest of what glimmer-vidya answers, so a client can ask its backend |
| 384 | ;; about the window it is in without knowing which backend that is. Thin on |
| 385 | ;; purpose: every one of these is jvui.host, and the indirection exists so |
| 386 | ;; the client requires one namespace rather than two. |
| 387 | |
| 388 | (def set-title! host/set-title!) |
| 389 | (def window-width host/window-width) |
| 390 | (def screen-size host/screen-size) |
| 391 | (def quit! host/quit!) |
| 392 | (def open-url! host/open-url!) |
| 393 | (def clipboard-image-png! host/clipboard-image-png!) |
| 394 | |
| 395 | ;; False and nil on a desktop, which is the right answer rather than a gap: |
| 396 | ;; the chooser exists so a phone can hand back a grant for one picture, and |
| 397 | ;; a caller reads the false and offers a file browser instead. glimmer-vidya |
| 398 | ;; says the same thing here. |
| 399 | (def pick-image! host/pick-image!) |
| 400 | (def picked-image! host/picked-image!) |