(ns glimmer-vidya.core "The Vidya/egui backend for glimmer. Requiring this namespace installs it, after which glimmer's portable reconciler renders hiccup into a GPU window: (ns myapp (:require [glimmer.ratom :refer [atom]] [glimmer.core :as ui] [glimmer-vidya.core])) ; installs this backend (defn -main [& _] (ui/run my-app :title \"myapp\")) egui is an immediate-mode toolkit: it has no widgets to hand a reconciler, only calls you make every frame. So the widget tree lives one layer down, in the Rust library, and this namespace is the thin part — it turns glimmer's create/patch/append/remove into node mutations, runs the frame loop, and routes the events that come back to the handlers the components declared. A widget, from the reconciler's point of view, is an integer node handle. It never looks inside one, which is exactly why an id is enough. **Handlers do not cross the FFI.** A jolt closure cannot be a callback in a library painting at 60fps, so identity travels instead: a node reports that it was clicked, and the handler map here says whose `:on-click` that was." (:require [glimmer.backend :as b] [glimmer-vidya.ffi :as ffi])) ;; Node id -> the :on-* props that node was last rendered with. Kept here ;; rather than sent across because a closure has no C representation. ;; ;; Ids are recycled by the arena, which is safe only because every id is ;; written here by `create!` before anything can raise an event against it — a ;; reused id has its predecessor's handlers overwritten in the same breath. (defonce ^:private handlers (atom {})) ;; Work posted from other threads, run on the loop thread at the top of a tick. (defonce ^:private pending (atom [])) ;; Set by quit!, read by the loop. (defonce ^:private quit-requested (atom false)) ;; --- props ------------------------------------------------------------------- ;; :hbox and :vbox are one node in the library; the tag only implies an ;; orientation, and an explicit :orientation prop still wins. (def ^:private tag-orientation {:hbox "horizontal" :vbox "vertical"}) (defn- handler-key? "True for a prop that names an event handler rather than a value." [k] (let [s (name k)] (and (> (count s) 3) (= "on-" (subs s 0 3))))) (defn- set-prop! "Write one prop to a node, in the ABI type that fits its value. nil clears nothing — the prop was already dropped by the clear that precedes a write — and an unrecognized value is stringified rather than refused, so a prop this backend has not learned yet still reaches the library." [node k v] (let [key (name k)] (cond (nil? v) nil (true? v) (ffi/node-set-bool! node key true) (false? v) (ffi/node-set-bool! node key false) (number? v) (ffi/node-set-num! node key (double v)) (string? v) (ffi/node-set-str! node key v) (keyword? v) (ffi/node-set-str! node key (name v)) :else (ffi/node-set-str! node key (str v))))) (defn- write-props! "Replace a node's props with `props`. Cleared first, deliberately: a re-render that stops setting `:placeholder` means the placeholder is gone, and patching in place would leave the old one behind. It also discards the value the library wrote back when the user typed in an entry or clicked a checkbutton — which is the point. The component's state is the truth, and this is the frame where it says so." [node tag props] (ffi/node-clear-props! node) (when-let [orientation (tag-orientation tag)] (when-not (contains? props :orientation) (ffi/node-set-str! node "orientation" orientation))) (doseq [[k v] props] (when-not (handler-key? k) (set-prop! node k v))) (swap! handlers assoc node (reduce (fn [acc [k v]] (if (and (handler-key? k) (fn? v)) (assoc acc k v) acc)) {} props)) nil) (defn- forget-dead-handlers! "Drop handler entries for nodes the library has freed. Removing a subtree frees every node under it, and only the library knows which those were — so rather than mirror the tree here to walk it, the map is filtered against what still exists. It runs once per removal, over a map the size of the UI." [] (swap! handlers (fn [m] (reduce (fn [acc [id hs]] (if (ffi/node-exists? id) (assoc acc id hs) acc)) {} m))) nil) ;; --- the backend operations -------------------------------------------------- (defn- create! "glimmer.backend's :create!. Children are appended by the reconciler, not here." [tag props] (let [node (ffi/node-new (name tag))] (when (zero? node) (throw (ex-info "vidya could not allocate a node" {:tag tag}))) (write-props! node tag props) node)) (defn- apply-props! [tag node props] (write-props! node tag props)) (defn- append-child! [_parent-tag parent child] (ffi/node-append! parent child) nil) (defn- remove-child! [_parent-tag parent child] ;; The library frees the subtree; glimmer never mentions it again. (ffi/node-remove! parent child) (forget-dead-handlers!) nil) (defn- replace-child! [_parent-tag parent old-child new-child] (ffi/node-replace! parent old-child new-child) (forget-dead-handlers!) nil) (defn- reorder-child! [_parent-tag parent child sibling] ;; nil sibling means "first"; the ABI spells that 0. (ffi/node-insert-after! parent child (or sibling 0)) nil) ;; --- the UI thread ----------------------------------------------------------- (defn- schedule "glimmer.backend's :schedule. Every node call belongs to the thread that opened the window, so a ratom mutated on an nREPL worker (or any future) queues its re-render here and the loop performs it on the next tick." [work] (swap! pending conj work) nil) (defn- drain! "Run everything `schedule` queued. compare-and-set! rather than reset!, so work posted while the queue is being taken is not dropped." [] (loop [] (let [q @pending] (when (seq q) (if (compare-and-set! pending q []) (doseq [f q] (f)) (recur)))))) ;; --- timers ------------------------------------------------------------------ ;; A spinner or a clock has to change with nothing being pressed. The loop ;; already wakes every frame, so a timer is a due time and a thunk. Both ;; entry points are safe to call from another thread, and both run their thunk ;; ON the loop thread, the only one allowed to touch nodes. (defonce ^:private timers (atom {:next-id 0 :entries {}})) (defn- now-ms [] (System/currentTimeMillis)) (defn- add-timer! [ms every? f] (let [id (:next-id (swap! timers update :next-id inc))] (swap! timers assoc-in [:entries id] {:due (+ (now-ms) ms) :every (when every? ms) :f f}) id)) (defn after! "Run `f` on the loop thread in about `ms` milliseconds. Returns an id for `cancel!`. Resolution is one frame." [ms f] (add-timer! ms false f)) (defn every! "Run `f` on the loop thread about every `ms` milliseconds until cancelled: (every! 80 #(swap! tick inc))" [ms f] (add-timer! ms true f)) (defn cancel! "Stop the timer `id`." [id] (swap! timers update :entries dissoc id) nil) (defn cancel-all! "Stop every timer, so a repeating one does not outlive the UI it animated." [] (swap! timers assoc :entries {}) nil) (defn- pump-timers! [] (let [t (now-ms) due (reduce (fn [acc [id e]] (if (<= (:due e) t) (conj acc [id e]) acc)) [] (:entries @timers))] (doseq [[id e] due] (if-let [period (:every e)] (swap! timers assoc-in [:entries id :due] (+ t period)) (swap! timers update :entries dissoc id)) ((:f e))) nil)) ;; --- events ------------------------------------------------------------------ (defn- dispatch-events! "Drain the frame's interactions and call the handlers they belong to. An event whose node has no handler for it is dropped, which is what makes a control that ignores its own event still work: the library wrote the new state into the node, and the next render either confirms it or overwrites it." [] (loop [] (when (ffi/poll-event!) (let [node (ffi/event-node) kind (ffi/event-name) hs (get @handlers node)] (case kind "click" (when-let [f (:on-click hs)] (f)) "toggled" (when-let [f (:on-toggled hs)] (f)) ;; The text is read before anything else can overwrite the library's ;; one scratch buffer — jolt copies it as it crosses. "change" (when-let [f (:on-change hs)] (f (ffi/event-text))) "activate" (when-let [f (:on-activate hs)] (f)) ;; The two edges of a pointer hover, not the middle of one: the ;; backend says so when it starts and again when it ends, so a ;; handler can put something on screen and take it away again. "hover" (when-let [f (:on-hover hs)] (f)) "unhover" (when-let [f (:on-unhover hs)] (f)) ;; Ctrl+V on a clipboard with no text on it. What is on it instead is ;; the caller's to find out — `clipboard-image-png!` is the only ;; question the backend answers about it. "paste-empty" (when-let [f (:on-paste-empty hs)] (f)) nil)) (recur)))) ;; --- the event loop ---------------------------------------------------------- (defn- clear-children! "Drop everything under `node`. Removing a child frees it, so this walks the first slot until there is nothing left rather than iterating an index." [node] (loop [] (when (pos? (ffi/node-child-count node)) (ffi/node-remove! node (ffi/node-child-at node 0)) (recur))) (forget-dead-handlers!) nil) (defn set-title! "Rename the open window. `run` names it once at startup; this is for a title that has something to say later — who is signed in, which document is open, what a second window of the same app is for. A no-op before the window exists and after it closes." [title] (ffi/set-title! (str title)) nil) (defn window-width "The width in points of the window's content area, as the last painted frame measured it. 0 before the first frame. This is how a layout asks how much room it has: nothing else here reports a size, and the window is not resizable from this side either. Read it from a timer — `every!` — rather than per render, and hold it in a ratom, so that the components which switch on it re-render only when it actually changes." [] (ffi/node-get-num (ffi/tree-root) "window-width")) (defn clipboard-image-png! "Write the picture on the system clipboard to `path` as a PNG. True when there was one; false for an empty clipboard, text on it, an unwritable path, or a platform with no clipboard of images (Android). A paste of a picture reaches no handler of its own — the backend delivers clipboard text only — so a caller asks for it, from whatever gesture it means paste by. An `:entry`'s `:on-paste-empty` is that gesture where the reader expects it: a Ctrl+V the field had no text to answer with." [path] (ffi/clipboard-image-png! path)) (defn open-url! "Hand `url` to whatever shows web pages here — xdg-open or `open` on a desktop, an ACTION_VIEW intent on Android. True when something took it; false leaves the caller to show the URL and let the reader carry it across. A sign-in that goes through a browser is the reason this exists: the app leaves for a page and the page comes back to it." [url] (ffi/open-url! url)) (defn pick-image! "Open the platform's own picture chooser. True when one opened, false where there is none — a desktop, or an Android activity that does not offer it — and a false is the caller's cue to browse the filesystem itself. It does not answer with the picture. The reader is in another screen by then, so what they chose arrives at `picked-image!`, which the caller polls." [] (ffi/pick-image!)) (defn picked-image! "Move the picture chosen since the last call to `path`; true when there was one. The answer is handed over once, so a poll still running does not take the same picture twice." [path] (ffi/picked-image! path)) (defn screen-size "The window's size in points, as `[width height]`. `[0 0]` before the first frame has been painted. For laying something out as a share of the window — a row of tiles that should divide the width between them — where how many there are and how much gap goes between them is the caller's arithmetic, and not something one widget can work out from the space it was handed. Follows the window as it is dragged, so a component that reads it wants to be re-rendered when it changes: keep it in a ratom rather than asking here at render time, or the layout will be whatever it was on the first frame." [] [(ffi/screen-width) (ffi/screen-height)]) (defn frame-rgba! "Hand the backend a frame of live pixels under `key`. An `:image` node with `:feed key` paints the latest one. `rgba` is a foreign pointer — `width * height * 4` un-premultiplied bytes, row-major — and is copied before this returns, so whoever owns it may reuse it immediately. False when the length does not match the dimensions. This is the source an `:image` `:src` cannot be: a `:src` decodes a file and caches it by path for the life of the process, which is right for a picture in a message and wrong for one that is new thirty times a second. Frames coalesce rather than queue, so a source faster than the window costs nothing. On the window's thread, like everything else here: a frame produced on a decoder thread is the caller's to hand across." [key width height rgba] (ffi/frame-rgba! key width height rgba)) (defn frame-drop! "Forget the feed named `key` and release its texture; true when there was one. A source that has stopped keeps painting its last frame otherwise." [key] (ffi/frame-drop! key)) (defn quit! "Stop the running loop and close the window." [] (reset! quit-requested true) nil) (defn- run! "glimmer.backend's :run. Opens a window, mounts the root component into the library's root node, and paints until the window closes or `quit!` is called. Blocks, like every UI main loop. Options (on top of glimmer's own): :title :width :height the window :fps frame rate cap (default 60) :mode :dark (default) or :light :font path to a TTF/OTF to use for UI text :auto-quit-ms stop after roughly this long — for smoke tests, which have nobody to close the window The window is closed in a finally, so a handler that throws does not leave a GPU surface and an event loop behind." [opts mount-root!] (let [{:keys [title width height fps mode font auto-quit-ms] :or {title "glimmer" width 900 height 640 fps 60}} opts] (when-not (ffi/open! width height title) (throw (ex-info "vidya could not open a window" {:title title :width width :height height}))) (reset! quit-requested false) (ffi/set-target-fps! fps) (ffi/set-mode! (if (= mode :light) ffi/light-mode ffi/dark-mode)) (when font (ffi/load-font! font)) (let [started (now-ms) root (ffi/tree-root)] (try ;; The library's root outlives a run — it is process-wide, not per ;; window — so a second `ui/run` in one session (a REPL, a test) would ;; otherwise mount its tree alongside the last one's. (clear-children! root) (mount-root! root :window) (reset! b/loop-running? true) (loop [] (drain!) (pump-timers!) ;; One call paints the whole tree; the events it produced are read ;; straight after, while the frame that caused them is still the ;; frame the components rendered. (ffi/tree-frame!) (dispatch-events!) (when-not (or @quit-requested (ffi/should-close?) (and auto-quit-ms (>= (- (now-ms) started) auto-quit-ms))) (recur))) (finally (reset! b/loop-running? false) (cancel-all!) (ffi/close!) (reset! handlers {})))))) ;; --- looking at what was rendered -------------------------------------------- (defn dump-str "The rendered tree as hiccup text, read back out of the library. With no argument, the whole window; with a node handle, that subtree. This is the tree as it *is* after the reconciler has run, not what a component returned, so paste it into a bug report and the two can be compared. Two things to know when reading one. `:hbox` and `:vbox` are one node down there and both dump as `:box`, with the orientation in the props; and no `:on-*` appears, because handlers are held on this side and never sent." ([] (dump-str 0)) ([node] (ffi/tree-dump node))) (defn dump "`dump-str`, read back as hiccup data — vectors, keywords and maps — for a test that wants to assert on a subtree rather than on a string." ([] (dump 0)) ([node] (read-string (dump-str node)))) (defn dump! "Print `dump-str` to stdout. The one you want from a handler or the REPL." ([] (dump! 0)) ([node] (println (dump-str node)) nil)) ;; --- the backend ------------------------------------------------------------- (def backend "The Vidya backend map handed to glimmer.backend/register!. See that namespace for the contract each key satisfies." {:name :vidya :create! create! :apply-props! apply-props! :append-child! append-child! :remove-child! remove-child! :replace-child! replace-child! :reorder-child! reorder-child! :schedule schedule :run run!}) (defn install! "Make Vidya/egui the backend glimmer renders with. Called on load, so requiring this namespace is enough; exposed for code that wants to be explicit, or to switch back after another backend was installed." [] (b/register! backend) nil) (defonce ^:private installed (do (install!) true))