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 78fc961c00..77eddcd22b 100644 --- a/lib/model/content.dart +++ b/lib/model/content.dart @@ -320,24 +320,30 @@ class CodeBlockNode extends BlockContentNode { } class CodeBlockSpanNode extends ContentNode { - const CodeBlockSpanNode({super.debugHtmlNode, required this.text, required this.type}); + const CodeBlockSpanNode({ + super.debugHtmlNode, + required this.text, + required this.spanTypes, + }); final String text; - final CodeBlockSpanType type; + final List spanTypes; @override bool operator ==(Object other) { - return other is CodeBlockSpanNode && other.text == text && other.type == type; + return other is CodeBlockSpanNode && + other.text == text && + other.spanTypes == spanTypes; } @override - int get hashCode => Object.hash('CodeBlockSpanNode', text, type); + int get hashCode => Object.hash('CodeBlockSpanNode', text, spanTypes); @override void debugFillProperties(DiagnosticPropertiesBuilder properties) { super.debugFillProperties(properties); properties.add(StringProperty('text', text)); - properties.add(EnumProperty('type', type)); + properties.add(StringProperty('spanTypes', spanTypes.toString())); } } @@ -1232,50 +1238,68 @@ class _ZulipContentParser { return UnimplementedBlockContentNode(htmlNode: divElement); } - final spans = []; + // 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); + } + + List spans = []; + List spanTypes = []; + bool hasFailed = false; + for (int i = 0; i < mainElement.nodes.length; i++) { final child = mainElement.nodes[i]; - final CodeBlockSpanNode span; - 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$'), ''); - } - if (text.isEmpty) { - continue; - } - 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: + void parseCodeBlockSpan(dom.Node child, bool isLastNode) { + switch (child) { + case dom.Text(:var text): + if (isLastNode) { + // 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$'), ''); + } + if (text.isEmpty) { + break; + } + spans.add(CodeBlockSpanNode( + text: text, + spanTypes: spanTypes.isEmpty + ? const [CodeBlockSpanType.text] + : List.unmodifiable(spanTypes))); + + 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. - 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); - } + hasFailed = true; + return; + } - default: - return UnimplementedBlockContentNode(htmlNode: divElement); + spanTypes.add(spanType); + + for (int i = 0; i < child.nodes.length; i++) { + final grandchild = child.nodes[i]; + parseCodeBlockSpan(grandchild, + isLastNode ? i == child.nodes.length - 1 : false); + if (hasFailed) return; + } + + assert(spanTypes.removeLast() == spanType); + + default: + hasFailed = true; + return; + } } - spans.add(span); + parseCodeBlockSpan(child, i == mainElement.nodes.length - 1); + 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 52ab7008b8..919e7ae34c 100644 --- a/lib/widgets/content.dart +++ b/lib/widgets/content.dart @@ -770,13 +770,23 @@ class CodeBlock extends StatelessWidget { @override Widget build(BuildContext context) { - final styles = ContentTheme.of(context).codeBlockTextStyles; + final codeBlockTextStyles = ContentTheme.of(context).codeBlockTextStyles; return _CodeBlockContainer( borderColor: Colors.transparent, child: Text.rich(TextSpan( - style: styles.plain, + style: codeBlockTextStyles.plain, children: node.spans - .map((node) => TextSpan(style: styles.forSpan(node.type), text: node.text)) + .map((node) { + TextStyle? style; + for (final spanType in node.spanTypes) { + final spanStyle = codeBlockTextStyles.forSpan(spanType); + if (spanStyle == null) continue; + style = style == null + ? spanStyle + : style.merge(spanStyle); + } + return TextSpan(style: style, text: node.text); + }) .toList(growable: false)))); } } diff --git a/test/model/content_test.dart b/test/model/content_test.dart index c19e1c02a5..5af7f06d12 100644 --- a/test/model/content_test.dart +++ b/test/model/content_test.dart @@ -379,9 +379,11 @@ 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), + CodeBlockSpanNode(text: 'verb\natim', spanTypes: [CodeBlockSpanType.text]), ]), ]); @@ -389,16 +391,20 @@ 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), - CodeBlockSpanNode(text: 'A', type: CodeBlockSpanType.nameClass), - CodeBlockSpanNode(text: ' ', type: CodeBlockSpanType.whitespace), - CodeBlockSpanNode(text: '{}', type: CodeBlockSpanType.punctuation), + CodeBlockSpanNode(text: 'class', spanTypes: [CodeBlockSpanType.keywordDeclaration]), + CodeBlockSpanNode(text: ' ', spanTypes: [CodeBlockSpanType.whitespace]), + CodeBlockSpanNode(text: 'A', spanTypes: [CodeBlockSpanType.nameClass]), + CodeBlockSpanNode(text: ' ', spanTypes: [CodeBlockSpanType.whitespace]), + CodeBlockSpanNode(text: '{}', spanTypes: [CodeBlockSpanType.punctuation]), ]), ]); @@ -406,38 +412,50 @@ 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), - CodeBlockSpanNode(text: 'main', type: CodeBlockSpanType.nameFunction), - CodeBlockSpanNode(text: '()', type: CodeBlockSpanType.punctuation), - CodeBlockSpanNode(text: ' ', type: CodeBlockSpanType.whitespace), - CodeBlockSpanNode(text: '{', type: CodeBlockSpanType.punctuation), - CodeBlockSpanNode(text: '\n', type: CodeBlockSpanType.text), - CodeBlockSpanNode(text: ' ', type: CodeBlockSpanType.whitespace), - CodeBlockSpanNode(text: 'print!', type: CodeBlockSpanType.nameFunctionMagic), - CodeBlockSpanNode(text: '(', type: CodeBlockSpanType.punctuation), - CodeBlockSpanNode(text: '"Hello "', type: CodeBlockSpanType.string), - CodeBlockSpanNode(text: ');', type: CodeBlockSpanType.punctuation), - CodeBlockSpanNode(text: '\n\n', type: CodeBlockSpanType.text), - CodeBlockSpanNode(text: ' ', type: CodeBlockSpanType.whitespace), - CodeBlockSpanNode(text: 'print!', type: CodeBlockSpanType.nameFunctionMagic), - CodeBlockSpanNode(text: '(', type: CodeBlockSpanType.punctuation), - CodeBlockSpanNode(text: '"world!', type: CodeBlockSpanType.string), - CodeBlockSpanNode(text: '\\n', type: CodeBlockSpanType.stringEscape), - CodeBlockSpanNode(text: '"', type: CodeBlockSpanType.string), - CodeBlockSpanNode(text: ');', type: CodeBlockSpanType.punctuation), - CodeBlockSpanNode(text: '\n', type: CodeBlockSpanType.text), - CodeBlockSpanNode(text: '}', type: CodeBlockSpanType.punctuation), + CodeBlockSpanNode(text: 'fn', spanTypes: [CodeBlockSpanType.keyword]), + CodeBlockSpanNode(text: ' ', spanTypes: [CodeBlockSpanType.text]), + CodeBlockSpanNode(text: 'main', spanTypes: [CodeBlockSpanType.nameFunction]), + CodeBlockSpanNode(text: '()', spanTypes: [CodeBlockSpanType.punctuation]), + CodeBlockSpanNode(text: ' ', spanTypes: [CodeBlockSpanType.whitespace]), + CodeBlockSpanNode(text: '{', spanTypes: [CodeBlockSpanType.punctuation]), + CodeBlockSpanNode(text: '\n', spanTypes: [CodeBlockSpanType.text]), + CodeBlockSpanNode(text: ' ', spanTypes: [CodeBlockSpanType.whitespace]), + CodeBlockSpanNode(text: 'print!', spanTypes: [CodeBlockSpanType.nameFunctionMagic]), + CodeBlockSpanNode(text: '(', spanTypes: [CodeBlockSpanType.punctuation]), + CodeBlockSpanNode(text: '"Hello "', spanTypes: [CodeBlockSpanType.string]), + CodeBlockSpanNode(text: ');', spanTypes: [CodeBlockSpanType.punctuation]), + CodeBlockSpanNode(text: '\n\n', spanTypes: [CodeBlockSpanType.text]), + CodeBlockSpanNode(text: ' ', spanTypes: [CodeBlockSpanType.whitespace]), + CodeBlockSpanNode(text: 'print!', spanTypes: [CodeBlockSpanType.nameFunctionMagic]), + CodeBlockSpanNode(text: '(', spanTypes: [CodeBlockSpanType.punctuation]), + CodeBlockSpanNode(text: '"world!', spanTypes: [CodeBlockSpanType.string]), + CodeBlockSpanNode(text: '\\n', spanTypes: [CodeBlockSpanType.stringEscape]), + CodeBlockSpanNode(text: '"', spanTypes: [CodeBlockSpanType.string]), + CodeBlockSpanNode(text: ');', spanTypes: [CodeBlockSpanType.punctuation]), + CodeBlockSpanNode(text: '\n', spanTypes: [CodeBlockSpanType.text]), + CodeBlockSpanNode(text: '}', spanTypes: [CodeBlockSpanType.punctuation]), ]), ]); @@ -447,14 +465,15 @@ 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), - CodeBlockSpanNode(text: "item", type: CodeBlockSpanType.literal) + CodeBlockSpanNode(text: "-", spanTypes: [CodeBlockSpanType.punctuation]), + CodeBlockSpanNode(text: " ", spanTypes: [CodeBlockSpanType.whitespace]), + CodeBlockSpanNode(text: "item", spanTypes: [CodeBlockSpanType.literal]) ]), ]); @@ -473,18 +492,22 @@ class 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', spanTypes: [CodeBlockSpanType.text]), + CodeBlockSpanNode(text: '# he', spanTypes: [CodeBlockSpanType.highlightedLines, CodeBlockSpanType.genericHeading]), + CodeBlockSpanNode(text: '\n', spanTypes: [CodeBlockSpanType.highlightedLines]), + CodeBlockSpanNode(text: '## llo', spanTypes: [CodeBlockSpanType.genericSubheading]), + CodeBlockSpanNode(text: '\n', spanTypes: [CodeBlockSpanType.text]), + CodeBlockSpanNode(text: '### world', spanTypes: [CodeBlockSpanType.highlightedLines, CodeBlockSpanType.genericSubheading]), + ]), ]); static final codeBlockWithUnknownSpanType = ContentExample( @@ -503,12 +526,56 @@ class ContentExample { ' code block.\n\nsome content', // https://chat.zulip.org/#narrow/stream/7-test-here/near/1774823 '
' - '
code block.\n
\n\n' + '
'
+        'code block.\n
\n\n' '

