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>
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")) | ||