| 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 | |
| Fill the window's height, and find a glyph the UI font has not got 7d907d0 nandi 9d ago | 73 | (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 ago | 90 | (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 ago | 92 | ([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 ago | 94 | (cond-> {:key key |
| Let a container fill its parent, and report "end" as the word e72d7b0 nandi 9d ago | 95 | :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 ago | 107 | ;; :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 ago | 118 | (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 ago | 119 | ;; 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 ago | 123 | (: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 ago | 126 | (: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 ago | 134 | [0.0 0.0]))))) |
| Write dvui's shape in jolt, on SDL3, with no shared object 109c7e4 Veronika Winters 9d ago | 135 | |
| 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 ago | 156 | (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 ago | 179 | (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 ago | 184 | (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 ago | 195 | (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 ago | 208 | |
| 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 ago | 225 | ;; 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 ago | 229 | ;; :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 ago | 234 | (: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 ago | 244 | ;; "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 ago | 255 | (assoc :on-at-end |
| 256 | (fn [at-end?] |
| Let a container fill its parent, and report "end" as the word e72d7b0 nandi 9d ago | 257 | (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 ago | 258 | (emit-children! n)) |
| 259 | |
| Fill the window's height, and find a glyph the UI font has not got 7d907d0 nandi 9d ago | 260 | :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 ago | 261 | (emit-children! n)) |
| 262 | |
| Fill the window's height, and find a glyph the UI font has not got 7d907d0 nandi 9d ago | 263 | (: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 ago | 265 | |
| Take the eight tags a client still had nowhere to put d5dfd53 nandi 9d ago | 266 | ;; 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 ago | 304 | 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 ago | 306 | :mine? (boolean (:mine props)) |
| 307 | :size (:size props) |
| wip hover card 65272e3 nandi 9d ago | 308 | :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 ago | 314 | ;; 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 ago | 324 | (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 ago | 328 | |
| Write dvui's shape in jolt, on SDL3, with no shared object 109c7e4 Veronika Winters 9d ago | 329 | :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 ago | 339 | ;; :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 ago | 342 | ;; :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 ago | 360 | |
| 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 ago | 374 | 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 ago | 381 | ;; 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 ago | 391 | :expand (if (false? (:hexpand props)) |
| 392 | :none :horizontal)})] |
| Write dvui's shape in jolt, on SDL3, with no shared object 109c7e4 Veronika Winters 9d ago | 393 | (record! n id) |
| Report Enter from a field, and take its width request c03a752 nandi 9d ago | 394 | (when (not= now was) (fire! n :on-change now)) |
| Take another line when the message stops fitting b44d525 nandi 9d ago | 395 | ;; 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 ago | 403 | ;; 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 ago | 406 | ;; |
| 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 ago | 412 | (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 ago | 418 | |
| 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 ago | 435 | ;; --- 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 ago | 491 | (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 ago | 498 | (run-timers!) |
| Write dvui's shape in jolt, on SDL3, with no shared object 109c7e4 Veronika Winters 9d ago | 499 | (let [[ws] (reset-vals! pending [])] |
| Settle the layout before painting it, not after 51145df nandi 9d ago | 500 | (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 ago | 515 | |
| 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 ago | 533 | :before before! |
| Write dvui's shape in jolt, on SDL3, with no shared object 109c7e4 Veronika Winters 9d ago | 534 | :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 ago | 562 | (before! cx) |
| Write dvui's shape in jolt, on SDL3, with no shared object 109c7e4 Veronika Winters 9d ago | 563 | (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 ago | 568 | |
| 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 ago | 594 | |
| 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!) |