Draw emoji in colour, and keep the scrollbar off the words
Two things a bitmap font forced, and one that follows from the first. A colour emoji face cannot be a fallback font. NotoColorEmoji is bitmap only — a single 128-pixel strike — so SDL_ttf answers 128 to every size it is asked for, measurement as much as drawing, and one emoji in a line makes that line a hundred and twenty-eight pixels tall. `usable?` refuses it for that reason, which left the monochrome outline face as the only emoji anything, and a machine without one drawing tofu. So colour goes down a different path: rendered once at the size the face insists on, kept as a texture, and blitted into whatever box the layout gave the glyph. The scale happens on the GPU, where the size the face will not do costs nothing. `emoji` and `reaction` take that path when there is a colour face and stay exactly as they were when there is not — which is also what a headless walk gets, having no painter and so no colour anything. `colour-emoji?` is answered off the painter rather than off `drawing?` for that reason: a glyph that will be a square picture has to be MEASURED as one, three walks before it is drawn. `reaction` measures and draws its glyph apart from its tally now. A picture cannot be concatenated onto a number. And the scrollbar stops sitting on the text. It is painted over the viewport's right edge, so the last characters of every wrapped line were under it — the conversation list where it showed worst, a card's worth of text ending in a grey bar. `box*` grows a `:pad-right`: a strip along the right the children are not given, taken off the content and not off the box, so the bar is drawn inside the box's own edge. A scroll always reserves it, showing the bar or not — reserving only when the bar appears means every line rewraps under the reader the moment a list grows past the window. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
5145c3b parent: 28dc136 modified
jvui/src/jvui/core.clj +27 -4 | @@ -128,6 +128,21 @@ | ||
| 128 | 128 | (when (and (drawing?) tex) |
| 129 | 129 | (paint/frame! (:painter (ui)) tex tw th rect))) |
| 130 | 130 | |
| 131 | +(defn colour-emoji? | |
| 132 | + "Whether `s` would be drawn as a colour picture rather than as text. | |
| 133 | + | |
| 134 | + Answered off the painter and not off `drawing?`, so the layout passes and | |
| 135 | + the paint pass agree: a glyph that will be a square picture has to be | |
| 136 | + MEASURED as one, three walks before it is drawn. A headless walk has no | |
| 137 | + painter and no colour anything, and answers false." | |
| 138 | + [s] | |
| 139 | + (boolean (when-let [p (:painter (ui))] (paint/colour-emoji? p s)))) | |
| 140 | + | |
| 141 | +(defn draw-emoji! | |
| 142 | + "Paint `s` as a colour picture in `rect`, if there is one." | |
| 143 | + [s rect] | |
| 144 | + (when (drawing?) (paint/emoji! (:painter (ui)) s rect))) | |
| 145 | + | |
| 131 | 146 | (defn draw-frame! |
| 132 | 147 | "Paint feed `key`'s latest picture into `rect`, if it has one." |
| 133 | 148 | [key rect] |
| @@ -366,7 +381,7 @@ | ||
| 366 | 381 | child on a line of its own." |
| 367 | 382 | [opts body] |
| 368 | 383 | (let [{:keys [dir spacing padding margin expand gravity key fill border |
| 369 | - radius min-size clip? offset fixed wrap] | |
| 384 | + radius min-size clip? offset fixed wrap pad-right] | |
| 370 | 385 | :or {dir :vertical expand :none gravity [0.0 0.0] clip? false}} opts |
| 371 | 386 | spacing (double (or spacing 0.0)) |
| 372 | 387 | padding (double (or padding 0.0)) |
| @@ -385,8 +400,13 @@ | ||
| 385 | 400 | [ox oy ow oh] outer |
| 386 | 401 | box-rect [(+ ox margin) (+ oy margin) |
| 387 | 402 | (- ow (* 2 margin)) (- oh (* 2 margin))] |
| 403 | + ;; A strip along the right the children never get, on top of the | |
| 404 | + ;; padding: it is where a scrollbar goes. Only the CONTENT loses | |
| 405 | + ;; it — the box keeps its rectangle, so the bar is drawn inside the | |
| 406 | + ;; box's own edge rather than beyond it. | |
| 407 | + pad-right (double (or pad-right 0.0)) | |
| 388 | 408 | content [(+ (first box-rect) padding) (+ (second box-rect) padding) |
| 389 | - (max 0.0 (- (nth box-rect 2) (* 2 padding))) | |
| 409 | + (max 0.0 (- (nth box-rect 2) (* 2 padding) pad-right)) | |
| 390 | 410 | (max 0.0 (- (nth box-rect 3) (* 2 padding)))] |
| 391 | 411 | ;; Extra space to hand out along our own axis, computed from what we |
| 392 | 412 | ;; counted last frame: whatever the fixed children took is spoken for, |
| @@ -419,9 +439,12 @@ | ||
| 419 | 439 | ;; the run of them; this swap is the only place that distinction is spelled |
| 420 | 440 | ;; out. |
| 421 | 441 | (let [pad2 (+ (* 2 padding) (* 2 margin)) |
| 442 | + ;; The gutter counts towards the WIDTH the box needs and nothing | |
| 443 | + ;; else: it is a strip the children were not given, so a box | |
| 444 | + ;; wide enough for them is not wide enough for them and it. | |
| 422 | 445 | computed (if (= dir :vertical) |
| 423 | - [(+ (ctr f CROSS) pad2) (+ (ctr f ALONG) pad2)] | |
| 424 | - [(+ (ctr f ALONG) pad2) (+ (ctr f CROSS) pad2)]) | |
| 446 | + [(+ (ctr f CROSS) pad2 pad-right) (+ (ctr f ALONG) pad2)] | |
| 447 | + [(+ (ctr f ALONG) pad2 pad-right) (+ (ctr f CROSS) pad2)]) | |
| 425 | 448 | computed (if min-size |
| 426 | 449 | [(max (first computed) (first min-size)) |
| 427 | 450 | (max (second computed) (second min-size))] |
| @@ -128,6 +128,21 @@ | |||
| 128 | (when (and (drawing?) tex) | 128 | (when (and (drawing?) tex) |
| 129 | (paint/frame! (:painter (ui)) tex tw th rect))) | 129 | (paint/frame! (:painter (ui)) tex tw th rect))) |
| 130 | 130 | ||
| 131 | +(defn colour-emoji? | ||
| 132 | + "Whether `s` would be drawn as a colour picture rather than as text. | ||
| 133 | + | ||
| 134 | + Answered off the painter and not off `drawing?`, so the layout passes and | ||
| 135 | + the paint pass agree: a glyph that will be a square picture has to be | ||
| 136 | + MEASURED as one, three walks before it is drawn. A headless walk has no | ||
| 137 | + painter and no colour anything, and answers false." | ||
| 138 | + [s] | ||
| 139 | + (boolean (when-let [p (:painter (ui))] (paint/colour-emoji? p s)))) | ||
| 140 | + | ||
| 141 | +(defn draw-emoji! | ||
| 142 | + "Paint `s` as a colour picture in `rect`, if there is one." | ||
| 143 | + [s rect] | ||
| 144 | + (when (drawing?) (paint/emoji! (:painter (ui)) s rect))) | ||
| 145 | + | ||
| 131 | (defn draw-frame! | 146 | (defn draw-frame! |
| 132 | "Paint feed `key`'s latest picture into `rect`, if it has one." | 147 | "Paint feed `key`'s latest picture into `rect`, if it has one." |
| 133 | [key rect] | 148 | [key rect] |
| @@ -366,7 +381,7 @@ | |||
| 366 | child on a line of its own." | 381 | child on a line of its own." |
| 367 | [opts body] | 382 | [opts body] |
| 368 | (let [{:keys [dir spacing padding margin expand gravity key fill border | 383 | (let [{:keys [dir spacing padding margin expand gravity key fill border |
| 369 | - radius min-size clip? offset fixed wrap] | 384 | + radius min-size clip? offset fixed wrap pad-right] |
| 370 | :or {dir :vertical expand :none gravity [0.0 0.0] clip? false}} opts | 385 | :or {dir :vertical expand :none gravity [0.0 0.0] clip? false}} opts |
| 371 | spacing (double (or spacing 0.0)) | 386 | spacing (double (or spacing 0.0)) |
| 372 | padding (double (or padding 0.0)) | 387 | padding (double (or padding 0.0)) |
| @@ -385,8 +400,13 @@ | |||
| 385 | [ox oy ow oh] outer | 400 | [ox oy ow oh] outer |
| 386 | box-rect [(+ ox margin) (+ oy margin) | 401 | box-rect [(+ ox margin) (+ oy margin) |
| 387 | (- ow (* 2 margin)) (- oh (* 2 margin))] | 402 | (- ow (* 2 margin)) (- oh (* 2 margin))] |
| 403 | + ;; A strip along the right the children never get, on top of the | ||
| 404 | + ;; padding: it is where a scrollbar goes. Only the CONTENT loses | ||
| 405 | + ;; it — the box keeps its rectangle, so the bar is drawn inside the | ||
| 406 | + ;; box's own edge rather than beyond it. | ||
| 407 | + pad-right (double (or pad-right 0.0)) | ||
| 388 | content [(+ (first box-rect) padding) (+ (second box-rect) padding) | 408 | content [(+ (first box-rect) padding) (+ (second box-rect) padding) |
| 389 | - (max 0.0 (- (nth box-rect 2) (* 2 padding))) | 409 | + (max 0.0 (- (nth box-rect 2) (* 2 padding) pad-right)) |
| 390 | (max 0.0 (- (nth box-rect 3) (* 2 padding)))] | 410 | (max 0.0 (- (nth box-rect 3) (* 2 padding)))] |
| 391 | ;; Extra space to hand out along our own axis, computed from what we | 411 | ;; Extra space to hand out along our own axis, computed from what we |
| 392 | ;; counted last frame: whatever the fixed children took is spoken for, | 412 | ;; counted last frame: whatever the fixed children took is spoken for, |
| @@ -419,9 +439,12 @@ | |||
| 419 | ;; the run of them; this swap is the only place that distinction is spelled | 439 | ;; the run of them; this swap is the only place that distinction is spelled |
| 420 | ;; out. | 440 | ;; out. |
| 421 | (let [pad2 (+ (* 2 padding) (* 2 margin)) | 441 | (let [pad2 (+ (* 2 padding) (* 2 margin)) |
| 442 | + ;; The gutter counts towards the WIDTH the box needs and nothing | ||
| 443 | + ;; else: it is a strip the children were not given, so a box | ||
| 444 | + ;; wide enough for them is not wide enough for them and it. | ||
| 422 | computed (if (= dir :vertical) | 445 | computed (if (= dir :vertical) |
| 423 | - [(+ (ctr f CROSS) pad2) (+ (ctr f ALONG) pad2)] | 446 | + [(+ (ctr f CROSS) pad2 pad-right) (+ (ctr f ALONG) pad2)] |
| 424 | - [(+ (ctr f ALONG) pad2) (+ (ctr f CROSS) pad2)]) | 447 | + [(+ (ctr f ALONG) pad2 pad-right) (+ (ctr f CROSS) pad2)]) |
| 425 | computed (if min-size | 448 | computed (if min-size |
| 426 | [(max (first computed) (first min-size)) | 449 | [(max (first computed) (first min-size)) |
| 427 | (max (second computed) (second min-size))] | 450 | (max (second computed) (second min-size))] |
modified
jvui/src/jvui/font.clj +94 -0 | @@ -70,11 +70,103 @@ | ||
| 70 | 70 | (remove clojure.string/blank? (clojure.string/split env #":")) |
| 71 | 71 | (filter #(.exists (java.io.File. %)) fallbacks))) |
| 72 | 72 | |
| 73 | +(def ^:private colour-faces | |
| 74 | + "Colour emoji faces, for the picture path below rather than as fallbacks. | |
| 75 | + | |
| 76 | + JVUI_COLOUR_EMOJI_FONT overrides, and an empty value turns colour off — | |
| 77 | + which is the way to get the monochrome fallback back on a machine that | |
| 78 | + has both." | |
| 79 | + ["/usr/share/fonts/noto/NotoColorEmoji.ttf" | |
| 80 | + "/usr/share/fonts/truetype/noto/NotoColorEmoji.ttf" | |
| 81 | + "/usr/share/fonts/noto/NotoColorEmoji-Regular.ttf" | |
| 82 | + "/System/Library/Fonts/Apple Color Emoji.ttc"]) | |
| 83 | + | |
| 84 | +(defn- colour-path [] | |
| 85 | + (if-let [env (System/getenv "JVUI_COLOUR_EMOJI_FONT")] | |
| 86 | + (when (seq env) env) | |
| 87 | + (first (filter #(.exists (java.io.File. %)) colour-faces)))) | |
| 88 | + | |
| 73 | 89 | (defn open |
| 74 | 90 | "A font cache over `path`. Sizes are opened lazily and kept." |
| 75 | 91 | [path] |
| 76 | 92 | (atom {:path path :faces {} :sizes {} :textures {}})) |
| 77 | 93 | |
| 94 | +;; --- colour emoji ------------------------------------------------------------ | |
| 95 | +;; | |
| 96 | +;; Not through the fallback list, and it cannot be: a colour emoji face is a | |
| 97 | +;; BITMAP with one strike — NotoColorEmoji has a single 128-pixel one — so | |
| 98 | +;; SDL_ttf answers 128 to every size it is asked for, measurement as much as | |
| 99 | +;; drawing. Added as a fallback it makes any line holding an emoji a hundred | |
| 100 | +;; and twenty-eight pixels tall, which is what `usable?` above refuses. | |
| 101 | +;; | |
| 102 | +;; So it is drawn as a PICTURE instead: rendered once at the size it insists | |
| 103 | +;; on, kept as a texture, and blitted into whatever box the layout gave the | |
| 104 | +;; glyph. The scale happens on the GPU at draw time, where the size the face | |
| 105 | +;; will not do costs nothing. The monochrome fallback stays as it is and | |
| 106 | +;; answers everything this has no glyph for. | |
| 107 | + | |
| 108 | +(defn- colour-face | |
| 109 | + "The colour emoji face, opened once, or nil where there is none. | |
| 110 | + | |
| 111 | + `:colour` is a three-state cell: absent means not looked for, false means | |
| 112 | + looked for and not found. Without the false a machine with no colour face | |
| 113 | + re-opens nothing but re-walks the candidate list on every glyph." | |
| 114 | + [cache] | |
| 115 | + (let [v (:colour @cache)] | |
| 116 | + (if (some? v) | |
| 117 | + (or v nil) | |
| 118 | + (let [path (colour-path) | |
| 119 | + f (when path (sdl/open-font path (float 32))) | |
| 120 | + f (when-not (ffi-null? f) f)] | |
| 121 | + (swap! cache assoc :colour (or f false)) | |
| 122 | + f)))) | |
| 123 | + | |
| 124 | +(defn- ignorable? | |
| 125 | + "Codepoints an emoji carries that no face is expected to have a glyph for: | |
| 126 | + the variation selectors that ask for emoji presentation, the zero-width | |
| 127 | + joiner that binds a sequence, and the tag characters flags are spelled | |
| 128 | + with." | |
| 129 | + [cp] | |
| 130 | + (or (= cp 0x200D) (<= 0xFE00 cp 0xFE0F) (<= 0xE0020 cp 0xE007F))) | |
| 131 | + | |
| 132 | +(defn colour-glyph? | |
| 133 | + "Whether the colour face can draw `s` — every codepoint in it that is a | |
| 134 | + glyph at all. | |
| 135 | + | |
| 136 | + Every one, not the first: taking the first would claim a family the face | |
| 137 | + has only the man of, and draw it as a man." | |
| 138 | + [cache s] | |
| 139 | + (boolean | |
| 140 | + (when-let [f (and (seq s) (colour-face cache))] | |
| 141 | + ;; `seq` over the codepoints, not over UTF-16: a jolt string is | |
| 142 | + ;; Scheme's, whose characters are whole scalars, so there are no | |
| 143 | + ;; surrogate halves here to pair up. frq.glyphs walks emoji the same | |
| 144 | + ;; way. | |
| 145 | + (let [cps (remove ignorable? (map int (seq s)))] | |
| 146 | + (and (seq cps) (every? #(sdl/font-has-glyph? f (int %)) cps)))))) | |
| 147 | + | |
| 148 | +(defn colour-texture | |
| 149 | + "An SDL texture of `s` in colour, and its [w h], cached. nil where the | |
| 150 | + colour face cannot draw it. | |
| 151 | + | |
| 152 | + One texture per string rather than per string-and-size, because the face | |
| 153 | + has one size: the caller scales it into the rectangle the layout gave." | |
| 154 | + [cache renderer s] | |
| 155 | + (when (colour-glyph? cache s) | |
| 156 | + (or (get-in @cache [:colour-textures s]) | |
| 157 | + (let [surf (sdl/render-blended (colour-face cache) s)] | |
| 158 | + (when-not (ffi-null? surf) | |
| 159 | + (let [tex (sdl/texture-from-surface renderer surf) | |
| 160 | + wh (sdl/surface-size surf)] | |
| 161 | + (sdl/destroy-surface! surf) | |
| 162 | + (sdl/texture-blend-mode! tex sdl/BLEND) | |
| 163 | + ;; Linear, because this texture is only ever drawn scaled DOWN | |
| 164 | + ;; — 128 pixels into a sixteen-pixel line — and nearest at that | |
| 165 | + ;; ratio is a handful of the original pixels and looks it. | |
| 166 | + (sdl/texture-scale-mode! tex sdl/SCALE-LINEAR) | |
| 167 | + (swap! cache assoc-in [:colour-textures s] [tex wh]) | |
| 168 | + [tex wh])))))) | |
| 169 | + | |
| 78 | 170 | (defn- face |
| 79 | 171 | [cache size] |
| 80 | 172 | (let [k (int size)] |
| @@ -140,5 +232,7 @@ | ||
| 140 | 232 | (defn close! |
| 141 | 233 | [cache] |
| 142 | 234 | (doseq [[_ [tex _]] (:textures @cache)] (sdl/destroy-texture! tex)) |
| 235 | + (doseq [[_ [tex _]] (:colour-textures @cache)] (sdl/destroy-texture! tex)) | |
| 143 | 236 | (doseq [[_ f] (:faces @cache)] (sdl/close-font! f)) |
| 237 | + (when-let [f (:colour @cache)] (sdl/close-font! f)) | |
| 144 | 238 | (reset! cache {:path (:path @cache) :faces {} :sizes {} :textures {}})) |
| @@ -70,11 +70,103 @@ | |||
| 70 | (remove clojure.string/blank? (clojure.string/split env #":")) | 70 | (remove clojure.string/blank? (clojure.string/split env #":")) |
| 71 | (filter #(.exists (java.io.File. %)) fallbacks))) | 71 | (filter #(.exists (java.io.File. %)) fallbacks))) |
| 72 | 72 | ||
| 73 | +(def ^:private colour-faces | ||
| 74 | + "Colour emoji faces, for the picture path below rather than as fallbacks. | ||
| 75 | + | ||
| 76 | + JVUI_COLOUR_EMOJI_FONT overrides, and an empty value turns colour off — | ||
| 77 | + which is the way to get the monochrome fallback back on a machine that | ||
| 78 | + has both." | ||
| 79 | + ["/usr/share/fonts/noto/NotoColorEmoji.ttf" | ||
| 80 | + "/usr/share/fonts/truetype/noto/NotoColorEmoji.ttf" | ||
| 81 | + "/usr/share/fonts/noto/NotoColorEmoji-Regular.ttf" | ||
| 82 | + "/System/Library/Fonts/Apple Color Emoji.ttc"]) | ||
| 83 | + | ||
| 84 | +(defn- colour-path [] | ||
| 85 | + (if-let [env (System/getenv "JVUI_COLOUR_EMOJI_FONT")] | ||
| 86 | + (when (seq env) env) | ||
| 87 | + (first (filter #(.exists (java.io.File. %)) colour-faces)))) | ||
| 88 | + | ||
| 73 | (defn open | 89 | (defn open |
| 74 | "A font cache over `path`. Sizes are opened lazily and kept." | 90 | "A font cache over `path`. Sizes are opened lazily and kept." |
| 75 | [path] | 91 | [path] |
| 76 | (atom {:path path :faces {} :sizes {} :textures {}})) | 92 | (atom {:path path :faces {} :sizes {} :textures {}})) |
| 77 | 93 | ||
| 94 | +;; --- colour emoji ------------------------------------------------------------ | ||
| 95 | +;; | ||
| 96 | +;; Not through the fallback list, and it cannot be: a colour emoji face is a | ||
| 97 | +;; BITMAP with one strike — NotoColorEmoji has a single 128-pixel one — so | ||
| 98 | +;; SDL_ttf answers 128 to every size it is asked for, measurement as much as | ||
| 99 | +;; drawing. Added as a fallback it makes any line holding an emoji a hundred | ||
| 100 | +;; and twenty-eight pixels tall, which is what `usable?` above refuses. | ||
| 101 | +;; | ||
| 102 | +;; So it is drawn as a PICTURE instead: rendered once at the size it insists | ||
| 103 | +;; on, kept as a texture, and blitted into whatever box the layout gave the | ||
| 104 | +;; glyph. The scale happens on the GPU at draw time, where the size the face | ||
| 105 | +;; will not do costs nothing. The monochrome fallback stays as it is and | ||
| 106 | +;; answers everything this has no glyph for. | ||
| 107 | + | ||
| 108 | +(defn- colour-face | ||
| 109 | + "The colour emoji face, opened once, or nil where there is none. | ||
| 110 | + | ||
| 111 | + `:colour` is a three-state cell: absent means not looked for, false means | ||
| 112 | + looked for and not found. Without the false a machine with no colour face | ||
| 113 | + re-opens nothing but re-walks the candidate list on every glyph." | ||
| 114 | + [cache] | ||
| 115 | + (let [v (:colour @cache)] | ||
| 116 | + (if (some? v) | ||
| 117 | + (or v nil) | ||
| 118 | + (let [path (colour-path) | ||
| 119 | + f (when path (sdl/open-font path (float 32))) | ||
| 120 | + f (when-not (ffi-null? f) f)] | ||
| 121 | + (swap! cache assoc :colour (or f false)) | ||
| 122 | + f)))) | ||
| 123 | + | ||
| 124 | +(defn- ignorable? | ||
| 125 | + "Codepoints an emoji carries that no face is expected to have a glyph for: | ||
| 126 | + the variation selectors that ask for emoji presentation, the zero-width | ||
| 127 | + joiner that binds a sequence, and the tag characters flags are spelled | ||
| 128 | + with." | ||
| 129 | + [cp] | ||
| 130 | + (or (= cp 0x200D) (<= 0xFE00 cp 0xFE0F) (<= 0xE0020 cp 0xE007F))) | ||
| 131 | + | ||
| 132 | +(defn colour-glyph? | ||
| 133 | + "Whether the colour face can draw `s` — every codepoint in it that is a | ||
| 134 | + glyph at all. | ||
| 135 | + | ||
| 136 | + Every one, not the first: taking the first would claim a family the face | ||
| 137 | + has only the man of, and draw it as a man." | ||
| 138 | + [cache s] | ||
| 139 | + (boolean | ||
| 140 | + (when-let [f (and (seq s) (colour-face cache))] | ||
| 141 | + ;; `seq` over the codepoints, not over UTF-16: a jolt string is | ||
| 142 | + ;; Scheme's, whose characters are whole scalars, so there are no | ||
| 143 | + ;; surrogate halves here to pair up. frq.glyphs walks emoji the same | ||
| 144 | + ;; way. | ||
| 145 | + (let [cps (remove ignorable? (map int (seq s)))] | ||
| 146 | + (and (seq cps) (every? #(sdl/font-has-glyph? f (int %)) cps)))))) | ||
| 147 | + | ||
| 148 | +(defn colour-texture | ||
| 149 | + "An SDL texture of `s` in colour, and its [w h], cached. nil where the | ||
| 150 | + colour face cannot draw it. | ||
| 151 | + | ||
| 152 | + One texture per string rather than per string-and-size, because the face | ||
| 153 | + has one size: the caller scales it into the rectangle the layout gave." | ||
| 154 | + [cache renderer s] | ||
| 155 | + (when (colour-glyph? cache s) | ||
| 156 | + (or (get-in @cache [:colour-textures s]) | ||
| 157 | + (let [surf (sdl/render-blended (colour-face cache) s)] | ||
| 158 | + (when-not (ffi-null? surf) | ||
| 159 | + (let [tex (sdl/texture-from-surface renderer surf) | ||
| 160 | + wh (sdl/surface-size surf)] | ||
| 161 | + (sdl/destroy-surface! surf) | ||
| 162 | + (sdl/texture-blend-mode! tex sdl/BLEND) | ||
| 163 | + ;; Linear, because this texture is only ever drawn scaled DOWN | ||
| 164 | + ;; — 128 pixels into a sixteen-pixel line — and nearest at that | ||
| 165 | + ;; ratio is a handful of the original pixels and looks it. | ||
| 166 | + (sdl/texture-scale-mode! tex sdl/SCALE-LINEAR) | ||
| 167 | + (swap! cache assoc-in [:colour-textures s] [tex wh]) | ||
| 168 | + [tex wh])))))) | ||
| 169 | + | ||
| 78 | (defn- face | 170 | (defn- face |
| 79 | [cache size] | 171 | [cache size] |
| 80 | (let [k (int size)] | 172 | (let [k (int size)] |
| @@ -140,5 +232,7 @@ | |||
| 140 | (defn close! | 232 | (defn close! |
| 141 | [cache] | 233 | [cache] |
| 142 | (doseq [[_ [tex _]] (:textures @cache)] (sdl/destroy-texture! tex)) | 234 | (doseq [[_ [tex _]] (:textures @cache)] (sdl/destroy-texture! tex)) |
| 235 | + (doseq [[_ [tex _]] (:colour-textures @cache)] (sdl/destroy-texture! tex)) | ||
| 143 | (doseq [[_ f] (:faces @cache)] (sdl/close-font! f)) | 236 | (doseq [[_ f] (:faces @cache)] (sdl/close-font! f)) |
| 237 | + (when-let [f (:colour @cache)] (sdl/close-font! f)) | ||
| 144 | (reset! cache {:path (:path @cache) :faces {} :sizes {} :textures {}})) | 238 | (reset! cache {:path (:path @cache) :faces {} :sizes {} :textures {}})) |
modified
jvui/src/jvui/paint.clj +14 -0 | @@ -150,6 +150,20 @@ | ||
| 150 | 150 | (flush! p) |
| 151 | 151 | (sdl/blit! r tex nil [(+ x (/ (- w dw) 2.0)) (+ y (/ (- h dh) 2.0)) dw dh]))) |
| 152 | 152 | |
| 153 | +(defn colour-emoji? | |
| 154 | + "Whether `s` has a colour picture to be drawn as." | |
| 155 | + [p s] | |
| 156 | + (font/colour-glyph? (:fonts @p) s)) | |
| 157 | + | |
| 158 | +(defn emoji! | |
| 159 | + "Draw `s` as a colour picture in `rect`. Answers whether it did — a caller | |
| 160 | + that gets false draws it as text instead." | |
| 161 | + [p s rect] | |
| 162 | + (let [{:keys [r fonts]} @p] | |
| 163 | + (if-let [[tex [tw th]] (font/colour-texture fonts r s)] | |
| 164 | + (do (frame! p tex tw th rect) true) | |
| 165 | + false))) | |
| 166 | + | |
| 153 | 167 | (defn line! |
| 154 | 168 | "A `width`-thick line. Axis-aligned lines are a rectangle; the diagonal case |
| 155 | 169 | — which in this toolkit is a checkbox tick — is a few offset hairlines, |
| @@ -150,6 +150,20 @@ | |||
| 150 | (flush! p) | 150 | (flush! p) |
| 151 | (sdl/blit! r tex nil [(+ x (/ (- w dw) 2.0)) (+ y (/ (- h dh) 2.0)) dw dh]))) | 151 | (sdl/blit! r tex nil [(+ x (/ (- w dw) 2.0)) (+ y (/ (- h dh) 2.0)) dw dh]))) |
| 152 | 152 | ||
| 153 | +(defn colour-emoji? | ||
| 154 | + "Whether `s` has a colour picture to be drawn as." | ||
| 155 | + [p s] | ||
| 156 | + (font/colour-glyph? (:fonts @p) s)) | ||
| 157 | + | ||
| 158 | +(defn emoji! | ||
| 159 | + "Draw `s` as a colour picture in `rect`. Answers whether it did — a caller | ||
| 160 | + that gets false draws it as text instead." | ||
| 161 | + [p s rect] | ||
| 162 | + (let [{:keys [r fonts]} @p] | ||
| 163 | + (if-let [[tex [tw th]] (font/colour-texture fonts r s)] | ||
| 164 | + (do (frame! p tex tw th rect) true) | ||
| 165 | + false))) | ||
| 166 | + | ||
| 153 | (defn line! | 167 | (defn line! |
| 154 | "A `width`-thick line. Axis-aligned lines are a rectangle; the diagonal case | 168 | "A `width`-thick line. Axis-aligned lines are a rectangle; the diagonal case |
| 155 | — which in this toolkit is a checkbox tick — is a few offset hairlines, | 169 | — which in this toolkit is a checkbox tick — is a few offset hairlines, |
modified
jvui/src/jvui/sdl.clj +1 -0 | @@ -70,6 +70,7 @@ | ||
| 70 | 70 | ;; A face to try for characters the main one has no glyph for. SDL_ttf keeps |
| 71 | 71 | ;; a list of them and asks each in turn. |
| 72 | 72 | (ffi/defcfn add-fallback-font! "TTF_AddFallbackFont" [:pointer :pointer] :bool) |
| 73 | +(ffi/defcfn font-has-glyph? "TTF_FontHasGlyph" [:pointer :uint] :bool) | |
| 73 | 74 | (ffi/defcfn ^:private raw-string-size "TTF_GetStringSize" |
| 74 | 75 | [:pointer :string :ulong :pointer :pointer] :bool) |
| 75 | 76 | (ffi/defcfn ^:private raw-render-blended "TTF_RenderText_Blended" |
| @@ -70,6 +70,7 @@ | |||
| 70 | ;; A face to try for characters the main one has no glyph for. SDL_ttf keeps | 70 | ;; A face to try for characters the main one has no glyph for. SDL_ttf keeps |
| 71 | ;; a list of them and asks each in turn. | 71 | ;; a list of them and asks each in turn. |
| 72 | (ffi/defcfn add-fallback-font! "TTF_AddFallbackFont" [:pointer :pointer] :bool) | 72 | (ffi/defcfn add-fallback-font! "TTF_AddFallbackFont" [:pointer :pointer] :bool) |
| 73 | +(ffi/defcfn font-has-glyph? "TTF_FontHasGlyph" [:pointer :uint] :bool) | ||
| 73 | (ffi/defcfn ^:private raw-string-size "TTF_GetStringSize" | 74 | (ffi/defcfn ^:private raw-string-size "TTF_GetStringSize" |
| 74 | [:pointer :string :ulong :pointer :pointer] :bool) | 75 | [:pointer :string :ulong :pointer :pointer] :bool) |
| 75 | (ffi/defcfn ^:private raw-render-blended "TTF_RenderText_Blended" | 76 | (ffi/defcfn ^:private raw-render-blended "TTF_RenderText_Blended" |
modified
jvui/src/jvui/widgets.clj +48 -14 | @@ -473,8 +473,18 @@ | ||
| 473 | 473 | jump? maxoff |
| 474 | 474 | stick? maxoff |
| 475 | 475 | :else wheeled) |
| 476 | + ;; The gutter the bar is drawn in, taken off the content whether or | |
| 477 | + ;; not there is anything to scroll yet. Always, because the | |
| 478 | + ;; alternative is that a list reflows the moment it grows past the | |
| 479 | + ;; window — every line rewrapping under the reader as the message | |
| 480 | + ;; that overflowed arrives. And taken at all because the bar is | |
| 481 | + ;; painted over the viewport's right edge: without it the last | |
| 482 | + ;; characters of every wrapped line sit under the bar, which is | |
| 483 | + ;; where "the sidebar truncates" comes from. | |
| 484 | + gutter (double (c/th :scrollbar)) | |
| 476 | 485 | r (c/box* (merge {:dir :vertical |
| 477 | 486 | :expand :both |
| 487 | + :pad-right gutter | |
| 478 | 488 | ;; An explicit :height IS a demand and is |
| 479 | 489 | ;; reported as one. Without it the viewport |
| 480 | 490 | ;; fills what is left and asks for nothing — |
| @@ -623,14 +633,23 @@ | ||
| 623 | 633 | "One emoji, drawn as a character and sized to sit level with the words |
| 624 | 634 | either side. |
| 625 | 635 | |
| 626 | - Through the text font, which is where this differs from libvidya: that | |
| 627 | - draws from an image pack of its own, and this asks SDL_ttf for the glyph. | |
| 628 | - On a system whose UI font has no emoji coverage — most of them — what | |
| 629 | - appears is the missing-glyph box. The fix is a font with the coverage, | |
| 630 | - not code here, which is why JVUI_FONT exists." | |
| 636 | + In colour where there is a colour face to draw it from, and as text | |
| 637 | + where there is not. | |
| 638 | + | |
| 639 | + The two paths differ in more than the picture: a colour emoji face is a | |
| 640 | + bitmap with one 128-pixel strike, so it cannot be a fallback font and is | |
| 641 | + drawn as an image scaled into the line — which means the glyph occupies a | |
| 642 | + SQUARE of the size asked for, decided here rather than measured. The text | |
| 643 | + path stays exactly what it was, a label in the UI font, and is what a | |
| 644 | + machine with no colour face gets." | |
| 631 | 645 | ([s] (emoji s nil)) |
| 632 | 646 | ([s size] |
| 633 | - (label s {:size (or size (c/th :font-size))}))) | |
| 647 | + (let [sz (double (or size (c/th :font-size)))] | |
| 648 | + (if (c/colour-emoji? s) | |
| 649 | + (let [rect (c/leaf [sz sz] :none [0.5 0.5])] | |
| 650 | + (c/draw-emoji! s rect) | |
| 651 | + rect) | |
| 652 | + (label s {:size sz}))))) | |
| 634 | 653 | |
| 635 | 654 | (defn avatar |
| 636 | 655 | "A round picture for somebody, or their initial on a colour if there is |
| @@ -660,16 +679,25 @@ | ||
| 660 | 679 | "A tally wearing a pill: an emoji, how many people, and whether you are one |
| 661 | 680 | of them. |
| 662 | 681 | |
| 663 | - The same glyph `emoji` draws, but a reaction is not a character — it | |
| 664 | - answers the pointer and it is a count. `mine?` is bordered rather than | |
| 665 | - filled differently, because the pill has to stay readable at the size a | |
| 666 | - line of them ends up." | |
| 682 | + The same glyph `emoji` draws, and the same two ways of drawing it: a | |
| 683 | + colour face gives a square picture, and everything else is text. So the | |
| 684 | + glyph and the tally are measured and drawn apart rather than as one | |
| 685 | + string — a picture cannot be concatenated onto a number. | |
| 686 | + | |
| 687 | + A reaction is not a character otherwise either: it answers the pointer | |
| 688 | + and it is a count. `mine?` is bordered rather than filled differently, | |
| 689 | + because the pill has to stay readable at the size a line of them ends | |
| 690 | + up." | |
| 667 | 691 | ([glyph] (reaction glyph {})) |
| 668 | 692 | ([glyph {:keys [count mine? size key] |
| 669 | 693 | :or {count 0 mine? false}}] |
| 670 | - (let [sz (or size (c/th :font-size)) | |
| 671 | - txt (if (pos? count) (str glyph " " count) (str glyph)) | |
| 672 | - [tw th*] (c/measure txt sz) | |
| 694 | + (let [sz (double (or size (c/th :font-size))) | |
| 695 | + colour? (c/colour-emoji? (str glyph)) | |
| 696 | + tally (when (pos? count) (str " " count)) | |
| 697 | + [gw gh] (if colour? [sz sz] (c/measure (str glyph) sz)) | |
| 698 | + [cw ch] (if tally (c/measure tally sz) [0.0 0.0]) | |
| 699 | + tw (+ (double gw) (double cw)) | |
| 700 | + th* (max (double gh) (double ch)) | |
| 673 | 701 | pad 6.0 |
| 674 | 702 | rect (c/leaf [(+ tw (* 2 pad)) (+ th* 4.0)] :none [0.0 0.5]) |
| 675 | 703 | id (c/next-id key) |
| @@ -677,5 +705,11 @@ | ||
| 677 | 705 | (c/fill! rect (if mine? (c/th :press) (c/th :surface-alt)) |
| 678 | 706 | (/ h 2.0) |
| 679 | 707 | (when mine? (c/th :accent)) (if mine? 1.0 0.0)) |
| 680 | - (c/draw-text! txt (+ x pad) (+ y (/ (- h th*) 2.0)) sz (c/th :text)) | |
| 708 | + (if colour? | |
| 709 | + (c/draw-emoji! (str glyph) [(+ x pad) (+ y (/ (- h sz) 2.0)) sz sz]) | |
| 710 | + (c/draw-text! (str glyph) (+ x pad) (+ y (/ (- h (double gh)) 2.0)) | |
| 711 | + sz (c/th :text))) | |
| 712 | + (when tally | |
| 713 | + (c/draw-text! tally (+ x pad (double gw)) (+ y (/ (- h (double ch)) 2.0)) | |
| 714 | + sz (c/th :text))) | |
| 681 | 715 | (:clicked? (c/interact! id rect))))) |
| @@ -473,8 +473,18 @@ | |||
| 473 | jump? maxoff | 473 | jump? maxoff |
| 474 | stick? maxoff | 474 | stick? maxoff |
| 475 | :else wheeled) | 475 | :else wheeled) |
| 476 | + ;; The gutter the bar is drawn in, taken off the content whether or | ||
| 477 | + ;; not there is anything to scroll yet. Always, because the | ||
| 478 | + ;; alternative is that a list reflows the moment it grows past the | ||
| 479 | + ;; window — every line rewrapping under the reader as the message | ||
| 480 | + ;; that overflowed arrives. And taken at all because the bar is | ||
| 481 | + ;; painted over the viewport's right edge: without it the last | ||
| 482 | + ;; characters of every wrapped line sit under the bar, which is | ||
| 483 | + ;; where "the sidebar truncates" comes from. | ||
| 484 | + gutter (double (c/th :scrollbar)) | ||
| 476 | r (c/box* (merge {:dir :vertical | 485 | r (c/box* (merge {:dir :vertical |
| 477 | :expand :both | 486 | :expand :both |
| 487 | + :pad-right gutter | ||
| 478 | ;; An explicit :height IS a demand and is | 488 | ;; An explicit :height IS a demand and is |
| 479 | ;; reported as one. Without it the viewport | 489 | ;; reported as one. Without it the viewport |
| 480 | ;; fills what is left and asks for nothing — | 490 | ;; fills what is left and asks for nothing — |
| @@ -623,14 +633,23 @@ | |||
| 623 | "One emoji, drawn as a character and sized to sit level with the words | 633 | "One emoji, drawn as a character and sized to sit level with the words |
| 624 | either side. | 634 | either side. |
| 625 | 635 | ||
| 626 | - Through the text font, which is where this differs from libvidya: that | 636 | + In colour where there is a colour face to draw it from, and as text |
| 627 | - draws from an image pack of its own, and this asks SDL_ttf for the glyph. | 637 | + where there is not. |
| 628 | - On a system whose UI font has no emoji coverage — most of them — what | 638 | + |
| 629 | - appears is the missing-glyph box. The fix is a font with the coverage, | 639 | + The two paths differ in more than the picture: a colour emoji face is a |
| 630 | - not code here, which is why JVUI_FONT exists." | 640 | + bitmap with one 128-pixel strike, so it cannot be a fallback font and is |
| 641 | + drawn as an image scaled into the line — which means the glyph occupies a | ||
| 642 | + SQUARE of the size asked for, decided here rather than measured. The text | ||
| 643 | + path stays exactly what it was, a label in the UI font, and is what a | ||
| 644 | + machine with no colour face gets." | ||
| 631 | ([s] (emoji s nil)) | 645 | ([s] (emoji s nil)) |
| 632 | ([s size] | 646 | ([s size] |
| 633 | - (label s {:size (or size (c/th :font-size))}))) | 647 | + (let [sz (double (or size (c/th :font-size)))] |
| 648 | + (if (c/colour-emoji? s) | ||
| 649 | + (let [rect (c/leaf [sz sz] :none [0.5 0.5])] | ||
| 650 | + (c/draw-emoji! s rect) | ||
| 651 | + rect) | ||
| 652 | + (label s {:size sz}))))) | ||
| 634 | 653 | ||
| 635 | (defn avatar | 654 | (defn avatar |
| 636 | "A round picture for somebody, or their initial on a colour if there is | 655 | "A round picture for somebody, or their initial on a colour if there is |
| @@ -660,16 +679,25 @@ | |||
| 660 | "A tally wearing a pill: an emoji, how many people, and whether you are one | 679 | "A tally wearing a pill: an emoji, how many people, and whether you are one |
| 661 | of them. | 680 | of them. |
| 662 | 681 | ||
| 663 | - The same glyph `emoji` draws, but a reaction is not a character — it | 682 | + The same glyph `emoji` draws, and the same two ways of drawing it: a |
| 664 | - answers the pointer and it is a count. `mine?` is bordered rather than | 683 | + colour face gives a square picture, and everything else is text. So the |
| 665 | - filled differently, because the pill has to stay readable at the size a | 684 | + glyph and the tally are measured and drawn apart rather than as one |
| 666 | - line of them ends up." | 685 | + string — a picture cannot be concatenated onto a number. |
| 686 | + | ||
| 687 | + A reaction is not a character otherwise either: it answers the pointer | ||
| 688 | + and it is a count. `mine?` is bordered rather than filled differently, | ||
| 689 | + because the pill has to stay readable at the size a line of them ends | ||
| 690 | + up." | ||
| 667 | ([glyph] (reaction glyph {})) | 691 | ([glyph] (reaction glyph {})) |
| 668 | ([glyph {:keys [count mine? size key] | 692 | ([glyph {:keys [count mine? size key] |
| 669 | :or {count 0 mine? false}}] | 693 | :or {count 0 mine? false}}] |
| 670 | - (let [sz (or size (c/th :font-size)) | 694 | + (let [sz (double (or size (c/th :font-size))) |
| 671 | - txt (if (pos? count) (str glyph " " count) (str glyph)) | 695 | + colour? (c/colour-emoji? (str glyph)) |
| 672 | - [tw th*] (c/measure txt sz) | 696 | + tally (when (pos? count) (str " " count)) |
| 697 | + [gw gh] (if colour? [sz sz] (c/measure (str glyph) sz)) | ||
| 698 | + [cw ch] (if tally (c/measure tally sz) [0.0 0.0]) | ||
| 699 | + tw (+ (double gw) (double cw)) | ||
| 700 | + th* (max (double gh) (double ch)) | ||
| 673 | pad 6.0 | 701 | pad 6.0 |
| 674 | rect (c/leaf [(+ tw (* 2 pad)) (+ th* 4.0)] :none [0.0 0.5]) | 702 | rect (c/leaf [(+ tw (* 2 pad)) (+ th* 4.0)] :none [0.0 0.5]) |
| 675 | id (c/next-id key) | 703 | id (c/next-id key) |
| @@ -677,5 +705,11 @@ | |||
| 677 | (c/fill! rect (if mine? (c/th :press) (c/th :surface-alt)) | 705 | (c/fill! rect (if mine? (c/th :press) (c/th :surface-alt)) |
| 678 | (/ h 2.0) | 706 | (/ h 2.0) |
| 679 | (when mine? (c/th :accent)) (if mine? 1.0 0.0)) | 707 | (when mine? (c/th :accent)) (if mine? 1.0 0.0)) |
| 680 | - (c/draw-text! txt (+ x pad) (+ y (/ (- h th*) 2.0)) sz (c/th :text)) | 708 | + (if colour? |
| 709 | + (c/draw-emoji! (str glyph) [(+ x pad) (+ y (/ (- h sz) 2.0)) sz sz]) | ||
| 710 | + (c/draw-text! (str glyph) (+ x pad) (+ y (/ (- h (double gh)) 2.0)) | ||
| 711 | + sz (c/th :text))) | ||
| 712 | + (when tally | ||
| 713 | + (c/draw-text! tally (+ x pad (double gw)) (+ y (/ (- h (double ch)) 2.0)) | ||
| 714 | + sz (c/th :text))) | ||
| 681 | (:clicked? (c/interact! id rect))))) | 715 | (:clicked? (c/interact! id rect))))) |