diff --git a/lib/model/code_block.dart b/lib/model/code_block.dart index 222981f6e1..139c0f7e6b 100644 --- a/lib/model/code_block.dart +++ b/lib/model/code_block.dart @@ -8,6 +8,11 @@ enum CodeBlockSpanType { unknown, /// A run of unstyled text in a code block. text, + /// A code-block span with CSS class `highlight`. + /// + /// This span is emitted by server for content matching + /// used for displaying keyword search highlighting. + highlight, /// A code-block span with CSS class `hll`. /// /// Unlike most `CodeBlockSpanToken` values, this does not correspond to @@ -174,6 +179,7 @@ enum CodeBlockSpanType { CodeBlockSpanType codeBlockSpanTypeFromClassName(String className) { return switch (className) { + 'highlight' => CodeBlockSpanType.highlight, 'hll' => CodeBlockSpanType.highlightedLines, 'w' => CodeBlockSpanType.whitespace, 'esc' => CodeBlockSpanType.escape, diff --git a/lib/model/content.dart b/lib/model/content.dart index 78d7af24bc..3c161715c8 100644 --- a/lib/model/content.dart +++ b/lib/model/content.dart @@ -320,10 +320,16 @@ class CodeBlockNode extends BlockContentNode { } class CodeBlockSpanNode extends ContentNode { - const CodeBlockSpanNode({super.debugHtmlNode, required this.text, required this.type}); + const CodeBlockSpanNode({ + super.debugHtmlNode, + this.text, + required this.type, + this.spans, + }) : assert((text != null) ^ (spans != null)); - final String text; + final String? text; final CodeBlockSpanType type; + final List? spans; @override bool operator ==(Object other) { @@ -339,6 +345,11 @@ class CodeBlockSpanNode extends ContentNode { properties.add(StringProperty('text', text)); properties.add(EnumProperty('type', type)); } + + @override + List debugDescribeChildren() { + return spans?.map((span) => span.toDiagnosticsNode()).toList() ?? const []; + } } /// A complete KaTeX math expression within Zulip content, @@ -1317,50 +1328,83 @@ class _ZulipContentParser { return UnimplementedBlockContentNode(htmlNode: divElement); } - final spans = []; - for (int i = 0; i < mainElement.nodes.length; i++) { - final child = mainElement.nodes[i]; + // Empirically, when a Pygments node has multiple classes, the first + // class names a standard token type and the rest are for non-standard + // token types specific to the language. Zulip web only styles the + // standard token classes and ignores the others, so we do the same. + // See: https://github.com/zulip/zulip-flutter/issues/933 + CodeBlockSpanType? parseCodeBlockSpanType(String className) { + return className.split(' ') + .map(codeBlockSpanTypeFromClassName) + .firstWhereOrNull((e) => e != CodeBlockSpanType.unknown); + } + + String parseCodeBlockSpanTextNode(dom.Text textNode, bool isLastNode) { + final text = textNode.text; + if (!isLastNode) return text; - final CodeBlockSpanNode span; + // The HTML tends to have a final newline here. If included in the + // [Text] widget, that would make a trailing blank line. So cut it out. + return text.replaceFirst(RegExp(r'\n$'), ''); + } + + bool hasFailed = false; + + void parseCodeBlockSpan(dom.Node child, bool isLastNode, List result) { switch (child) { - case dom.Text(:var text): - if (i == mainElement.nodes.length - 1) { - // The HTML tends to have a final newline here. If included in the - // [Text] widget, that would make a trailing blank line. So cut it out. - text = text.replaceFirst(RegExp(r'\n$'), ''); + case dom.Text(): + final text = parseCodeBlockSpanTextNode(child, isLastNode); + if (text.isEmpty) return; + + result.add(CodeBlockSpanNode( + text: text, + type: CodeBlockSpanType.text, + debugHtmlNode: debugHtmlNode)); + + case dom.Element(localName: 'span', :final className): + final spanType = parseCodeBlockSpanType(className); + if (spanType == null) { + // TODO(#194): Show these as un-syntax-highlighted code, in production. + hasFailed = true; + return; } - if (text.isEmpty) { - continue; + + if (child.nodes case [dom.Text() && final grandchild]) { + final text = parseCodeBlockSpanTextNode(grandchild, isLastNode); + if (text.isEmpty) return; + + result.add(CodeBlockSpanNode( + type: spanType, + text: text, + debugHtmlNode: debugHtmlNode)); + return; } - span = CodeBlockSpanNode(text: text, type: CodeBlockSpanType.text); - - case dom.Element(localName: 'span', :final text, :final className): - // Empirically, when a Pygments node has multiple classes, the first - // class names a standard token type and the rest are for non-standard - // token types specific to the language. Zulip web only styles the - // standard token classes and ignores the others, so we do the same. - // See: https://github.com/zulip/zulip-flutter/issues/933 - final spanType = className.split(' ') - .map(codeBlockSpanTypeFromClassName) - .firstWhereOrNull((e) => e != CodeBlockSpanType.unknown); - - switch (spanType) { - case null: - // TODO(#194): Show these as un-syntax-highlighted code, in production. - return UnimplementedBlockContentNode(htmlNode: divElement); - case CodeBlockSpanType.highlightedLines: - // TODO: Implement nesting in CodeBlockSpanNode to support hierarchically - // inherited styles for `span.hll` nodes. - return UnimplementedBlockContentNode(htmlNode: divElement); - default: - span = CodeBlockSpanNode(text: text, type: spanType); + + final childSpans = []; + + for (int i = 0; i < child.nodes.length; i++) { + final grandchild = child.nodes[i]; + parseCodeBlockSpan(grandchild, + isLastNode ? i == child.nodes.length - 1 : false, childSpans); + if (hasFailed) return; } + result.add(CodeBlockSpanNode( + type: spanType, + spans: childSpans, + debugHtmlNode: debugHtmlNode)); + default: - return UnimplementedBlockContentNode(htmlNode: divElement); + hasFailed = true; + return; } + } - spans.add(span); + List spans = []; + for (int i = 0; i < mainElement.nodes.length; i++) { + final child = mainElement.nodes[i]; + parseCodeBlockSpan(child, i == mainElement.nodes.length - 1, spans); + if (hasFailed) return UnimplementedBlockContentNode(htmlNode: divElement); } return CodeBlockNode(spans, debugHtmlNode: debugHtmlNode); diff --git a/lib/widgets/code_block.dart b/lib/widgets/code_block.dart index e88bb7f5a7..8c57e6ffd3 100644 --- a/lib/widgets/code_block.dart +++ b/lib/widgets/code_block.dart @@ -19,6 +19,10 @@ class CodeBlockTextStyles { height: 1.4)) .merge(weightVariableTextStyle(context)), + // .highlight { background-color: hsl(51deg 100% 79%); } + // See https://github.com/zulip/zulip/blob/f87479703/web/styles/rendered_markdown.css#L1037-L1039 + highlight: TextStyle(backgroundColor: const HSLColor.fromAHSL(1, 51, 1, 0.79).toColor()), + // .hll { background-color: hsl(60deg 100% 90%); } hll: TextStyle(backgroundColor: const HSLColor.fromAHSL(1, 60, 1, 0.90).toColor()), @@ -259,6 +263,10 @@ class CodeBlockTextStyles { height: 1.4)) .merge(weightVariableTextStyle(context)), + // .highlight { background-color: hsl(51deg 100% 23%); } + // See https://github.com/zulip/zulip/blob/f87479703/web/styles/dark_theme.css#L410-L412 + highlight: TextStyle(backgroundColor: const HSLColor.fromAHSL(1, 51, 1, 0.23).toColor()), + // .hll { background-color: #49483e; } hll: const TextStyle(backgroundColor: Color(0xff49483e)), @@ -500,6 +508,7 @@ class CodeBlockTextStyles { CodeBlockTextStyles._({ required this.plain, + required TextStyle highlight, required TextStyle hll, required TextStyle c, required TextStyle err, @@ -580,6 +589,7 @@ class CodeBlockTextStyles { required TextStyle? vm, required TextStyle il, }) : + _highlight = highlight, _hll = hll, _c = c, _err = err, @@ -663,6 +673,7 @@ class CodeBlockTextStyles { /// The baseline style that the [forSpan] styles get applied on top of. final TextStyle plain; + final TextStyle _highlight; final TextStyle _hll; final TextStyle _c; final TextStyle _err; @@ -751,6 +762,7 @@ class CodeBlockTextStyles { TextStyle? forSpan(CodeBlockSpanType type) { return switch (type) { CodeBlockSpanType.text => null, // A span with type of text is always unstyled. + CodeBlockSpanType.highlight => _highlight, CodeBlockSpanType.highlightedLines => _hll, CodeBlockSpanType.comment => _c, CodeBlockSpanType.error => _err, @@ -839,6 +851,7 @@ class CodeBlockTextStyles { return CodeBlockTextStyles._( plain: TextStyle.lerp(a.plain, b.plain, t)!, + highlight: TextStyle.lerp(a._highlight, b._highlight, t)!, hll: TextStyle.lerp(a._hll, b._hll, t)!, c: TextStyle.lerp(a._c, b._c, t)!, err: TextStyle.lerp(a._err, b._err, t)!, diff --git a/lib/widgets/content.dart b/lib/widgets/content.dart index 5d6dfa5084..67ac7e0435 100644 --- a/lib/widgets/content.dart +++ b/lib/widgets/content.dart @@ -764,14 +764,27 @@ class CodeBlock extends StatelessWidget { @override Widget build(BuildContext context) { - final styles = ContentTheme.of(context).codeBlockTextStyles; + final codeBlockTextStyles = ContentTheme.of(context).codeBlockTextStyles; + + TextSpan buildNode(CodeBlockSpanNode child) { + final style = codeBlockTextStyles.forSpan(child.type); + + if (child.text != null) { + return TextSpan( + style: style, + text: child.text!); + } + + return TextSpan( + style: style, + children: List.unmodifiable(child.spans!.map(buildNode))); + } + return _CodeBlockContainer( borderColor: Colors.transparent, child: Text.rich(TextSpan( - style: styles.plain, - children: node.spans - .map((node) => TextSpan(style: styles.forSpan(node.type), text: node.text)) - .toList(growable: false)))); + style: codeBlockTextStyles.plain, + children: List.unmodifiable(node.spans.map(buildNode))))); } } diff --git a/test/model/content_test.dart b/test/model/content_test.dart index 5eaf5500fa..41fd4e9df3 100644 --- a/test/model/content_test.dart +++ b/test/model/content_test.dart @@ -379,7 +379,9 @@ class ContentExample { 'code block without syntax highlighting', "```\nverb\natim\n```", expectedText: 'verb\natim', - '
verb\natim\n
', [ + '
' + '
'
+        'verb\natim\n
', [ CodeBlockNode([ CodeBlockSpanNode(text: 'verb\natim', type: CodeBlockSpanType.text), ]), @@ -389,10 +391,14 @@ class ContentExample { 'code block with syntax highlighting', "```dart\nclass A {}\n```", expectedText: 'class A {}', - '
'
-        'class '
-        'A {}'
-        '\n
', [ + '
' + '
'
+        ''
+          'class'
+          ' '
+          'A'
+          ' '
+          '{}\n
', [ CodeBlockNode([ CodeBlockSpanNode(text: 'class', type: CodeBlockSpanType.keywordDeclaration), CodeBlockSpanNode(text: ' ', type: CodeBlockSpanType.whitespace), @@ -406,15 +412,27 @@ class ContentExample { 'code block, multiline, with syntax highlighting', '```rust\nfn main() {\n print!("Hello ");\n\n print!("world!\\n");\n}\n```', expectedText: 'fn main() {\n print!("Hello ");\n\n print!("world!\\n");\n}', - '
'
-        'fn main'
-        '() {\n'
-        '    print!('
-        '"Hello ");\n\n'
-        '    print!('
-        '"world!\\n"'
-        ');\n}\n'
-        '
', [ + '
' + '
'
+        ''
+          'fn '
+          'main'
+          '()'
+          ' '
+          '{\n'
+          '    '
+          'print!'
+          '('
+          '"Hello "'
+          ');\n\n'
+          '    '
+          'print!'
+          '('
+          '"world!'
+          '\\n'
+          '"'
+          ');\n'
+          '}\n
', [ CodeBlockNode([ CodeBlockSpanNode(text: 'fn', type: CodeBlockSpanType.keyword), CodeBlockSpanNode(text: ' ', type: CodeBlockSpanType.text), @@ -447,10 +465,11 @@ class ContentExample { expectedText: '- item', // https://chat.zulip.org/#narrow/channel/7-test-here/topic/Greg/near/1949014 '
' - '
-'
-        ' '
-        'item\n'
-        '
', [ + '
'
+        ''
+          '-'
+          ' '
+          'item\n
', [ CodeBlockNode([ CodeBlockSpanNode(text: "-", type: CodeBlockSpanType.punctuation), CodeBlockSpanNode(text: " ", type: CodeBlockSpanType.whitespace), @@ -472,19 +491,27 @@ class ContentExample { static final codeBlockWithHighlightedLines = ContentExample( 'code block, with syntax highlighting and highlighted lines', '```\n::markdown hl_lines="2 4"\n# he\n## llo\n### world\n```', - '
'
-        '::markdown hl_lines="2 4"\n'
-        '# he\n'
-        '## llo\n'
-        '### world\n'
-        '
', [ - // TODO: Fix this, see comment under `CodeBlockSpanType.highlightedLines` case in lib/model/content.dart. - blockUnimplemented('
'
-        '::markdown hl_lines="2 4"\n'
-        '# he\n'
-        '## llo\n'
-        '### world\n'
-        '
'), + '
' + '
'
+        ''
+          '::markdown hl_lines="2 4"\n'
+          ''
+            '# he\n'
+          '## llo\n'
+          ''
+            '### world\n
', [ + CodeBlockNode([ + CodeBlockSpanNode(text: '::markdown hl_lines="2 4"\n', type: CodeBlockSpanType.text), + CodeBlockSpanNode(type: CodeBlockSpanType.highlightedLines, spans: [ + CodeBlockSpanNode(text: '# he', type: CodeBlockSpanType.genericHeading), + CodeBlockSpanNode(text: '\n', type: CodeBlockSpanType.text), + ]), + CodeBlockSpanNode(text: '## llo', type: CodeBlockSpanType.genericSubheading), + CodeBlockSpanNode(text: '\n', type: CodeBlockSpanType.text), + CodeBlockSpanNode(type: CodeBlockSpanType.highlightedLines, spans: [ + CodeBlockSpanNode(text: '### world', type: CodeBlockSpanType.genericSubheading), + ]), + ]), ]); static final codeBlockWithUnknownSpanType = ContentExample( @@ -509,6 +536,82 @@ class ContentExample { ParagraphNode(links: null, nodes: [TextNode("some content")]), ]); + static const codeBlockKeywordHighlight = ContentExample( + 'code block, search highlight', + '```dart\nclass A {}\n```', + '
' + '
'
+        ''
+          ''
+            'class'
+          ' '
+          'A'
+          ' '
+          '{}\n
', [ + CodeBlockNode([ + CodeBlockSpanNode(type: CodeBlockSpanType.keywordDeclaration, spans: [ + CodeBlockSpanNode(text: 'class', type: CodeBlockSpanType.highlight), + ]), + CodeBlockSpanNode(text: ' ', type: CodeBlockSpanType.whitespace), + CodeBlockSpanNode(text: 'A', type: CodeBlockSpanType.nameClass), + CodeBlockSpanNode(text: ' ', type: CodeBlockSpanType.whitespace), + CodeBlockSpanNode(text: '{}', type: CodeBlockSpanType.punctuation), + ]), + ]); + + static const codeBlockKeywordHighlightBetweenText = ContentExample( + 'code block, search highlight between text', + '```console\n# postgresql\nThe World\'s Most Advanced Open Source Relational Database\n```', + '
' + '
'
+        ''
+          '# postgresql\n'
+          ''
+            'The '
+            'World'
+            '\'s Most Advanced Open Source Relational Database\n
', [ + CodeBlockNode([ + CodeBlockSpanNode(text: '# ', type: CodeBlockSpanType.genericPrompt), + CodeBlockSpanNode(text: "postgresql\n", type: CodeBlockSpanType.text), + CodeBlockSpanNode(type: CodeBlockSpanType.genericOutput, spans: [ + CodeBlockSpanNode(text: 'The ', type: CodeBlockSpanType.text), + CodeBlockSpanNode(text: 'World', type: CodeBlockSpanType.highlight), + CodeBlockSpanNode(text: '\'s Most Advanced Open Source Relational Database', type: CodeBlockSpanType.text), + ]), + ]), + ]); + + static const codeBlockWithHighlightedLinesAndKeywordHighlight = ContentExample( + 'code block with highlighted lines and keyword highlight', + '```\n::markdown hl_lines="2 4"\n# he\n## llo\n### world\n```', + '
' + '
'
+        ''
+          '::markdown hl_lines="2 4"\n'
+          ''
+            '# he\n'
+          '## llo\n'
+          ''
+            ''
+              '### '
+              'world\n
', [ + CodeBlockNode([ + CodeBlockSpanNode(text: '::markdown hl_lines="2 4"\n', type: CodeBlockSpanType.text), + CodeBlockSpanNode(type: CodeBlockSpanType.highlightedLines, spans: [ + CodeBlockSpanNode(text: '# he', type: CodeBlockSpanType.genericHeading), + CodeBlockSpanNode(text: '\n', type: CodeBlockSpanType.text), + ]), + CodeBlockSpanNode(text: '## llo', type: CodeBlockSpanType.genericSubheading), + CodeBlockSpanNode(text: '\n', type: CodeBlockSpanType.text), + CodeBlockSpanNode(type: CodeBlockSpanType.highlightedLines, spans: [ + CodeBlockSpanNode(type: CodeBlockSpanType.genericSubheading, spans: [ + CodeBlockSpanNode(text: '### ', type: CodeBlockSpanType.text), + CodeBlockSpanNode(text: 'world', type: CodeBlockSpanType.highlight), + ]), + ]), + ]), + ]); + static final mathInline = ContentExample.inline( 'inline math', r"$$ \lambda $$", @@ -1772,6 +1875,9 @@ void main() async { testParseExample(ContentExample.codeBlockWithHighlightedLines); testParseExample(ContentExample.codeBlockWithUnknownSpanType); testParseExample(ContentExample.codeBlockFollowedByMultipleLineBreaks); + testParseExample(ContentExample.codeBlockKeywordHighlight); + testParseExample(ContentExample.codeBlockKeywordHighlightBetweenText); + testParseExample(ContentExample.codeBlockWithHighlightedLinesAndKeywordHighlight); // The math examples in this file are about how math blocks and spans fit // into the context of a Zulip message.