nandi/jolt-nativepublic Fork 0
d5dfd53
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.

Take the eight tags a client still had nowhere to put

title-2, status, spinner, link, emoji, reaction, avatar and image. With
these, every hiccup tag frq renders — twenty-one of them — has somewhere to
land here. An unknown tag becomes a bare container, so the ones that were
missing would not have failed; they would have quietly rendered as nothing.

:image, NOT a :video tag. That was mine and it was wrong: libvidya makes
the source a prop and frq writes [:image {:feed k}] for a call tile and
[:image {:src p}] for an attachment. A feed and a file differ in where the
pixels come from and in nothing downstream — the fit, the bounds, the
click are one piece of code — so they are one tag. My own video test had
to be updated to the real spelling, which is the same edit frq would have
been forced into later and worse.

Files decode through SDL3_image, declared :optional beside SDL3 and
SDL3_ttf: a caller that shows no pictures, and the layout tests, run
without it. Cached by path and never re-read — frq writes an attachment
once under a name it chose, and a picture that changes gets a new path.
Nothing decodes to a broken-image glyph; the text beside an attachment
already says what it was meant to be.

An avatar with no picture is not a placeholder waiting for one. Most people
in most rooms have no picture, so the initial IS the avatar, and its colour
is a hash of the name rather than a counter — the same person has to be the
same colour in the member list, in their message and in the tile they are
speaking from, or the colour is decoration instead of identity.

The spinner turns off the wall clock, not a frame counter. A loop that
dropped frames would otherwise stutter it in a way that reads as the work
having stalled, which is the one thing it exists to deny.

EMOJI IS THE WEAK ONE and the source says so. libvidya draws from an image
pack; this asks SDL_ttf for the glyph, so on a system whose UI font has no
emoji coverage — most of them — what appears is the missing-glyph box. The
fix is a font, not code, which is what JVUI_FONT is for.

