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>
1cb40af parent: 32dfa6e modified
flutter/lib/nim_renderer.dart +26 -2 | @@ -107,6 +107,30 @@ class _NimAppState extends State<NimApp> { | ||
| 107 | 107 | TextStyle _style(double size, Color color) => |
| 108 | 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 | 134 | double _d(dynamic v, double fallback) => |
| 111 | 135 | v is num ? v.toDouble() : fallback; |
| 112 | 136 | |
| @@ -428,7 +452,7 @@ class _NimAppState extends State<NimApp> { | ||
| 428 | 452 | return _wrapTap( |
| 429 | 453 | n.prop('onClick', ''), |
| 430 | 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 | 458 | /// A reaction pill: the glyph, and the tally beside it where there is |
| @@ -458,7 +482,7 @@ class _NimAppState extends State<NimApp> { | ||
| 458 | 482 | child: Row( |
| 459 | 483 | mainAxisSize: MainAxisSize.min, |
| 460 | 484 | children: [ |
| 461 | - Text(n.prop('emoji', ''), style: TextStyle(fontSize: size)), | |
| 485 | + Text(n.prop('emoji', ''), style: _emojiStyle(size)), | |
| 462 | 486 | if (count > 0) ...[ |
| 463 | 487 | const SizedBox(width: 4), |
| 464 | 488 | 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 is | 458 | /// 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() { | ||
| 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 | } |
| @@ -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 = | ||
| 51 | 51 | ## |
| 52 | 52 | ## A pill carries its count and toggles: clicking one you are already on |
| 53 | 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 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. | |
| 57 | 58 | result = n("hbox", %*{"key": "pills", "spacing": chipGap}) |
| 58 | 59 | var emojis: seq[string] |
| 59 | 60 | 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 on | 52 | ## 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 the | 54 | + ## 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 the | 55 | + ## `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.emoji | 60 | for r in m.reactions: emojis.add r.emoji |