Skip to content

Use emoji font to show explicit emoji #1108

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 8 commits into from
Dec 6, 2024
15 changes: 14 additions & 1 deletion lib/widgets/content.dart
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,8 @@ class ContentTheme extends ThemeExtension<ContentTheme> {
textStylePlainParagraph: _plainParagraphCommon(context).copyWith(
color: const HSLColor.fromAHSL(1, 0, 0, 0.15).toColor(),
debugLabel: 'ContentTheme.textStylePlainParagraph'),
textStyleEmoji: TextStyle(
fontFamily: emojiFontFamily, fontFamilyFallback: const []),
codeBlockTextStyles: CodeBlockTextStyles.light(context),
textStyleError: const TextStyle(fontSize: kBaseFontSize, color: Colors.red)
.merge(weightVariableTextStyle(context, wght: 700)),
Expand Down Expand Up @@ -85,6 +87,8 @@ class ContentTheme extends ThemeExtension<ContentTheme> {
textStylePlainParagraph: _plainParagraphCommon(context).copyWith(
color: const HSLColor.fromAHSL(1, 0, 0, 0.85).toColor(),
debugLabel: 'ContentTheme.textStylePlainParagraph'),
textStyleEmoji: TextStyle(
fontFamily: emojiFontFamily, fontFamilyFallback: const []),
codeBlockTextStyles: CodeBlockTextStyles.dark(context),
textStyleError: const TextStyle(fontSize: kBaseFontSize, color: Colors.red)
.merge(weightVariableTextStyle(context, wght: 700)),
Expand Down Expand Up @@ -113,6 +117,7 @@ class ContentTheme extends ThemeExtension<ContentTheme> {
required this.colorTableHeaderBackground,
required this.colorThematicBreak,
required this.textStylePlainParagraph,
required this.textStyleEmoji,
required this.codeBlockTextStyles,
required this.textStyleError,
required this.textStyleErrorCode,
Expand Down Expand Up @@ -152,6 +157,9 @@ class ContentTheme extends ThemeExtension<ContentTheme> {
/// should not need styles from other sources, such as Material defaults.
final TextStyle textStylePlainParagraph;

/// The [TextStyle] to use for Unicode emoji.
final TextStyle textStyleEmoji;

final CodeBlockTextStyles codeBlockTextStyles;
final TextStyle textStyleError;
final TextStyle textStyleErrorCode;
Expand Down Expand Up @@ -201,6 +209,7 @@ class ContentTheme extends ThemeExtension<ContentTheme> {
Color? colorTableHeaderBackground,
Color? colorThematicBreak,
TextStyle? textStylePlainParagraph,
TextStyle? textStyleEmoji,
CodeBlockTextStyles? codeBlockTextStyles,
TextStyle? textStyleError,
TextStyle? textStyleErrorCode,
Expand All @@ -222,6 +231,7 @@ class ContentTheme extends ThemeExtension<ContentTheme> {
colorTableHeaderBackground: colorTableHeaderBackground ?? this.colorTableHeaderBackground,
colorThematicBreak: colorThematicBreak ?? this.colorThematicBreak,
textStylePlainParagraph: textStylePlainParagraph ?? this.textStylePlainParagraph,
textStyleEmoji: textStyleEmoji ?? this.textStyleEmoji,
codeBlockTextStyles: codeBlockTextStyles ?? this.codeBlockTextStyles,
textStyleError: textStyleError ?? this.textStyleError,
textStyleErrorCode: textStyleErrorCode ?? this.textStyleErrorCode,
Expand Down Expand Up @@ -250,6 +260,7 @@ class ContentTheme extends ThemeExtension<ContentTheme> {
colorTableHeaderBackground: Color.lerp(colorTableHeaderBackground, other.colorTableHeaderBackground, t)!,
colorThematicBreak: Color.lerp(colorThematicBreak, other.colorThematicBreak, t)!,
textStylePlainParagraph: TextStyle.lerp(textStylePlainParagraph, other.textStylePlainParagraph, t)!,
textStyleEmoji: TextStyle.lerp(textStyleEmoji, other.textStyleEmoji, t)!,
codeBlockTextStyles: CodeBlockTextStyles.lerp(codeBlockTextStyles, other.codeBlockTextStyles, t),
textStyleError: TextStyle.lerp(textStyleError, other.textStyleError, t)!,
textStyleErrorCode: TextStyle.lerp(textStyleErrorCode, other.textStyleErrorCode, t)!,
Expand Down Expand Up @@ -1031,7 +1042,9 @@ class _InlineContentBuilder {
child: UserMention(ambientTextStyle: widget.style, node: node));

case UnicodeEmojiNode():
return TextSpan(text: node.emojiUnicode, recognizer: _recognizer);
return TextSpan(text: node.emojiUnicode, recognizer: _recognizer,
style: widget.style
.merge(ContentTheme.of(_context!).textStyleEmoji));

case ImageEmojiNode():
return WidgetSpan(alignment: PlaceholderAlignment.middle,
Expand Down
8 changes: 5 additions & 3 deletions lib/widgets/emoji.dart
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,8 @@ class UnicodeEmojiWidget extends StatelessWidget {

case TargetPlatform.iOS:
case TargetPlatform.macOS:
// We expect the font "Apple Color Emoji" to be used. There are some
// surprises in how Flutter ends up rendering emojis in this font:
// We use the font "Apple Color Emoji". There are some surprises in how
// Flutter ends up rendering emojis in this font:
// - With a font size of 17px, the emoji visually seems to be about 17px
// square. (Unlike on Android, with Noto Color Emoji, where a 14.5px font
// size gives an emoji that looks 17px square.) See:
Expand All @@ -71,7 +71,9 @@ class UnicodeEmojiWidget extends StatelessWidget {
SizedBox(height: boxSize, width: boxSize),
PositionedDirectional(start: 0, child: Text(
textScaler: textScaler,
style: TextStyle(fontSize: size),
style: TextStyle(
fontFamily: 'Apple Color Emoji',
fontSize: size),
strutStyle: StrutStyle(fontSize: size, forceStrutHeight: true),
emojiDisplay.emojiUnicode)),
]);
Expand Down
28 changes: 23 additions & 5 deletions lib/widgets/text.dart
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import 'package:flutter/material.dart';
/// For example, the base style for message content;
/// see [ContentTheme.textStylePlainParagraph].
///
/// Applies [kDefaultFontFamily] and [kDefaultFontFamilyFallback],
/// Applies [kDefaultFontFamily] and [defaultFontFamilyFallback],
/// being faithful to the Material-default font weights
/// by running them through [weightVariableTextStyle].
/// (That is needed because [kDefaultFontFamily] is a variable-weight font).
Expand Down Expand Up @@ -153,12 +153,30 @@ const kDefaultFontFamily = 'Source Sans 3';

/// The [TextStyle.fontFamilyFallback] for use with [kDefaultFontFamily].
List<String> get defaultFontFamilyFallback => [
// iOS doesn't support any of the formats this font is available in.
// If we use it on iOS, we'll get blank spaces where we could have had Apple-
// style emojis.
if (defaultTargetPlatform == TargetPlatform.android) 'Noto Color Emoji',
emojiFontFamily,
];

String get emojiFontFamily {
return _useAppleEmoji ? 'Apple Color Emoji' : 'Noto Color Emoji';
}

/// Whether to use the Apple Color Emoji font for showing emoji.
///
/// When false, we use Noto Color Emoji instead.
bool get _useAppleEmoji => switch (defaultTargetPlatform) {
// iOS doesn't support any of the formats Noto Color Emoji is available in.
// If we use it on iOS, we'll get blank spaces where we could have had
// Apple-style emojis. We presume the same is true of macOS.
// Conversely, both platforms provide Apple Color Emoji. So we use that.
TargetPlatform.iOS || TargetPlatform.macOS => true,

// The Noto Color Emoji font works fine on Android.
// We presume it works on the other platforms.
// Conversely Apple Color Emoji isn't an option on any of these.
TargetPlatform.android || TargetPlatform.linux
|| TargetPlatform.fuchsia || TargetPlatform.windows => false,
};

/// A mergeable [TextStyle] with 'Source Code Pro' and platform-aware fallbacks.
///
/// Callers should also call [weightVariableTextStyle] and merge that in too,
Expand Down
82 changes: 48 additions & 34 deletions test/widgets/content_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -65,21 +65,41 @@ TextStyle? mergeSpanStylesOuterToInner(
});
}

/// The "merged style" ([mergeSpanStylesOuterToInner]) of a nested span.
TextStyle? mergedStyleOfSubstring(InlineSpan rootSpan, Pattern substringPattern) {
/// The "merged style" ([mergeSpanStylesOuterToInner]) of a text span
/// whose whole text matches the given pattern, under the given root span.
///
/// See also [mergedStyleOf], which can be more convenient.
TextStyle? mergedStyleOfSubstring(InlineSpan rootSpan, Pattern spanPattern) {
return mergeSpanStylesOuterToInner(rootSpan,
(span) {
if (span is! TextSpan) return false;
final text = span.text;
if (text == null) return false;
return switch (substringPattern) {
String() => text == substringPattern,
_ => substringPattern.allMatches(text)
return switch (spanPattern) {
String() => text == spanPattern,
_ => spanPattern.allMatches(text)
.any((match) => match.start == 0 && match.end == text.length),
};
});
}

/// The "merged style" ([mergeSpanStylesOuterToInner]) of a text span
/// whose whole text matches the given pattern, somewhere in the tree.
///
/// This finds the relevant [Text] widget by a search for [spanPattern].
/// If [findAncestor] is non-null, the search will only consider descendants
/// of widgets matching [findAncestor].
TextStyle? mergedStyleOf(WidgetTester tester, Pattern spanPattern, {
Finder? findAncestor,
}) {
var findTextWidget = find.textContaining(spanPattern);
if (findAncestor != null) {
findTextWidget = find.descendant(of: findAncestor, matching: findTextWidget);
}
final rootSpan = tester.renderObject<RenderParagraph>(findTextWidget).text;
return mergedStyleOfSubstring(rootSpan, spanPattern);
}

/// A callback that finds some target subspan within the given span,
/// and reports the target's font size.
typedef TargetFontSizeFinder = double Function(InlineSpan rootSpan);
Expand Down Expand Up @@ -485,18 +505,12 @@ void main() {
testFontWeight('syntax highlighting: non-bold span',
expectedWght: 400,
content: plainContent(ContentExample.codeBlockHighlightedShort.html),
styleFinder: (tester) {
final root = tester.renderObject<RenderParagraph>(find.textContaining('class')).text;
return mergedStyleOfSubstring(root, 'class')!;
});
styleFinder: (tester) => mergedStyleOf(tester, 'class')!);

testFontWeight('syntax highlighting: bold span',
expectedWght: 700,
content: plainContent(ContentExample.codeBlockHighlightedShort.html),
styleFinder: (tester) {
final root = tester.renderObject<RenderParagraph>(find.textContaining('A')).text;
return mergedStyleOfSubstring(root, 'A')!;
});
styleFinder: (tester) => mergedStyleOf(tester, 'A')!);
});

testContentSmoke(ContentExample.mathBlock);
Expand Down Expand Up @@ -549,8 +563,7 @@ void main() {
testContentSmoke(ContentExample.strong);

TextStyle findWordBold(WidgetTester tester) {
final root = tester.renderObject<RenderParagraph>(find.textContaining('bold')).text;
return mergedStyleOfSubstring(root, 'bold')!;
return mergedStyleOf(tester, 'bold')!;
}

testFontWeight('in plain paragraph',
Expand Down Expand Up @@ -670,11 +683,8 @@ void main() {
}

TextStyle textStyleFromWidget(WidgetTester tester, UserMention widget, String mentionText) {
final fullNameSpan = tester.renderObject<RenderParagraph>(
find.descendant(
of: find.byWidget(widget), matching: find.text(mentionText))
).text;
return mergedStyleOfSubstring(fullNameSpan, mentionText)!;
return mergedStyleOf(tester,
findAncestor: find.byWidget(widget), mentionText)!;
}

testWidgets('maintains font-size ratio with surrounding text', (tester) async {
Expand Down Expand Up @@ -867,6 +877,19 @@ void main() {
testContentSmoke(ContentExample.emojiUnicode);
testContentSmoke(ContentExample.emojiUnicodeMultiCodepoint);
testContentSmoke(ContentExample.emojiUnicodeLiteral);

testWidgets('use emoji font', (tester) async {
// Compare [ContentExample.emojiUnicode].
const emojiHeartHtml =
'<p><span aria-label="heart" class="emoji emoji-2764" role="img" title="heart">:heart:</span></p>';
await prepareContent(tester, plainContent(emojiHeartHtml));
check(mergedStyleOf(tester, '\u{2764}')).isNotNull()
.fontFamily.equals(switch (defaultTargetPlatform) {
TargetPlatform.android => 'Noto Color Emoji',
TargetPlatform.iOS => 'Apple Color Emoji',
_ => throw StateError('unexpected platform in test'),
});
}, variant: const TargetPlatformVariant({TargetPlatform.android, TargetPlatform.iOS}));
});

group('inline math', () {
Expand Down Expand Up @@ -905,11 +928,8 @@ void main() {
find.descendant(of: find.byType(GlobalTime),
matching: find.byIcon(ZulipIcons.clock)));

final textSpan = tester.renderObject<RenderParagraph>(
find.descendant(of: find.byType(GlobalTime),
matching: find.textContaining(renderedTextRegexp)
)).text;
final textColor = mergedStyleOfSubstring(textSpan, renderedTextRegexp)!.color;
final textColor = mergedStyleOf(tester,
findAncestor: find.byType(GlobalTime), renderedTextRegexp)!.color;
check(textColor).isNotNull();

check(icon).color.isNotNull().isSameColorAs(textColor!);
Expand Down Expand Up @@ -938,11 +958,8 @@ void main() {

testWidgets('text is scaled', (tester) async {
await doCheck(tester, (widget) {
final textSpan = tester.renderObject<RenderParagraph>(
find.descendant(of: find.byWidget(widget),
matching: find.textContaining(renderedTextRegexp)
)).text;
return mergedStyleOfSubstring(textSpan, renderedTextRegexp)!.fontSize!;
return mergedStyleOf(tester, findAncestor: find.byWidget(widget),
renderedTextRegexp)!.fontSize!;
});
});

Expand Down Expand Up @@ -1095,10 +1112,7 @@ void main() {
// | 1 | 2 | 3 | 4 |
content: plainContent(ContentExample.tableWithSingleRow.html),
expectedWght: 700,
styleFinder: (tester) {
final root = tester.renderObject<RenderParagraph>(find.textContaining('a')).text;
return mergedStyleOfSubstring(root, 'a')!;
});
styleFinder: (tester) => mergedStyleOf(tester, 'a')!);

testWidgets('header row background color', (tester) async {
await prepareContent(tester, plainContent(ContentExample.tableWithSingleRow.html));
Expand Down
18 changes: 18 additions & 0 deletions test/widgets/emoji_reaction_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import '../flutter_checks.dart';
import '../model/binding.dart';
import '../model/test_store.dart';
import '../test_images.dart';
import 'content_test.dart';
import 'test_app.dart';
import 'text_test.dart';

Expand Down Expand Up @@ -259,6 +260,23 @@ void main() {
.isSameColorAs(EmojiReactionTheme.dark().bgUnselected);
});

testWidgets('use emoji font', (tester) async {
await prepare();
await store.addUser(eg.selfUser);
await setupChipsInBox(tester, reactions: [
Reaction.fromJson({
'user_id': eg.selfUser.userId,
'emoji_name': 'heart', 'emoji_code': '2764', 'reaction_type': 'unicode_emoji'}),
]);

check(mergedStyleOf(tester, '\u{2764}')).isNotNull()
.fontFamily.equals(switch (defaultTargetPlatform) {
TargetPlatform.android => 'Noto Color Emoji',
TargetPlatform.iOS => 'Apple Color Emoji',
_ => throw StateError('unexpected platform in test'),
});
}, variant: const TargetPlatformVariant({TargetPlatform.android, TargetPlatform.iOS}));

// TODO more tests:
// - Tapping a chip does the right thing
// - When an image emoji fails to load, falls back to :text_emoji:
Expand Down