Skip to content

Commit ab16013

Browse files
committed
content: Show code, mentions, and times properly even in headings etc.
Fixes: zulip#538
1 parent 6766995 commit ab16013

File tree

3 files changed

+132
-20
lines changed

3 files changed

+132
-20
lines changed

lib/widgets/content.dart

Lines changed: 52 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -553,12 +553,19 @@ class InlineContent extends StatelessWidget {
553553
required this.style,
554554
required this.nodes,
555555
}) {
556+
assert(style.fontSize != null);
556557
_builder = _InlineContentBuilder(this);
557558
}
558559

559560
final GestureRecognizer? recognizer;
560561
final Map<LinkNode, GestureRecognizer>? linkRecognizers;
562+
563+
/// A [TextStyle] applied to this content and provided to descendants.
564+
///
565+
/// Must set [TextStyle.fontSize]. Some descendant spans will consume it,
566+
/// e.g., to make their content slightly smaller than surrounding text.
561567
final TextStyle style;
568+
562569
final List<InlineContentNode> nodes;
563570

564571
late final _InlineContentBuilder _builder;
@@ -630,18 +637,21 @@ class _InlineContentBuilder {
630637
return _buildInlineCode(node);
631638
} else if (node is UserMentionNode) {
632639
return WidgetSpan(alignment: PlaceholderAlignment.middle,
633-
child: UserMention(node: node));
640+
child: UserMention(surroundingTextStyle: widget.style, node: node));
634641
} else if (node is UnicodeEmojiNode) {
635642
return TextSpan(text: node.emojiUnicode, recognizer: _recognizer);
636643
} else if (node is ImageEmojiNode) {
637644
return WidgetSpan(alignment: PlaceholderAlignment.middle,
638645
child: MessageImageEmoji(node: node));
639646
} else if (node is MathInlineNode) {
640-
return TextSpan(style: _kInlineMathStyle,
647+
return TextSpan(
648+
style: widget.style
649+
.merge(_kInlineMathStyle)
650+
.apply(fontSizeFactor: _kInlineCodeFontSizeFactor),
641651
children: [TextSpan(text: node.texSource)]);
642652
} else if (node is GlobalTimeNode) {
643653
return WidgetSpan(alignment: PlaceholderAlignment.middle,
644-
child: GlobalTime(node: node));
654+
child: GlobalTime(node: node, surroundingTextStyle: widget.style));
645655
} else if (node is UnimplementedInlineContentNode) {
646656
return _errorUnimplemented(node);
647657
} else {
@@ -690,8 +700,13 @@ class _InlineContentBuilder {
690700

691701
// TODO `code`: find equivalent of web's `unicode-bidi: embed; direction: ltr`
692702

693-
// Use a light gray background, instead of a border.
694-
return _buildNodes(style: _kInlineCodeStyle, node.nodes);
703+
return _buildNodes(
704+
// Use a light gray background, instead of a border.
705+
style: widget.style
706+
.merge(_kInlineCodeStyle)
707+
.apply(fontSizeFactor: _kInlineCodeFontSizeFactor),
708+
node.nodes,
709+
);
695710

696711
// Another fun solution -- we can in fact have a border! Like so:
697712
// TextStyle(
@@ -711,17 +726,27 @@ class _InlineContentBuilder {
711726
}
712727
}
713728

729+
/// The [TextStyle] for inline math, excluding font-size adjustment.
730+
///
731+
/// Inline math should use this and also apply [_kInlineCodeFontSizeFactor]
732+
/// to the font size of the surrounding text
733+
/// (which might be a Paragraph, a Heading, etc.).
714734
final _kInlineMathStyle = _kInlineCodeStyle.merge(TextStyle(
715735
backgroundColor: const HSLColor.fromAHSL(1, 240, 0.4, 0.93).toColor()));
716736

737+
const _kInlineCodeFontSizeFactor = 0.825;
738+
739+
/// The [TextStyle] for inline code, excluding font-size adjustment.
740+
///
741+
/// Inline code should use this and also apply [_kInlineCodeFontSizeFactor]
742+
/// to the font size of the surrounding text
743+
/// (which might be a Paragraph, a Heading, etc.).
717744
// Even though [kMonospaceTextStyle] is a variable-weight font,
718745
// it's acceptable to skip [weightVariableTextStyle] here,
719746
// assuming the text gets the effect of [weightVariableTextStyle]
720747
// through inheritance, e.g., from a [DefaultTextStyle].
721748
final _kInlineCodeStyle = kMonospaceTextStyle
722-
.merge(const TextStyle(
723-
backgroundColor: Color(0xffeeeeee),
724-
fontSize: 0.825 * kBaseFontSize));
749+
.merge(const TextStyle(backgroundColor: Color(0xffeeeeee)));
725750

726751
final _kCodeBlockStyle = kMonospaceTextStyle
727752
.merge(const TextStyle(
@@ -752,22 +777,27 @@ final _kCodeBlockStyle = kMonospaceTextStyle
752777
// const _kInlineCodeRightBracket = '⟩';
753778

754779
class UserMention extends StatelessWidget {
755-
const UserMention({super.key, required this.node});
780+
const UserMention({
781+
super.key,
782+
required this.surroundingTextStyle,
783+
required this.node,
784+
});
756785

786+
final TextStyle surroundingTextStyle;
757787
final UserMentionNode node;
758788

759789
@override
760790
Widget build(BuildContext context) {
761791
return Container(
762792
decoration: _kDecoration,
763-
padding: const EdgeInsets.symmetric(horizontal: 0.2 * kBaseFontSize),
793+
padding: EdgeInsets.symmetric(horizontal: 0.2 * surroundingTextStyle.fontSize!),
764794
child: InlineContent(
765795
// If an @-mention is inside a link, let the @-mention override it.
766796
recognizer: null, // TODO make @-mentions tappable, for info on user
767797
// One hopes an @-mention can't contain an embedded link.
768798
// (The parser on creating a UserMentionNode has a TODO to check that.)
769799
linkRecognizers: null,
770-
style: Paragraph.textStyle,
800+
style: surroundingTextStyle,
771801
nodes: node.nodes));
772802
}
773803

@@ -834,16 +864,23 @@ class MessageImageEmoji extends StatelessWidget {
834864
}
835865

836866
class GlobalTime extends StatelessWidget {
837-
const GlobalTime({super.key, required this.node});
867+
const GlobalTime({
868+
super.key,
869+
required this.node,
870+
required this.surroundingTextStyle,
871+
});
838872

839873
final GlobalTimeNode node;
874+
final TextStyle surroundingTextStyle;
840875

841876
static final _backgroundColor = const HSLColor.fromAHSL(1, 0, 0, 0.93).toColor();
842877
static final _borderColor = const HSLColor.fromAHSL(1, 0, 0, 0.8).toColor();
843878
static final _dateFormat = DateFormat('EEE, MMM d, y, h:mm a'); // TODO(intl): localize date
844879

845880
@override
846881
Widget build(BuildContext context) {
882+
final surroundingFontSize = surroundingTextStyle.fontSize!;
883+
847884
// Design taken from css for `.rendered_markdown & time` in web,
848885
// see zulip:web/styles/rendered_markdown.css .
849886
final text = _dateFormat.format(node.datetime.toLocal());
@@ -855,15 +892,15 @@ class GlobalTime extends StatelessWidget {
855892
border: Border.all(width: 1, color: _borderColor),
856893
borderRadius: BorderRadius.circular(3)),
857894
child: Padding(
858-
padding: const EdgeInsets.symmetric(horizontal: 0.2 * kBaseFontSize),
895+
padding: EdgeInsets.symmetric(horizontal: 0.2 * surroundingFontSize),
859896
child: Row(
860897
mainAxisSize: MainAxisSize.min,
861898
children: [
862-
const Icon(ZulipIcons.clock, size: kBaseFontSize),
899+
Icon(ZulipIcons.clock, size: surroundingFontSize),
863900
// Ad-hoc spacing adjustment per feedback:
864901
// https://chat.zulip.org/#narrow/stream/101-design/topic/clock.20icons/near/1729345
865902
const SizedBox(width: 1),
866-
Text(text, style: Paragraph.textStyle),
903+
Text(text, style: surroundingTextStyle),
867904
]))));
868905
}
869906
}

test/flutter_checks.dart

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ extension ValueNotifierChecks<T> on Subject<ValueNotifier<T>> {
5757

5858
extension TextChecks on Subject<Text> {
5959
Subject<String?> get data => has((t) => t.data, 'data');
60+
Subject<TextStyle?> get style => has((t) => t.style, 'style');
6061
}
6162

6263
extension TextFieldChecks on Subject<TextField> {
@@ -65,6 +66,7 @@ extension TextFieldChecks on Subject<TextField> {
6566

6667
extension TextStyleChecks on Subject<TextStyle> {
6768
Subject<bool> get inherit => has((t) => t.inherit, 'inherit');
69+
Subject<double?> get fontSize => has((t) => t.fontSize, 'fontSize');
6870
Subject<FontWeight?> get fontWeight => has((t) => t.fontWeight, 'fontWeight');
6971
Subject<double?> get letterSpacing => has((t) => t.letterSpacing, 'letterSpacing');
7072
Subject<List<FontVariation>?> get fontVariations => has((t) => t.fontVariations, 'fontVariations');
@@ -100,3 +102,7 @@ extension TypographyChecks on Subject<Typography> {
100102
Subject<TextTheme> get dense => has((t) => t.dense, 'dense');
101103
Subject<TextTheme> get tall => has((t) => t.tall, 'tall');
102104
}
105+
106+
extension InlineSpanChecks on Subject<InlineSpan> {
107+
Subject<TextStyle?> get style => has((x) => x.style, 'style');
108+
}

test/widgets/content_test.dart

Lines changed: 74 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import 'dart:io';
33
import 'package:checks/checks.dart';
44
import 'package:flutter/foundation.dart';
55
import 'package:flutter/material.dart';
6+
import 'package:flutter/rendering.dart';
67
import 'package:flutter_gen/gen_l10n/zulip_localizations.dart';
78
import 'package:flutter_test/flutter_test.dart';
89
import 'package:url_launcher/url_launcher.dart';
@@ -14,6 +15,7 @@ import 'package:zulip/widgets/content.dart';
1415
import 'package:zulip/widgets/message_list.dart';
1516
import 'package:zulip/widgets/page.dart';
1617
import 'package:zulip/widgets/store.dart';
18+
import 'package:zulip/widgets/text.dart';
1719

1820
import '../example_data.dart' as eg;
1921
import '../flutter_checks.dart';
@@ -59,10 +61,15 @@ void main() {
5961
TestZulipBinding.ensureInitialized();
6062

6163
Future<void> prepareContentBare(WidgetTester tester, String html) async {
62-
await tester.pumpWidget(MaterialApp(
63-
localizationsDelegates: ZulipLocalizations.localizationsDelegates,
64-
supportedLocales: ZulipLocalizations.supportedLocales,
65-
home: BlockContentList(nodes: parseContent(html).nodes),
64+
await tester.pumpWidget(Builder(
65+
builder: (context) {
66+
return MaterialApp(
67+
theme: ThemeData(typography: zulipTypography(context)),
68+
localizationsDelegates: ZulipLocalizations.localizationsDelegates,
69+
supportedLocales: ZulipLocalizations.supportedLocales,
70+
home: Scaffold(body: BlockContentList(nodes: parseContent(html).nodes)),
71+
);
72+
}
6673
));
6774
}
6875

@@ -294,7 +301,69 @@ void main() {
294301

295302
testContentSmoke(ContentExample.emphasis);
296303

297-
testContentSmoke(ContentExample.inlineCode);
304+
group('InlineCodeNode', () {
305+
testContentSmoke(ContentExample.inlineCode);
306+
307+
testWidgets('maintains font-size ratio with surrounding text', (tester) async {
308+
/// Simulate a nested span's style by merging all ancestor-span styles,
309+
/// starting from the root.
310+
///
311+
/// This isn't how the style actually gets applied in the code under test,
312+
/// but it should hopefully simulate it closely enough.
313+
TextStyle? mergeStylesOuterToInner(InlineSpan outerSpan, InlineSpan innerSpan) {
314+
final styles = <TextStyle?>[];
315+
bool recurse(InlineSpan span) {
316+
styles.add(span.style);
317+
if (span == innerSpan) {
318+
return false;
319+
}
320+
final notInSubtree = span.visitDirectChildren(recurse);
321+
if (notInSubtree) {
322+
styles.removeLast();
323+
}
324+
return notInSubtree;
325+
}
326+
recurse(outerSpan);
327+
328+
return styles.reduce((value, element) => switch ((value, element)) {
329+
(TextStyle(), TextStyle()) => value!.merge(element!),
330+
(TextStyle(), null) => value,
331+
(null, TextStyle()) => element,
332+
(null, null) => null,
333+
});
334+
}
335+
336+
TextStyle? mergedStyleOfSubstring(InlineSpan rootSpan, String substring) {
337+
final substringSpan = rootSpan.getSpanForPosition(
338+
TextPosition(offset: rootSpan.toPlainText().indexOf(substring)));
339+
check(substringSpan).isNotNull();
340+
return mergeStylesOuterToInner(rootSpan, substringSpan!);
341+
}
342+
343+
await prepareContentBare(tester,
344+
'<h1>header-plain<code>header-code</code></h1>\n'
345+
'<p>paragraph-plain<code>paragraph-code</code></p>');
346+
347+
final headerRootSpan = tester.renderObject<RenderParagraph>(find.textContaining('header')).text;
348+
final headerPlainStyle = mergedStyleOfSubstring(headerRootSpan, 'header-plain');
349+
final headerCodeStyle = mergedStyleOfSubstring(headerRootSpan, 'header-code');
350+
351+
final paragraphRootSpan = tester.renderObject<RenderParagraph>(find.textContaining('paragraph')).text;
352+
final paragraphPlainStyle = mergedStyleOfSubstring(paragraphRootSpan, 'paragraph-plain');
353+
final paragraphCodeStyle = mergedStyleOfSubstring(paragraphRootSpan, 'paragraph-code');
354+
355+
check(headerPlainStyle).isNotNull().fontSize.isNotNull();
356+
check(headerCodeStyle).isNotNull().fontSize.isNotNull();
357+
check(paragraphPlainStyle).isNotNull().fontSize.isNotNull();
358+
check(paragraphCodeStyle).isNotNull().fontSize.isNotNull();
359+
360+
final ratioInHeader = headerCodeStyle!.fontSize! / headerPlainStyle!.fontSize!;
361+
final ratioInParagraph = paragraphCodeStyle!.fontSize! / paragraphPlainStyle!.fontSize!;
362+
363+
// Empirically we might have e.g. 0.825 and 0.8250000000000001.
364+
check((ratioInHeader - ratioInParagraph).abs()).isLessThan(0.001);
365+
});
366+
});
298367

299368
testContentSmoke(ContentExample.userMentionPlain);
300369
testContentSmoke(ContentExample.userMentionSilent);

0 commit comments

Comments
 (0)