some content

', [ - CodeBlockNode([CodeBlockSpanNode(text: "code block.", type: CodeBlockSpanType.text)]), + CodeBlockNode([CodeBlockSpanNode(text: "code block.", spanTypes: [CodeBlockSpanType.text])]), ParagraphNode(links: null, nodes: [TextNode("some content")]), ]); + static const codeBlockSearchHighlight = ContentExample( + 'code block, search highlight', + '```dart\nclass A {}\n```', + '
' + '
'
+        ''
+        ''
+          ''
+            'class'
+          ' '
+          'A'
+          ' '
+          '{}\n
', [ + CodeBlockNode([ + CodeBlockSpanNode(text: 'class', spanTypes: [CodeBlockSpanType.keywordDeclaration, CodeBlockSpanType.highlight]), + CodeBlockSpanNode(text: ' ', spanTypes: [CodeBlockSpanType.whitespace]), + CodeBlockSpanNode(text: 'A', spanTypes: [CodeBlockSpanType.nameClass]), + CodeBlockSpanNode(text: ' ', spanTypes: [CodeBlockSpanType.whitespace]), + CodeBlockSpanNode(text: '{}', spanTypes: [CodeBlockSpanType.punctuation]), + ]), + ]); + + static const codeBlockSearchHighlightBetweenText = 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: '# ', spanTypes: [CodeBlockSpanType.genericPrompt]), + CodeBlockSpanNode(text: "postgresql\n", spanTypes: [CodeBlockSpanType.text]), + CodeBlockSpanNode(text: 'The ', spanTypes: [CodeBlockSpanType.genericOutput]), + CodeBlockSpanNode(text: 'World', spanTypes: [CodeBlockSpanType.genericOutput, CodeBlockSpanType.highlight]), + CodeBlockSpanNode(text: '\'s Most Advanced Open Source Relational Database', spanTypes: [CodeBlockSpanType.genericOutput]), + ]), + ]); + static final mathInline = ContentExample.inline( 'inline math', r"$$ \lambda $$", @@ -2023,6 +2090,8 @@ void main() async { testParseExample(ContentExample.codeBlockWithHighlightedLines); testParseExample(ContentExample.codeBlockWithUnknownSpanType); testParseExample(ContentExample.codeBlockFollowedByMultipleLineBreaks); + testParseExample(ContentExample.codeBlockSearchHighlight); + testParseExample(ContentExample.codeBlockSearchHighlightBetweenText); testParseExample(ContentExample.mathBlock); testParseExample(ContentExample.mathBlocksMultipleInParagraph); @@ -2088,7 +2157,7 @@ void main() async { ]]), ]), CodeBlockNode([ - CodeBlockSpanNode(text: 'four', type: CodeBlockSpanType.text), + CodeBlockSpanNode(text: 'four', spanTypes: [CodeBlockSpanType.text]), ]), ParagraphNode(wasImplicit: true, links: null, nodes: [TextNode('\n\n')]), // TODO avoid this; it renders wrong ]]),