| Bring vidya in cfd3e36 nandi 19d ago | 1 | (ns glimmer-vidya.core |
| 2 | "The Vidya/egui backend for glimmer. Requiring this namespace installs it, |
| 3 | after which glimmer's portable reconciler renders hiccup into a GPU window: |
| 4 | |
| 5 | (ns myapp |
| 6 | (:require [glimmer.ratom :refer [atom]] |
| 7 | [glimmer.core :as ui] |
| 8 | [glimmer-vidya.core])) ; installs this backend |
| 9 | |
| 10 | (defn -main [& _] (ui/run my-app :title \"myapp\")) |
| 11 | |
| 12 | egui is an immediate-mode toolkit: it has no widgets to hand a reconciler, |
| 13 | only calls you make every frame. So the widget tree lives one layer down, in |
| 14 | the Rust library, and this namespace is the thin part — it turns glimmer's |
| 15 | create/patch/append/remove into node mutations, runs the frame loop, and |
| 16 | routes the events that come back to the handlers the components declared. |
| 17 | |
| 18 | A widget, from the reconciler's point of view, is an integer node handle. It |
| 19 | never looks inside one, which is exactly why an id is enough. |
| 20 | |
| 21 | **Handlers do not cross the FFI.** A jolt closure cannot be a callback in a |
| 22 | library painting at 60fps, so identity travels instead: a node reports that |
| 23 | it was clicked, and the handler map here says whose `:on-click` that was." |
| 24 | (:require [glimmer.backend :as b] |
| 25 | [glimmer-vidya.ffi :as ffi])) |
| 26 | |
| 27 | ;; Node id -> the :on-* props that node was last rendered with. Kept here |
| 28 | ;; rather than sent across because a closure has no C representation. |
| 29 | ;; |
| 30 | ;; Ids are recycled by the arena, which is safe only because every id is |
| 31 | ;; written here by `create!` before anything can raise an event against it — a |
| 32 | ;; reused id has its predecessor's handlers overwritten in the same breath. |
| 33 | (defonce ^:private handlers (atom {})) |
| 34 | |
| 35 | ;; Work posted from other threads, run on the loop thread at the top of a tick. |
| 36 | (defonce ^:private pending (atom [])) |
| 37 | |
| 38 | ;; Set by quit!, read by the loop. |
| 39 | (defonce ^:private quit-requested (atom false)) |
| 40 | |
| 41 | ;; --- props ------------------------------------------------------------------- |
| 42 | ;; :hbox and :vbox are one node in the library; the tag only implies an |
| 43 | ;; orientation, and an explicit :orientation prop still wins. |
| 44 | (def ^:private tag-orientation {:hbox "horizontal" :vbox "vertical"}) |
| 45 | |
| 46 | (defn- handler-key? |
| 47 | "True for a prop that names an event handler rather than a value." |
| 48 | [k] |
| 49 | (let [s (name k)] |
| 50 | (and (> (count s) 3) (= "on-" (subs s 0 3))))) |
| 51 | |
| 52 | (defn- set-prop! |
| 53 | "Write one prop to a node, in the ABI type that fits its value. nil clears |
| 54 | nothing — the prop was already dropped by the clear that precedes a write — |
| 55 | and an unrecognized value is stringified rather than refused, so a prop this |
| 56 | backend has not learned yet still reaches the library." |
| 57 | [node k v] |
| 58 | (let [key (name k)] |
| 59 | (cond |
| 60 | (nil? v) nil |
| 61 | (true? v) (ffi/node-set-bool! node key true) |
| 62 | (false? v) (ffi/node-set-bool! node key false) |
| 63 | (number? v) (ffi/node-set-num! node key (double v)) |
| 64 | (string? v) (ffi/node-set-str! node key v) |
| 65 | (keyword? v) (ffi/node-set-str! node key (name v)) |
| 66 | :else (ffi/node-set-str! node key (str v))))) |
| 67 | |
| 68 | (defn- write-props! |
| 69 | "Replace a node's props with `props`. |
| 70 | |
| 71 | Cleared first, deliberately: a re-render that stops setting `:placeholder` |
| 72 | means the placeholder is gone, and patching in place would leave the old one |
| 73 | behind. It also discards the value the library wrote back when the user typed |
| 74 | in an entry or clicked a checkbutton — which is the point. The component's |
| 75 | state is the truth, and this is the frame where it says so." |
| 76 | [node tag props] |
| 77 | (ffi/node-clear-props! node) |
| 78 | (when-let [orientation (tag-orientation tag)] |
| 79 | (when-not (contains? props :orientation) |
| 80 | (ffi/node-set-str! node "orientation" orientation))) |
| 81 | (doseq [[k v] props] |
| 82 | (when-not (handler-key? k) |
| 83 | (set-prop! node k v))) |
| 84 | (swap! handlers assoc node |
| 85 | (reduce (fn [acc [k v]] |
| 86 | (if (and (handler-key? k) (fn? v)) (assoc acc k v) acc)) |
| 87 | {} |
| 88 | props)) |
| 89 | nil) |
| 90 | |
| 91 | (defn- forget-dead-handlers! |
| 92 | "Drop handler entries for nodes the library has freed. |
| 93 | |
| 94 | Removing a subtree frees every node under it, and only the library knows |
| 95 | which those were — so rather than mirror the tree here to walk it, the map is |
| 96 | filtered against what still exists. It runs once per removal, over a map the |
| 97 | size of the UI." |
| 98 | [] |
| 99 | (swap! handlers |
| 100 | (fn [m] |
| 101 | (reduce (fn [acc [id hs]] |
| 102 | (if (ffi/node-exists? id) (assoc acc id hs) acc)) |
| 103 | {} |
| 104 | m))) |
| 105 | nil) |
| 106 | |
| 107 | ;; --- the backend operations -------------------------------------------------- |
| 108 | (defn- create! |
| 109 | "glimmer.backend's :create!. Children are appended by the reconciler, not |
| 110 | here." |
| 111 | [tag props] |
| 112 | (let [node (ffi/node-new (name tag))] |
| 113 | (when (zero? node) |
| 114 | (throw (ex-info "vidya could not allocate a node" {:tag tag}))) |
| 115 | (write-props! node tag props) |
| 116 | node)) |
| 117 | |
| 118 | (defn- apply-props! [tag node props] (write-props! node tag props)) |
| 119 | |
| 120 | (defn- append-child! [_parent-tag parent child] |
| 121 | (ffi/node-append! parent child) |
| 122 | nil) |
| 123 | |
| 124 | (defn- remove-child! [_parent-tag parent child] |
| 125 | ;; The library frees the subtree; glimmer never mentions it again. |
| 126 | (ffi/node-remove! parent child) |
| 127 | (forget-dead-handlers!) |
| 128 | nil) |
| 129 | |
| 130 | (defn- replace-child! [_parent-tag parent old-child new-child] |
| 131 | (ffi/node-replace! parent old-child new-child) |
| 132 | (forget-dead-handlers!) |
| 133 | nil) |
| 134 | |
| 135 | (defn- reorder-child! [_parent-tag parent child sibling] |
| 136 | ;; nil sibling means "first"; the ABI spells that 0. |
| 137 | (ffi/node-insert-after! parent child (or sibling 0)) |
| 138 | nil) |
| 139 | |
| 140 | ;; --- the UI thread ----------------------------------------------------------- |
| 141 | (defn- schedule |
| 142 | "glimmer.backend's :schedule. Every node call belongs to the thread that |
| 143 | opened the window, so a ratom mutated on an nREPL worker (or any future) |
| 144 | queues its re-render here and the loop performs it on the next tick." |
| 145 | [work] |
| 146 | (swap! pending conj work) |
| 147 | nil) |
| 148 | |
| 149 | (defn- drain! |
| 150 | "Run everything `schedule` queued. compare-and-set! rather than reset!, so |
| 151 | work posted while the queue is being taken is not dropped." |
| 152 | [] |
| 153 | (loop [] |
| 154 | (let [q @pending] |
| 155 | (when (seq q) |
| 156 | (if (compare-and-set! pending q []) |
| 157 | (doseq [f q] (f)) |
| 158 | (recur)))))) |
| 159 | |
| 160 | ;; --- timers ------------------------------------------------------------------ |
| 161 | ;; A spinner or a clock has to change with nothing being pressed. The loop |
| 162 | ;; already wakes every frame, so a timer is a due time and a thunk. Both |
| 163 | ;; entry points are safe to call from another thread, and both run their thunk |
| 164 | ;; ON the loop thread, the only one allowed to touch nodes. |
| 165 | (defonce ^:private timers (atom {:next-id 0 :entries {}})) |
| 166 | |
| 167 | (defn- now-ms [] (System/currentTimeMillis)) |
| 168 | |
| 169 | (defn- add-timer! [ms every? f] |
| 170 | (let [id (:next-id (swap! timers update :next-id inc))] |
| 171 | (swap! timers assoc-in [:entries id] |
| 172 | {:due (+ (now-ms) ms) :every (when every? ms) :f f}) |
| 173 | id)) |
| 174 | |
| 175 | (defn after! |
| 176 | "Run `f` on the loop thread in about `ms` milliseconds. Returns an id for |
| 177 | `cancel!`. Resolution is one frame." |
| 178 | [ms f] (add-timer! ms false f)) |
| 179 | |
| 180 | (defn every! |
| 181 | "Run `f` on the loop thread about every `ms` milliseconds until cancelled: |
| 182 | |
| 183 | (every! 80 #(swap! tick inc))" |
| 184 | [ms f] (add-timer! ms true f)) |
| 185 | |
| 186 | (defn cancel! |
| 187 | "Stop the timer `id`." |
| 188 | [id] (swap! timers update :entries dissoc id) nil) |
| 189 | |
| 190 | (defn cancel-all! |
| 191 | "Stop every timer, so a repeating one does not outlive the UI it animated." |
| 192 | [] (swap! timers assoc :entries {}) nil) |
| 193 | |
| 194 | (defn- pump-timers! [] |
| 195 | (let [t (now-ms) |
| 196 | due (reduce (fn [acc [id e]] (if (<= (:due e) t) (conj acc [id e]) acc)) |
| 197 | [] |
| 198 | (:entries @timers))] |
| 199 | (doseq [[id e] due] |
| 200 | (if-let [period (:every e)] |
| 201 | (swap! timers assoc-in [:entries id :due] (+ t period)) |
| 202 | (swap! timers update :entries dissoc id)) |
| 203 | ((:f e))) |
| 204 | nil)) |
| 205 | |
| 206 | ;; --- events ------------------------------------------------------------------ |
| 207 | (defn- dispatch-events! |
| 208 | "Drain the frame's interactions and call the handlers they belong to. |
| 209 | |
| 210 | An event whose node has no handler for it is dropped, which is what makes a |
| 211 | control that ignores its own event still work: the library wrote the new |
| 212 | state into the node, and the next render either confirms it or overwrites it." |
| 213 | [] |
| 214 | (loop [] |
| 215 | (when (ffi/poll-event!) |
| 216 | (let [node (ffi/event-node) |
| 217 | kind (ffi/event-name) |
| 218 | hs (get @handlers node)] |
| 219 | (case kind |
| 220 | "click" (when-let [f (:on-click hs)] (f)) |
| 221 | "toggled" (when-let [f (:on-toggled hs)] (f)) |
| 222 | ;; The text is read before anything else can overwrite the library's |
| 223 | ;; one scratch buffer — jolt copies it as it crosses. |
| 224 | "change" (when-let [f (:on-change hs)] (f (ffi/event-text))) |
| 225 | "activate" (when-let [f (:on-activate hs)] (f)) |
| 226 | ;; Ctrl+V on a clipboard with no text on it. What is on it instead is |
| 227 | ;; the caller's to find out — `clipboard-image-png!` is the only |
| 228 | ;; question the backend answers about it. |
| 229 | "paste-empty" (when-let [f (:on-paste-empty hs)] (f)) |
| 230 | nil)) |
| 231 | (recur)))) |
| 232 | |
| 233 | ;; --- the event loop ---------------------------------------------------------- |
| 234 | (defn- clear-children! |
| 235 | "Drop everything under `node`. Removing a child frees it, so this walks the |
| 236 | first slot until there is nothing left rather than iterating an index." |
| 237 | [node] |
| 238 | (loop [] |
| 239 | (when (pos? (ffi/node-child-count node)) |
| 240 | (ffi/node-remove! node (ffi/node-child-at node 0)) |
| 241 | (recur))) |
| 242 | (forget-dead-handlers!) |
| 243 | nil) |
| 244 | |
| 245 | (defn window-width |
| 246 | "The width in points of the window's content area, as the last painted frame |
| 247 | measured it. 0 before the first frame. |
| 248 | |
| 249 | This is how a layout asks how much room it has: nothing else here reports a |
| 250 | size, and the window is not resizable from this side either. Read it from a |
| 251 | timer — `every!` — rather than per render, and hold it in a ratom, so that |
| 252 | the components which switch on it re-render only when it actually changes." |
| 253 | [] |
| 254 | (ffi/node-get-num (ffi/tree-root) "window-width")) |
| 255 | |
| 256 | (defn clipboard-image-png! |
| 257 | "Write the picture on the system clipboard to `path` as a PNG. True when |
| 258 | there was one; false for an empty clipboard, text on it, an unwritable path, |
| 259 | or a platform with no clipboard of images (Android). |
| 260 | |
| 261 | A paste of a picture reaches no handler of its own — the backend delivers |
| 262 | clipboard text only — so a caller asks for it, from whatever gesture it means |
| 263 | paste by. An `:entry`'s `:on-paste-empty` is that gesture where the reader |
| 264 | expects it: a Ctrl+V the field had no text to answer with." |
| 265 | [path] |
| 266 | (ffi/clipboard-image-png! path)) |
| 267 | |
| 268 | (defn open-url! |
| 269 | "Hand `url` to whatever shows web pages here — xdg-open or `open` on a |
| 270 | desktop, an ACTION_VIEW intent on Android. True when something took it; |
| 271 | false leaves the caller to show the URL and let the reader carry it across. |
| 272 | |
| 273 | A sign-in that goes through a browser is the reason this exists: the app |
| 274 | leaves for a page and the page comes back to it." |
| 275 | [url] |
| 276 | (ffi/open-url! url)) |
| 277 | |
| Catch up with vidya c90f8af nandi 19d ago | 278 | (defn pick-image! |
| 279 | "Open the platform's own picture chooser. True when one opened, false where |
| 280 | there is none — a desktop, or an Android activity that does not offer it — |
| 281 | and a false is the caller's cue to browse the filesystem itself. |
| 282 | |
| 283 | It does not answer with the picture. The reader is in another screen by then, |
| 284 | so what they chose arrives at `picked-image!`, which the caller polls." |
| 285 | [] |
| 286 | (ffi/pick-image!)) |
| 287 | |
| 288 | (defn picked-image! |
| 289 | "Move the picture chosen since the last call to `path`; true when there was |
| 290 | one. The answer is handed over once, so a poll still running does not take |
| 291 | the same picture twice." |
| 292 | [path] |
| 293 | (ffi/picked-image! path)) |
| 294 | |
| Tell a caller how big the window is 42dabb0 nandi 19d ago | 295 | (defn screen-size |
| 296 | "The window's size in points, as `[width height]`. `[0 0]` before the first |
| 297 | frame has been painted. |
| 298 | |
| 299 | For laying something out as a share of the window — a row of tiles that |
| 300 | should divide the width between them — where how many there are and how much |
| 301 | gap goes between them is the caller's arithmetic, and not something one |
| 302 | widget can work out from the space it was handed. |
| 303 | |
| 304 | Follows the window as it is dragged, so a component that reads it wants to be |
| 305 | re-rendered when it changes: keep it in a ratom rather than asking here at |
| 306 | render time, or the layout will be whatever it was on the first frame." |
| 307 | [] |
| 308 | [(ffi/screen-width) (ffi/screen-height)]) |
| 309 | |
| Bring vidya in cfd3e36 nandi 19d ago | 310 | (defn frame-rgba! |
| 311 | "Hand the backend a frame of live pixels under `key`. An `:image` node with |
| 312 | `:feed key` paints the latest one. |
| 313 | |
| 314 | `rgba` is a foreign pointer — `width * height * 4` un-premultiplied bytes, |
| 315 | row-major — and is copied before this returns, so whoever owns it may reuse |
| 316 | it immediately. False when the length does not match the dimensions. |
| 317 | |
| 318 | This is the source an `:image` `:src` cannot be: a `:src` decodes a file and |
| 319 | caches it by path for the life of the process, which is right for a picture |
| 320 | in a message and wrong for one that is new thirty times a second. Frames |
| 321 | coalesce rather than queue, so a source faster than the window costs nothing. |
| 322 | |
| 323 | On the window's thread, like everything else here: a frame produced on a |
| 324 | decoder thread is the caller's to hand across." |
| 325 | [key width height rgba] |
| 326 | (ffi/frame-rgba! key width height rgba)) |
| 327 | |
| 328 | (defn frame-drop! |
| 329 | "Forget the feed named `key` and release its texture; true when there was |
| 330 | one. A source that has stopped keeps painting its last frame otherwise." |
| 331 | [key] |
| 332 | (ffi/frame-drop! key)) |
| 333 | |
| 334 | (defn quit! |
| 335 | "Stop the running loop and close the window." |
| 336 | [] |
| 337 | (reset! quit-requested true) |
| 338 | nil) |
| 339 | |
| 340 | (defn- run! |
| 341 | "glimmer.backend's :run. Opens a window, mounts the root component into the |
| 342 | library's root node, and paints until the window closes or `quit!` is called. |
| 343 | Blocks, like every UI main loop. |
| 344 | |
| 345 | Options (on top of glimmer's own): |
| 346 | :title :width :height the window |
| 347 | :fps frame rate cap (default 60) |
| 348 | :mode :dark (default) or :light |
| 349 | :font path to a TTF/OTF to use for UI text |
| 350 | :auto-quit-ms stop after roughly this long — for smoke tests, |
| 351 | which have nobody to close the window |
| 352 | |
| 353 | The window is closed in a finally, so a handler that throws does not leave a |
| 354 | GPU surface and an event loop behind." |
| 355 | [opts mount-root!] |
| 356 | (let [{:keys [title width height fps mode font auto-quit-ms] |
| 357 | :or {title "glimmer" width 900 height 640 fps 60}} opts] |
| 358 | (when-not (ffi/open! width height title) |
| 359 | (throw (ex-info "vidya could not open a window" |
| 360 | {:title title :width width :height height}))) |
| 361 | (reset! quit-requested false) |
| 362 | (ffi/set-target-fps! fps) |
| 363 | (ffi/set-mode! (if (= mode :light) ffi/light-mode ffi/dark-mode)) |
| 364 | (when font (ffi/load-font! font)) |
| 365 | (let [started (now-ms) |
| 366 | root (ffi/tree-root)] |
| 367 | (try |
| 368 | ;; The library's root outlives a run — it is process-wide, not per |
| 369 | ;; window — so a second `ui/run` in one session (a REPL, a test) would |
| 370 | ;; otherwise mount its tree alongside the last one's. |
| 371 | (clear-children! root) |
| 372 | (mount-root! root :window) |
| 373 | (reset! b/loop-running? true) |
| 374 | (loop [] |
| 375 | (drain!) |
| 376 | (pump-timers!) |
| 377 | ;; One call paints the whole tree; the events it produced are read |
| 378 | ;; straight after, while the frame that caused them is still the |
| 379 | ;; frame the components rendered. |
| 380 | (ffi/tree-frame!) |
| 381 | (dispatch-events!) |
| 382 | (when-not (or @quit-requested |
| 383 | (ffi/should-close?) |
| 384 | (and auto-quit-ms (>= (- (now-ms) started) auto-quit-ms))) |
| 385 | (recur))) |
| 386 | (finally |
| 387 | (reset! b/loop-running? false) |
| 388 | (cancel-all!) |
| 389 | (ffi/close!) |
| 390 | (reset! handlers {})))))) |
| 391 | |
| 392 | ;; --- looking at what was rendered -------------------------------------------- |
| 393 | (defn dump-str |
| 394 | "The rendered tree as hiccup text, read back out of the library. |
| 395 | |
| 396 | With no argument, the whole window; with a node handle, that subtree. This is |
| 397 | the tree as it *is* after the reconciler has run, not what a component |
| 398 | returned, so paste it into a bug report and the two can be compared. |
| 399 | |
| 400 | Two things to know when reading one. `:hbox` and `:vbox` are one node down |
| 401 | there and both dump as `:box`, with the orientation in the props; and no |
| 402 | `:on-*` appears, because handlers are held on this side and never sent." |
| 403 | ([] (dump-str 0)) |
| 404 | ([node] (ffi/tree-dump node))) |
| 405 | |
| 406 | (defn dump |
| 407 | "`dump-str`, read back as hiccup data — vectors, keywords and maps — for a |
| 408 | test that wants to assert on a subtree rather than on a string." |
| 409 | ([] (dump 0)) |
| 410 | ([node] (read-string (dump-str node)))) |
| 411 | |
| 412 | (defn dump! |
| 413 | "Print `dump-str` to stdout. The one you want from a handler or the REPL." |
| 414 | ([] (dump! 0)) |
| 415 | ([node] (println (dump-str node)) nil)) |
| 416 | |
| 417 | ;; --- the backend ------------------------------------------------------------- |
| 418 | (def backend |
| 419 | "The Vidya backend map handed to glimmer.backend/register!. See that |
| 420 | namespace for the contract each key satisfies." |
| 421 | {:name :vidya |
| 422 | :create! create! |
| 423 | :apply-props! apply-props! |
| 424 | :append-child! append-child! |
| 425 | :remove-child! remove-child! |
| 426 | :replace-child! replace-child! |
| 427 | :reorder-child! reorder-child! |
| 428 | :schedule schedule |
| 429 | :run run!}) |
| 430 | |
| 431 | (defn install! |
| 432 | "Make Vidya/egui the backend glimmer renders with. Called on load, so |
| 433 | requiring this namespace is enough; exposed for code that wants to be |
| 434 | explicit, or to switch back after another backend was installed." |
| 435 | [] |
| 436 | (b/register! backend) |
| 437 | nil) |
| 438 | |
| 439 | (defonce ^:private installed (do (install!) true)) |