diff --git a/lib/widgets/content.dart b/lib/widgets/content.dart index 5851222a1c..5304c1de33 100644 --- a/lib/widgets/content.dart +++ b/lib/widgets/content.dart @@ -822,7 +822,7 @@ class MathBlock extends StatelessWidget { child: SingleChildScrollViewWithScrollbar( scrollDirection: Axis.horizontal, child: KatexWidget( - textStyle: ContentTheme.of(context).textStylePlainParagraph, + ambientTextStyle: ContentTheme.of(context).textStylePlainParagraph, nodes: nodes)))); } } @@ -1081,10 +1081,21 @@ class _InlineContentBuilder { _recognizer = _recognizerStack!.removeLast(); } - InlineSpan _buildNodes(List nodes, {required TextStyle? style}) { + final List _styleStack = []; + + TextStyle _resolveStyleStack() { + assert(_styleStack.isNotEmpty); // first item is `widget.style` + return _styleStack.reduce((value, element) => value.merge(element)); + } + + InlineSpan _buildNodes(List nodes, {required TextStyle style}) { + _styleStack.add(style); + final children = nodes.map(_buildNode).toList(growable: false); + _styleStack.removeLast(); + return TextSpan( style: style, - children: nodes.map(_buildNode).toList(growable: false)); + children: children); } InlineSpan _buildNode(InlineContentNode node) { @@ -1123,7 +1134,7 @@ class _InlineContentBuilder { case UserMentionNode(): return WidgetSpan(alignment: PlaceholderAlignment.middle, - child: UserMention(ambientTextStyle: widget.style, node: node)); + child: UserMention(ambientTextStyle: _resolveStyleStack(), node: node)); case UnicodeEmojiNode(): return TextSpan(text: node.emojiUnicode, recognizer: _recognizer, @@ -1145,11 +1156,11 @@ class _InlineContentBuilder { : WidgetSpan( alignment: PlaceholderAlignment.baseline, baseline: TextBaseline.alphabetic, - child: KatexWidget(textStyle: widget.style, nodes: nodes)); + child: KatexWidget(ambientTextStyle: _resolveStyleStack(), nodes: nodes)); case GlobalTimeNode(): return WidgetSpan(alignment: PlaceholderAlignment.middle, - child: GlobalTime(node: node, ambientTextStyle: widget.style)); + child: GlobalTime(node: node, ambientTextStyle: _resolveStyleStack())); case UnimplementedInlineContentNode(): return _errorUnimplemented(node, context: _context!); @@ -1341,7 +1352,7 @@ class GlobalTime extends StatelessWidget { size: ambientTextStyle.fontSize!, // (When GlobalTime appears in a link, it should be blue // like the text.) - color: DefaultTextStyle.of(context).style.color!, + color: ambientTextStyle.color, ZulipIcons.clock), // Ad-hoc spacing adjustment per feedback: // https://chat.zulip.org/#narrow/stream/101-design/topic/clock.20icons/near/1729345 diff --git a/lib/widgets/katex.dart b/lib/widgets/katex.dart index 4b4f39aa3f..b85b3e7f0d 100644 --- a/lib/widgets/katex.dart +++ b/lib/widgets/katex.dart @@ -6,17 +6,36 @@ import 'package:flutter/rendering.dart'; import '../model/content.dart'; import '../model/katex.dart'; -import 'content.dart'; /// Creates a base text style for rendering KaTeX content. /// -/// This applies the CSS styles defined in .katex class in katex.scss : +/// This cancels out some attributes that may be ambient from Zulip content +/// (italic, bold, etc.) +/// and applies the CSS styles defined in .katex class in katex.scss : /// https://github.com/KaTeX/KaTeX/blob/613c3da8/src/styles/katex.scss#L13-L15 /// -/// Requires the [style.fontSize] to be non-null. -TextStyle mkBaseKatexTextStyle(TextStyle style) { - return style.copyWith( - fontSize: style.fontSize! * 1.21, +/// Requires the [ambientStyle.fontSize] to be non-null. +TextStyle mkBaseKatexTextStyle(TextStyle ambientStyle) { + return ambientStyle.copyWith( + ////// Overrides of our own styles: + + // Bold formatting is removed below by setting FontWeight.normal… + // Just for completeness, remove "wght", but it wouldn't do anything anyway + // since KaTeX_Main is not a variable-weight font. + fontVariations: [], + // Italic is removed below. + + // Strikethrough is removed below, which affects formatting of rendered + // KatexSpanNodes…but a single strikethrough on the whole KatexWidget will + // be visible as long as it doesn't paint an opaque background. (The line + // "shows through" from an ancestor span.) I think we're happy with this: + // the message author asked for a strikethrough by wrapping the math in ~~, + // but we should render it as one unbroken line, not separate lines on each + // KaTeX span. + + ////// From the .katex class in katex.scss: + + fontSize: ambientStyle.fontSize! * 1.21, fontFamily: 'KaTeX_Main', height: 1.2, fontWeight: FontWeight.normal, @@ -31,11 +50,11 @@ TextStyle mkBaseKatexTextStyle(TextStyle style) { class KatexWidget extends StatelessWidget { const KatexWidget({ super.key, - required this.textStyle, + required this.ambientTextStyle, required this.nodes, }); - final TextStyle textStyle; + final TextStyle ambientTextStyle; final List nodes; @override @@ -45,8 +64,7 @@ class KatexWidget extends StatelessWidget { return Directionality( textDirection: TextDirection.ltr, child: DefaultTextStyle( - style: mkBaseKatexTextStyle(textStyle).copyWith( - color: ContentTheme.of(context).textStylePlainParagraph.color), + style: mkBaseKatexTextStyle(ambientTextStyle), child: widget)); } } diff --git a/test/widgets/content_test.dart b/test/widgets/content_test.dart index aef5ef3cc0..a1126938d5 100644 --- a/test/widgets/content_test.dart +++ b/test/widgets/content_test.dart @@ -763,6 +763,14 @@ void main() { }); }); + testWidgets('is italic in italic span', (tester) async { + // Regression test for: https://github.com/zulip/zulip-flutter/issues/1813 + await prepareContent(tester, + plainContent('

@Chris Bobbe

')); + final style = mergedStyleOf(tester, '@Chris Bobbe'); + check(style!.fontStyle).equals(FontStyle.italic); + }); + testFontWeight('silent or non-self mention in plain paragraph', expectedWght: 400, // @_**Greg Price** @@ -989,13 +997,22 @@ void main() { testContentSmoke(ContentExample.mathInline); + final mathInlineHtml = '' + 'λ' + ' \\lambda ' + ''; + + testWidgets('is link-colored in link span', (tester) async { + // Regression test for: https://github.com/zulip/zulip-flutter/issues/1823 + await prepareContent(tester, + plainContent('

$mathInlineHtml

')); + final style = mergedStyleOf(tester, 'λ'); + check(style!.color).equals(const HSLColor.fromAHSL(1, 200, 1, 0.4).toColor()); + }); + testWidgets('maintains font-size ratio with surrounding text', (tester) async { - const html = '' - 'λ' - ' \\lambda ' - ''; await checkFontSizeRatio(tester, - targetHtml: html, + targetHtml: mathInlineHtml, targetFontSizeFinder: (rootSpan) { late final double result; rootSpan.visitChildren((span) { @@ -1081,7 +1098,19 @@ void main() { check(find.textContaining(renderedTextRegexpTwelveHour)).findsOne(); }); - void testIconAndTextSameColor(String description, String html) { + testWidgets('is italic in italic span', (tester) async { + // Regression test for: https://github.com/zulip/zulip-flutter/issues/1813 + await prepareContent(tester, + // We use the self-account's time-format setting. + wrapWithPerAccountStoreWidget: true, + initialSnapshot: eg.initialSnapshot(), + plainContent('

$timeSpanHtml

')); + final style = mergedStyleOf(tester, + findAncestor: find.byType(GlobalTime), renderedTextRegexp); + check(style!.fontStyle).equals(FontStyle.italic); + }); + + void testIconAndTextSameColor(String description, String html, {Color? expectedColor}) { testWidgets('clock icon and text are the same color: $description', (tester) async { await prepareContent(tester, // We use the self-account's time-format setting. @@ -1097,11 +1126,16 @@ void main() { check(textColor).isNotNull(); check(icon).color.isNotNull().isSameColorAs(textColor!); + if (expectedColor != null) { + check(icon).color.equals(expectedColor); + } }); } testIconAndTextSameColor('common case', '

$timeSpanHtml

'); - testIconAndTextSameColor('inside link', '

$timeSpanHtml

'); + // Regression test for: https://github.com/zulip/zulip-flutter/issues/1819 + testIconAndTextSameColor('inside link', '

$timeSpanHtml

', + expectedColor: const HSLColor.fromAHSL(1, 200, 1, 0.4).toColor()); group('maintains font-size ratio with surrounding text', () { Future doCheck(WidgetTester tester, double Function(GlobalTime widget) sizeFromWidget) async {