nandi/frqpublic Fork 0
0f00136
Commits
Clone
git clone https://git.rickub.com/nandi/frq.git
git clone ssh://git@rickub.com/nandi/frq.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

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>
nandi committed 2026-09-02T17:55:36-07:00 Browse files
0f00136 parent: a400a00
modified src/frq/app.jolt +41 -5
@@ -11,6 +11,7 @@
1111 [frq.av :as av]
1212 [frq.avatars :as avatars]
1313 [frq.clock :as clock]
14+ [frq.glyphs :as glyphs]
1415 [frq.media :as media]
1516 [frq.platform :as platform]
1617 [frq.profile :as profile]
@@ -273,14 +274,49 @@
273274 (let [tail (subs text pos)]
274275 (cond-> acc (seq tail) (conj [:text tail])))))))
275276
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+
276290 (defn- run-node
277291 "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."
279306 [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?)))))
284320
285321 (defn- summarise
286322 "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-node290 (defn- run-node
277 "One run as a widget. A link is accent-coloured and opens on click; the rest291 "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- (cond307+ (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- summarise321 (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)))