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>d5dfd53 parent: 26defff modified
glimmer-backends/glimmer-jvui/src/glimmer_jvui/core.clj +45 -10 | @@ -128,16 +128,51 @@ | ||
| 128 | 128 | |
| 129 | 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 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))) | |
| 141 | 176 | |
| 142 | 177 | :title (w/title s) |
| 143 | 178 | |
| @@ -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 in | 131 | + ;; ONE tag for both kinds of picture: `:feed` is live pixels pushed |
| 132 | - ;; between frames. The node carries only the KEY — the pixels never go | 132 | + ;; in under a name and re-uploaded as they arrive, `:src` is a file |
| 133 | - ;; through the reconciler, because a video frame arrives when the | 133 | + ;; 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 a | 134 | + ;; 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 @@ | ||
| 1 | 1 | (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 | 8 | The layout tests run headless and cannot see this: with no renderer every |
| 5 | 9 | frame is dropped and a video tile is an empty rectangle that lays out |
| @@ -60,7 +64,7 @@ | ||
| 60 | 64 | ;; dropped for want of a renderer and the tile was empty for a |
| 61 | 65 | ;; reason that had nothing to do with the picture. |
| 62 | 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 | 68 | {:title "video" :width 160 :height 160 :frames 6 :shot shot}) |
| 65 | 69 | (finally (ffi/free px))) |
| 66 | 70 | (let [left (bmp-pixel shot 40 80) |
| @@ -1,5 +1,9 @@ | |||
| 1 | (ns glimmer-jvui.video-check | 1 | (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 every | 8 | 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 out | 9 | 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 a | 64 | ;; 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 @@ | ||
| 12 | 12 | :jolt/native [{:name "SDL3" :optional true |
| 13 | 13 | :linux ["libSDL3.so.0"] :darwin ["libSDL3.0.dylib"]} |
| 14 | 14 | {: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 | 22 | :aliases {:test {:extra-paths ["test"] :main-opts ["-m" "jvui.tests"]} |
| 18 | 23 | :counter {:extra-paths ["examples"] :main-opts ["-m" "jvui.counter"]} |
| @@ -12,7 +12,12 @@ | |||
| 12 | :jolt/native [{:name "SDL3" :optional true | 12 | :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 true | 14 | {: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 @@ | ||
| 122 | 122 | [s x y size colour] |
| 123 | 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 | 131 | (defn draw-frame! |
| 126 | 132 | "Paint feed `key`'s latest picture into `rect`, if it has one." |
| 127 | 133 | [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))))) | |
| 131 | 154 | |
| 132 | 155 | (defn draw-line! |
| 133 | 156 | [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 @@ | ||
| 19 | 19 | Which is why the renderer is held here as state rather than passed in. It |
| 20 | 20 | is not lovely, and the alternative is worse: threading a painter through a |
| 21 | 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 | 25 | (defonce ^:private renderer (atom nil)) |
| 25 | 26 | (defonce ^:private textures (atom {})) |
| @@ -72,15 +73,41 @@ | ||
| 72 | 73 | (swap! textures assoc key t)))) |
| 73 | 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 | 101 | (defn lookup |
| 76 | 102 | "{:tex :w :h} for a feed, or nil." |
| 77 | 103 | [key] |
| 78 | 104 | (get @textures key)) |
| 79 | 105 | |
| 80 | 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 | 112 | (defn clear! |
| 86 | 113 | "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. It | 19 | 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 a | 20 | 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 lookup | 101 | (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 @@ | ||
| 317 | 317 | (when (and p (not (ffi/null? p)) (pos? n)) |
| 318 | 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 | 332 | (defn update-texture-raw! |
| 321 | 333 | "Upload `h` rows of `pitch` bytes from FOREIGN memory into the whole of `tex`. |
| 322 | 334 | |
| @@ -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 @@ | ||
| 13 | 13 | closure. They are thin: each is `core/box*` with a different set of defaults |
| 14 | 14 | from the theme." |
| 15 | 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 | 20 | ;; -------------------------------------------------------------- containers |
| 19 | 21 | |
| @@ -328,19 +330,163 @@ | ||
| 328 | 330 | |
| 329 | 331 | (defmacro scroll [opts & body] `(scroll* ~opts (fn [~'_id ~'_rect] ~@body))) |
| 330 | 332 | |
| 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)) | |
| 346 | 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))))) | |
| @@ -13,7 +13,9 @@ | |||
| 13 | closure. They are thin: each is `core/box*` with a different set of defaults | 13 | 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 | ;; -------------------------------------------------------------- containers | 20 | ;; -------------------------------------------------------------- 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 video | 333 | +(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 once | 339 | +(defn image |
| 338 | - a frame arrives rearranges itself under the person every time somebody | 340 | + "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))))) | ||