nandi/jolt-nativepublic Fork 0
f554a01
Commits
Clone
git clone https://git.rickub.com/nandi/jolt-native.git
git clone ssh://git@rickub.com/nandi/jolt-native.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

Paint glimmer with nothing under it

A third backend beside glimmer-vidya and glimmer-tui, and the first here with
no shared object of its own. The rasterizer, the layout and the font are jolt;
the only foreign code is Xlib, and Xlib draws nothing -- it opens a window and
takes a finished framebuffer.

That makes it the backend that runs where no toolkit is installed, and the one
to read to see what a backend actually has to do. The reconciler needs a tree
to patch, which an immediate-mode painter has not got: glimmer-vidya keeps that
tree in Rust behind a second C ABI because egui hands it nothing to hold, and
here it is sixty lines of atoms, because there is no ABI to cross. What is left
is the work a toolkit would have done for you -- measure bottom-up, place
top-down, hit-test deepest-first, and hold a pressed widget until release so a
slider keeps tracking when the pointer leaves its rect.

The tests drive all of that headless: they mount a real component, run the real
reconciler, and assert against a framebuffer, so they need no window and no
display. They cover the scheduled re-render path as well as the inline one,
which is the path a running window actually takes.

Text is a 3x5 bitmap font authored as art in raster.clj. A real font means
FreeType, which means a crate, at which point this stops being the backend that
runs anywhere.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
nandi committed 2026-09-07T11:58:03-07:00 Browse files
f554a01 parent: 6f016e7
added jolt/glimmer-gfx/README.md +78 -0
new file mode 100644
@@ -0,0 +1,78 @@
1+# glimmer-gfx
2+
3+glimmer's **software** backend: no toolkit, no shared object, no GPU. The
4+rasterizer, the layout and the font are jolt; the only foreign code is Xlib,
5+and Xlib draws nothing — it opens a window and takes a finished framebuffer.
6+
7+It is [glimmer-vidya](../glimmer-vidya) with nothing underneath it. The
8+reconciler does not know what it is patching, so the same hiccup that egui
9+paints as a window and [glimmer-tui](../glimmer-tui) paints as cells is painted
10+here, a pixel at a time, by code in this directory:
11+
12+```clojure
13+(ns myapp
14+ (:require [glimmer.ratom :as ra]
15+ [glimmer.core :as ui]
16+ [glimmer-gfx.core])) ; installs this backend
17+
18+(defn app []
19+ [:card {:spacing 8}
20+ [:title {:label "Counter"}]
21+ [:label {:label (str "Count: " (ra/deref count))}]
22+ [:button {:label "+ 1" :kind :primary :on-click #(ra/swap! count inc)}]])
23+
24+(defn -main [& _] (ui/run app :title "myapp"))
25+```
26+
27+## Running
28+
29+```bash
30+jolt test # headless: no window, no display
31+LD_LIBRARY_PATH=/path/to/libX11 jolt counter
32+```
33+
34+Xlib is a *system* library here, not one of this repo's crates, and it must be
35+one the jolt binary can load — on a nix-built jolt, the host `/usr/lib` copy is
36+a different glibc and fails before `dlopen` returns.
37+
38+## What a backend has to do
39+
40+Read this one first. It is the smallest complete backend in the repo, and the
41+only one where every part of the answer is visible rather than behind an ABI.
42+
43+ src/glimmer_gfx/raster.clj framebuffer, rect/line, a 3x5 font no FFI
44+ src/glimmer_gfx/core.clj the backend: tree, layout, paint no FFI
45+ src/glimmer_gfx/x11.clj the window, and nothing else
46+
47+`glimmer.backend`'s map is mostly trivial — `create!` allocates an atom,
48+`append-child!` conjes onto a vector. The work is what a toolkit would have
49+done for you:
50+
51+* **A tree to patch.** The reconciler needs somewhere to hold widgets between
52+ frames, which an immediate-mode painter has not got. Here it is atoms of
53+ `{:tag :props :children}`, about 60 lines. glimmer-vidya needs the same thing
54+ and keeps it in Rust behind a second C ABI, because egui hands the
55+ reconciler nothing to hold.
56+* **Layout.** `measure!` sizes bottom-up, `place!` positions top-down. This is
57+ most of the file. `:spacing`, `:max-width` and container padding are
58+ honoured; `:align` and `:grow` are not, so children keep their measured
59+ width, left-aligned.
60+* **Hit testing.** Deepest interactive node under the cursor, last match wins.
61+ A press claims a widget and holds it until release, so a slider keeps
62+ tracking when the pointer leaves its rect.
63+
64+Tags: `:page` `:card` `:vbox` `:hbox` `:title` `:label` `:button` `:checkbox`
65+`:slider` `:spacer`. Props are the shared vocabulary — `:label`, `:spacing`,
66+`:max-width`, `:kind`, `:checked`, `:value`/`:min`/`:max`, `:on-click`,
67+`:on-change`.
68+
69+## Text
70+
71+There is no font stack. `raster.clj` carries a 3x5 bitmap font for ASCII,
72+authored as art and editable in place:
73+
74+ "A.#.|#.#|###|#.#|#.#"
75+
76+It scales by whole pixels, which is why the UI looks like it does. A real font
77+means FreeType, which means a crate — at which point this stops being the
78+backend that runs anywhere.
new file mode 100644
@@ -0,0 +1,78 @@
1+# glimmer-gfx
2+
3+glimmer's **software** backend: no toolkit, no shared object, no GPU. The
4+rasterizer, the layout and the font are jolt; the only foreign code is Xlib,
5+and Xlib draws nothing — it opens a window and takes a finished framebuffer.
6+
7+It is [glimmer-vidya](../glimmer-vidya) with nothing underneath it. The
8+reconciler does not know what it is patching, so the same hiccup that egui
9+paints as a window and [glimmer-tui](../glimmer-tui) paints as cells is painted
10+here, a pixel at a time, by code in this directory:
11+
12+```clojure
13+(ns myapp
14+ (:require [glimmer.ratom :as ra]
15+ [glimmer.core :as ui]
16+ [glimmer-gfx.core])) ; installs this backend
17+
18+(defn app []
19+ [:card {:spacing 8}
20+ [:title {:label "Counter"}]
21+ [:label {:label (str "Count: " (ra/deref count))}]
22+ [:button {:label "+ 1" :kind :primary :on-click #(ra/swap! count inc)}]])
23+
24+(defn -main [& _] (ui/run app :title "myapp"))
25+```
26+
27+## Running
28+
29+```bash
30+jolt test # headless: no window, no display
31+LD_LIBRARY_PATH=/path/to/libX11 jolt counter
32+```
33+
34+Xlib is a *system* library here, not one of this repo's crates, and it must be
35+one the jolt binary can load — on a nix-built jolt, the host `/usr/lib` copy is
36+a different glibc and fails before `dlopen` returns.
37+
38+## What a backend has to do
39+
40+Read this one first. It is the smallest complete backend in the repo, and the
41+only one where every part of the answer is visible rather than behind an ABI.
42+
43+ src/glimmer_gfx/raster.clj framebuffer, rect/line, a 3x5 font no FFI
44+ src/glimmer_gfx/core.clj the backend: tree, layout, paint no FFI
45+ src/glimmer_gfx/x11.clj the window, and nothing else
46+
47+`glimmer.backend`'s map is mostly trivial — `create!` allocates an atom,
48+`append-child!` conjes onto a vector. The work is what a toolkit would have
49+done for you:
50+
51+* **A tree to patch.** The reconciler needs somewhere to hold widgets between
52+ frames, which an immediate-mode painter has not got. Here it is atoms of
53+ `{:tag :props :children}`, about 60 lines. glimmer-vidya needs the same thing
54+ and keeps it in Rust behind a second C ABI, because egui hands the
55+ reconciler nothing to hold.
56+* **Layout.** `measure!` sizes bottom-up, `place!` positions top-down. This is
57+ most of the file. `:spacing`, `:max-width` and container padding are
58+ honoured; `:align` and `:grow` are not, so children keep their measured
59+ width, left-aligned.
60+* **Hit testing.** Deepest interactive node under the cursor, last match wins.
61+ A press claims a widget and holds it until release, so a slider keeps
62+ tracking when the pointer leaves its rect.
63+
64+Tags: `:page` `:card` `:vbox` `:hbox` `:title` `:label` `:button` `:checkbox`
65+`:slider` `:spacer`. Props are the shared vocabulary — `:label`, `:spacing`,
66+`:max-width`, `:kind`, `:checked`, `:value`/`:min`/`:max`, `:on-click`,
67+`:on-change`.
68+
69+## Text
70+
71+There is no font stack. `raster.clj` carries a 3x5 bitmap font for ASCII,
72+authored as art and editable in place:
73+
74+ "A.#.|#.#|###|#.#|#.#"
75+
76+It scales by whole pixels, which is why the UI looks like it does. A real font
77+means FreeType, which means a crate — at which point this stops being the
78+backend that runs anywhere.
added jolt/glimmer-gfx/deps.edn +28 -0
new file mode 100644
@@ -0,0 +1,28 @@
1+{:paths ["src"]
2+
3+ ;; glimmer-gfx is a backend for glimmer, beside glimmer-vidya and glimmer-tui
4+ ;; and against the same reconciler: glimmer owns the reactive core and knows
5+ ;; about no toolkit, and this project supplies widgets, painting and the frame
6+ ;; loop.
7+ ;;
8+ ;; Unlike its siblings there is no shared object under it. The rasterizer is
9+ ;; jolt, the layout is jolt, the font is jolt; the only foreign code is Xlib,
10+ ;; and Xlib draws nothing here — it opens a window and takes a finished
11+ ;; framebuffer. So this backend is the one that runs where no toolkit is
12+ ;; installed, and the one to read to see what a backend actually has to do.
13+ :deps {jolt-lang/glimmer {:git/url "https://github.com/jolt-lang/glimmer"
14+ :git/tag "v0.1.0"
15+ :git/sha "5581c331c51aff989259b9e8e92ec920fe5e6741"}}
16+
17+ ;; The system Xlib, not one of this repo's crates. :optional so the tests,
18+ ;; which open no window, run on a machine with no X11 at all.
19+ :jolt/native [{:name "X11" :optional true
20+ :linux ["libX11.so.6"] :darwin ["libX11.6.dylib"]}]
21+
22+ :aliases {:counter {:extra-paths ["examples"]
23+ :main-opts ["-m" "glimmer-gfx.counter"]}
24+ :test {:extra-paths ["test"]
25+ :main-opts ["-m" "glimmer-gfx.tests"]}}
26+
27+ :tasks {counter "jolt -M:counter"
28+ test "jolt -M:test"}}
new file mode 100644
@@ -0,0 +1,28 @@
1+{:paths ["src"]
2+
3+ ;; glimmer-gfx is a backend for glimmer, beside glimmer-vidya and glimmer-tui
4+ ;; and against the same reconciler: glimmer owns the reactive core and knows
5+ ;; about no toolkit, and this project supplies widgets, painting and the frame
6+ ;; loop.
7+ ;;
8+ ;; Unlike its siblings there is no shared object under it. The rasterizer is
9+ ;; jolt, the layout is jolt, the font is jolt; the only foreign code is Xlib,
10+ ;; and Xlib draws nothing here — it opens a window and takes a finished
11+ ;; framebuffer. So this backend is the one that runs where no toolkit is
12+ ;; installed, and the one to read to see what a backend actually has to do.
13+ :deps {jolt-lang/glimmer {:git/url "https://github.com/jolt-lang/glimmer"
14+ :git/tag "v0.1.0"
15+ :git/sha "5581c331c51aff989259b9e8e92ec920fe5e6741"}}
16+
17+ ;; The system Xlib, not one of this repo's crates. :optional so the tests,
18+ ;; which open no window, run on a machine with no X11 at all.
19+ :jolt/native [{:name "X11" :optional true
20+ :linux ["libX11.so.6"] :darwin ["libX11.6.dylib"]}]
21+
22+ :aliases {:counter {:extra-paths ["examples"]
23+ :main-opts ["-m" "glimmer-gfx.counter"]}
24+ :test {:extra-paths ["test"]
25+ :main-opts ["-m" "glimmer-gfx.tests"]}}
26+
27+ :tasks {counter "jolt -M:counter"
28+ test "jolt -M:test"}}
added jolt/glimmer-gfx/examples/glimmer_gfx/counter.clj +31 -0
new file mode 100644
@@ -0,0 +1,31 @@
1+(ns glimmer-gfx.counter
2+ "The glimmer counter, rendered by gfx's own rasterizer.
3+
4+ Compare examples/ in glimmer-vidya: the component is the same shape, because
5+ the component never knew which backend it had."
6+ (:require [glimmer.ratom :as ra]
7+ [glimmer.core :as ui]
8+ [glimmer-gfx.core])) ; installs the backend
9+
10+(def state (ra/atom {:count 0 :volume 40 :loud? false}))
11+
12+(defn app []
13+ (let [{:keys [count volume loud?]} (ra/deref state)]
14+ [:page {:max-width 440}
15+ [:card {:spacing 8}
16+ [:title {:label "Counter"}]
17+ [:label {:label (str "Count: " count)}]
18+ [:hbox {:spacing 8}
19+ [:button {:label "- 1" :on-click #(ra/swap! state update :count dec)}]
20+ [:button {:label "+ 1" :kind :primary
21+ :on-click #(ra/swap! state update :count inc)}]
22+ [:button {:label "reset" :on-click #(ra/swap! state assoc :count 0)}]]
23+ [:spacer {:size 6}]
24+ [:label {:label "Volume" :dim true}]
25+ [:slider {:value volume :min 0 :max 100
26+ :on-change #(ra/swap! state assoc :volume (long %))}]
27+ [:checkbox {:label "loud" :checked loud?
28+ :on-click #(ra/swap! state update :loud? not)}]]]))
29+
30+(defn -main [& _]
31+ (ui/run app :title "glimmer-gfx" :width 480 :height 340))
new file mode 100644
@@ -0,0 +1,31 @@
1+(ns glimmer-gfx.counter
2+ "The glimmer counter, rendered by gfx's own rasterizer.
3+
4+ Compare examples/ in glimmer-vidya: the component is the same shape, because
5+ the component never knew which backend it had."
6+ (:require [glimmer.ratom :as ra]
7+ [glimmer.core :as ui]
8+ [glimmer-gfx.core])) ; installs the backend
9+
10+(def state (ra/atom {:count 0 :volume 40 :loud? false}))
11+
12+(defn app []
13+ (let [{:keys [count volume loud?]} (ra/deref state)]
14+ [:page {:max-width 440}
15+ [:card {:spacing 8}
16+ [:title {:label "Counter"}]
17+ [:label {:label (str "Count: " count)}]
18+ [:hbox {:spacing 8}
19+ [:button {:label "- 1" :on-click #(ra/swap! state update :count dec)}]
20+ [:button {:label "+ 1" :kind :primary
21+ :on-click #(ra/swap! state update :count inc)}]
22+ [:button {:label "reset" :on-click #(ra/swap! state assoc :count 0)}]]
23+ [:spacer {:size 6}]
24+ [:label {:label "Volume" :dim true}]
25+ [:slider {:value volume :min 0 :max 100
26+ :on-change #(ra/swap! state assoc :volume (long %))}]
27+ [:checkbox {:label "loud" :checked loud?
28+ :on-click #(ra/swap! state update :loud? not)}]]]))
29+
30+(defn -main [& _]
31+ (ui/run app :title "glimmer-gfx" :width 480 :height 340))
added jolt/glimmer-gfx/src/glimmer_gfx/core.clj +245 -0
new file mode 100644
@@ -0,0 +1,245 @@
1+(ns glimmer-gfx.core
2+ "A glimmer backend that paints with gfx's software rasterizer.
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 and
6+ glimmer-vidya with a Rust node arena painted by egui. This fills it in with
7+ `gfx.raster`, so the same components render to pixels this project computes
8+ itself, with no toolkit underneath.
9+
10+ A widget here is an atom holding {:tag :props :children :size :rect}. The
11+ reconciler mutates that tree; once a frame the loop measures it, places it,
12+ paints it, and dispatches the mouse against the rects it just computed.
13+
14+ What the reconciler patches is a RETAINED tree, which gfx.core is not -- its
15+ widgets draw and return in one call. So this namespace is the second half that
16+ an immediate-mode library does not have: a tree to hold still between frames.
17+ glimmer-vidya needed the same thing and built it in Rust behind a second C
18+ ABI; here it is 60 lines of Clojure, because there is no ABI to cross."
19+ (:require [glimmer.backend :as b]
20+ [glimmer-gfx.raster :as r]
21+ [glimmer-gfx.x11 :as w]))
22+
23+(def theme {:bg 0x1c1e26 :card 0x22252e :panel 0x373b49 :hot 0x46506e
24+ :active 0x5a6ea0 :fg 0xffffff :dim 0x9aa0b0 :accent 0x5a6ea0})
25+
26+(def ^:private PAD 10) ; container inset
27+(def ^:private GAP 6) ; default gap between siblings
28+(def ^:private LINE 14) ; label box height at scale 2
29+(def ^:private BTN-H 26)
30+(def ^:private ROW-H 20)
31+
32+;; --- the widget tree ---------------------------------------------------------
33+
34+(defn- node [tag props] (atom {:tag tag :props props :children []}))
35+
36+(defn- create! [tag props] (node tag props))
37+(defn- apply-props! [_tag n props] (swap! n assoc :props props) nil)
38+(defn- append-child! [_t parent child] (swap! parent update :children conj child) nil)
39+(defn- remove-child! [_t parent child]
40+ (swap! parent update :children #(vec (remove #{child} %))) nil)
41+(defn- replace-child! [_t parent old new]
42+ (swap! parent update :children #(mapv (fn [c] (if (= c old) new c)) %)) nil)
43+(defn- reorder-child! [_t parent child sibling]
44+ (swap! parent update :children
45+ (fn [cs]
46+ (let [cs (vec (remove #{child} cs))
47+ i (if (nil? sibling) 0 (inc (.indexOf cs sibling)))]
48+ (vec (concat (subvec cs 0 i) [child] (subvec cs i))))))
49+ nil)
50+
51+;; --- measure -----------------------------------------------------------------
52+;; Bottom-up: every node learns its own [w h] given the width available to it.
53+
54+(defn- container? [tag] (contains? #{:vbox :hbox :card :page} tag))
55+(defn- horizontal? [n] (= :hbox (:tag @n)))
56+(defn- inset [tag] (if (#{:card :page} tag) PAD 0))
57+(defn- txt [props] (str (or (:label props) (:text props) "")))
58+
59+(defn- measure! [n avail]
60+ (let [{:keys [tag props children]} @n
61+ gap (or (:spacing props) GAP)
62+ pad (inset tag)
63+ avail (- (min avail (or (:max-width props) avail)) (* 2 pad))
64+ size
65+ (cond
66+ (container? tag)
67+ (let [sizes (mapv #(measure! % avail) children)
68+ ws (map first sizes) hs (map second sizes)
69+ n' (max 0 (dec (count children)))]
70+ (if (= :hbox tag)
71+ [(+ (reduce + 0 ws) (* gap n')) (reduce max 0 hs)]
72+ [(reduce max 0 ws) (+ (reduce + 0 hs) (* gap n'))]))
73+
74+ (= :title tag) [(r/text-w (txt props) 3) 22]
75+ (= :label tag) [(r/text-w (txt props) 2) LINE]
76+ (= :button tag) [(+ 24 (r/text-w (txt props) 2)) BTN-H]
77+ (= :checkbox tag) [(+ 24 (r/text-w (txt props) 2)) ROW-H]
78+ (= :slider tag) [(min avail 200) ROW-H]
79+ (= :spacer tag) [0 (or (:size props) 8)]
80+ :else [(r/text-w (txt props) 2) LINE])
81+ size [(+ (first size) (* 2 pad)) (+ (second size) (* 2 pad))]]
82+ (swap! n assoc :size size)
83+ size))
84+
85+;; --- place -------------------------------------------------------------------
86+;; Top-down: every node learns where it sits. Children keep their measured
87+;; width, left-aligned. ponytail: no :align/:grow -- add them when a layout
88+;; actually needs to stretch.
89+
90+(defn- place! [n x y]
91+ (let [{:keys [tag props children size]} @n
92+ pad (inset tag)
93+ gap (or (:spacing props) GAP)]
94+ (swap! n assoc :rect (into [x y] size))
95+ (loop [cs children, cx (+ x pad), cy (+ y pad)]
96+ (when-let [c (first cs)]
97+ (place! c cx cy)
98+ (let [[cw ch] (:size @c)]
99+ (if (= :hbox tag)
100+ (recur (rest cs) (+ cx cw gap) cy)
101+ (recur (rest cs) cx (+ cy ch gap))))))))
102+
103+;; --- paint -------------------------------------------------------------------
104+
105+(defn- paint! [buf n hot active]
106+ (let [{:keys [tag props children rect]} @n
107+ [x y wd ht] rect
108+ s (txt props)]
109+ (case tag
110+ :card (r/rect! buf x y wd ht (:card theme))
111+ :page nil
112+ (:vbox :hbox) nil
113+ :title (r/text! buf x (+ y 4) s (:fg theme) 3)
114+ :label (r/text! buf x (+ y 2) s (if (:dim props) (:dim theme) (:fg theme)) 2)
115+ :button
116+ (do (r/rect! buf x y wd ht
117+ (cond (= n active) (:active theme)
118+ (= n hot) (:hot theme)
119+ (= :primary (:kind props)) (:accent theme)
120+ :else (:panel theme)))
121+ (r/text! buf (+ x (quot (- wd (r/text-w s 2)) 2)) (+ y 8) s (:fg theme) 2))
122+ :checkbox
123+ (do (r/rect! buf x (+ y 3) 14 14 (:panel theme))
124+ (when (:checked props) (r/rect! buf (+ x 3) (+ y 6) 8 8 (:accent theme)))
125+ (r/text! buf (+ x 20) (+ y 4) s (:fg theme) 2))
126+ :slider
127+ (let [{:keys [value min max] :or {value 0 min 0 max 100}} props
128+ t (if (= max min) 0 (/ (- value min) (double (- max min))))]
129+ (r/rect! buf x y wd ht (:panel theme))
130+ (r/rect! buf x y (int (* wd t)) ht (:accent theme))
131+ (r/text! buf (+ x 6) (+ y 5) (str (long value)) (:fg theme) 2))
132+ nil)
133+ (doseq [c children] (paint! buf c hot active))))
134+
135+;; --- hit testing and events --------------------------------------------------
136+
137+(defn- interactive? [n]
138+ (let [{:keys [tag props]} @n]
139+ (or (= :slider tag) (:on-click props) (:on-change props))))
140+
141+(defn- in? [[x y wd ht] [mx my]]
142+ (and (<= x mx (+ x wd)) (<= y my (+ y ht))))
143+
144+(defn- hit
145+ "Deepest interactive node under the point. Children paint over parents, so
146+ the last match wins."
147+ [n p]
148+ (let [{:keys [children rect]} @n]
149+ (or (some #(hit % p) (reverse children))
150+ (when (and rect (in? rect p) (interactive? n)) n))))
151+
152+(defn- fire! [n k & args]
153+ (when-let [f (get (:props @n) k)] (apply f args)))
154+
155+(defn- slider-value [n [mx _]]
156+ (let [{:keys [props rect]} @n
157+ {:keys [min max] :or {min 0 max 100}} props
158+ [x _ wd _] rect]
159+ (-> (- mx x) (/ (double wd)) (clojure.core/max 0.0) (clojure.core/min 1.0)
160+ (* (- max min)) (+ min))))
161+
162+(defn- dispatch!
163+ "Route one frame of mouse input against the rects just placed."
164+ [root input state]
165+ (let [{:keys [mouse down? released?]} input
166+ {:keys [active prev-down]} @state
167+ over (hit root mouse)]
168+ (swap! state assoc :hot over :prev-down down?)
169+ (cond
170+ ;; press edge: claim the widget under the cursor
171+ (and down? (not prev-down))
172+ (do (swap! state assoc :active over)
173+ (when (and over (= :slider (:tag @over)))
174+ (fire! over :on-change (slider-value over mouse))))
175+
176+ ;; drag: only a slider tracks outside its own rect
177+ (and down? active (= :slider (:tag @active)))
178+ (fire! active :on-change (slider-value active mouse))
179+
180+ released?
181+ (do (when (and active (= active over)) (fire! active :on-click))
182+ (swap! state assoc :active nil)))))
183+
184+;; --- the loop ----------------------------------------------------------------
185+
186+(defonce ^:private pending (atom []))
187+
188+(defn- schedule! [work]
189+ (swap! pending conj work) nil)
190+
191+(defn- drain-pending! []
192+ (let [[ws] (reset-vals! pending [])]
193+ (doseq [w ws] (w))))
194+
195+(defn- run!
196+ "glimmer.backend's :run. Creates the root container, mounts into it, then owns
197+ the loop until the window closes."
198+ [opts mount-root!]
199+ (let [{:keys [title width height auto-quit-ms]
200+ :or {title "glimmer" width 480 height 320}} opts
201+ root (create! :vbox {:padding PAD})
202+ state (atom {})]
203+ (mount-root! root :vbox)
204+ (reset! b/loop-running? true)
205+ (try
206+ (w/run-window
207+ {:width width :height height :title title :auto-quit-ms auto-quit-ms}
208+ (fn [buf input]
209+ (drain-pending!) ; re-renders queued by handlers
210+ (measure! root width)
211+ (place! root 0 0)
212+ (dispatch! root input state)
213+ (r/clear buf (:bg theme))
214+ (paint! buf root (:hot @state) (:active @state))))
215+ (finally (reset! b/loop-running? false)))))
216+
217+;; --- registration ------------------------------------------------------------
218+
219+(def backend
220+ {:name :gfx
221+ :create! create! :apply-props! apply-props!
222+ :append-child! append-child! :remove-child! remove-child!
223+ :replace-child! replace-child! :reorder-child! reorder-child!
224+ :schedule schedule! :run run!})
225+
226+(b/register! backend)
227+
228+;; --- headless driving, for tests ---------------------------------------------
229+
230+(defn render-once
231+ "Measure, place and paint `root` into `buf` with no window. Returns the state
232+ atom after dispatching `input`, so a test can drive clicks without X11."
233+ ([root buf w] (render-once root buf w {:mouse [-1 -1] :down? false} (atom {})))
234+ ([root buf w input state]
235+ (drain-pending!) ; same order as the real loop
236+ (measure! root w)
237+ (place! root 0 0)
238+ (dispatch! root input state)
239+ (r/clear buf (:bg theme))
240+ (paint! buf root (:hot @state) (:active @state))
241+ state))
242+
243+(defn root-node
244+ "A bare root container, for mounting into without a window."
245+ [] (create! :vbox {}))
new file mode 100644
@@ -0,0 +1,245 @@
1+(ns glimmer-gfx.core
2+ "A glimmer backend that paints with gfx's software rasterizer.
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 and
6+ glimmer-vidya with a Rust node arena painted by egui. This fills it in with
7+ `gfx.raster`, so the same components render to pixels this project computes
8+ itself, with no toolkit underneath.
9+
10+ A widget here is an atom holding {:tag :props :children :size :rect}. The
11+ reconciler mutates that tree; once a frame the loop measures it, places it,
12+ paints it, and dispatches the mouse against the rects it just computed.
13+
14+ What the reconciler patches is a RETAINED tree, which gfx.core is not -- its
15+ widgets draw and return in one call. So this namespace is the second half that
16+ an immediate-mode library does not have: a tree to hold still between frames.
17+ glimmer-vidya needed the same thing and built it in Rust behind a second C
18+ ABI; here it is 60 lines of Clojure, because there is no ABI to cross."
19+ (:require [glimmer.backend :as b]
20+ [glimmer-gfx.raster :as r]
21+ [glimmer-gfx.x11 :as w]))
22+
23+(def theme {:bg 0x1c1e26 :card 0x22252e :panel 0x373b49 :hot 0x46506e
24+ :active 0x5a6ea0 :fg 0xffffff :dim 0x9aa0b0 :accent 0x5a6ea0})
25+
26+(def ^:private PAD 10) ; container inset
27+(def ^:private GAP 6) ; default gap between siblings
28+(def ^:private LINE 14) ; label box height at scale 2
29+(def ^:private BTN-H 26)
30+(def ^:private ROW-H 20)
31+
32+;; --- the widget tree ---------------------------------------------------------
33+
34+(defn- node [tag props] (atom {:tag tag :props props :children []}))
35+
36+(defn- create! [tag props] (node tag props))
37+(defn- apply-props! [_tag n props] (swap! n assoc :props props) nil)
38+(defn- append-child! [_t parent child] (swap! parent update :children conj child) nil)
39+(defn- remove-child! [_t parent child]
40+ (swap! parent update :children #(vec (remove #{child} %))) nil)
41+(defn- replace-child! [_t parent old new]
42+ (swap! parent update :children #(mapv (fn [c] (if (= c old) new c)) %)) nil)
43+(defn- reorder-child! [_t parent child sibling]
44+ (swap! parent update :children
45+ (fn [cs]
46+ (let [cs (vec (remove #{child} cs))
47+ i (if (nil? sibling) 0 (inc (.indexOf cs sibling)))]
48+ (vec (concat (subvec cs 0 i) [child] (subvec cs i))))))
49+ nil)
50+
51+;; --- measure -----------------------------------------------------------------
52+;; Bottom-up: every node learns its own [w h] given the width available to it.
53+
54+(defn- container? [tag] (contains? #{:vbox :hbox :card :page} tag))
55+(defn- horizontal? [n] (= :hbox (:tag @n)))
56+(defn- inset [tag] (if (#{:card :page} tag) PAD 0))
57+(defn- txt [props] (str (or (:label props) (:text props) "")))
58+
59+(defn- measure! [n avail]
60+ (let [{:keys [tag props children]} @n
61+ gap (or (:spacing props) GAP)
62+ pad (inset tag)
63+ avail (- (min avail (or (:max-width props) avail)) (* 2 pad))
64+ size
65+ (cond
66+ (container? tag)
67+ (let [sizes (mapv #(measure! % avail) children)
68+ ws (map first sizes) hs (map second sizes)
69+ n' (max 0 (dec (count children)))]
70+ (if (= :hbox tag)
71+ [(+ (reduce + 0 ws) (* gap n')) (reduce max 0 hs)]
72+ [(reduce max 0 ws) (+ (reduce + 0 hs) (* gap n'))]))
73+
74+ (= :title tag) [(r/text-w (txt props) 3) 22]
75+ (= :label tag) [(r/text-w (txt props) 2) LINE]
76+ (= :button tag) [(+ 24 (r/text-w (txt props) 2)) BTN-H]
77+ (= :checkbox tag) [(+ 24 (r/text-w (txt props) 2)) ROW-H]
78+ (= :slider tag) [(min avail 200) ROW-H]
79+ (= :spacer tag) [0 (or (:size props) 8)]
80+ :else [(r/text-w (txt props) 2) LINE])
81+ size [(+ (first size) (* 2 pad)) (+ (second size) (* 2 pad))]]
82+ (swap! n assoc :size size)
83+ size))
84+
85+;; --- place -------------------------------------------------------------------
86+;; Top-down: every node learns where it sits. Children keep their measured
87+;; width, left-aligned. ponytail: no :align/:grow -- add them when a layout
88+;; actually needs to stretch.
89+
90+(defn- place! [n x y]
91+ (let [{:keys [tag props children size]} @n
92+ pad (inset tag)
93+ gap (or (:spacing props) GAP)]
94+ (swap! n assoc :rect (into [x y] size))
95+ (loop [cs children, cx (+ x pad), cy (+ y pad)]
96+ (when-let [c (first cs)]
97+ (place! c cx cy)
98+ (let [[cw ch] (:size @c)]
99+ (if (= :hbox tag)
100+ (recur (rest cs) (+ cx cw gap) cy)
101+ (recur (rest cs) cx (+ cy ch gap))))))))
102+
103+;; --- paint -------------------------------------------------------------------
104+
105+(defn- paint! [buf n hot active]
106+ (let [{:keys [tag props children rect]} @n
107+ [x y wd ht] rect
108+ s (txt props)]
109+ (case tag
110+ :card (r/rect! buf x y wd ht (:card theme))
111+ :page nil
112+ (:vbox :hbox) nil
113+ :title (r/text! buf x (+ y 4) s (:fg theme) 3)
114+ :label (r/text! buf x (+ y 2) s (if (:dim props) (:dim theme) (:fg theme)) 2)
115+ :button
116+ (do (r/rect! buf x y wd ht
117+ (cond (= n active) (:active theme)
118+ (= n hot) (:hot theme)
119+ (= :primary (:kind props)) (:accent theme)
120+ :else (:panel theme)))
121+ (r/text! buf (+ x (quot (- wd (r/text-w s 2)) 2)) (+ y 8) s (:fg theme) 2))
122+ :checkbox
123+ (do (r/rect! buf x (+ y 3) 14 14 (:panel theme))
124+ (when (:checked props) (r/rect! buf (+ x 3) (+ y 6) 8 8 (:accent theme)))
125+ (r/text! buf (+ x 20) (+ y 4) s (:fg theme) 2))
126+ :slider
127+ (let [{:keys [value min max] :or {value 0 min 0 max 100}} props
128+ t (if (= max min) 0 (/ (- value min) (double (- max min))))]
129+ (r/rect! buf x y wd ht (:panel theme))
130+ (r/rect! buf x y (int (* wd t)) ht (:accent theme))
131+ (r/text! buf (+ x 6) (+ y 5) (str (long value)) (:fg theme) 2))
132+ nil)
133+ (doseq [c children] (paint! buf c hot active))))
134+
135+;; --- hit testing and events --------------------------------------------------
136+
137+(defn- interactive? [n]
138+ (let [{:keys [tag props]} @n]
139+ (or (= :slider tag) (:on-click props) (:on-change props))))
140+
141+(defn- in? [[x y wd ht] [mx my]]
142+ (and (<= x mx (+ x wd)) (<= y my (+ y ht))))
143+
144+(defn- hit
145+ "Deepest interactive node under the point. Children paint over parents, so
146+ the last match wins."
147+ [n p]
148+ (let [{:keys [children rect]} @n]
149+ (or (some #(hit % p) (reverse children))
150+ (when (and rect (in? rect p) (interactive? n)) n))))
151+
152+(defn- fire! [n k & args]
153+ (when-let [f (get (:props @n) k)] (apply f args)))
154+
155+(defn- slider-value [n [mx _]]
156+ (let [{:keys [props rect]} @n
157+ {:keys [min max] :or {min 0 max 100}} props
158+ [x _ wd _] rect]
159+ (-> (- mx x) (/ (double wd)) (clojure.core/max 0.0) (clojure.core/min 1.0)
160+ (* (- max min)) (+ min))))
161+
162+(defn- dispatch!
163+ "Route one frame of mouse input against the rects just placed."
164+ [root input state]
165+ (let [{:keys [mouse down? released?]} input
166+ {:keys [active prev-down]} @state
167+ over (hit root mouse)]
168+ (swap! state assoc :hot over :prev-down down?)
169+ (cond
170+ ;; press edge: claim the widget under the cursor
171+ (and down? (not prev-down))
172+ (do (swap! state assoc :active over)
173+ (when (and over (= :slider (:tag @over)))
174+ (fire! over :on-change (slider-value over mouse))))
175+
176+ ;; drag: only a slider tracks outside its own rect
177+ (and down? active (= :slider (:tag @active)))
178+ (fire! active :on-change (slider-value active mouse))
179+
180+ released?
181+ (do (when (and active (= active over)) (fire! active :on-click))
182+ (swap! state assoc :active nil)))))
183+
184+;; --- the loop ----------------------------------------------------------------
185+
186+(defonce ^:private pending (atom []))
187+
188+(defn- schedule! [work]
189+ (swap! pending conj work) nil)
190+
191+(defn- drain-pending! []
192+ (let [[ws] (reset-vals! pending [])]
193+ (doseq [w ws] (w))))
194+
195+(defn- run!
196+ "glimmer.backend's :run. Creates the root container, mounts into it, then owns
197+ the loop until the window closes."
198+ [opts mount-root!]
199+ (let [{:keys [title width height auto-quit-ms]
200+ :or {title "glimmer" width 480 height 320}} opts
201+ root (create! :vbox {:padding PAD})
202+ state (atom {})]
203+ (mount-root! root :vbox)
204+ (reset! b/loop-running? true)
205+ (try
206+ (w/run-window
207+ {:width width :height height :title title :auto-quit-ms auto-quit-ms}
208+ (fn [buf input]
209+ (drain-pending!) ; re-renders queued by handlers
210+ (measure! root width)
211+ (place! root 0 0)
212+ (dispatch! root input state)
213+ (r/clear buf (:bg theme))
214+ (paint! buf root (:hot @state) (:active @state))))
215+ (finally (reset! b/loop-running? false)))))
216+
217+;; --- registration ------------------------------------------------------------
218+
219+(def backend
220+ {:name :gfx
221+ :create! create! :apply-props! apply-props!
222+ :append-child! append-child! :remove-child! remove-child!
223+ :replace-child! replace-child! :reorder-child! reorder-child!
224+ :schedule schedule! :run run!})
225+
226+(b/register! backend)
227+
228+;; --- headless driving, for tests ---------------------------------------------
229+
230+(defn render-once
231+ "Measure, place and paint `root` into `buf` with no window. Returns the state
232+ atom after dispatching `input`, so a test can drive clicks without X11."
233+ ([root buf w] (render-once root buf w {:mouse [-1 -1] :down? false} (atom {})))
234+ ([root buf w input state]
235+ (drain-pending!) ; same order as the real loop
236+ (measure! root w)
237+ (place! root 0 0)
238+ (dispatch! root input state)
239+ (r/clear buf (:bg theme))
240+ (paint! buf root (:hot @state) (:active @state))
241+ state))
242+
243+(defn root-node
244+ "A bare root container, for mounting into without a window."
245+ [] (create! :vbox {}))
added jolt/glimmer-gfx/src/glimmer_gfx/raster.clj +84 -0
new file mode 100644
@@ -0,0 +1,84 @@
1+(ns glimmer-gfx.raster
2+ "Software rasterizer over an int-array of 0xRRGGBB. Nothing here knows what
3+ a window is -- and nothing here calls into Java. aget/aset/int-array/abs are
4+ all clojure.core; the ^ints/^long hints are hints, not calls."
5+ (:require [clojure.string :as str]))
6+
7+(defrecord Buf [^ints px ^long w ^long h])
8+
9+(defn buf [w h] (->Buf (int-array (* w h)) w h))
10+
11+(defn clear [{:keys [^ints px]} ^long c]
12+ (let [c (int c)] (dotimes [i (alength px)] (aset px i c))))
13+
14+(defn px!
15+ [{:keys [^ints px ^long w ^long h]} ^long x ^long y ^long c]
16+ (when (and (>= x 0) (< x w) (>= y 0) (< y h))
17+ (aset px (unchecked-add (unchecked-multiply y w) x) (int c))))
18+
19+(defn rect!
20+ "Filled, clipped."
21+ [{:keys [^ints px ^long w ^long h]} x0 y0 rw rh c]
22+ (let [x1 (min w (+ x0 rw)) y1 (min h (+ y0 rh))
23+ c (int c)]
24+ (loop [y (max 0 y0)]
25+ (when (< y y1)
26+ (let [row (unchecked-multiply y w)]
27+ (loop [x (max 0 x0)]
28+ (when (< x x1)
29+ (aset px (unchecked-add row x) c)
30+ (recur (inc x)))))
31+ (recur (inc y))))))
32+
33+(defn line!
34+ "Bresenham. ponytail: no AA -- add Wu's if the jaggies bother you."
35+ [b x0 y0 x1 y1 c]
36+ (let [dx (abs (- x1 x0)) dy (- (abs (- y1 y0)))
37+ sx (if (< x0 x1) 1 -1) sy (if (< y0 y1) 1 -1)]
38+ (loop [x x0 y y0 err (+ dx dy)]
39+ (px! b x y c)
40+ (when-not (and (= x x1) (= y y1))
41+ (let [e2 (* 2 err)]
42+ (recur (if (>= e2 dy) (+ x sx) x)
43+ (if (<= e2 dx) (+ y sy) y)
44+ (cond-> err (>= e2 dy) (+ dy) (<= e2 dx) (+ dx))))))))
45+
46+;; ------------------------------------------------------------------ 3x5 font
47+;; Authored as strings so a glyph is readable and fixable in place.
48+
49+(def ^:private glyphs
50+ (->> ["A.#.|#.#|###|#.#|#.#" "B##.|#.#|##.|#.#|##." "C.##|#..|#..|#..|.##"
51+ "D##.|#.#|#.#|#.#|##." "E###|#..|##.|#..|###" "F###|#..|##.|#..|#.."
52+ "G.##|#..|#.#|#.#|.##" "H#.#|#.#|###|#.#|#.#" "I###|.#.|.#.|.#.|###"
53+ "J..#|..#|..#|#.#|.#." "K#.#|#.#|##.|#.#|#.#" "L#..|#..|#..|#..|###"
54+ "M#.#|###|###|#.#|#.#" "N##.|#.#|#.#|#.#|#.#" "O.#.|#.#|#.#|#.#|.#."
55+ "P##.|#.#|##.|#..|#.." "Q.#.|#.#|#.#|###|.##" "R##.|#.#|##.|#.#|#.#"
56+ "S.##|#..|.#.|..#|##." "T###|.#.|.#.|.#.|.#." "U#.#|#.#|#.#|#.#|.##"
57+ "V#.#|#.#|#.#|#.#|.#." "W#.#|#.#|###|###|#.#" "X#.#|#.#|.#.|#.#|#.#"
58+ "Y#.#|#.#|.#.|.#.|.#." "Z###|..#|.#.|#..|###"
59+ "0###|#.#|#.#|#.#|###" "1.#.|##.|.#.|.#.|###" "2##.|..#|.#.|#..|###"
60+ "3##.|..#|.#.|..#|##." "4#.#|#.#|###|..#|..#" "5###|#..|##.|..#|##."
61+ "6.##|#..|###|#.#|###" "7###|..#|.#.|.#.|.#." "8###|#.#|###|#.#|###"
62+ "9###|#.#|###|..#|##."
63+ " ...|...|...|...|..." "....|...|...|...|.#." ":...|.#.|...|.#.|..."
64+ "-...|...|###|...|..." "/..#|..#|.#.|#..|#.." "%#.#|..#|.#.|#..|#.#"
65+ "(.#.|#..|#..|#..|.#." ")#..|.#.|.#.|.#.|#.."]
66+ (map (fn [s] [(first s) (->> (subs s 1) (remove #{\|}) (partition 3)
67+ (mapv str/join))]))
68+ (into {})))
69+
70+(def ^:const glyph-w 4) ; 3px + 1px gap
71+
72+(defn text!
73+ "Draws s at (x,y), scaled. Unknown chars render blank."
74+ ([b x y s c] (text! b x y s c 2))
75+ ([b x y s c k]
76+ (let [s (str/upper-case (str s))]
77+ (dotimes [i (count s)]
78+ (when-let [g (glyphs (nth s i))]
79+ (dotimes [row 5]
80+ (dotimes [col 3]
81+ (when (= \# (nth (g row) col))
82+ (rect! b (+ x (* k (+ (* i glyph-w) col))) (+ y (* k row)) k k c)))))))))
83+
84+(defn text-w ^long [s ^long k] (* k glyph-w (count s)))
new file mode 100644
@@ -0,0 +1,84 @@
1+(ns glimmer-gfx.raster
2+ "Software rasterizer over an int-array of 0xRRGGBB. Nothing here knows what
3+ a window is -- and nothing here calls into Java. aget/aset/int-array/abs are
4+ all clojure.core; the ^ints/^long hints are hints, not calls."
5+ (:require [clojure.string :as str]))
6+
7+(defrecord Buf [^ints px ^long w ^long h])
8+
9+(defn buf [w h] (->Buf (int-array (* w h)) w h))
10+
11+(defn clear [{:keys [^ints px]} ^long c]
12+ (let [c (int c)] (dotimes [i (alength px)] (aset px i c))))
13+
14+(defn px!
15+ [{:keys [^ints px ^long w ^long h]} ^long x ^long y ^long c]
16+ (when (and (>= x 0) (< x w) (>= y 0) (< y h))
17+ (aset px (unchecked-add (unchecked-multiply y w) x) (int c))))
18+
19+(defn rect!
20+ "Filled, clipped."
21+ [{:keys [^ints px ^long w ^long h]} x0 y0 rw rh c]
22+ (let [x1 (min w (+ x0 rw)) y1 (min h (+ y0 rh))
23+ c (int c)]
24+ (loop [y (max 0 y0)]
25+ (when (< y y1)
26+ (let [row (unchecked-multiply y w)]
27+ (loop [x (max 0 x0)]
28+ (when (< x x1)
29+ (aset px (unchecked-add row x) c)
30+ (recur (inc x)))))
31+ (recur (inc y))))))
32+
33+(defn line!
34+ "Bresenham. ponytail: no AA -- add Wu's if the jaggies bother you."
35+ [b x0 y0 x1 y1 c]
36+ (let [dx (abs (- x1 x0)) dy (- (abs (- y1 y0)))
37+ sx (if (< x0 x1) 1 -1) sy (if (< y0 y1) 1 -1)]
38+ (loop [x x0 y y0 err (+ dx dy)]
39+ (px! b x y c)
40+ (when-not (and (= x x1) (= y y1))
41+ (let [e2 (* 2 err)]
42+ (recur (if (>= e2 dy) (+ x sx) x)
43+ (if (<= e2 dx) (+ y sy) y)
44+ (cond-> err (>= e2 dy) (+ dy) (<= e2 dx) (+ dx))))))))
45+
46+;; ------------------------------------------------------------------ 3x5 font
47+;; Authored as strings so a glyph is readable and fixable in place.
48+
49+(def ^:private glyphs
50+ (->> ["A.#.|#.#|###|#.#|#.#" "B##.|#.#|##.|#.#|##." "C.##|#..|#..|#..|.##"
51+ "D##.|#.#|#.#|#.#|##." "E###|#..|##.|#..|###" "F###|#..|##.|#..|#.."
52+ "G.##|#..|#.#|#.#|.##" "H#.#|#.#|###|#.#|#.#" "I###|.#.|.#.|.#.|###"
53+ "J..#|..#|..#|#.#|.#." "K#.#|#.#|##.|#.#|#.#" "L#..|#..|#..|#..|###"
54+ "M#.#|###|###|#.#|#.#" "N##.|#.#|#.#|#.#|#.#" "O.#.|#.#|#.#|#.#|.#."
55+ "P##.|#.#|##.|#..|#.." "Q.#.|#.#|#.#|###|.##" "R##.|#.#|##.|#.#|#.#"
56+ "S.##|#..|.#.|..#|##." "T###|.#.|.#.|.#.|.#." "U#.#|#.#|#.#|#.#|.##"
57+ "V#.#|#.#|#.#|#.#|.#." "W#.#|#.#|###|###|#.#" "X#.#|#.#|.#.|#.#|#.#"
58+ "Y#.#|#.#|.#.|.#.|.#." "Z###|..#|.#.|#..|###"
59+ "0###|#.#|#.#|#.#|###" "1.#.|##.|.#.|.#.|###" "2##.|..#|.#.|#..|###"
60+ "3##.|..#|.#.|..#|##." "4#.#|#.#|###|..#|..#" "5###|#..|##.|..#|##."
61+ "6.##|#..|###|#.#|###" "7###|..#|.#.|.#.|.#." "8###|#.#|###|#.#|###"
62+ "9###|#.#|###|..#|##."
63+ " ...|...|...|...|..." "....|...|...|...|.#." ":...|.#.|...|.#.|..."
64+ "-...|...|###|...|..." "/..#|..#|.#.|#..|#.." "%#.#|..#|.#.|#..|#.#"
65+ "(.#.|#..|#..|#..|.#." ")#..|.#.|.#.|.#.|#.."]
66+ (map (fn [s] [(first s) (->> (subs s 1) (remove #{\|}) (partition 3)
67+ (mapv str/join))]))
68+ (into {})))
69+
70+(def ^:const glyph-w 4) ; 3px + 1px gap
71+
72+(defn text!
73+ "Draws s at (x,y), scaled. Unknown chars render blank."
74+ ([b x y s c] (text! b x y s c 2))
75+ ([b x y s c k]
76+ (let [s (str/upper-case (str s))]
77+ (dotimes [i (count s)]
78+ (when-let [g (glyphs (nth s i))]
79+ (dotimes [row 5]
80+ (dotimes [col 3]
81+ (when (= \# (nth (g row) col))
82+ (rect! b (+ x (* k (+ (* i glyph-w) col))) (+ y (* k row)) k k c)))))))))
83+
84+(defn text-w ^long [s ^long k] (* k glyph-w (count s)))
added jolt/glimmer-gfx/src/glimmer_gfx/x11.clj +103 -0
new file mode 100644
@@ -0,0 +1,103 @@
1+(ns glimmer-gfx.x11
2+ "The port layer: Xlib through jolt.ffi, with no toolkit under it. Opens a
3+ surface, blits an int-array, reports the mouse; it draws nothing itself.
4+
5+ `run-window` is the loop glimmer-gfx.core drives; nothing else in this
6+ project talks to X."
7+ (:require [glimmer-gfx.raster :as r] [jolt.ffi :as ffi]))
8+
9+(ffi/defcfn x-open "XOpenDisplay" [:pointer] :pointer)
10+(ffi/defcfn x-screen "XDefaultScreen" [:pointer] :int)
11+(ffi/defcfn x-root "XRootWindow" [:pointer :int] :ulong)
12+(ffi/defcfn x-visual "XDefaultVisual" [:pointer :int] :pointer)
13+(ffi/defcfn x-gc "XDefaultGC" [:pointer :int] :pointer)
14+(ffi/defcfn x-depth "XDefaultDepth" [:pointer :int] :int)
15+(ffi/defcfn x-black "XBlackPixel" [:pointer :int] :ulong)
16+(ffi/defcfn x-create "XCreateSimpleWindow"
17+ [:pointer :ulong :int :int :uint :uint :uint :ulong :ulong] :ulong)
18+(ffi/defcfn x-select "XSelectInput" [:pointer :ulong :long] :int)
19+(ffi/defcfn x-map "XMapWindow" [:pointer :ulong] :int)
20+(ffi/defcfn x-store-name "XStoreName" [:pointer :ulong :pointer] :int)
21+(ffi/defcfn x-atom "XInternAtom" [:pointer :string :int] :ulong)
22+(ffi/defcfn x-protocols "XSetWMProtocols" [:pointer :ulong :pointer :int] :int)
23+(ffi/defcfn x-image "XCreateImage"
24+ [:pointer :pointer :uint :int :int :pointer :uint :uint :int :int] :pointer)
25+(ffi/defcfn x-put "XPutImage"
26+ [:pointer :ulong :pointer :pointer :int :int :int :int :uint :uint] :int)
27+(ffi/defcfn x-flush "XFlush" [:pointer] :int)
28+(ffi/defcfn x-pending "XPending" [:pointer] :int)
29+(ffi/defcfn x-next "XNextEvent" [:pointer :pointer] :int)
30+(ffi/defcfn x-close "XCloseDisplay" [:pointer] :int)
31+
32+(def ^:private ZPixmap 2)
33+(def ^:private event-mask (bit-or 32768 4 8 64 131072)) ; expose|btn|motion|structure
34+
35+;; XEvent is a 192-byte union. Offsets verified against X11/Xlib.h with
36+;; offsetof on x86-64 -- do not infer one struct's layout from another's:
37+;; XButtonEvent x=64 y=68
38+;; XClientMessageEvent message_type=40 format=48 data=56
39+;; XClientMessageEvent has no root/subwindow/time, so its data sits 16 bytes
40+;; earlier than a pointer event's coordinates would suggest. Reading it at 72
41+;; is why the window close button did nothing: the atom never compared equal.
42+(def ^:private EV-SIZE 192)
43+(def ^:private EV-X 64) (def ^:private EV-Y 68) (def ^:private EV-DATA 56)
44+(def ^:private PRESS 4) (def ^:private RELEASE 5)
45+(def ^:private MOTION 6) (def ^:private CLIENT 33)
46+
47+(defn- drain!
48+ "Fold every queued X event into the input map. :quit? on window close."
49+ [dpy ev input wm-delete]
50+ (loop [in input]
51+ (if (zero? (x-pending dpy))
52+ in
53+ (do (x-next dpy ev)
54+ (let [t (ffi/read ev :int 0)
55+ p [(ffi/read ev :int EV-X) (ffi/read ev :int EV-Y)]]
56+ (recur (condp = t
57+ PRESS (assoc in :down? true :mouse p)
58+ RELEASE (assoc in :down? false :released? true :mouse p)
59+ MOTION (assoc in :mouse p)
60+ CLIENT (cond-> in
61+ (= wm-delete (ffi/read ev :ulong EV-DATA))
62+ (assoc :quit? true))
63+ in)))))))
64+
65+(defn run-window
66+ "Open a window and run `frame-fn` once per frame until it closes.
67+
68+ frame-fn is (fn [buf input] ...) -- it paints into `buf`; the loop blits.
69+ :auto-quit-ms closes the window on a timer, for tests."
70+ [{:keys [width height title auto-quit-ms] :or {width 480 height 320 title "gfx"}}
71+ frame-fn]
72+ (let [dpy (x-open ffi/null)]
73+ (when (ffi/null? dpy)
74+ (throw (ex-info "no X display -- is DISPLAY set?" {})))
75+ (let [scr (x-screen dpy)
76+ win (x-create dpy (x-root dpy scr) 0 0 width height 0
77+ (x-black dpy scr) (x-black dpy scr))
78+ gc (x-gc dpy scr)
79+ data (ffi/alloc (* width height 4))
80+ img (x-image dpy (x-visual dpy scr) (x-depth dpy scr) ZPixmap 0
81+ data width height 32 0)
82+ ev (ffi/alloc EV-SIZE)
83+ prot (ffi/alloc 8)
84+ wm-delete (x-atom dpy "WM_DELETE_WINDOW" 0)
85+ buf (r/buf width height)
86+ deadline (when auto-quit-ms (+ (System/currentTimeMillis) auto-quit-ms))]
87+ (ffi/write prot :ulong wm-delete 0)
88+ (x-protocols dpy win prot 1)
89+ (ffi/with-c-string [t title] (x-store-name dpy win t))
90+ (x-select dpy win event-mask)
91+ (x-map dpy win)
92+ (try
93+ (loop [input {:mouse [0 0] :down? false :released? false}]
94+ (let [input (drain! dpy ev input wm-delete)]
95+ (frame-fn buf input)
96+ (ffi/write-array data :int (:px buf))
97+ (x-put dpy win gc img 0 0 0 0 width height)
98+ (x-flush dpy)
99+ (Thread/sleep 16)
100+ (when-not (or (:quit? input)
101+ (and deadline (> (System/currentTimeMillis) deadline)))
102+ (recur (assoc input :released? false)))))
103+ (finally (ffi/free data) (ffi/free ev) (ffi/free prot) (x-close dpy))))))
new file mode 100644
@@ -0,0 +1,103 @@
1+(ns glimmer-gfx.x11
2+ "The port layer: Xlib through jolt.ffi, with no toolkit under it. Opens a
3+ surface, blits an int-array, reports the mouse; it draws nothing itself.
4+
5+ `run-window` is the loop glimmer-gfx.core drives; nothing else in this
6+ project talks to X."
7+ (:require [glimmer-gfx.raster :as r] [jolt.ffi :as ffi]))
8+
9+(ffi/defcfn x-open "XOpenDisplay" [:pointer] :pointer)
10+(ffi/defcfn x-screen "XDefaultScreen" [:pointer] :int)
11+(ffi/defcfn x-root "XRootWindow" [:pointer :int] :ulong)
12+(ffi/defcfn x-visual "XDefaultVisual" [:pointer :int] :pointer)
13+(ffi/defcfn x-gc "XDefaultGC" [:pointer :int] :pointer)
14+(ffi/defcfn x-depth "XDefaultDepth" [:pointer :int] :int)
15+(ffi/defcfn x-black "XBlackPixel" [:pointer :int] :ulong)
16+(ffi/defcfn x-create "XCreateSimpleWindow"
17+ [:pointer :ulong :int :int :uint :uint :uint :ulong :ulong] :ulong)
18+(ffi/defcfn x-select "XSelectInput" [:pointer :ulong :long] :int)
19+(ffi/defcfn x-map "XMapWindow" [:pointer :ulong] :int)
20+(ffi/defcfn x-store-name "XStoreName" [:pointer :ulong :pointer] :int)
21+(ffi/defcfn x-atom "XInternAtom" [:pointer :string :int] :ulong)
22+(ffi/defcfn x-protocols "XSetWMProtocols" [:pointer :ulong :pointer :int] :int)
23+(ffi/defcfn x-image "XCreateImage"
24+ [:pointer :pointer :uint :int :int :pointer :uint :uint :int :int] :pointer)
25+(ffi/defcfn x-put "XPutImage"
26+ [:pointer :ulong :pointer :pointer :int :int :int :int :uint :uint] :int)
27+(ffi/defcfn x-flush "XFlush" [:pointer] :int)
28+(ffi/defcfn x-pending "XPending" [:pointer] :int)
29+(ffi/defcfn x-next "XNextEvent" [:pointer :pointer] :int)
30+(ffi/defcfn x-close "XCloseDisplay" [:pointer] :int)
31+
32+(def ^:private ZPixmap 2)
33+(def ^:private event-mask (bit-or 32768 4 8 64 131072)) ; expose|btn|motion|structure
34+
35+;; XEvent is a 192-byte union. Offsets verified against X11/Xlib.h with
36+;; offsetof on x86-64 -- do not infer one struct's layout from another's:
37+;; XButtonEvent x=64 y=68
38+;; XClientMessageEvent message_type=40 format=48 data=56
39+;; XClientMessageEvent has no root/subwindow/time, so its data sits 16 bytes
40+;; earlier than a pointer event's coordinates would suggest. Reading it at 72
41+;; is why the window close button did nothing: the atom never compared equal.
42+(def ^:private EV-SIZE 192)
43+(def ^:private EV-X 64) (def ^:private EV-Y 68) (def ^:private EV-DATA 56)
44+(def ^:private PRESS 4) (def ^:private RELEASE 5)
45+(def ^:private MOTION 6) (def ^:private CLIENT 33)
46+
47+(defn- drain!
48+ "Fold every queued X event into the input map. :quit? on window close."
49+ [dpy ev input wm-delete]
50+ (loop [in input]
51+ (if (zero? (x-pending dpy))
52+ in
53+ (do (x-next dpy ev)
54+ (let [t (ffi/read ev :int 0)
55+ p [(ffi/read ev :int EV-X) (ffi/read ev :int EV-Y)]]
56+ (recur (condp = t
57+ PRESS (assoc in :down? true :mouse p)
58+ RELEASE (assoc in :down? false :released? true :mouse p)
59+ MOTION (assoc in :mouse p)
60+ CLIENT (cond-> in
61+ (= wm-delete (ffi/read ev :ulong EV-DATA))
62+ (assoc :quit? true))
63+ in)))))))
64+
65+(defn run-window
66+ "Open a window and run `frame-fn` once per frame until it closes.
67+
68+ frame-fn is (fn [buf input] ...) -- it paints into `buf`; the loop blits.
69+ :auto-quit-ms closes the window on a timer, for tests."
70+ [{:keys [width height title auto-quit-ms] :or {width 480 height 320 title "gfx"}}
71+ frame-fn]
72+ (let [dpy (x-open ffi/null)]
73+ (when (ffi/null? dpy)
74+ (throw (ex-info "no X display -- is DISPLAY set?" {})))
75+ (let [scr (x-screen dpy)
76+ win (x-create dpy (x-root dpy scr) 0 0 width height 0
77+ (x-black dpy scr) (x-black dpy scr))
78+ gc (x-gc dpy scr)
79+ data (ffi/alloc (* width height 4))
80+ img (x-image dpy (x-visual dpy scr) (x-depth dpy scr) ZPixmap 0
81+ data width height 32 0)
82+ ev (ffi/alloc EV-SIZE)
83+ prot (ffi/alloc 8)
84+ wm-delete (x-atom dpy "WM_DELETE_WINDOW" 0)
85+ buf (r/buf width height)
86+ deadline (when auto-quit-ms (+ (System/currentTimeMillis) auto-quit-ms))]
87+ (ffi/write prot :ulong wm-delete 0)
88+ (x-protocols dpy win prot 1)
89+ (ffi/with-c-string [t title] (x-store-name dpy win t))
90+ (x-select dpy win event-mask)
91+ (x-map dpy win)
92+ (try
93+ (loop [input {:mouse [0 0] :down? false :released? false}]
94+ (let [input (drain! dpy ev input wm-delete)]
95+ (frame-fn buf input)
96+ (ffi/write-array data :int (:px buf))
97+ (x-put dpy win gc img 0 0 0 0 width height)
98+ (x-flush dpy)
99+ (Thread/sleep 16)
100+ (when-not (or (:quit? input)
101+ (and deadline (> (System/currentTimeMillis) deadline)))
102+ (recur (assoc input :released? false)))))
103+ (finally (ffi/free data) (ffi/free ev) (ffi/free prot) (x-close dpy))))))
added jolt/glimmer-gfx/test/glimmer_gfx/tests.clj +123 -0
new file mode 100644
@@ -0,0 +1,123 @@
1+(ns glimmer-gfx.tests
2+ "Every check here runs headless: the backend measures, places, paints and
3+ dispatches into a plain framebuffer, so none of it needs a window or a
4+ display. `jolt test`."
5+ (:require [glimmer.ratom :as ra]
6+ [glimmer.core :as gui]
7+ [glimmer.backend :as backend]
8+ [glimmer-gfx.core :as gfx]
9+ [glimmer-gfx.raster :as r]
10+ [clojure.string :as str]))
11+
12+;; --- the rasterizer ----------------------------------------------------------
13+
14+(defn- check-raster! []
15+ (let [b (r/buf 200 120)
16+ at (fn [x y] (aget ^ints (:px b) (+ (* y (:w b)) x)))]
17+ (r/clear b 0)
18+ (r/rect! b 10 10 5 5 0xff0000)
19+ (assert (= 0xff0000 (at 12 12)))
20+ (assert (= 0 (at 9 9)) "a rect must not bleed past its edge")
21+ (r/rect! b -5 -5 3 3 0x00ff00) ; offscreen, must not throw
22+ (r/line! b 0 0 199 119 0x0000ff)
23+ (assert (= 0x0000ff (at 0 0)))
24+ (r/clear b 0)
25+ (r/text! b 0 0 "A" 0xffffff 1)
26+ (assert (some #(= 0xffffff (at % 0)) (range 3)) "the glyph drew nothing")))
27+
28+;; --- a component, through the real reconciler --------------------------------
29+
30+(def ^:private clicks (ra/atom 0))
31+
32+(defn- counter []
33+ [:card {}
34+ [:title {:label "Counter"}]
35+ [:label {:label (str "Count: " (ra/deref clicks))}]
36+ [:hbox {:spacing 8}
37+ [:button {:label "-1" :on-click #(ra/swap! clicks dec)}]
38+ [:button {:label "+1" :kind :primary :on-click #(ra/swap! clicks inc)}]]])
39+
40+(defn- walk [n] (cons n (mapcat walk (:children @n))))
41+(defn- tagged [root tag] (first (filter #(= tag (:tag @%)) (walk root))))
42+(defn- buttons [root] (filter #(= :button (:tag @%)) (walk root)))
43+(defn- labelled? [root prefix]
44+ (some #(and (= :label (:tag @%))
45+ (str/starts-with? (str (:label (:props @%))) prefix))
46+ (walk root)))
47+(defn- centre [n]
48+ (let [[x y w h] (:rect @n)] [(+ x (quot w 2)) (+ y (quot h 2))]))
49+
50+(defn- check-tree! [root]
51+ (assert (tagged root :card) "no card in the tree")
52+ (assert (= 2 (count (buttons root))) "expected two buttons")
53+ (assert (labelled? root "Count: 0") "the label did not render the ratom"))
54+
55+(defn- check-layout! [root]
56+ (let [[cx cy] (:rect @(tagged root :card))
57+ [tx ty] (:rect @(tagged root :title))
58+ [_ by bw bh] (:rect @(first (buttons root)))]
59+ (assert (= [0 0] [cx cy]) "the card should sit at the origin")
60+ (assert (= [10 10] [tx ty]) "a card must inset its child by PAD")
61+ (assert (and (pos? bw) (= 26 bh)) "button box is the wrong size")
62+ (assert (> by ty) "the title must be placed above the buttons"))
63+ ;; an hbox lays its children left to right, with the gap it was given
64+ (let [[b1 b2] (map #(:rect @%) (buttons root))]
65+ (assert (= (second b1) (second b2)) "hbox children must share a baseline")
66+ (assert (= (+ (first b1) (nth b1 2) 8) (first b2)) "hbox gap not honoured")))
67+
68+(defn- check-clicks! [root buf]
69+ (let [plus (second (buttons root))
70+ p (centre plus)
71+ state (atom {})
72+ before (ra/deref clicks)]
73+ (gfx/render-once root buf 400 {:mouse p :down? true :released? false} state)
74+ (assert (= before (ra/deref clicks)) "a press alone must not fire the handler")
75+ (gfx/render-once root buf 400 {:mouse p :down? false :released? true} state)
76+ (assert (= (inc before) (ra/deref clicks)) "the click did not fire")
77+ (gfx/render-once root buf 400)
78+ (assert (labelled? root (str "Count: " (inc before)))
79+ "the reactive re-render never reached the tree")
80+
81+ ;; press, then release somewhere else: not a click
82+ (gfx/render-once root buf 400 {:mouse p :down? true :released? false} state)
83+ (gfx/render-once root buf 400 {:mouse [399 239] :down? false :released? true} state)
84+ (assert (= (inc before) (ra/deref clicks)) "releasing off the button must not fire")))
85+
86+(defn- check-painted! [root buf]
87+ (gfx/render-once root buf 400)
88+ (let [at (fn [x y] (aget ^ints (:px buf) (+ (* y (:w buf)) x)))
89+ [x y] (:rect @(tagged root :card))
90+ row (+ 4 (second (:rect @(second (buttons root)))))]
91+ (assert (= (:card gfx/theme) (at (+ x 2) (+ y 2))) "card background not painted")
92+ (assert (some #(= (:accent gfx/theme) (at % row)) (range 400))
93+ "the primary button is not painted in the accent colour")))
94+
95+(defn- check-scheduled! [root buf]
96+ ;; With a loop running, glimmer refuses to re-render inline and posts the work
97+ ;; through :schedule instead. That is the path a real window takes, and a
98+ ;; different one from every check above.
99+ (reset! backend/loop-running? true)
100+ (try
101+ (let [before (ra/deref clicks)
102+ p (centre (second (buttons root)))
103+ state (atom {})]
104+ (gfx/render-once root buf 400 {:mouse p :down? true :released? false} state)
105+ (gfx/render-once root buf 400 {:mouse p :down? false :released? true} state)
106+ (assert (= (inc before) (ra/deref clicks)) "the handler did not fire under a loop")
107+ (gfx/render-once root buf 400) ; the next frame drains the queue
108+ (assert (labelled? root (str "Count: " (inc before)))
109+ "the scheduled re-render never reached the tree"))
110+ (finally (reset! backend/loop-running? false))))
111+
112+(defn -main [& _]
113+ (check-raster!)
114+ (let [root (gfx/root-node)
115+ buf (r/buf 400 240)]
116+ (gui/mount root :vbox [counter])
117+ (gfx/render-once root buf 400)
118+ (check-tree! root)
119+ (check-layout! root)
120+ (check-clicks! root buf)
121+ (check-painted! root buf)
122+ (check-scheduled! root buf))
123+ (println "ok"))
new file mode 100644
@@ -0,0 +1,123 @@
1+(ns glimmer-gfx.tests
2+ "Every check here runs headless: the backend measures, places, paints and
3+ dispatches into a plain framebuffer, so none of it needs a window or a
4+ display. `jolt test`."
5+ (:require [glimmer.ratom :as ra]
6+ [glimmer.core :as gui]
7+ [glimmer.backend :as backend]
8+ [glimmer-gfx.core :as gfx]
9+ [glimmer-gfx.raster :as r]
10+ [clojure.string :as str]))
11+
12+;; --- the rasterizer ----------------------------------------------------------
13+
14+(defn- check-raster! []
15+ (let [b (r/buf 200 120)
16+ at (fn [x y] (aget ^ints (:px b) (+ (* y (:w b)) x)))]
17+ (r/clear b 0)
18+ (r/rect! b 10 10 5 5 0xff0000)
19+ (assert (= 0xff0000 (at 12 12)))
20+ (assert (= 0 (at 9 9)) "a rect must not bleed past its edge")
21+ (r/rect! b -5 -5 3 3 0x00ff00) ; offscreen, must not throw
22+ (r/line! b 0 0 199 119 0x0000ff)
23+ (assert (= 0x0000ff (at 0 0)))
24+ (r/clear b 0)
25+ (r/text! b 0 0 "A" 0xffffff 1)
26+ (assert (some #(= 0xffffff (at % 0)) (range 3)) "the glyph drew nothing")))
27+
28+;; --- a component, through the real reconciler --------------------------------
29+
30+(def ^:private clicks (ra/atom 0))
31+
32+(defn- counter []
33+ [:card {}
34+ [:title {:label "Counter"}]
35+ [:label {:label (str "Count: " (ra/deref clicks))}]
36+ [:hbox {:spacing 8}
37+ [:button {:label "-1" :on-click #(ra/swap! clicks dec)}]
38+ [:button {:label "+1" :kind :primary :on-click #(ra/swap! clicks inc)}]]])
39+
40+(defn- walk [n] (cons n (mapcat walk (:children @n))))
41+(defn- tagged [root tag] (first (filter #(= tag (:tag @%)) (walk root))))
42+(defn- buttons [root] (filter #(= :button (:tag @%)) (walk root)))
43+(defn- labelled? [root prefix]
44+ (some #(and (= :label (:tag @%))
45+ (str/starts-with? (str (:label (:props @%))) prefix))
46+ (walk root)))
47+(defn- centre [n]
48+ (let [[x y w h] (:rect @n)] [(+ x (quot w 2)) (+ y (quot h 2))]))
49+
50+(defn- check-tree! [root]
51+ (assert (tagged root :card) "no card in the tree")
52+ (assert (= 2 (count (buttons root))) "expected two buttons")
53+ (assert (labelled? root "Count: 0") "the label did not render the ratom"))
54+
55+(defn- check-layout! [root]
56+ (let [[cx cy] (:rect @(tagged root :card))
57+ [tx ty] (:rect @(tagged root :title))
58+ [_ by bw bh] (:rect @(first (buttons root)))]
59+ (assert (= [0 0] [cx cy]) "the card should sit at the origin")
60+ (assert (= [10 10] [tx ty]) "a card must inset its child by PAD")
61+ (assert (and (pos? bw) (= 26 bh)) "button box is the wrong size")
62+ (assert (> by ty) "the title must be placed above the buttons"))
63+ ;; an hbox lays its children left to right, with the gap it was given
64+ (let [[b1 b2] (map #(:rect @%) (buttons root))]
65+ (assert (= (second b1) (second b2)) "hbox children must share a baseline")
66+ (assert (= (+ (first b1) (nth b1 2) 8) (first b2)) "hbox gap not honoured")))
67+
68+(defn- check-clicks! [root buf]
69+ (let [plus (second (buttons root))
70+ p (centre plus)
71+ state (atom {})
72+ before (ra/deref clicks)]
73+ (gfx/render-once root buf 400 {:mouse p :down? true :released? false} state)
74+ (assert (= before (ra/deref clicks)) "a press alone must not fire the handler")
75+ (gfx/render-once root buf 400 {:mouse p :down? false :released? true} state)
76+ (assert (= (inc before) (ra/deref clicks)) "the click did not fire")
77+ (gfx/render-once root buf 400)
78+ (assert (labelled? root (str "Count: " (inc before)))
79+ "the reactive re-render never reached the tree")
80+
81+ ;; press, then release somewhere else: not a click
82+ (gfx/render-once root buf 400 {:mouse p :down? true :released? false} state)
83+ (gfx/render-once root buf 400 {:mouse [399 239] :down? false :released? true} state)
84+ (assert (= (inc before) (ra/deref clicks)) "releasing off the button must not fire")))
85+
86+(defn- check-painted! [root buf]
87+ (gfx/render-once root buf 400)
88+ (let [at (fn [x y] (aget ^ints (:px buf) (+ (* y (:w buf)) x)))
89+ [x y] (:rect @(tagged root :card))
90+ row (+ 4 (second (:rect @(second (buttons root)))))]
91+ (assert (= (:card gfx/theme) (at (+ x 2) (+ y 2))) "card background not painted")
92+ (assert (some #(= (:accent gfx/theme) (at % row)) (range 400))
93+ "the primary button is not painted in the accent colour")))
94+
95+(defn- check-scheduled! [root buf]
96+ ;; With a loop running, glimmer refuses to re-render inline and posts the work
97+ ;; through :schedule instead. That is the path a real window takes, and a
98+ ;; different one from every check above.
99+ (reset! backend/loop-running? true)
100+ (try
101+ (let [before (ra/deref clicks)
102+ p (centre (second (buttons root)))
103+ state (atom {})]
104+ (gfx/render-once root buf 400 {:mouse p :down? true :released? false} state)
105+ (gfx/render-once root buf 400 {:mouse p :down? false :released? true} state)
106+ (assert (= (inc before) (ra/deref clicks)) "the handler did not fire under a loop")
107+ (gfx/render-once root buf 400) ; the next frame drains the queue
108+ (assert (labelled? root (str "Count: " (inc before)))
109+ "the scheduled re-render never reached the tree"))
110+ (finally (reset! backend/loop-running? false))))
111+
112+(defn -main [& _]
113+ (check-raster!)
114+ (let [root (gfx/root-node)
115+ buf (r/buf 400 240)]
116+ (gui/mount root :vbox [counter])
117+ (gfx/render-once root buf 400)
118+ (check-tree! root)
119+ (check-layout! root)
120+ (check-clicks! root buf)
121+ (check-painted! root buf)
122+ (check-scheduled! root buf))
123+ (println "ok"))