Skip to content

content: Add support for keyword highlighting in code blocks #1707

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions lib/model/code_block.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
108 changes: 66 additions & 42 deletions lib/model/content.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<CodeBlockSpanType> 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()));
}
}

Expand Down Expand Up @@ -1232,50 +1238,68 @@ class _ZulipContentParser {
return UnimplementedBlockContentNode(htmlNode: divElement);
}

final spans = <CodeBlockSpanNode>[];
// 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<CodeBlockSpanNode> spans = [];
List<CodeBlockSpanType> 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);
Expand Down
13 changes: 13 additions & 0 deletions lib/widgets/code_block.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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()),

Expand Down Expand Up @@ -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)),

Expand Down Expand Up @@ -500,6 +508,7 @@ class CodeBlockTextStyles {

CodeBlockTextStyles._({
required this.plain,
required TextStyle highlight,
required TextStyle hll,
required TextStyle c,
required TextStyle err,
Expand Down Expand Up @@ -580,6 +589,7 @@ class CodeBlockTextStyles {
required TextStyle? vm,
required TextStyle il,
}) :
_highlight = highlight,
_hll = hll,
_c = c,
_err = err,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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)!,
Expand Down
16 changes: 13 additions & 3 deletions lib/widgets/content.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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))));
}
}
Expand Down
Loading
Loading