Checked by rendering all eight into a window under SDL's dummy driver and
reading the screenshot back: a real PNG must come out the green it was
written as, a live feed the red it was pushed as, and an avatar with no
picture must be a filled disc rather than nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
nandi committed 2026-09-10T05:50:22-07:00 Browse files
d5dfd53 parent: 26defff
modified glimmer-backends/glimmer-jvui/src/glimmer_jvui/core.clj +45 -10
@@ -128,16 +128,51 @@
128128
129129 (:vbox :box) (c/box* (box-opts props key) (emit-children! n))
130130
131- ;; A live picture: a camera, a call, anything a decoder is filling in
132- ;; between frames. The node carries only the KEY — the pixels never go
133- ;; through the reconciler, because a video frame arrives when the
134- ;; network says so and a props diff at thirty a second would be a
135- ;; re-render per frame per peer.
136- :video (w/video (or (:feed props) (:key props) (str key))
137- (cond-> {}
138- (:size props) (assoc :size (:size props))
139- (:expand props) (assoc :expand (:expand props))
140- (:gravity props) (assoc :gravity (:gravity props))))
131+ ;; ONE tag for both kinds of picture: `:feed` is live pixels pushed
132+ ;; in under a name and re-uploaded as they arrive, `:src` is a file
133+ ;; decoded once and kept by path. Everything downstream — the fit, the
134+ ;; bounds, the click — is the same, which is why libvidya makes this a
135+ ;; prop and not a second tag, and why frq writes [:image {:feed k}]
136+ ;; for a call tile and [:image {:src p}] for an attachment.
137+ ;;
138+ ;; The pixels never go through the reconciler either way: a frame
139+ ;; arrives when the network says so, and a props diff at thirty a
140+ ;; second would be a re-render per frame per peer.
141+ :image (let [id (c/next-id key)
142+ rect (w/image {:feed (:feed props) :src (:src props)}
143+ {:fit (:fit props)
144+ :max-width (:max-width props)
145+ :max-height (:max-height props)
146+ :size (:size props)
147+ :expand (:expand props)})]
148+ (record! n id)
149+ (when (:clicked? (c/interact! id rect)) (fire! n :on-click)))
150+
151+ :title-2 (w/title-2 s)
152+
153+ :status (w/status s (boolean (:live props)))
154+
155+ :spinner (w/spinner s)
156+
157+ :link (let [id (c/next-id key)]
158+ (record! n id)
159+ (when (w/link s {:key key}) (fire! n :on-click)))
160+
161+ :emoji (w/emoji (or (:emoji props) s) (:size props))
162+
163+ :avatar (w/avatar (or (:label props) s)
164+ (cond-> {}
165+ (:src props) (assoc :src (:src props))
166+ (:size props) (assoc :size (:size props))))
167+
168+ :reaction (let [id (c/next-id key)
169+ glyph (or (:emoji props) s)]
170+ (record! n id)
171+ (when (w/reaction glyph {:count (or (:count props) 0)
172+ :mine? (boolean (:mine props))
173+ :size (:size props)
174+ :key key})
175+ (fire! n :on-click)))
141176
142177 :title (w/title s)
143178
@@ -128,16 +128,51 @@
128 128
129 (:vbox :box) (c/box* (box-opts props key) (emit-children! n))129 (:vbox :box) (c/box* (box-opts props key) (emit-children! n))
130 130
131- ;; A live picture: a camera, a call, anything a decoder is filling in131+ ;; ONE tag for both kinds of picture: `:feed` is live pixels pushed
132- ;; between frames. The node carries only the KEY — the pixels never go132+ ;; in under a name and re-uploaded as they arrive, `:src` is a file
133- ;; through the reconciler, because a video frame arrives when the133+ ;; decoded once and kept by path. Everything downstream — the fit, the
134- ;; network says so and a props diff at thirty a second would be a134+ ;; bounds, the click — is the same, which is why libvidya makes this a
135- ;; re-render per frame per peer.135+ ;; prop and not a second tag, and why frq writes [:image {:feed k}]
136- :video (w/video (or (:feed props) (:key props) (str key))136+ ;; for a call tile and [:image {:src p}] for an attachment.
137- (cond-> {}137+ ;;
138- (:size props) (assoc :size (:size props))138+ ;; The pixels never go through the reconciler either way: a frame
139- (:expand props) (assoc :expand (:expand props))139+ ;; arrives when the network says so, and a props diff at thirty a
140- (:gravity props) (assoc :gravity (:gravity props))))140+ ;; second would be a re-render per frame per peer.
141+ :image (let [id (c/next-id key)
142+ rect (w/image {:feed (:feed props) :src (:src props)}
143+ {:fit (:fit props)
144+ :max-width (:max-width props)
145+ :max-height (:max-height props)
146+ :size (:size props)
147+ :expand (:expand props)})]
148+ (record! n id)
149+ (when (:clicked? (c/interact! id rect)) (fire! n :on-click)))
150+
151+ :title-2 (w/title-2 s)
152+
153+ :status (w/status s (boolean (:live props)))
154+
155+ :spinner (w/spinner s)
156+
157+ :link (let [id (c/next-id key)]
158+ (record! n id)
159+ (when (w/link s {:key key}) (fire! n :on-click)))
160+
161+ :emoji (w/emoji (or (:emoji props) s) (:size props))
162+
163+ :avatar (w/avatar (or (:label props) s)
164+ (cond-> {}
165+ (:src props) (assoc :src (:src props))
166+ (:size props) (assoc :size (:size props))))
167+
168+ :reaction (let [id (c/next-id key)
169+ glyph (or (:emoji props) s)]
170+ (record! n id)
171+ (when (w/reaction glyph {:count (or (:count props) 0)
172+ :mine? (boolean (:mine props))
173+ :size (:size props)
174+ :key key})
175+ (fire! n :on-click)))
141 176
142 :title (w/title s)177 :title (w/title s)
143 178
added glimmer-backends/glimmer-jvui/test/glimmer_jvui/tags_check.clj +81 -0
new file mode 100644
@@ -0,0 +1,81 @@
1+(ns glimmer-jvui.tags-check
2+ "Every tag frq renders, rendered, and the ones with a colour checked.
3+
4+ The layout tests already prove a tag is not an unknown container. What
5+ they cannot see is whether it drew anything, because headless there is no
6+ renderer — so this opens a window under SDL's dummy driver and reads the
7+ screenshot back.
8+
9+ Only the tags with a colour worth asserting on are checked by pixel: a
10+ decoded PNG must be the green it was written as, and an avatar with no
11+ picture must be a filled disc rather than nothing. The rest are checked
12+ for not throwing and not being swallowed as unknown, which is the failure
13+ they would actually have."
14+ (:require [glimmer.core :as ui]
15+ [glimmer-jvui.core :as backend]
16+ [jolt.ffi :as ffi]))
17+
18+(defn- bmp [path]
19+ (let [b (java.nio.file.Files/readAllBytes
20+ (java.nio.file.Path/of path (into-array String [])))
21+ u (fn [i] (bit-and (int (aget b i)) 255))
22+ le (fn [i] (+ (u i) (bit-shift-left (u (+ i 1)) 8)
23+ (bit-shift-left (u (+ i 2)) 16) (bit-shift-left (u (+ i 3)) 24)))
24+ off (le 10) w (le 18) h (le 22)
25+ stride (* 4 (quot (+ (* w 3) 3) 4))]
26+ {:w w :h h
27+ :px (fn [x y] (let [i (+ off (* (- h 1 y) stride) (* 3 x))]
28+ {:b (u i) :g (u (+ i 1)) :r (u (+ i 2))}))}))
29+
30+(defn- scan
31+ "Does any pixel in the image satisfy `p`?"
32+ [{:keys [w h px]} p]
33+ (boolean (some (fn [y] (some (fn [x] (p (px x y))) (range 0 w 2))) (range 0 h 2))))
34+
35+(defn -main [& _]
36+ (let [shot (str (System/getProperty "java.io.tmpdir") "/jvui-tags.bmp")
37+ feed (ffi/alloc (* 4 16 16))
38+ out (atom [])
39+ ck! (fn [n ok?] (swap! out conj [n (boolean ok?)]))]
40+ ;; A live feed frame: pure red, so it cannot be confused with the file.
41+ (dotimes [i (* 16 16)]
42+ (ffi/write (+ feed (* 4 i) 0) :uint8 240)
43+ (ffi/write (+ feed (* 4 i) 1) :uint8 0)
44+ (ffi/write (+ feed (* 4 i) 2) :uint8 0)
45+ (ffi/write (+ feed (* 4 i) 3) :uint8 255))
46+ (backend/every! 16 (fn [] (backend/frame-rgba! "cam" 16 16 feed)))
47+ (try
48+ (ui/run
49+ (fn []
50+ [:vbox {:spacing 4}
51+ [:title-2 {:label "heading"}]
52+ [:status {:label "connected" :live true}]
53+ [:spinner {:label "working"}]
54+ [:link {:label "a link"}]
55+ [:emoji {:emoji "x" :size 14}]
56+ [:reaction {:emoji "y" :count 3 :mine true}]
57+ [:hbox {:spacing 4}
58+ [:avatar {:label "nandi" :size 24}]
59+ [:image {:src "/tmp/jvui-green.png" :max-width 40}]
60+ [:image {:feed "cam" :size [40.0 40.0]}]]])
61+ {:title "tags" :width 320 :height 320 :frames 6 :shot shot})
62+ (finally (ffi/free feed)))
63+ (let [img (bmp shot)]
64+ (ck! "a decoded PNG is on screen, and green"
65+ (scan img (fn [{:keys [r g b]}] (and (> g 150) (< r 90) (< b 90)))))
66+ (ck! "a live feed is on screen, and red"
67+ (scan img (fn [{:keys [r g b]}] (and (> r 180) (< g 80) (< b 80)))))
68+ (ck! "an avatar with no picture is a filled disc"
69+ (scan img (fn [{:keys [r g b]}]
70+ ;; any of the name palette, none of which is the
71+ ;; background or the two above
72+ (and (> (max r g b) 90) (< (max r g b) 230)
73+ (> (- (max r g b) (min r g b)) 40)
74+ (not (and (> g 150) (< r 90)))
75+ (not (and (> r 180) (< g 80))))))))
76+ (doseq [[n ok?] @out] (println (if ok? "- " "FAIL ") n))
77+ (let [bad (remove second @out)]
78+ (println (if (seq bad)
79+ (str (count bad) " of " (count @out) " checks FAILED")
80+ (str "all " (count @out) " checks passed")))
81+ (when (seq bad) (System/exit 1)))))
new file mode 100644
@@ -0,0 +1,81 @@
1+(ns glimmer-jvui.tags-check
2+ "Every tag frq renders, rendered, and the ones with a colour checked.
3+
4+ The layout tests already prove a tag is not an unknown container. What
5+ they cannot see is whether it drew anything, because headless there is no
6+ renderer — so this opens a window under SDL's dummy driver and reads the
7+ screenshot back.
8+
9+ Only the tags with a colour worth asserting on are checked by pixel: a
10+ decoded PNG must be the green it was written as, and an avatar with no
11+ picture must be a filled disc rather than nothing. The rest are checked
12+ for not throwing and not being swallowed as unknown, which is the failure
13+ they would actually have."
14+ (:require [glimmer.core :as ui]
15+ [glimmer-jvui.core :as backend]
16+ [jolt.ffi :as ffi]))
17+
18+(defn- bmp [path]
19+ (let [b (java.nio.file.Files/readAllBytes
20+ (java.nio.file.Path/of path (into-array String [])))
21+ u (fn [i] (bit-and (int (aget b i)) 255))
22+ le (fn [i] (+ (u i) (bit-shift-left (u (+ i 1)) 8)
23+ (bit-shift-left (u (+ i 2)) 16) (bit-shift-left (u (+ i 3)) 24)))
24+ off (le 10) w (le 18) h (le 22)
25+ stride (* 4 (quot (+ (* w 3) 3) 4))]
26+ {:w w :h h
27+ :px (fn [x y] (let [i (+ off (* (- h 1 y) stride) (* 3 x))]
28+ {:b (u i) :g (u (+ i 1)) :r (u (+ i 2))}))}))
29+
30+(defn- scan
31+ "Does any pixel in the image satisfy `p`?"
32+ [{:keys [w h px]} p]
33+ (boolean (some (fn [y] (some (fn [x] (p (px x y))) (range 0 w 2))) (range 0 h 2))))
34+
35+(defn -main [& _]
36+ (let [shot (str (System/getProperty "java.io.tmpdir") "/jvui-tags.bmp")
37+ feed (ffi/alloc (* 4 16 16))
38+ out (atom [])
39+ ck! (fn [n ok?] (swap! out conj [n (boolean ok?)]))]
40+ ;; A live feed frame: pure red, so it cannot be confused with the file.
41+ (dotimes [i (* 16 16)]
42+ (ffi/write (+ feed (* 4 i) 0) :uint8 240)
43+ (ffi/write (+ feed (* 4 i) 1) :uint8 0)
44+ (ffi/write (+ feed (* 4 i) 2) :uint8 0)
45+ (ffi/write (+ feed (* 4 i) 3) :uint8 255))
46+ (backend/every! 16 (fn [] (backend/frame-rgba! "cam" 16 16 feed)))
47+ (try
48+ (ui/run
49+ (fn []
50+ [:vbox {:spacing 4}
51+ [:title-2 {:label "heading"}]
52+ [:status {:label "connected" :live true}]
53+ [:spinner {:label "working"}]
54+ [:link {:label "a link"}]
55+ [:emoji {:emoji "x" :size 14}]
56+ [:reaction {:emoji "y" :count 3 :mine true}]
57+ [:hbox {:spacing 4}
58+ [:avatar {:label "nandi" :size 24}]
59+ [:image {:src "/tmp/jvui-green.png" :max-width 40}]
60+ [:image {:feed "cam" :size [40.0 40.0]}]]])
61+ {:title "tags" :width 320 :height 320 :frames 6 :shot shot})
62+ (finally (ffi/free feed)))
63+ (let [img (bmp shot)]
64+ (ck! "a decoded PNG is on screen, and green"
65+ (scan img (fn [{:keys [r g b]}] (and (> g 150) (< r 90) (< b 90)))))
66+ (ck! "a live feed is on screen, and red"
67+ (scan img (fn [{:keys [r g b]}] (and (> r 180) (< g 80) (< b 80)))))
68+ (ck! "an avatar with no picture is a filled disc"
69+ (scan img (fn [{:keys [r g b]}]
70+ ;; any of the name palette, none of which is the
71+ ;; background or the two above
72+ (and (> (max r g b) 90) (< (max r g b) 230)
73+ (> (- (max r g b) (min r g b)) 40)
74+ (not (and (> g 150) (< r 90)))
75+ (not (and (> r 180) (< g 80))))))))
76+ (doseq [[n ok?] @out] (println (if ok? "- " "FAIL ") n))
77+ (let [bad (remove second @out)]
78+ (println (if (seq bad)
79+ (str (count bad) " of " (count @out) " checks FAILED")
80+ (str "all " (count @out) " checks passed")))
81+ (when (seq bad) (System/exit 1)))))
modified glimmer-backends/glimmer-jvui/test/glimmer_jvui/video_check.clj +6 -2
@@ -1,5 +1,9 @@
11 (ns glimmer-jvui.video-check
2- "A :video node, painted, and the pixels checked.
2+ "An :image node with a live :feed, painted, and the pixels checked.
3+
4+ :image and not a :video tag of its own, because that is how libvidya has
5+ it and how frq writes it: a feed and a file differ in where the pixels
6+ come from and in nothing downstream of that.
37
48 The layout tests run headless and cannot see this: with no renderer every
59 frame is dropped and a video tile is an empty rectangle that lays out
@@ -60,7 +64,7 @@
6064 ;; dropped for want of a renderer and the tile was empty for a
6165 ;; reason that had nothing to do with the picture.
6266 (backend/every! 16 (fn [] (backend/frame-rgba! "peer" 64 64 px)))
63- (ui/run (fn [] [:video {:feed "peer" :size [120.0 120.0]}])
67+ (ui/run (fn [] [:image {:feed "peer" :size [120.0 120.0]}])
6468 {:title "video" :width 160 :height 160 :frames 6 :shot shot})
6569 (finally (ffi/free px)))
6670 (let [left (bmp-pixel shot 40 80)
@@ -1,5 +1,9 @@
1 (ns glimmer-jvui.video-check1 (ns glimmer-jvui.video-check
2- "A :video node, painted, and the pixels checked.2+ "An :image node with a live :feed, painted, and the pixels checked.
3+
4+ :image and not a :video tag of its own, because that is how libvidya has
5+ it and how frq writes it: a feed and a file differ in where the pixels
6+ come from and in nothing downstream of that.
3 7
4 The layout tests run headless and cannot see this: with no renderer every8 The layout tests run headless and cannot see this: with no renderer every
5 frame is dropped and a video tile is an empty rectangle that lays out9 frame is dropped and a video tile is an empty rectangle that lays out
@@ -60,7 +64,7 @@
60 ;; dropped for want of a renderer and the tile was empty for a64 ;; dropped for want of a renderer and the tile was empty for a
61 ;; reason that had nothing to do with the picture.65 ;; reason that had nothing to do with the picture.
62 (backend/every! 16 (fn [] (backend/frame-rgba! "peer" 64 64 px)))66 (backend/every! 16 (fn [] (backend/frame-rgba! "peer" 64 64 px)))
63- (ui/run (fn [] [:video {:feed "peer" :size [120.0 120.0]}])67+ (ui/run (fn [] [:image {:feed "peer" :size [120.0 120.0]}])
64 {:title "video" :width 160 :height 160 :frames 6 :shot shot})68 {:title "video" :width 160 :height 160 :frames 6 :shot shot})
65 (finally (ffi/free px)))69 (finally (ffi/free px)))
66 (let [left (bmp-pixel shot 40 80)70 (let [left (bmp-pixel shot 40 80)
modified jvui/deps.edn +6 -1
@@ -12,7 +12,12 @@
1212 :jolt/native [{:name "SDL3" :optional true
1313 :linux ["libSDL3.so.0"] :darwin ["libSDL3.0.dylib"]}
1414 {:name "SDL3_ttf" :optional true
15- :linux ["libSDL3_ttf.so.0"] :darwin ["libSDL3_ttf.0.dylib"]}]
15+ :linux ["libSDL3_ttf.so.0"] :darwin ["libSDL3_ttf.0.dylib"]}
16+ ;; Pictures from files — an avatar, an attachment, a paste.
17+ ;; Optional like the other two: a caller that shows none, and
18+ ;; the layout tests, run without it.
19+ {:name "SDL3_image" :optional true
20+ :linux ["libSDL3_image.so.0"] :darwin ["libSDL3_image.0.dylib"]}]
1621
1722 :aliases {:test {:extra-paths ["test"] :main-opts ["-m" "jvui.tests"]}
1823 :counter {:extra-paths ["examples"] :main-opts ["-m" "jvui.counter"]}
@@ -12,7 +12,12 @@
12 :jolt/native [{:name "SDL3" :optional true12 :jolt/native [{:name "SDL3" :optional true
13 :linux ["libSDL3.so.0"] :darwin ["libSDL3.0.dylib"]}13 :linux ["libSDL3.so.0"] :darwin ["libSDL3.0.dylib"]}
14 {:name "SDL3_ttf" :optional true14 {:name "SDL3_ttf" :optional true
15- :linux ["libSDL3_ttf.so.0"] :darwin ["libSDL3_ttf.0.dylib"]}]15+ :linux ["libSDL3_ttf.so.0"] :darwin ["libSDL3_ttf.0.dylib"]}
16+ ;; Pictures from files — an avatar, an attachment, a paste.
17+ ;; Optional like the other two: a caller that shows none, and
18+ ;; the layout tests, run without it.
19+ {:name "SDL3_image" :optional true
20+ :linux ["libSDL3_image.so.0"] :darwin ["libSDL3_image.0.dylib"]}]
16 21
17 :aliases {:test {:extra-paths ["test"] :main-opts ["-m" "jvui.tests"]}22 :aliases {:test {:extra-paths ["test"] :main-opts ["-m" "jvui.tests"]}
18 :counter {:extra-paths ["examples"] :main-opts ["-m" "jvui.counter"]}23 :counter {:extra-paths ["examples"] :main-opts ["-m" "jvui.counter"]}
modified jvui/src/jvui/core.clj +26 -3
@@ -122,12 +122,35 @@
122122 [s x y size colour]
123123 (when (drawing?) (paint/text! (:painter (ui)) s x y size colour)))
124124
125+(defn draw-picture!
126+ "Paint a texture into `rect`, letterboxed to keep its shape."
127+ [tex tw th rect]
128+ (when (and (drawing?) tex)
129+ (paint/frame! (:painter (ui)) tex tw th rect)))
130+
125131 (defn draw-frame!
126132 "Paint feed `key`'s latest picture into `rect`, if it has one."
127133 [key rect]
128- (when (drawing?)
129- (when-let [{:keys [tex w h]} (frames/lookup key)]
130- (when tex (paint/frame! (:painter (ui)) tex w h rect)))))
134+ (when-let [{:keys [tex w h]} (frames/lookup key)]
135+ (draw-picture! tex w h rect)))
136+
137+(def ^:private name-palette
138+ ;; Eight, and chosen to stay apart at avatar size against both themes.
139+ ;; More would collide less and look less like a set.
140+ [[214 96 96 255] [214 148 60 255] [186 186 64 255] [104 186 104 255]
141+ [76 176 176 255] [92 140 214 255] [148 112 208 255] [204 104 168 255]])
142+
143+(defn name-colour
144+ "A stable colour for a name.
145+
146+ Stable is the whole requirement: the same person must be the same colour
147+ in the member list, in the message they sent and in the tile they are
148+ speaking from, or the colour is decoration rather than identity. So it is
149+ a hash of the name and not a counter."
150+ [s]
151+ (let [h (reduce (fn [a ch] (bit-and (+ (* a 31) (int ch)) 0x7fffffff))
152+ 7 (seq (str s)))]
153+ (nth name-palette (mod h (count name-palette)))))
131154
132155 (defn draw-line!
133156 [x0 y0 x1 y1 colour width]
@@ -122,12 +122,35 @@
122 [s x y size colour]122 [s x y size colour]
123 (when (drawing?) (paint/text! (:painter (ui)) s x y size colour)))123 (when (drawing?) (paint/text! (:painter (ui)) s x y size colour)))
124 124
125+(defn draw-picture!
126+ "Paint a texture into `rect`, letterboxed to keep its shape."
127+ [tex tw th rect]
128+ (when (and (drawing?) tex)
129+ (paint/frame! (:painter (ui)) tex tw th rect)))
130+
125 (defn draw-frame!131 (defn draw-frame!
126 "Paint feed `key`'s latest picture into `rect`, if it has one."132 "Paint feed `key`'s latest picture into `rect`, if it has one."
127 [key rect]133 [key rect]
128- (when (drawing?)134+ (when-let [{:keys [tex w h]} (frames/lookup key)]
129- (when-let [{:keys [tex w h]} (frames/lookup key)]135+ (draw-picture! tex w h rect)))
130- (when tex (paint/frame! (:painter (ui)) tex w h rect)))))136+
137+(def ^:private name-palette
138+ ;; Eight, and chosen to stay apart at avatar size against both themes.
139+ ;; More would collide less and look less like a set.
140+ [[214 96 96 255] [214 148 60 255] [186 186 64 255] [104 186 104 255]
141+ [76 176 176 255] [92 140 214 255] [148 112 208 255] [204 104 168 255]])
142+
143+(defn name-colour
144+ "A stable colour for a name.
145+
146+ Stable is the whole requirement: the same person must be the same colour
147+ in the member list, in the message they sent and in the tile they are
148+ speaking from, or the colour is decoration rather than identity. So it is
149+ a hash of the name and not a counter."
150+ [s]
151+ (let [h (reduce (fn [a ch] (bit-and (+ (* a 31) (int ch)) 0x7fffffff))
152+ 7 (seq (str s)))]
153+ (nth name-palette (mod h (count name-palette)))))
131 154
132 (defn draw-line!155 (defn draw-line!
133 [x0 y0 x1 y1 colour width]156 [x0 y0 x1 y1 colour width]
modified jvui/src/jvui/frames.clj +30 -3
@@ -19,7 +19,8 @@
1919 Which is why the renderer is held here as state rather than passed in. It
2020 is not lovely, and the alternative is worse: threading a painter through a
2121 media pipeline so a codec can know about a toolkit."
22- (:require [jvui.sdl :as sdl]))
22+ (:require [jvui.sdl :as sdl]
23+ [jolt.ffi :as ffi]))
2324
2425 (defonce ^:private renderer (atom nil))
2526 (defonce ^:private textures (atom {}))
@@ -72,15 +73,41 @@
7273 (swap! textures assoc key t))))
7374 nil))
7475
76+(defn from-file
77+ "The texture for a picture on disk, decoded once and kept.
78+
79+ Cached by PATH, and never re-read: a file that changed under us is not a
80+ case this has frq writes an attachment once, under a name it chose, and
81+ a picture that needs to change gets a new path.
82+
83+ nil when there is no renderer yet, when SDL3_image is not installed, or
84+ when the file will not decode. All three are the same answer to a caller:
85+ there is no picture, draw what you would have drawn without one. A
86+ broken-image glyph would be worse the text beside it already says what
87+ it was meant to be."
88+ [path]
89+ (when-let [r @renderer]
90+ (let [k [::file path]]
91+ (or (get @textures k)
92+ (let [tex (try (sdl/img-load-texture r (str path))
93+ (catch Exception _ nil))]
94+ (when (and tex (not (ffi/null? tex)))
95+ (let [[w h] (or (sdl/texture-size tex) [0 0])
96+ t {:tex tex :w w :h h}]
97+ (sdl/texture-scale-mode! tex sdl/SCALE-LINEAR)
98+ (swap! textures assoc k t)
99+ t)))))))
100+
75101 (defn lookup
76102 "{:tex :w :h} for a feed, or nil."
77103 [key]
78104 (get @textures key))
79105
80106 (defn keys*
81- "Every feed with a picture."
107+ "Every live feed with a picture. Decoded files are not feeds and are not
108+ listed a caller asking this wants to know who is on camera."
82109 []
83- (set (keys @textures)))
110+ (set (remove vector? (keys @textures))))
84111
85112 (defn clear!
86113 "Release every texture the window is going away."
@@ -19,7 +19,8 @@
19 Which is why the renderer is held here as state rather than passed in. It19 Which is why the renderer is held here as state rather than passed in. It
20 is not lovely, and the alternative is worse: threading a painter through a20 is not lovely, and the alternative is worse: threading a painter through a
21 media pipeline so a codec can know about a toolkit."21 media pipeline so a codec can know about a toolkit."
22- (:require [jvui.sdl :as sdl]))22+ (:require [jvui.sdl :as sdl]
23+ [jolt.ffi :as ffi]))
23 24
24 (defonce ^:private renderer (atom nil))25 (defonce ^:private renderer (atom nil))
25 (defonce ^:private textures (atom {}))26 (defonce ^:private textures (atom {}))
@@ -72,15 +73,41 @@
72 (swap! textures assoc key t))))73 (swap! textures assoc key t))))
73 nil))74 nil))
74 75
76+(defn from-file
77+ "The texture for a picture on disk, decoded once and kept.
78+
79+ Cached by PATH, and never re-read: a file that changed under us is not a
80+ case this has frq writes an attachment once, under a name it chose, and
81+ a picture that needs to change gets a new path.
82+
83+ nil when there is no renderer yet, when SDL3_image is not installed, or
84+ when the file will not decode. All three are the same answer to a caller:
85+ there is no picture, draw what you would have drawn without one. A
86+ broken-image glyph would be worse the text beside it already says what
87+ it was meant to be."
88+ [path]
89+ (when-let [r @renderer]
90+ (let [k [::file path]]
91+ (or (get @textures k)
92+ (let [tex (try (sdl/img-load-texture r (str path))
93+ (catch Exception _ nil))]
94+ (when (and tex (not (ffi/null? tex)))
95+ (let [[w h] (or (sdl/texture-size tex) [0 0])
96+ t {:tex tex :w w :h h}]
97+ (sdl/texture-scale-mode! tex sdl/SCALE-LINEAR)
98+ (swap! textures assoc k t)
99+ t)))))))
100+
75 (defn lookup101 (defn lookup
76 "{:tex :w :h} for a feed, or nil."102 "{:tex :w :h} for a feed, or nil."
77 [key]103 [key]
78 (get @textures key))104 (get @textures key))
79 105
80 (defn keys*106 (defn keys*
81- "Every feed with a picture."107+ "Every live feed with a picture. Decoded files are not feeds and are not
108+ listed a caller asking this wants to know who is on camera."
82 []109 []
83- (set (keys @textures)))110+ (set (remove vector? (keys @textures))))
84 111
85 (defn clear!112 (defn clear!
86 "Release every texture the window is going away."113 "Release every texture the window is going away."
modified jvui/src/jvui/sdl.clj +12 -0
@@ -317,6 +317,18 @@
317317 (when (and p (not (ffi/null? p)) (pos? n))
318318 [p n]))))
319319
320+(ffi/defcfn img-load-texture "IMG_LoadTexture" [:pointer :string] :pointer)
321+(ffi/defcfn ^:private raw-texture-size "SDL_GetTextureSize"
322+ [:pointer :pointer :pointer] :bool)
323+
324+(defn texture-size
325+ "[w h] of a texture, or nil."
326+ [tex]
327+ (ffi/with-alloc [w 4]
328+ (ffi/with-alloc [h 4]
329+ (when (raw-texture-size tex w h)
330+ [(long (ffi/read w :float)) (long (ffi/read h :float))]))))
331+
320332 (defn update-texture-raw!
321333 "Upload `h` rows of `pitch` bytes from FOREIGN memory into the whole of `tex`.
322334
@@ -317,6 +317,18 @@
317 (when (and p (not (ffi/null? p)) (pos? n))317 (when (and p (not (ffi/null? p)) (pos? n))
318 [p n]))))318 [p n]))))
319 319
320+(ffi/defcfn img-load-texture "IMG_LoadTexture" [:pointer :string] :pointer)
321+(ffi/defcfn ^:private raw-texture-size "SDL_GetTextureSize"
322+ [:pointer :pointer :pointer] :bool)
323+
324+(defn texture-size
325+ "[w h] of a texture, or nil."
326+ [tex]
327+ (ffi/with-alloc [w 4]
328+ (ffi/with-alloc [h 4]
329+ (when (raw-texture-size tex w h)
330+ [(long (ffi/read w :float)) (long (ffi/read h :float))]))))
331+
320 (defn update-texture-raw!332 (defn update-texture-raw!
321 "Upload `h` rows of `pitch` bytes from FOREIGN memory into the whole of `tex`.333 "Upload `h` rows of `pitch` bytes from FOREIGN memory into the whole of `tex`.
322 334
modified jvui/src/jvui/widgets.clj +162 -16
@@ -13,7 +13,9 @@
1313 closure. They are thin: each is `core/box*` with a different set of defaults
1414 from the theme."
1515 (:require [jvui.core :as c]
16- [jvui.theme :as theme]))
16+ [jvui.theme :as theme]
17+ [jvui.frames :as frames]
18+ [clojure.string :as str]))
1719
1820 ;; -------------------------------------------------------------- containers
1921
@@ -328,19 +330,163 @@
328330
329331 (defmacro scroll [opts & body] `(scroll* ~opts (fn [~'_id ~'_rect] ~@body)))
330332
331-(defn video
332- "A tile showing feed `key`, or the empty frame it will fill.
333-
334- `size` is the space it asks for; the picture is letterboxed inside that,
335- so a tile keeps its shape whatever the camera on the other end is doing.
336-
337- Drawn even with no picture yet a call wall where tiles appear only once
338- a frame arrives rearranges itself under the person every time somebody
339- joins, which is worse than a dark rectangle that fills."
340- ([key] (video key {}))
341- ([key {:keys [size expand gravity]
342- :or {size [320.0 180.0] expand :both gravity [0.5 0.5]}}]
343- (let [rect (c/leaf size expand gravity)]
344- (c/fill! rect (c/th :surface-alt) 6.0)
345- (c/draw-frame! key rect)
333+(defn- picture
334+ "The texture for either kind of source: a live `feed` or a file `src`."
335+ [{:keys [feed src]}]
336+ (cond feed (frames/lookup feed)
337+ src (frames/from-file src)))
338+
339+(defn image
340+ "A picture: live pixels under `:feed`, or a file at `:src`.
341+
342+ One widget and not two, because everything downstream the fit, the
343+ bounds, the click is the same for both. That is libvidya's arrangement
344+ too, and frq writes [:image {:feed k}] for a call tile and
345+ [:image {:src p}] for an attachment.
346+
347+ `:fit` gives it every point of the space it has been handed; otherwise it
348+ asks for its own size, bounded by `:max-width` and `:max-height`. Either
349+ way the picture keeps its shape a 16:9 camera in a square tile is the
350+ ordinary case, and stretching it is the one thing nobody wants.
351+
352+ A source with no picture yet a feed before its first frame, a file that
353+ will not decode draws the empty tile and nothing else. Not a
354+ broken-image glyph: the text beside it already says what it was meant to
355+ be, and a tile that appears only once a frame lands rearranges the wall
356+ under the person every time somebody joins."
357+ ([opts] (image opts {}))
358+ ([{:keys [feed src] :as source}
359+ {:keys [fit max-width max-height size expand gravity placeholder?]
360+ :or {gravity [0.5 0.5] placeholder? true}}]
361+ (let [t (picture source)
362+ [tw th*] (if t [(:w t) (:h t)] [0 0])
363+ want (cond
364+ size size
365+ (and fit) [320.0 180.0]
366+ (pos? tw)
367+ (let [k (min (if max-width (/ (double max-width) tw) 1.0)
368+ (if max-height (/ (double max-height) th*) 1.0))]
369+ [(* tw k) (* th* k)])
370+ :else [(double (or max-width 160)) (double (or max-height 90))])
371+ rect (c/leaf (mapv double want)
372+ (or expand (if fit :both :none))
373+ gravity)]
374+ (when placeholder? (c/fill! rect (c/th :surface-alt) 6.0))
375+ (when (and t (:tex t)) (c/draw-picture! (:tex t) (:w t) (:h t) rect))
346376 rect)))
377+
378+(defn title-2
379+ "A second-level heading smaller than `title`, still bold."
380+ [s]
381+ (label s {:size (* 1.15 (c/th :font-size)) :colour (c/th :text)}))
382+
383+(defn status
384+ "A line of text with a dot in front of it saying whether the thing is live."
385+ ([s] (status s false))
386+ ([s live?]
387+ (c/box* {:dir :horizontal :spacing 6 :gravity [0.0 0.5]}
388+ (fn [_ _]
389+ (let [line (c/th :font-size)
390+ d (max 7.0 (min 12.0 (* line 0.55)))
391+ r (c/leaf [(+ d 4.0) line] :none [0.0 0.5])
392+ [x y w h] r]
393+ (c/fill! [(+ x 2.0) (+ y (/ (- h d) 2.0)) d d]
394+ (if live? (c/th :accent) (c/th :text-dim))
395+ (/ d 2.0)))
396+ (label s)))))
397+
398+(defn spinner
399+ "A turning mark, and a word beside it if there is one.
400+
401+ Turned from the wall clock rather than a frame counter: a loop that
402+ dropped frames would otherwise show a spinner that stutters in a way that
403+ reads as the work having stalled, which is the one thing it is there to
404+ deny."
405+ ([] (spinner ""))
406+ ([s]
407+ (c/box* {:dir :horizontal :spacing 6 :gravity [0.0 0.5]}
408+ (fn [_ _]
409+ (let [line (c/th :font-size)
410+ r (c/leaf [line line] :none [0.0 0.5])
411+ [x y w h] r
412+ cx (+ x (/ w 2.0)) cy (+ y (/ h 2.0))
413+ rad (* 0.4 (min w h))
414+ t (/ (double (mod (System/currentTimeMillis) 1000)) 1000.0)
415+ a (* t 2.0 Math/PI)]
416+ ;; Three ticks around a circle: enough to read as turning, and no
417+ ;; arc primitive needed.
418+ (dotimes [i 3]
419+ (let [th* (+ a (* i (/ (* 2.0 Math/PI) 3.0)))]
420+ (c/fill! [(+ cx (* rad (Math/cos th*)) -1.5)
421+ (+ cy (* rad (Math/sin th*)) -1.5) 3.0 3.0]
422+ (c/th :accent) 1.5))))
423+ (when (seq s) (label s))))))
424+
425+(defn link
426+ "Text that is a place to go. Answers true on the frame it was clicked."
427+ ([s] (link s {}))
428+ ([s {:keys [key]}]
429+ (let [id (c/next-id key)
430+ rect (label s {:colour (c/th :accent)})]
431+ (:clicked? (c/interact! id rect)))))
432+
433+(defn emoji
434+ "One emoji, drawn as a character and sized to sit level with the words
435+ either side.
436+
437+ Through the text font, which is where this differs from libvidya: that
438+ draws from an image pack of its own, and this asks SDL_ttf for the glyph.
439+ On a system whose UI font has no emoji coverage most of them what
440+ appears is the missing-glyph box. The fix is a font with the coverage,
441+ not code here, which is why JVUI_FONT exists."
442+ ([s] (emoji s nil))
443+ ([s size]
444+ (label s {:size (or size (c/th :font-size))})))
445+
446+(defn avatar
447+ "A round picture for somebody, or their initial on a colour if there is
448+ none.
449+
450+ The fallback is not a placeholder to be replaced later most people in
451+ most rooms have no picture, so the initial IS the avatar, and its colour
452+ comes from the name so the same person is the same colour everywhere."
453+ ([nick] (avatar nick {}))
454+ ([nick {:keys [src size] :or {size 24.0}}]
455+ (let [rect (c/leaf [size size] :none [0.5 0.5])
456+ t (when src (frames/from-file src))]
457+ (if (and t (:tex t))
458+ (c/draw-picture! (:tex t) (:w t) (:h t) rect)
459+ (let [name (str nick)
460+ bare (str/replace name #"^[#&@+%~]+" "")
461+ initial (if (seq bare) (str/upper-case (subs bare 0 1)) "?")
462+ [x y w h] rect]
463+ (c/fill! rect (c/name-colour name) (/ size 2.0))
464+ (let [sz (* size 0.5)
465+ [tw th*] (c/measure initial sz)]
466+ (c/draw-text! initial (+ x (/ (- w tw) 2.0)) (+ y (/ (- h th*) 2.0))
467+ sz (c/th :accent-text)))))
468+ rect)))
469+
470+(defn reaction
471+ "A tally wearing a pill: an emoji, how many people, and whether you are one
472+ of them.
473+
474+ The same glyph `emoji` draws, but a reaction is not a character it
475+ answers the pointer and it is a count. `mine?` is bordered rather than
476+ filled differently, because the pill has to stay readable at the size a
477+ line of them ends up."
478+ ([glyph] (reaction glyph {}))
479+ ([glyph {:keys [count mine? size key]
480+ :or {count 0 mine? false}}]
481+ (let [sz (or size (c/th :font-size))
482+ txt (if (pos? count) (str glyph " " count) (str glyph))
483+ [tw th*] (c/measure txt sz)
484+ pad 6.0
485+ rect (c/leaf [(+ tw (* 2 pad)) (+ th* 4.0)] :none [0.0 0.5])
486+ id (c/next-id key)
487+ [x y w h] rect]
488+ (c/fill! rect (if mine? (c/th :press) (c/th :surface-alt))
489+ (/ h 2.0)
490+ (when mine? (c/th :accent)) (if mine? 1.0 0.0))
491+ (c/draw-text! txt (+ x pad) (+ y (/ (- h th*) 2.0)) sz (c/th :text))
492+ (:clicked? (c/interact! id rect)))))
@@ -13,7 +13,9 @@
13 closure. They are thin: each is `core/box*` with a different set of defaults13 closure. They are thin: each is `core/box*` with a different set of defaults
14 from the theme."14 from the theme."
15 (:require [jvui.core :as c]15 (:require [jvui.core :as c]
16- [jvui.theme :as theme]))16+ [jvui.theme :as theme]
17+ [jvui.frames :as frames]
18+ [clojure.string :as str]))
17 19
18 ;; -------------------------------------------------------------- containers20 ;; -------------------------------------------------------------- containers
19 21
@@ -328,19 +330,163 @@
328 330
329 (defmacro scroll [opts & body] `(scroll* ~opts (fn [~'_id ~'_rect] ~@body)))331 (defmacro scroll [opts & body] `(scroll* ~opts (fn [~'_id ~'_rect] ~@body)))
330 332
331-(defn video333+(defn- picture
332- "A tile showing feed `key`, or the empty frame it will fill.334+ "The texture for either kind of source: a live `feed` or a file `src`."
333-335+ [{:keys [feed src]}]
334- `size` is the space it asks for; the picture is letterboxed inside that,336+ (cond feed (frames/lookup feed)
335- so a tile keeps its shape whatever the camera on the other end is doing.337+ src (frames/from-file src)))
336-338+
337- Drawn even with no picture yet a call wall where tiles appear only once339+(defn image
338- a frame arrives rearranges itself under the person every time somebody340+ "A picture: live pixels under `:feed`, or a file at `:src`.
339- joins, which is worse than a dark rectangle that fills."341+
340- ([key] (video key {}))342+ One widget and not two, because everything downstream the fit, the
341- ([key {:keys [size expand gravity]343+ bounds, the click is the same for both. That is libvidya's arrangement
342- :or {size [320.0 180.0] expand :both gravity [0.5 0.5]}}]344+ too, and frq writes [:image {:feed k}] for a call tile and
343- (let [rect (c/leaf size expand gravity)]345+ [:image {:src p}] for an attachment.
344- (c/fill! rect (c/th :surface-alt) 6.0)346+
345- (c/draw-frame! key rect)347+ `:fit` gives it every point of the space it has been handed; otherwise it
348+ asks for its own size, bounded by `:max-width` and `:max-height`. Either
349+ way the picture keeps its shape a 16:9 camera in a square tile is the
350+ ordinary case, and stretching it is the one thing nobody wants.
351+
352+ A source with no picture yet a feed before its first frame, a file that
353+ will not decode draws the empty tile and nothing else. Not a
354+ broken-image glyph: the text beside it already says what it was meant to
355+ be, and a tile that appears only once a frame lands rearranges the wall
356+ under the person every time somebody joins."
357+ ([opts] (image opts {}))
358+ ([{:keys [feed src] :as source}
359+ {:keys [fit max-width max-height size expand gravity placeholder?]
360+ :or {gravity [0.5 0.5] placeholder? true}}]
361+ (let [t (picture source)
362+ [tw th*] (if t [(:w t) (:h t)] [0 0])
363+ want (cond
364+ size size
365+ (and fit) [320.0 180.0]
366+ (pos? tw)
367+ (let [k (min (if max-width (/ (double max-width) tw) 1.0)
368+ (if max-height (/ (double max-height) th*) 1.0))]
369+ [(* tw k) (* th* k)])
370+ :else [(double (or max-width 160)) (double (or max-height 90))])
371+ rect (c/leaf (mapv double want)
372+ (or expand (if fit :both :none))
373+ gravity)]
374+ (when placeholder? (c/fill! rect (c/th :surface-alt) 6.0))
375+ (when (and t (:tex t)) (c/draw-picture! (:tex t) (:w t) (:h t) rect))
346 rect)))376 rect)))
377+
378+(defn title-2
379+ "A second-level heading smaller than `title`, still bold."
380+ [s]
381+ (label s {:size (* 1.15 (c/th :font-size)) :colour (c/th :text)}))
382+
383+(defn status
384+ "A line of text with a dot in front of it saying whether the thing is live."
385+ ([s] (status s false))
386+ ([s live?]
387+ (c/box* {:dir :horizontal :spacing 6 :gravity [0.0 0.5]}
388+ (fn [_ _]
389+ (let [line (c/th :font-size)
390+ d (max 7.0 (min 12.0 (* line 0.55)))
391+ r (c/leaf [(+ d 4.0) line] :none [0.0 0.5])
392+ [x y w h] r]
393+ (c/fill! [(+ x 2.0) (+ y (/ (- h d) 2.0)) d d]
394+ (if live? (c/th :accent) (c/th :text-dim))
395+ (/ d 2.0)))
396+ (label s)))))
397+
398+(defn spinner
399+ "A turning mark, and a word beside it if there is one.
400+
401+ Turned from the wall clock rather than a frame counter: a loop that
402+ dropped frames would otherwise show a spinner that stutters in a way that
403+ reads as the work having stalled, which is the one thing it is there to
404+ deny."
405+ ([] (spinner ""))
406+ ([s]
407+ (c/box* {:dir :horizontal :spacing 6 :gravity [0.0 0.5]}
408+ (fn [_ _]
409+ (let [line (c/th :font-size)
410+ r (c/leaf [line line] :none [0.0 0.5])
411+ [x y w h] r
412+ cx (+ x (/ w 2.0)) cy (+ y (/ h 2.0))
413+ rad (* 0.4 (min w h))
414+ t (/ (double (mod (System/currentTimeMillis) 1000)) 1000.0)
415+ a (* t 2.0 Math/PI)]
416+ ;; Three ticks around a circle: enough to read as turning, and no
417+ ;; arc primitive needed.
418+ (dotimes [i 3]
419+ (let [th* (+ a (* i (/ (* 2.0 Math/PI) 3.0)))]
420+ (c/fill! [(+ cx (* rad (Math/cos th*)) -1.5)
421+ (+ cy (* rad (Math/sin th*)) -1.5) 3.0 3.0]
422+ (c/th :accent) 1.5))))
423+ (when (seq s) (label s))))))
424+
425+(defn link
426+ "Text that is a place to go. Answers true on the frame it was clicked."
427+ ([s] (link s {}))
428+ ([s {:keys [key]}]
429+ (let [id (c/next-id key)
430+ rect (label s {:colour (c/th :accent)})]
431+ (:clicked? (c/interact! id rect)))))
432+
433+(defn emoji
434+ "One emoji, drawn as a character and sized to sit level with the words
435+ either side.
436+
437+ Through the text font, which is where this differs from libvidya: that
438+ draws from an image pack of its own, and this asks SDL_ttf for the glyph.
439+ On a system whose UI font has no emoji coverage most of them what
440+ appears is the missing-glyph box. The fix is a font with the coverage,
441+ not code here, which is why JVUI_FONT exists."
442+ ([s] (emoji s nil))
443+ ([s size]
444+ (label s {:size (or size (c/th :font-size))})))
445+
446+(defn avatar
447+ "A round picture for somebody, or their initial on a colour if there is
448+ none.
449+
450+ The fallback is not a placeholder to be replaced later most people in
451+ most rooms have no picture, so the initial IS the avatar, and its colour
452+ comes from the name so the same person is the same colour everywhere."
453+ ([nick] (avatar nick {}))
454+ ([nick {:keys [src size] :or {size 24.0}}]
455+ (let [rect (c/leaf [size size] :none [0.5 0.5])
456+ t (when src (frames/from-file src))]
457+ (if (and t (:tex t))
458+ (c/draw-picture! (:tex t) (:w t) (:h t) rect)
459+ (let [name (str nick)
460+ bare (str/replace name #"^[#&@+%~]+" "")
461+ initial (if (seq bare) (str/upper-case (subs bare 0 1)) "?")
462+ [x y w h] rect]
463+ (c/fill! rect (c/name-colour name) (/ size 2.0))
464+ (let [sz (* size 0.5)
465+ [tw th*] (c/measure initial sz)]
466+ (c/draw-text! initial (+ x (/ (- w tw) 2.0)) (+ y (/ (- h th*) 2.0))
467+ sz (c/th :accent-text)))))
468+ rect)))
469+
470+(defn reaction
471+ "A tally wearing a pill: an emoji, how many people, and whether you are one
472+ of them.
473+
474+ The same glyph `emoji` draws, but a reaction is not a character it
475+ answers the pointer and it is a count. `mine?` is bordered rather than
476+ filled differently, because the pill has to stay readable at the size a
477+ line of them ends up."
478+ ([glyph] (reaction glyph {}))
479+ ([glyph {:keys [count mine? size key]
480+ :or {count 0 mine? false}}]
481+ (let [sz (or size (c/th :font-size))
482+ txt (if (pos? count) (str glyph " " count) (str glyph))
483+ [tw th*] (c/measure txt sz)
484+ pad 6.0
485+ rect (c/leaf [(+ tw (* 2 pad)) (+ th* 4.0)] :none [0.0 0.5])
486+ id (c/next-id key)
487+ [x y w h] rect]
488+ (c/fill! rect (if mine? (c/th :press) (c/th :surface-alt))
489+ (/ h 2.0)
490+ (when mine? (c/th :accent)) (if mine? 1.0 0.0))
491+ (c/draw-text! txt (+ x pad) (+ y (/ (- h th*) 2.0)) sz (c/th :text))
492+ (:clicked? (c/interact! id rect)))))