nandi/frqpublic Fork 0
1cb40af
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.

The pencil was drawn by a text font

✏️ is U+270F followed by U+FE0F, and the variation selector is a request
for emoji presentation rather than a guarantee. DejaVu Sans claims
U+270F, so ordinary font fallback stopped there and drew the monochrome
pencil the text era had — the colour emoji font was installed and never
consulted. ↩️ and ❤️ went the same way, while 🙂 looked right only
because no text font covers U+1F642 and it fell all the way through.

So the renderer names the emoji font on the two tags whose whole content
is a glyph, `emoji` and `reaction`. The arrows and crosses on buttons —
→ ✕ ☰ — are deliberately text glyphs and keep the text font, as does the
reply chip's "↩ me: …", where the arrow sits inside a sentence.

The test asserts emoji presentation specifically, which is why it took
two passes to write: a check for "not a letter" fails on the button
arrows, and one for a lone glyph fails on →.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
nandi committed 2026-09-19T08:41:05-07:00 Browse files
1cb40af parent: 32dfa6e
modified flutter/lib/nim_renderer.dart +26 -2
@@ -107,6 +107,30 @@ class _NimAppState extends State<NimApp> {
107107 TextStyle _style(double size, Color color) =>
108108 TextStyle(fontSize: size, color: color, height: 1.35);
109109
110+ /// The style for a widget whose whole content is an emoji glyph.
111+ ///
112+ /// Naming the colour emoji font is not belt and braces: a glyph like ✏️ is
113+ /// U+270F plus U+FE0F, and the variation selector is a *request* for emoji
114+ /// presentation, not a guarantee. DejaVu Sans claims U+270F, so ordinary
115+ /// fallback stops there and draws the monochrome pencil the text era had —
116+ /// while 🙂, which no text font covers, falls all the way through to the
117+ /// emoji font and looks right. That is why only some of the chips were
118+ /// wrong.
119+ ///
120+ /// A family list rather than one name, because the font that has them
121+ /// differs by platform, and a name nothing matches costs nothing.
122+ static const List<String> _emojiFonts = <String>[
123+ 'Noto Color Emoji', // Linux, Android
124+ 'Apple Color Emoji', // macOS, iOS
125+ 'Segoe UI Emoji', // Windows
126+ ];
127+
128+ TextStyle _emojiStyle(double size) => TextStyle(
129+ fontSize: size,
130+ fontFamily: _emojiFonts.first,
131+ fontFamilyFallback: _emojiFonts,
132+ );
133+
110134 double _d(dynamic v, double fallback) =>
111135 v is num ? v.toDouble() : fallback;
112136
@@ -428,7 +452,7 @@ class _NimAppState extends State<NimApp> {
428452 return _wrapTap(
429453 n.prop('onClick', ''),
430454 Text(n.prop('glyph', n.prop('emoji', '')),
431- style: TextStyle(fontSize: _d(n.props['size'], 16))),
455+ style: _emojiStyle(_d(n.props['size'], 16))),
432456 );
433457
434458 /// A reaction pill: the glyph, and the tally beside it where there is
@@ -458,7 +482,7 @@ class _NimAppState extends State<NimApp> {
458482 child: Row(
459483 mainAxisSize: MainAxisSize.min,
460484 children: [
461- Text(n.prop('emoji', ''), style: TextStyle(fontSize: size)),
485+ Text(n.prop('emoji', ''), style: _emojiStyle(size)),
462486 if (count > 0) ...[
463487 const SizedBox(width: 4),
464488 Text('$count',
@@ -107,6 +107,30 @@ class _NimAppState extends State<NimApp> {
107 TextStyle _style(double size, Color color) =>107 TextStyle _style(double size, Color color) =>
108 TextStyle(fontSize: size, color: color, height: 1.35);108 TextStyle(fontSize: size, color: color, height: 1.35);
109 109
110+ /// The style for a widget whose whole content is an emoji glyph.
111+ ///
112+ /// Naming the colour emoji font is not belt and braces: a glyph like ✏️ is
113+ /// U+270F plus U+FE0F, and the variation selector is a *request* for emoji
114+ /// presentation, not a guarantee. DejaVu Sans claims U+270F, so ordinary
115+ /// fallback stops there and draws the monochrome pencil the text era had —
116+ /// while 🙂, which no text font covers, falls all the way through to the
117+ /// emoji font and looks right. That is why only some of the chips were
118+ /// wrong.
119+ ///
120+ /// A family list rather than one name, because the font that has them
121+ /// differs by platform, and a name nothing matches costs nothing.
122+ static const List<String> _emojiFonts = <String>[
123+ 'Noto Color Emoji', // Linux, Android
124+ 'Apple Color Emoji', // macOS, iOS
125+ 'Segoe UI Emoji', // Windows
126+ ];
127+
128+ TextStyle _emojiStyle(double size) => TextStyle(
129+ fontSize: size,
130+ fontFamily: _emojiFonts.first,
131+ fontFamilyFallback: _emojiFonts,
132+ );
133+
110 double _d(dynamic v, double fallback) =>134 double _d(dynamic v, double fallback) =>
111 v is num ? v.toDouble() : fallback;135 v is num ? v.toDouble() : fallback;
112 136
@@ -428,7 +452,7 @@ class _NimAppState extends State<NimApp> {
428 return _wrapTap(452 return _wrapTap(
429 n.prop('onClick', ''),453 n.prop('onClick', ''),
430 Text(n.prop('glyph', n.prop('emoji', '')),454 Text(n.prop('glyph', n.prop('emoji', '')),
431- style: TextStyle(fontSize: _d(n.props['size'], 16))),455+ style: _emojiStyle(_d(n.props['size'], 16))),
432 );456 );
433 457
434 /// A reaction pill: the glyph, and the tally beside it where there is458 /// A reaction pill: the glyph, and the tally beside it where there is
@@ -458,7 +482,7 @@ class _NimAppState extends State<NimApp> {
458 child: Row(482 child: Row(
459 mainAxisSize: MainAxisSize.min,483 mainAxisSize: MainAxisSize.min,
460 children: [484 children: [
461- Text(n.prop('emoji', ''), style: TextStyle(fontSize: size)),485+ Text(n.prop('emoji', ''), style: _emojiStyle(size)),
462 if (count > 0) ...[486 if (count > 0) ...[
463 const SizedBox(width: 4),487 const SizedBox(width: 4),
464 Text('$count',488 Text('$count',
modified flutter/test/nim_layout_test.dart +30 -0
@@ -178,4 +178,34 @@ void main() {
178178 }
179179 }
180180 });
181+
182+ group('emoji', () {
183+ // ✏️ is U+270F plus a variation selector asking for emoji presentation,
184+ // and DejaVu Sans claims U+270F — so ordinary fallback draws a monochrome
185+ // pencil and never reaches the emoji font. Naming the font is the fix,
186+ // and this is the assertion that it is still named.
187+ //
188+ // Emoji *presentation*, which is narrower than "not a letter". A glyph
189+ // carrying U+FE0F is asking for it, and so is anything from the emoji
190+ // blocks. The arrows and crosses on buttons — → ✕ ☰ — are not: they are
191+ // text glyphs on purpose and take the text font, as does the reply chip's
192+ // "↩ me: a picture", where the arrow sits in a sentence.
193+
194+ testWidgets('a lone glyph is drawn in the colour emoji font',
195+ (tester) async {
196+ core.demoUi();
197+ await layOut(tester, sizes['desktop']!);
198+ final glyphs = tester
199+ .widgetList<Text>(find.byType(Text))
200+ .where((t) =>
201+ t.data != null &&
202+ RegExp(r'[\ufe0f\u{1f300}-\u{1faff}]', unicode: true)
203+ .hasMatch(t.data!));
204+ expect(glyphs, isNotEmpty, reason: 'the chat screen draws no emoji');
205+ for (final g in glyphs) {
206+ expect(g.style?.fontFamilyFallback, contains('Noto Color Emoji'),
207+ reason: 'a bare glyph without the emoji font: ${g.data}');
208+ }
209+ });
210+ });
181211 }
@@ -178,4 +178,34 @@ void main() {
178 }178 }
179 }179 }
180 });180 });
181+
182+ group('emoji', () {
183+ // ✏️ is U+270F plus a variation selector asking for emoji presentation,
184+ // and DejaVu Sans claims U+270F — so ordinary fallback draws a monochrome
185+ // pencil and never reaches the emoji font. Naming the font is the fix,
186+ // and this is the assertion that it is still named.
187+ //
188+ // Emoji *presentation*, which is narrower than "not a letter". A glyph
189+ // carrying U+FE0F is asking for it, and so is anything from the emoji
190+ // blocks. The arrows and crosses on buttons — → ✕ ☰ — are not: they are
191+ // text glyphs on purpose and take the text font, as does the reply chip's
192+ // "↩ me: a picture", where the arrow sits in a sentence.
193+
194+ testWidgets('a lone glyph is drawn in the colour emoji font',
195+ (tester) async {
196+ core.demoUi();
197+ await layOut(tester, sizes['desktop']!);
198+ final glyphs = tester
199+ .widgetList<Text>(find.byType(Text))
200+ .where((t) =>
201+ t.data != null &&
202+ RegExp(r'[\ufe0f\u{1f300}-\u{1faff}]', unicode: true)
203+ .hasMatch(t.data!));
204+ expect(glyphs, isNotEmpty, reason: 'the chat screen draws no emoji');
205+ for (final g in glyphs) {
206+ expect(g.style?.fontFamilyFallback, contains('Noto Color Emoji'),
207+ reason: 'a bare glyph without the emoji font: ${g.data}');
208+ }
209+ });
210+ });
181 }211 }
modified nim/src/frq/screens/chat.nim +4 -3
@@ -51,9 +51,10 @@ func reactionRow(m: Message, me: string): Node =
5151 ##
5252 ## A pill carries its count and toggles: clicking one you are already on
5353 ## takes yours off, which is the same gesture that put it there. `reaction`
54- ## rather than a button with the emoji as its label — the chip draws the
55- ## glyph from the Twemoji pack, in colour, where a label gets whatever the
56- ## text font has.
54+ ## rather than a button with the emoji as its label — the renderer draws a
55+ ## `reaction` in the colour emoji font, where a label gets whatever ordinary
56+ ## fallback finds, which for ✏️ and ↩️ is a monochrome glyph out of a text
57+ ## font.
5758 result = n("hbox", %*{"key": "pills", "spacing": chipGap})
5859 var emojis: seq[string]
5960 for r in m.reactions: emojis.add r.emoji
@@ -51,9 +51,10 @@ func reactionRow(m: Message, me: string): Node =
51 ##51 ##
52 ## A pill carries its count and toggles: clicking one you are already on52 ## A pill carries its count and toggles: clicking one you are already on
53 ## takes yours off, which is the same gesture that put it there. `reaction`53 ## takes yours off, which is the same gesture that put it there. `reaction`
54- ## rather than a button with the emoji as its label — the chip draws the54+ ## rather than a button with the emoji as its label — the renderer draws a
55- ## glyph from the Twemoji pack, in colour, where a label gets whatever the55+ ## `reaction` in the colour emoji font, where a label gets whatever ordinary
56- ## text font has.56+ ## fallback finds, which for ✏️ and ↩️ is a monochrome glyph out of a text
57+ ## font.
57 result = n("hbox", %*{"key": "pills", "spacing": chipGap})58 result = n("hbox", %*{"key": "pills", "spacing": chipGap})
58 var emojis: seq[string]59 var emojis: seq[string]
59 for r in m.reactions: emojis.add r.emoji60 for r in m.reactions: emojis.add r.emoji