Skip to content
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