Draw the emoji in a message, rather than a box where one was
A `:label` is set in the text font, and that font — Ubuntu with a DejaVu subset behind it for punctuation, maths and box drawing — has no emoji in it at all. So every emoji anybody typed into a message came out as tofu, while the same glyph on a reaction pill drew properly all along: `:reaction` is the one node that looks a glyph up in the Twemoji pack instead of setting it as text. So take the emoji out of the text before it reaches a label and put them back as chips. frq.glyphs cuts a string into the parts a font can draw and the parts only the pack can, looking each candidate up in `emoji/catalog` — which is already filtered to what there is a picture for, so a codepoint the pack has missed stays text rather than becoming the same tofu by a longer road. Longest match first, because the short emoji is a prefix of the long one: 👨 is the head of 👨👩👧, and taking the man would leave his family as three more glyphs and two tofu joiners. Skin tones are stripped for the lookup and kept for the drawing, since the catalog leaves toned variants out and the pack has them. The pieces go in a wrapping row rather than the column the runs themselves are stacked in, so an emoji sits *in* a sentence instead of breaking the sentence around it. A run with no emoji is still one plain label — the row is only paid for where it is needed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
0f00136 parent: a400a00 modified
src/frq/app.jolt +41 -5 | @@ -11,6 +11,7 @@ | ||
| 11 | 11 | [frq.av :as av] |
| 12 | 12 | [frq.avatars :as avatars] |
| 13 | 13 | [frq.clock :as clock] |
| 14 | + [frq.glyphs :as glyphs] | |
| 14 | 15 | [frq.media :as media] |
| 15 | 16 | [frq.platform :as platform] |
| 16 | 17 | [frq.profile :as profile] |
| @@ -273,14 +274,49 @@ | ||
| 273 | 274 | (let [tail (subs text pos)] |
| 274 | 275 | (cond-> acc (seq tail) (conj [:text tail]))))))) |
| 275 | 276 | |
| 277 | +(def ^:private text-emoji-size | |
| 278 | + "An emoji in a message, at the size of the words around it. Bigger and the | |
| 279 | + line it sits in grows to make room for it; smaller and it reads as a | |
| 280 | + footnote on the sentence rather than a word of it." | |
| 281 | + 14) | |
| 282 | + | |
| 283 | +(defn- word-node | |
| 284 | + "Body text, or dim text for the lines the client writes itself." | |
| 285 | + [k value system?] | |
| 286 | + (if system? | |
| 287 | + [:dim-label {:key k :label value}] | |
| 288 | + [:label {:key k :label value}])) | |
| 289 | + | |
| 276 | 290 | (defn- run-node |
| 277 | 291 | "One run as a widget. A link is accent-coloured and opens on click; the rest |
| 278 | - is body text, or dim text for the lines the client writes itself." | |
| 292 | + is body text. | |
| 293 | + | |
| 294 | + Except for the emoji in it. A label is set in the text font, and that font — | |
| 295 | + Ubuntu with a DejaVu subset behind it — has no emoji in it at all, so every | |
| 296 | + one anybody typed was drawn as tofu. A `:reaction` is the one node that | |
| 297 | + draws from the Twemoji pack, which is why a reaction pill has always shown | |
| 298 | + the same glyph a message could not: so the emoji come out of the text and go | |
| 299 | + in as chips, and the words either side stay text. | |
| 300 | + | |
| 301 | + The pieces go in a wrapping row rather than the column the runs themselves | |
| 302 | + are stacked in: a row is what puts an emoji *in* a sentence instead of | |
| 303 | + breaking the sentence around it, and `:hbox` wraps its children by default, | |
| 304 | + so a long line still folds at the column's edge. A run with no emoji in it | |
| 305 | + is still one plain label — the row is only paid for where it is needed." | |
| 279 | 306 | [j [kind value] system?] |
| 280 | - (cond | |
| 281 | - (= :link kind) [:link {:key j :label value :on-click #(platform/open-url! value)}] | |
| 282 | - system? [:dim-label {:key j :label (str/trim value)}] | |
| 283 | - :else [:label {:key j :label (str/trim value)}])) | |
| 307 | + (if (= :link kind) | |
| 308 | + [:link {:key j :label value :on-click #(platform/open-url! value)}] | |
| 309 | + (let [pieces (glyphs/runs (str/trim value))] | |
| 310 | + (if (glyphs/emoji? pieces) | |
| 311 | + ;; No spacing of its own: the gaps between the words are the spaces | |
| 312 | + ;; the message was typed with, and a chip carries its own margin. | |
| 313 | + [:hbox {:key j :spacing 0} | |
| 314 | + (map-indexed (fn [k [pkind pvalue]] | |
| 315 | + (if (= :emoji pkind) | |
| 316 | + [:reaction {:key k :emoji pvalue :size text-emoji-size}] | |
| 317 | + (word-node k pvalue system?))) | |
| 318 | + pieces)] | |
| 319 | + (word-node j (str/trim value) system?))))) | |
| 284 | 320 | |
| 285 | 321 | (defn- summarise |
| 286 | 322 | "A message in one line's worth of words." |
| @@ -11,6 +11,7 @@ | |||
| 11 | [frq.av :as av] | 11 | [frq.av :as av] |
| 12 | [frq.avatars :as avatars] | 12 | [frq.avatars :as avatars] |
| 13 | [frq.clock :as clock] | 13 | [frq.clock :as clock] |
| 14 | + [frq.glyphs :as glyphs] | ||
| 14 | [frq.media :as media] | 15 | [frq.media :as media] |
| 15 | [frq.platform :as platform] | 16 | [frq.platform :as platform] |
| 16 | [frq.profile :as profile] | 17 | [frq.profile :as profile] |
| @@ -273,14 +274,49 @@ | |||
| 273 | (let [tail (subs text pos)] | 274 | (let [tail (subs text pos)] |
| 274 | (cond-> acc (seq tail) (conj [:text tail]))))))) | 275 | (cond-> acc (seq tail) (conj [:text tail]))))))) |
| 275 | 276 | ||
| 277 | +(def ^:private text-emoji-size | ||
| 278 | + "An emoji in a message, at the size of the words around it. Bigger and the | ||
| 279 | + line it sits in grows to make room for it; smaller and it reads as a | ||
| 280 | + footnote on the sentence rather than a word of it." | ||
| 281 | + 14) | ||
| 282 | + | ||
| 283 | +(defn- word-node | ||
| 284 | + "Body text, or dim text for the lines the client writes itself." | ||
| 285 | + [k value system?] | ||
| 286 | + (if system? | ||
| 287 | + [:dim-label {:key k :label value}] | ||
| 288 | + [:label {:key k :label value}])) | ||
| 289 | + | ||
| 276 | (defn- run-node | 290 | (defn- run-node |
| 277 | "One run as a widget. A link is accent-coloured and opens on click; the rest | 291 | "One run as a widget. A link is accent-coloured and opens on click; the rest |
| 278 | - is body text, or dim text for the lines the client writes itself." | 292 | + is body text. |
| 293 | + | ||
| 294 | + Except for the emoji in it. A label is set in the text font, and that font — | ||
| 295 | + Ubuntu with a DejaVu subset behind it — has no emoji in it at all, so every | ||
| 296 | + one anybody typed was drawn as tofu. A `:reaction` is the one node that | ||
| 297 | + draws from the Twemoji pack, which is why a reaction pill has always shown | ||
| 298 | + the same glyph a message could not: so the emoji come out of the text and go | ||
| 299 | + in as chips, and the words either side stay text. | ||
| 300 | + | ||
| 301 | + The pieces go in a wrapping row rather than the column the runs themselves | ||
| 302 | + are stacked in: a row is what puts an emoji *in* a sentence instead of | ||
| 303 | + breaking the sentence around it, and `:hbox` wraps its children by default, | ||
| 304 | + so a long line still folds at the column's edge. A run with no emoji in it | ||
| 305 | + is still one plain label — the row is only paid for where it is needed." | ||
| 279 | [j [kind value] system?] | 306 | [j [kind value] system?] |
| 280 | - (cond | 307 | + (if (= :link kind) |
| 281 | - (= :link kind) [:link {:key j :label value :on-click #(platform/open-url! value)}] | 308 | + [:link {:key j :label value :on-click #(platform/open-url! value)}] |
| 282 | - system? [:dim-label {:key j :label (str/trim value)}] | 309 | + (let [pieces (glyphs/runs (str/trim value))] |
| 283 | - :else [:label {:key j :label (str/trim value)}])) | 310 | + (if (glyphs/emoji? pieces) |
| 311 | + ;; No spacing of its own: the gaps between the words are the spaces | ||
| 312 | + ;; the message was typed with, and a chip carries its own margin. | ||
| 313 | + [:hbox {:key j :spacing 0} | ||
| 314 | + (map-indexed (fn [k [pkind pvalue]] | ||
| 315 | + (if (= :emoji pkind) | ||
| 316 | + [:reaction {:key k :emoji pvalue :size text-emoji-size}] | ||
| 317 | + (word-node k pvalue system?))) | ||
| 318 | + pieces)] | ||
| 319 | + (word-node j (str/trim value) system?))))) | ||
| 284 | 320 | ||
| 285 | (defn- summarise | 321 | (defn- summarise |
| 286 | "A message in one line's worth of words." | 322 | "A message in one line's worth of words." |
added
src/frq/glyphs.jolt +83 -0 | new file mode 100644 | ||
| @@ -0,0 +1,83 @@ | ||
| 1 | +(ns frq.glyphs | |
| 2 | + "Where a line of chat stops being text and becomes a picture. | |
| 3 | + | |
| 4 | + Vidya paints a `:label` with Ubuntu Light and a DejaVu subset behind it — | |
| 5 | + UI punctuation, maths-in-prose, box drawing — and no emoji at all. So an | |
| 6 | + emoji anybody typed into a message was drawn as tofu: the hollow box a font | |
| 7 | + puts up for a codepoint it has no glyph for. The same emoji in a reaction | |
| 8 | + pill drew properly all along, because `:reaction` is the one node that looks | |
| 9 | + a glyph up in the Twemoji pack instead of setting it as text. | |
| 10 | + | |
| 11 | + The split therefore has to happen before the text reaches a label, and this | |
| 12 | + is where: the message cut into the parts a font can draw and the parts only | |
| 13 | + the pack can." | |
| 14 | + (:require [frq.emoji :as emoji])) | |
| 15 | + | |
| 16 | +(def ^:private tones | |
| 17 | + "The five skin-tone modifiers. | |
| 18 | + | |
| 19 | + `emoji/catalog` leaves toned variants out on purpose — they multiply it by | |
| 20 | + five and say nothing a reaction needs to say — but the pack has a picture | |
| 21 | + for every one, and vidya falls back to the untoned picture where it has | |
| 22 | + none. So a tone is stripped for the lookup and kept for the drawing." | |
| 23 | + #{0x1F3FB 0x1F3FC 0x1F3FD 0x1F3FE 0x1F3FF}) | |
| 24 | + | |
| 25 | +(defn- untoned [s] | |
| 26 | + (apply str (remove #(contains? tones (int %)) (seq s)))) | |
| 27 | + | |
| 28 | +(def ^:private pack | |
| 29 | + "Every emoji there is a picture for. `emoji/catalog` is already filtered to | |
| 30 | + exactly that, which is why the lookup is against it and not against a range | |
| 31 | + of codepoints: a codepoint the pack has missed would be swapped out of the | |
| 32 | + text for a picture that does not exist, and drawn as the same tofu by a | |
| 33 | + longer road." | |
| 34 | + (set (map first emoji/catalog))) | |
| 35 | + | |
| 36 | +(def ^:private starts | |
| 37 | + "The first codepoint of each of them. A message with no emoji in it — which | |
| 38 | + is nearly all of them — costs one set lookup a character this way, and no | |
| 39 | + substrings at all." | |
| 40 | + (set (map #(first (seq (first %))) emoji/catalog))) | |
| 41 | + | |
| 42 | +(def ^:private scan-limit | |
| 43 | + "The most codepoints one emoji can run to. A family is a single glyph made | |
| 44 | + of four people, three joiners and a tone each, so the scan cannot assume a | |
| 45 | + cluster is short — it is the longest thing in the catalog, plus room for the | |
| 46 | + tones the catalog itself leaves out." | |
| 47 | + (+ 5 (reduce max 1 (map #(count (first %)) emoji/catalog)))) | |
| 48 | + | |
| 49 | +(defn- emoji-at | |
| 50 | + "The longest emoji the pack knows that starts at `i`, or nil. | |
| 51 | + | |
| 52 | + Longest first, because the short one is a prefix of the long one: 👨 is the | |
| 53 | + head of 👨👩👧, and taking the man would leave his family as three more | |
| 54 | + glyphs and two tofu joiners." | |
| 55 | + [cps i] | |
| 56 | + (loop [len (min scan-limit (- (count cps) i))] | |
| 57 | + (when (pos? len) | |
| 58 | + (let [s (apply str (subvec cps i (+ i len)))] | |
| 59 | + (if (or (contains? pack s) (contains? pack (untoned s))) | |
| 60 | + s | |
| 61 | + (recur (dec len))))))) | |
| 62 | + | |
| 63 | +(defn runs | |
| 64 | + "`text` as alternating `[:text s]` and `[:emoji s]` runs, in order. | |
| 65 | + | |
| 66 | + An `:emoji` run is one glyph: a chip draws one picture, so two emoji side by | |
| 67 | + side are two runs and not one." | |
| 68 | + [text] | |
| 69 | + (let [cps (vec (seq (or text ""))) | |
| 70 | + n (count cps) | |
| 71 | + flush (fn [acc buf] (cond-> acc (seq buf) (conj [:text (apply str buf)])))] | |
| 72 | + (loop [i 0 buf [] acc []] | |
| 73 | + (if (>= i n) | |
| 74 | + (flush acc buf) | |
| 75 | + (if-let [hit (and (contains? starts (nth cps i)) (emoji-at cps i))] | |
| 76 | + (recur (+ i (count hit)) [] (conj (flush acc buf) [:emoji hit])) | |
| 77 | + (recur (inc i) (conj buf (nth cps i)) acc)))))) | |
| 78 | + | |
| 79 | +(defn emoji? | |
| 80 | + "Whether any of `runs` is a picture — which is what decides between a plain | |
| 81 | + label and a row that has to mix the two." | |
| 82 | + [runs] | |
| 83 | + (boolean (some #(= :emoji (first %)) runs))) | |
| new file mode 100644 | |||
| @@ -0,0 +1,83 @@ | |||
| 1 | +(ns frq.glyphs | ||
| 2 | + "Where a line of chat stops being text and becomes a picture. | ||
| 3 | + | ||
| 4 | + Vidya paints a `:label` with Ubuntu Light and a DejaVu subset behind it — | ||
| 5 | + UI punctuation, maths-in-prose, box drawing — and no emoji at all. So an | ||
| 6 | + emoji anybody typed into a message was drawn as tofu: the hollow box a font | ||
| 7 | + puts up for a codepoint it has no glyph for. The same emoji in a reaction | ||
| 8 | + pill drew properly all along, because `:reaction` is the one node that looks | ||
| 9 | + a glyph up in the Twemoji pack instead of setting it as text. | ||
| 10 | + | ||
| 11 | + The split therefore has to happen before the text reaches a label, and this | ||
| 12 | + is where: the message cut into the parts a font can draw and the parts only | ||
| 13 | + the pack can." | ||
| 14 | + (:require [frq.emoji :as emoji])) | ||
| 15 | + | ||
| 16 | +(def ^:private tones | ||
| 17 | + "The five skin-tone modifiers. | ||
| 18 | + | ||
| 19 | + `emoji/catalog` leaves toned variants out on purpose — they multiply it by | ||
| 20 | + five and say nothing a reaction needs to say — but the pack has a picture | ||
| 21 | + for every one, and vidya falls back to the untoned picture where it has | ||
| 22 | + none. So a tone is stripped for the lookup and kept for the drawing." | ||
| 23 | + #{0x1F3FB 0x1F3FC 0x1F3FD 0x1F3FE 0x1F3FF}) | ||
| 24 | + | ||
| 25 | +(defn- untoned [s] | ||
| 26 | + (apply str (remove #(contains? tones (int %)) (seq s)))) | ||
| 27 | + | ||
| 28 | +(def ^:private pack | ||
| 29 | + "Every emoji there is a picture for. `emoji/catalog` is already filtered to | ||
| 30 | + exactly that, which is why the lookup is against it and not against a range | ||
| 31 | + of codepoints: a codepoint the pack has missed would be swapped out of the | ||
| 32 | + text for a picture that does not exist, and drawn as the same tofu by a | ||
| 33 | + longer road." | ||
| 34 | + (set (map first emoji/catalog))) | ||
| 35 | + | ||
| 36 | +(def ^:private starts | ||
| 37 | + "The first codepoint of each of them. A message with no emoji in it — which | ||
| 38 | + is nearly all of them — costs one set lookup a character this way, and no | ||
| 39 | + substrings at all." | ||
| 40 | + (set (map #(first (seq (first %))) emoji/catalog))) | ||
| 41 | + | ||
| 42 | +(def ^:private scan-limit | ||
| 43 | + "The most codepoints one emoji can run to. A family is a single glyph made | ||
| 44 | + of four people, three joiners and a tone each, so the scan cannot assume a | ||
| 45 | + cluster is short — it is the longest thing in the catalog, plus room for the | ||
| 46 | + tones the catalog itself leaves out." | ||
| 47 | + (+ 5 (reduce max 1 (map #(count (first %)) emoji/catalog)))) | ||
| 48 | + | ||
| 49 | +(defn- emoji-at | ||
| 50 | + "The longest emoji the pack knows that starts at `i`, or nil. | ||
| 51 | + | ||
| 52 | + Longest first, because the short one is a prefix of the long one: 👨 is the | ||
| 53 | + head of 👨👩👧, and taking the man would leave his family as three more | ||
| 54 | + glyphs and two tofu joiners." | ||
| 55 | + [cps i] | ||
| 56 | + (loop [len (min scan-limit (- (count cps) i))] | ||
| 57 | + (when (pos? len) | ||
| 58 | + (let [s (apply str (subvec cps i (+ i len)))] | ||
| 59 | + (if (or (contains? pack s) (contains? pack (untoned s))) | ||
| 60 | + s | ||
| 61 | + (recur (dec len))))))) | ||
| 62 | + | ||
| 63 | +(defn runs | ||
| 64 | + "`text` as alternating `[:text s]` and `[:emoji s]` runs, in order. | ||
| 65 | + | ||
| 66 | + An `:emoji` run is one glyph: a chip draws one picture, so two emoji side by | ||
| 67 | + side are two runs and not one." | ||
| 68 | + [text] | ||
| 69 | + (let [cps (vec (seq (or text ""))) | ||
| 70 | + n (count cps) | ||
| 71 | + flush (fn [acc buf] (cond-> acc (seq buf) (conj [:text (apply str buf)])))] | ||
| 72 | + (loop [i 0 buf [] acc []] | ||
| 73 | + (if (>= i n) | ||
| 74 | + (flush acc buf) | ||
| 75 | + (if-let [hit (and (contains? starts (nth cps i)) (emoji-at cps i))] | ||
| 76 | + (recur (+ i (count hit)) [] (conj (flush acc buf) [:emoji hit])) | ||
| 77 | + (recur (inc i) (conj buf (nth cps i)) acc)))))) | ||
| 78 | + | ||
| 79 | +(defn emoji? | ||
| 80 | + "Whether any of `runs` is a picture — which is what decides between a plain | ||
| 81 | + label and a row that has to mix the two." | ||
| 82 | + [runs] | ||
| 83 | + (boolean (some #(= :emoji (first %)) runs))) | ||