diff --git a/lib/model/code_block.dart b/lib/model/code_block.dart new file mode 100644 index 0000000000..34c7c5506b --- /dev/null +++ b/lib/model/code_block.dart @@ -0,0 +1,259 @@ +// List of all the tokens that pygments can emit for syntax highlighting +// https://github.com/pygments/pygments/blob/d0acfff1121f9ee3696b01a9077ebe9990216634/pygments/token.py#L123-L214 +// +// Note: If you update this list make sure to update the permalink +// and the `tryFromString` function below. +enum CodeBlockSpanType { + /// A code-block span that is unrecognized by the parser. + unknown, + /// A run of unstyled text in a code block. + text, + /// A code-block span with CSS class `hll`. + /// + /// Unlike most `CodeBlockSpanToken` values, this does not correspond to + /// a Pygments "token type". See discussion: + /// https://github.com/zulip/zulip-flutter/pull/242#issuecomment-1652450667 + highlightedLines, + /// A code-block span with CSS class `w`. + whitespace, + /// A code-block span with CSS class `esc`. + escape, + /// A code-block span with CSS class `err`. + error, + /// A code-block span with CSS class `x`. + other, + /// A code-block span with CSS class `k`. + keyword, + /// A code-block span with CSS class `kc`. + keywordConstant, + /// A code-block span with CSS class `kd`. + keywordDeclaration, + /// A code-block span with CSS class `kn`. + keywordNamespace, + /// A code-block span with CSS class `kp`. + keywordPseudo, + /// A code-block span with CSS class `kr`. + keywordReserved, + /// A code-block span with CSS class `kt`. + keywordType, + /// A code-block span with CSS class `n`. + name, + /// A code-block span with CSS class `na`. + nameAttribute, + /// A code-block span with CSS class `nb`. + nameBuiltin, + /// A code-block span with CSS class `bp`. + nameBuiltinPseudo, + /// A code-block span with CSS class `nc`. + nameClass, + /// A code-block span with CSS class `no`. + nameConstant, + /// A code-block span with CSS class `nd`. + nameDecorator, + /// A code-block span with CSS class `ni`. + nameEntity, + /// A code-block span with CSS class `ne`. + nameException, + /// A code-block span with CSS class `nf`. + nameFunction, + /// A code-block span with CSS class `fm`. + nameFunctionMagic, + /// A code-block span with CSS class `py`. + nameProperty, + /// A code-block span with CSS class `nl`. + nameLabel, + /// A code-block span with CSS class `nn`. + nameNamespace, + /// A code-block span with CSS class `nx`. + nameOther, + /// A code-block span with CSS class `nt`. + nameTag, + /// A code-block span with CSS class `nv`. + nameVariable, + /// A code-block span with CSS class `vc`. + nameVariableClass, + /// A code-block span with CSS class `vg`. + nameVariableGlobal, + /// A code-block span with CSS class `vi`. + nameVariableInstance, + /// A code-block span with CSS class `vm`. + nameVariableMagic, + /// A code-block span with CSS class `l`. + literal, + /// A code-block span with CSS class `ld`. + literalDate, + /// A code-block span with CSS class `s`. + string, + /// A code-block span with CSS class `sa`. + stringAffix, + /// A code-block span with CSS class `sb`. + stringBacktick, + /// A code-block span with CSS class `sc`. + stringChar, + /// A code-block span with CSS class `dl`. + stringDelimiter, + /// A code-block span with CSS class `sd`. + stringDoc, + /// A code-block span with CSS class `s2`. + stringDouble, + /// A code-block span with CSS class `se`. + stringEscape, + /// A code-block span with CSS class `sh`. + stringHeredoc, + /// A code-block span with CSS class `si`. + stringInterpol, + /// A code-block span with CSS class `sx`. + stringOther, + /// A code-block span with CSS class `sr`. + stringRegex, + /// A code-block span with CSS class `s1`. + stringSingle, + /// A code-block span with CSS class `ss`. + stringSymbol, + /// A code-block span with CSS class `m`. + number, + /// A code-block span with CSS class `mb`. + numberBin, + /// A code-block span with CSS class `mf`. + numberFloat, + /// A code-block span with CSS class `mh`. + numberHex, + /// A code-block span with CSS class `mi`. + numberInteger, + /// A code-block span with CSS class `il`. + numberIntegerLong, + /// A code-block span with CSS class `mo`. + numberOct, + /// A code-block span with CSS class `o`. + operator, + /// A code-block span with CSS class `ow`. + operatorWord, + /// A code-block span with CSS class `p`. + punctuation, + /// A code-block span with CSS class `pm`. + punctuationMarker, + /// A code-block span with CSS class `c`. + comment, + /// A code-block span with CSS class `ch`. + commentHashbang, + /// A code-block span with CSS class `cm`. + commentMultiline, + /// A code-block span with CSS class `cp`. + commentPreproc, + /// A code-block span with CSS class `cpf`. + commentPreprocFile, + /// A code-block span with CSS class `c1`. + commentSingle, + /// A code-block span with CSS class `cs`. + commentSpecial, + /// A code-block span with CSS class `g`. + generic, + /// A code-block span with CSS class `gd`. + genericDeleted, + /// A code-block span with CSS class `ge`. + genericEmph, + /// A code-block span with CSS class `gr`. + genericError, + /// A code-block span with CSS class `gh`. + genericHeading, + /// A code-block span with CSS class `gi`. + genericInserted, + /// A code-block span with CSS class `go`. + genericOutput, + /// A code-block span with CSS class `gp`. + genericPrompt, + /// A code-block span with CSS class `gs`. + genericStrong, + /// A code-block span with CSS class `gu`. + genericSubheading, + /// A code-block span with CSS class `ges`. + genericEmphStrong, + /// A code-block span with CSS class `gt`. + genericTraceback, +} + +CodeBlockSpanType codeBlockSpanTypeFromClassName(String className) { + return switch (className) { + '' => CodeBlockSpanType.text, + 'hll' => CodeBlockSpanType.highlightedLines, + 'w' => CodeBlockSpanType.whitespace, + 'esc' => CodeBlockSpanType.escape, + 'err' => CodeBlockSpanType.error, + 'x' => CodeBlockSpanType.other, + 'k' => CodeBlockSpanType.keyword, + 'kc' => CodeBlockSpanType.keywordConstant, + 'kd' => CodeBlockSpanType.keywordDeclaration, + 'kn' => CodeBlockSpanType.keywordNamespace, + 'kp' => CodeBlockSpanType.keywordPseudo, + 'kr' => CodeBlockSpanType.keywordReserved, + 'kt' => CodeBlockSpanType.keywordType, + 'n' => CodeBlockSpanType.name, + 'na' => CodeBlockSpanType.nameAttribute, + 'nb' => CodeBlockSpanType.nameBuiltin, + 'bp' => CodeBlockSpanType.nameBuiltinPseudo, + 'nc' => CodeBlockSpanType.nameClass, + 'no' => CodeBlockSpanType.nameConstant, + 'nd' => CodeBlockSpanType.nameDecorator, + 'ni' => CodeBlockSpanType.nameEntity, + 'ne' => CodeBlockSpanType.nameException, + 'nf' => CodeBlockSpanType.nameFunction, + 'fm' => CodeBlockSpanType.nameFunctionMagic, + 'py' => CodeBlockSpanType.nameProperty, + 'nl' => CodeBlockSpanType.nameLabel, + 'nn' => CodeBlockSpanType.nameNamespace, + 'nx' => CodeBlockSpanType.nameOther, + 'nt' => CodeBlockSpanType.nameTag, + 'nv' => CodeBlockSpanType.nameVariable, + 'vc' => CodeBlockSpanType.nameVariableClass, + 'vg' => CodeBlockSpanType.nameVariableGlobal, + 'vi' => CodeBlockSpanType.nameVariableInstance, + 'vm' => CodeBlockSpanType.nameVariableMagic, + 'l' => CodeBlockSpanType.literal, + 'ld' => CodeBlockSpanType.literalDate, + 's' => CodeBlockSpanType.string, + 'sa' => CodeBlockSpanType.stringAffix, + 'sb' => CodeBlockSpanType.stringBacktick, + 'sc' => CodeBlockSpanType.stringChar, + 'dl' => CodeBlockSpanType.stringDelimiter, + 'sd' => CodeBlockSpanType.stringDoc, + 's2' => CodeBlockSpanType.stringDouble, + 'se' => CodeBlockSpanType.stringEscape, + 'sh' => CodeBlockSpanType.stringHeredoc, + 'si' => CodeBlockSpanType.stringInterpol, + 'sx' => CodeBlockSpanType.stringOther, + 'sr' => CodeBlockSpanType.stringRegex, + 's1' => CodeBlockSpanType.stringSingle, + 'ss' => CodeBlockSpanType.stringSymbol, + 'm' => CodeBlockSpanType.number, + 'mb' => CodeBlockSpanType.numberBin, + 'mf' => CodeBlockSpanType.numberFloat, + 'mh' => CodeBlockSpanType.numberHex, + 'mi' => CodeBlockSpanType.numberInteger, + 'il' => CodeBlockSpanType.numberIntegerLong, + 'mo' => CodeBlockSpanType.numberOct, + 'o' => CodeBlockSpanType.operator, + 'ow' => CodeBlockSpanType.operatorWord, + 'p' => CodeBlockSpanType.punctuation, + 'pm' => CodeBlockSpanType.punctuationMarker, + 'c' => CodeBlockSpanType.comment, + 'ch' => CodeBlockSpanType.commentHashbang, + 'cm' => CodeBlockSpanType.commentMultiline, + 'cp' => CodeBlockSpanType.commentPreproc, + 'cpf' => CodeBlockSpanType.commentPreprocFile, + 'c1' => CodeBlockSpanType.commentSingle, + 'cs' => CodeBlockSpanType.commentSpecial, + 'g' => CodeBlockSpanType.generic, + 'gd' => CodeBlockSpanType.genericDeleted, + 'ge' => CodeBlockSpanType.genericEmph, + 'gr' => CodeBlockSpanType.genericError, + 'gh' => CodeBlockSpanType.genericHeading, + 'gi' => CodeBlockSpanType.genericInserted, + 'go' => CodeBlockSpanType.genericOutput, + 'gp' => CodeBlockSpanType.genericPrompt, + 'gs' => CodeBlockSpanType.genericStrong, + 'gu' => CodeBlockSpanType.genericSubheading, + 'ges' => CodeBlockSpanType.genericEmphStrong, + 'gt' => CodeBlockSpanType.genericTraceback, + _ => CodeBlockSpanType.unknown, + }; +} diff --git a/lib/model/content.dart b/lib/model/content.dart index 6d70b99b77..fef86a61f9 100644 --- a/lib/model/content.dart +++ b/lib/model/content.dart @@ -2,6 +2,8 @@ import 'package:flutter/foundation.dart'; import 'package:html/dom.dart' as dom; import 'package:html/parser.dart'; +import 'code_block.dart'; + /// A node in a parse tree for Zulip message-style content. /// /// See [ZulipContent]. @@ -255,23 +257,35 @@ class QuotationNode extends BlockContentNode { } class CodeBlockNode extends BlockContentNode { - // TODO(#191) represent the code-highlighting style spans in CodeBlockNode - const CodeBlockNode({super.debugHtmlNode, required this.text}); + const CodeBlockNode(this.spans, {super.debugHtmlNode}); + + final List spans; + + @override + List debugDescribeChildren() { + return spans.map((node) => node.toDiagnosticsNode()).toList(); + } +} + +class CodeBlockSpanNode extends InlineContentNode { + const CodeBlockSpanNode({super.debugHtmlNode, required this.text, required this.type}); final String text; + final CodeBlockSpanType type; @override bool operator ==(Object other) { - return other is CodeBlockNode && other.text == text; + return other is CodeBlockSpanNode && other.text == text && other.type == type; } @override - int get hashCode => Object.hash('CodeBlockNode', text); + int get hashCode => Object.hash('CodeBlockSpanNode', text, type); @override void debugFillProperties(DiagnosticPropertiesBuilder properties) { super.debugFillProperties(properties); properties.add(StringProperty('text', text)); + properties.add(EnumProperty('type', type)); } } @@ -658,27 +672,46 @@ class _ZulipContentParser { return UnimplementedBlockContentNode(htmlNode: divElement); } - final buffer = StringBuffer(); + final spans = []; for (int i = 0; i < mainElement.nodes.length; i++) { final child = mainElement.nodes[i]; - if (child is dom.Text) { - String text = child.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$'), ''); - } - buffer.write(text); - } else if (child is dom.Element && child.localName == 'span') { - // TODO(#191) parse the code-highlighting spans, to style them - buffer.write(child.text); - } else { - return UnimplementedBlockContentNode(htmlNode: divElement); + + 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 classes) + when classes.length == 1: + final CodeBlockSpanType type = codeBlockSpanTypeFromClassName(classes.first); + switch (type) { + case CodeBlockSpanType.unknown: + // 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: type); + } + + default: + return UnimplementedBlockContentNode(htmlNode: divElement); } + + spans.add(span); } - final text = buffer.toString(); - return CodeBlockNode(text: text, debugHtmlNode: debugHtmlNode); + return CodeBlockNode(spans, debugHtmlNode: debugHtmlNode); } BlockContentNode parseImageNode(dom.Element divElement) { diff --git a/lib/widgets/code_block.dart b/lib/widgets/code_block.dart new file mode 100644 index 0000000000..f650a21cc8 --- /dev/null +++ b/lib/widgets/code_block.dart @@ -0,0 +1,265 @@ +import 'package:flutter/material.dart'; + +import '../model/code_block.dart'; + +// Highlighted code block styles adapted from: +// https://github.com/zulip/zulip/blob/213387249e7ba7772084411b22d8cef64b135dd0/web/styles/pygments.css + +// .hll { background-color: hsl(60deg 100% 90%); } +final _kCodeBlockStyleHll = TextStyle(backgroundColor: const HSLColor.fromAHSL(1, 60, 1, 0.90).toColor()); + +// .c { color: hsl(180deg 33% 37%); font-style: italic; } +final _kCodeBlockStyleC = TextStyle(color: const HSLColor.fromAHSL(1, 180, 0.33, 0.37).toColor(), fontStyle: FontStyle.italic); + +// TODO: Borders are hard in TextSpan, see the comment in `_buildInlineCode` +// So, using a lighter background color for now (precisely it's +// the text color used in web app in `.err` class in dark mode) +// +// .err { border: 1px solid hsl(0deg 100% 50%); } +const _kCodeBlockStyleErr = TextStyle(backgroundColor: Color(0xffe2706e)); + +// .k { color: hsl(332deg 70% 38%); } +final _kCodeBlockStyleK = TextStyle(color: const HSLColor.fromAHSL(1, 332, 0.7, 0.38).toColor()); + +// .o { color: hsl(332deg 70% 38%); } +final _kCodeBlockStyleO = TextStyle(color: const HSLColor.fromAHSL(1, 332, 0.7, 0.38).toColor()); + +// .cm { color: hsl(180deg 33% 37%); font-style: italic; } +final _kCodeBlockStyleCm = TextStyle(color: const HSLColor.fromAHSL(1, 180, 0.33, 0.37).toColor(), fontStyle: FontStyle.italic); + +// .cp { color: hsl(38deg 100% 36%); } +final _kCodeBlockStyleCp = TextStyle(color: const HSLColor.fromAHSL(1, 38, 1, 0.36).toColor()); + +// .c1 { color: hsl(0deg 0% 67%); font-style: italic; } +final _kCodeBlockStyleC1 = TextStyle(color: const HSLColor.fromAHSL(1, 0, 0, 0.67).toColor(), fontStyle: FontStyle.italic); + +// .cs { color: hsl(180deg 33% 37%); font-style: italic; } +final _kCodeBlockStyleCs = TextStyle(color: const HSLColor.fromAHSL(1, 180, 0.33, 0.37).toColor(), fontStyle: FontStyle.italic); + +// .gd { color: hsl(0deg 100% 31%); } +final _kCodeBlockStyleGd = TextStyle(color: const HSLColor.fromAHSL(1, 0, 1, 0.31).toColor()); + +// .ge { font-style: italic; } +const _kCodeBlockStyleGe = TextStyle(fontStyle: FontStyle.italic); + +// .gr { color: hsl(0deg 100% 50%); } +final _kCodeBlockStyleGr = TextStyle(color: const HSLColor.fromAHSL(1, 0, 1, 0.50).toColor()); + +// .gh { color: hsl(240deg 100% 25%); font-weight: bold; } +final _kCodeBlockStyleGh = TextStyle(color: const HSLColor.fromAHSL(1, 240, 1, 0.25).toColor(), fontWeight: FontWeight.bold); + +// .gi { color: hsl(120deg 100% 31%); } +final _kCodeBlockStyleGi = TextStyle(color: const HSLColor.fromAHSL(1, 120, 1, 0.31).toColor()); + +// .go { color: hsl(0deg 0% 50%); } +final _kCodeBlockStyleGo = TextStyle(color: const HSLColor.fromAHSL(1, 0, 0, 0.50).toColor()); + +// .gp { color: hsl(240deg 100% 25%); font-weight: bold; } +final _kCodeBlockStyleGp = TextStyle(color: const HSLColor.fromAHSL(1, 240, 1, 0.25).toColor(), fontWeight: FontWeight.bold); + +// .gs { font-weight: bold; } +const _kCodeBlockStyleGs = TextStyle(fontWeight: FontWeight.bold); + +// .gu { color: hsl(300deg 100% 25%); font-weight: bold; } +final _kCodeBlockStyleGu = TextStyle(color: const HSLColor.fromAHSL(1, 300, 1, 0.25).toColor(), fontWeight: FontWeight.bold); + +// .gt { color: hsl(221deg 100% 40%); } +final _kCodeBlockStyleGt = TextStyle(color: const HSLColor.fromAHSL(1, 221, 1, 0.40).toColor()); + +// .kc { color: hsl(332deg 70% 38%); font-weight: bold; } +final _kCodeBlockStyleKc = TextStyle(color: const HSLColor.fromAHSL(1, 332, 0.70, 0.38).toColor(), fontWeight: FontWeight.bold); + +// .kd { color: hsl(332deg 70% 38%); } +final _kCodeBlockStyleKd = TextStyle(color: const HSLColor.fromAHSL(1, 332, 0.70, 0.38).toColor()); + +// .kn { color: hsl(332deg 70% 38%); font-weight: bold; } +final _kCodeBlockStyleKn = TextStyle(color: const HSLColor.fromAHSL(1, 332, 0.70, 0.38).toColor(), fontWeight: FontWeight.bold); + +// .kp { color: hsl(332deg 70% 38%); } +final _kCodeBlockStyleKp = TextStyle(color: const HSLColor.fromAHSL(1, 332, 0.70, 0.38).toColor()); + +// .kr { color: hsl(332deg 70% 38%); font-weight: bold; } +final _kCodeBlockStyleKr = TextStyle(color: const HSLColor.fromAHSL(1, 332, 0.70, 0.38).toColor(), fontWeight: FontWeight.bold); + +// .kt { color: hsl(332deg 70% 38%); } +final _kCodeBlockStyleKt = TextStyle(color: const HSLColor.fromAHSL(1, 332, 0.70, 0.38).toColor()); + +// .m { color: hsl(0deg 0% 40%); } +final _kCodeBlockStyleM = TextStyle(color: const HSLColor.fromAHSL(1, 0, 0, 0.40).toColor()); + +// .s { color: hsl(86deg 57% 40%); } +final _kCodeBlockStyleS = TextStyle(color: const HSLColor.fromAHSL(1, 86, 0.57, 0.40).toColor()); + +// .na { color: hsl(71deg 55% 36%); } +final _kCodeBlockStyleNa = TextStyle(color: const HSLColor.fromAHSL(1, 71, 0.55, 0.36).toColor()); + +// .nb { color: hsl(195deg 100% 35%); } +final _kCodeBlockStyleNb = TextStyle(color: const HSLColor.fromAHSL(1, 195, 1, 0.35).toColor()); + +// .nc { color: hsl(264deg 27% 50%); font-weight: bold; } +final _kCodeBlockStyleNc = TextStyle(color: const HSLColor.fromAHSL(1, 264, 0.27, 0.50).toColor(), fontWeight: FontWeight.bold); + +// .no { color: hsl(0deg 100% 26%); } +final _kCodeBlockStyleNo = TextStyle(color: const HSLColor.fromAHSL(1, 0, 1, 0.26).toColor()); + +// .nd { color: hsl(276deg 100% 56%); } +final _kCodeBlockStyleNd = TextStyle(color: const HSLColor.fromAHSL(1, 276, 1, 0.56).toColor()); + +// .ni { color: hsl(0deg 0% 60%); font-weight: bold; } +final _kCodeBlockStyleNi = TextStyle(color: const HSLColor.fromAHSL(1, 0, 0, 0.60).toColor(), fontWeight: FontWeight.bold); + +// .ne { color: hsl(2deg 62% 52%); font-weight: bold; } +final _kCodeBlockStyleNe = TextStyle(color: const HSLColor.fromAHSL(1, 2, 0.62, 0.52).toColor(), fontWeight: FontWeight.bold); + +// .nf { color: hsl(264deg 27% 50%); } +final _kCodeBlockStyleNf = TextStyle(color: const HSLColor.fromAHSL(1, 264, 0.27, 0.50).toColor()); + +// .nl { color: hsl(60deg 100% 31%); } +final _kCodeBlockStyleNl = TextStyle(color: const HSLColor.fromAHSL(1, 60, 1, 0.31).toColor()); + +// .nn { color: hsl(264deg 27% 50%); font-weight: bold; } +final _kCodeBlockStyleNn = TextStyle(color: const HSLColor.fromAHSL(1, 264, 0.27, 0.50).toColor(), fontWeight: FontWeight.bold); + +// .nt { color: hsl(120deg 100% 25%); font-weight: bold; } +final _kCodeBlockStyleNt = TextStyle(color: const HSLColor.fromAHSL(1, 120, 1, 0.25).toColor(), fontWeight: FontWeight.bold); + +// .nv { color: hsl(241deg 68% 28%); } +final _kCodeBlockStyleNv = TextStyle(color: const HSLColor.fromAHSL(1, 241, 0.68, 0.28).toColor()); + +// .nx { color: hsl(0deg 0% 26%); } +final _kCodeBlockStyleNx = TextStyle(color: const HSLColor.fromAHSL(1, 0, 0, 0.26).toColor()); + +// .ow { color: hsl(276deg 100% 56%); font-weight: bold; } +final _kCodeBlockStyleOw = TextStyle(color: const HSLColor.fromAHSL(1, 276, 1, 0.56).toColor(), fontWeight: FontWeight.bold); + +// .w { color: hsl(0deg 0% 73%); } +final _kCodeBlockStyleW = TextStyle(color: const HSLColor.fromAHSL(1, 0, 0, 0.73).toColor()); + +// .mf { color: hsl(195deg 100% 35%); } +final _kCodeBlockStyleMf = TextStyle(color: const HSLColor.fromAHSL(1, 195, 1, 0.35).toColor()); + +// .mh { color: hsl(195deg 100% 35%); } +final _kCodeBlockStyleMh = TextStyle(color: const HSLColor.fromAHSL(1, 195, 1, 0.35).toColor()); + +// .mi { color: hsl(195deg 100% 35%); } +final _kCodeBlockStyleMi = TextStyle(color: const HSLColor.fromAHSL(1, 195, 1, 0.35).toColor()); + +// .mo { color: hsl(195deg 100% 35%); } +final _kCodeBlockStyleMo = TextStyle(color: const HSLColor.fromAHSL(1, 195, 1, 0.35).toColor()); + +// .sb { color: hsl(86deg 57% 40%); } +final _kCodeBlockStyleSb = TextStyle(color: const HSLColor.fromAHSL(1, 86, 0.57, 0.40).toColor()); + +// .sc { color: hsl(86deg 57% 40%); } +final _kCodeBlockStyleSc = TextStyle(color: const HSLColor.fromAHSL(1, 86, 0.57, 0.40).toColor()); + +// .sd { color: hsl(86deg 57% 40%); font-style: italic; } +final _kCodeBlockStyleSd = TextStyle(color: const HSLColor.fromAHSL(1, 86, 0.57, 0.40).toColor(), fontStyle: FontStyle.italic); + +// .s2 { color: hsl(225deg 71% 33%); } +final _kCodeBlockStyleS2 = TextStyle(color: const HSLColor.fromAHSL(1, 225, 0.71, 0.33).toColor()); + +// .se { color: hsl(26deg 69% 43%); font-weight: bold; } +final _kCodeBlockStyleSe = TextStyle(color: const HSLColor.fromAHSL(1, 26, 0.69, 0.43).toColor(), fontWeight: FontWeight.bold); + +// .sh { color: hsl(86deg 57% 40%); } +final _kCodeBlockStyleSh = TextStyle(color: const HSLColor.fromAHSL(1, 86, 0.57, 0.40).toColor()); + +// .si { color: hsl(336deg 38% 56%); font-weight: bold; } +final _kCodeBlockStyleSi = TextStyle(color: const HSLColor.fromAHSL(1, 336, 0.38, 0.56).toColor(), fontWeight: FontWeight.bold); + +// .sx { color: hsl(120deg 100% 25%); } +final _kCodeBlockStyleSx = TextStyle(color: const HSLColor.fromAHSL(1, 120, 1, 0.25).toColor()); + +// .sr { color: hsl(189deg 54% 49%); } +final _kCodeBlockStyleSr = TextStyle(color: const HSLColor.fromAHSL(1, 189, 0.54, 0.49).toColor()); + +// .s1 { color: hsl(86deg 57% 40%); } +final _kCodeBlockStyleS1 = TextStyle(color: const HSLColor.fromAHSL(1, 86, 0.57, 0.40).toColor()); + +// .ss { color: hsl(241deg 68% 28%); } +final _kCodeBlockStyleSs = TextStyle(color: const HSLColor.fromAHSL(1, 241, 0.68, 0.28).toColor()); + +// .bp { color: hsl(120deg 100% 25%); } +final _kCodeBlockStyleBp = TextStyle(color: const HSLColor.fromAHSL(1, 120, 1, 0.25).toColor()); + +// .vc { color: hsl(241deg 68% 28%); } +final _kCodeBlockStyleVc = TextStyle(color: const HSLColor.fromAHSL(1, 241, 0.68, 0.28).toColor()); + +// .vg { color: hsl(241deg 68% 28%); } +final _kCodeBlockStyleVg = TextStyle(color: const HSLColor.fromAHSL(1, 241, 0.68, 0.28).toColor()); + +// .vi { color: hsl(241deg 68% 28%); } +final _kCodeBlockStyleVi = TextStyle(color: const HSLColor.fromAHSL(1, 241, 0.68, 0.28).toColor()); + +// .il { color: hsl(0deg 0% 40%); } +final _kCodeBlockStyleIl = TextStyle(color: const HSLColor.fromAHSL(1, 0, 0, 0.40).toColor()); + +TextStyle? codeBlockTextStyle(CodeBlockSpanType type) { + return switch (type) { + CodeBlockSpanType.text => null, // A span with type of text is always unstyled. + CodeBlockSpanType.highlightedLines => _kCodeBlockStyleHll, + CodeBlockSpanType.comment => _kCodeBlockStyleC, + CodeBlockSpanType.error => _kCodeBlockStyleErr, + CodeBlockSpanType.keyword => _kCodeBlockStyleK, + CodeBlockSpanType.operator => _kCodeBlockStyleO, + CodeBlockSpanType.commentMultiline => _kCodeBlockStyleCm, + CodeBlockSpanType.commentPreproc => _kCodeBlockStyleCp, + CodeBlockSpanType.commentSingle => _kCodeBlockStyleC1, + CodeBlockSpanType.commentSpecial => _kCodeBlockStyleCs, + CodeBlockSpanType.genericDeleted => _kCodeBlockStyleGd, + CodeBlockSpanType.genericEmph => _kCodeBlockStyleGe, + CodeBlockSpanType.genericError => _kCodeBlockStyleGr, + CodeBlockSpanType.genericHeading => _kCodeBlockStyleGh, + CodeBlockSpanType.genericInserted => _kCodeBlockStyleGi, + CodeBlockSpanType.genericOutput => _kCodeBlockStyleGo, + CodeBlockSpanType.genericPrompt => _kCodeBlockStyleGp, + CodeBlockSpanType.genericStrong => _kCodeBlockStyleGs, + CodeBlockSpanType.genericSubheading => _kCodeBlockStyleGu, + CodeBlockSpanType.genericTraceback => _kCodeBlockStyleGt, + CodeBlockSpanType.keywordConstant => _kCodeBlockStyleKc, + CodeBlockSpanType.keywordDeclaration => _kCodeBlockStyleKd, + CodeBlockSpanType.keywordNamespace => _kCodeBlockStyleKn, + CodeBlockSpanType.keywordPseudo => _kCodeBlockStyleKp, + CodeBlockSpanType.keywordReserved => _kCodeBlockStyleKr, + CodeBlockSpanType.keywordType => _kCodeBlockStyleKt, + CodeBlockSpanType.number => _kCodeBlockStyleM, + CodeBlockSpanType.string => _kCodeBlockStyleS, + CodeBlockSpanType.nameAttribute => _kCodeBlockStyleNa, + CodeBlockSpanType.nameBuiltin => _kCodeBlockStyleNb, + CodeBlockSpanType.nameClass => _kCodeBlockStyleNc, + CodeBlockSpanType.nameConstant => _kCodeBlockStyleNo, + CodeBlockSpanType.nameDecorator => _kCodeBlockStyleNd, + CodeBlockSpanType.nameEntity => _kCodeBlockStyleNi, + CodeBlockSpanType.nameException => _kCodeBlockStyleNe, + CodeBlockSpanType.nameFunction => _kCodeBlockStyleNf, + CodeBlockSpanType.nameLabel => _kCodeBlockStyleNl, + CodeBlockSpanType.nameNamespace => _kCodeBlockStyleNn, + CodeBlockSpanType.nameTag => _kCodeBlockStyleNt, + CodeBlockSpanType.nameVariable => _kCodeBlockStyleNv, + CodeBlockSpanType.nameOther => _kCodeBlockStyleNx, + CodeBlockSpanType.operatorWord => _kCodeBlockStyleOw, + CodeBlockSpanType.whitespace => _kCodeBlockStyleW, + CodeBlockSpanType.numberFloat => _kCodeBlockStyleMf, + CodeBlockSpanType.numberHex => _kCodeBlockStyleMh, + CodeBlockSpanType.numberInteger => _kCodeBlockStyleMi, + CodeBlockSpanType.numberOct => _kCodeBlockStyleMo, + CodeBlockSpanType.stringBacktick => _kCodeBlockStyleSb, + CodeBlockSpanType.stringChar => _kCodeBlockStyleSc, + CodeBlockSpanType.stringDoc => _kCodeBlockStyleSd, + CodeBlockSpanType.stringDouble => _kCodeBlockStyleS2, + CodeBlockSpanType.stringEscape => _kCodeBlockStyleSe, + CodeBlockSpanType.stringHeredoc => _kCodeBlockStyleSh, + CodeBlockSpanType.stringInterpol => _kCodeBlockStyleSi, + CodeBlockSpanType.stringOther => _kCodeBlockStyleSx, + CodeBlockSpanType.stringRegex => _kCodeBlockStyleSr, + CodeBlockSpanType.stringSingle => _kCodeBlockStyleS1, + CodeBlockSpanType.stringSymbol => _kCodeBlockStyleSs, + CodeBlockSpanType.nameBuiltinPseudo => _kCodeBlockStyleBp, + CodeBlockSpanType.nameVariableClass => _kCodeBlockStyleVc, + CodeBlockSpanType.nameVariableGlobal => _kCodeBlockStyleVg, + CodeBlockSpanType.nameVariableInstance => _kCodeBlockStyleVi, + CodeBlockSpanType.numberIntegerLong => _kCodeBlockStyleIl, + _ => null, // not every token is styled + }; +} diff --git a/lib/widgets/content.dart b/lib/widgets/content.dart index 602f9e801c..a61cc0ce4d 100644 --- a/lib/widgets/content.dart +++ b/lib/widgets/content.dart @@ -8,9 +8,10 @@ import '../api/model/model.dart'; import '../model/binding.dart'; import '../model/content.dart'; import '../model/store.dart'; +import 'code_block.dart'; import 'dialog.dart'; -import 'store.dart'; import 'lightbox.dart'; +import 'store.dart'; import 'text.dart'; /// The font size for message content in a plain unstyled paragraph. @@ -255,18 +256,28 @@ class CodeBlock extends StatelessWidget { @override Widget build(BuildContext context) { - final text = node.text; - return Container( - padding: const EdgeInsets.fromLTRB(7, 5, 7, 3), decoration: BoxDecoration( color: Colors.white, border: Border.all( width: 1, - color: const HSLColor.fromAHSL(0.15, 0, 0, 0).toColor())), + color: const HSLColor.fromAHSL(0.15, 0, 0, 0).toColor()), + borderRadius: BorderRadius.circular(4)), child: SingleChildScrollViewWithScrollbar( scrollDirection: Axis.horizontal, - child: Text(text, style: _kCodeBlockStyle))); + child: Padding( + padding: const EdgeInsets.fromLTRB(7, 5, 7, 3), + child: Text.rich(_buildNodes(node.spans))))); + } + + InlineSpan _buildNodes(List nodes) { + return TextSpan( + style: _kCodeBlockStyle, + children: nodes.map(_buildNode).toList(growable: false)); + } + + InlineSpan _buildNode(CodeBlockSpanNode node) { + return TextSpan(text: node.text, style: codeBlockTextStyle(node.type)); } } diff --git a/test/model/content_test.dart b/test/model/content_test.dart index 274496d48e..87ae0300db 100644 --- a/test/model/content_test.dart +++ b/test/model/content_test.dart @@ -1,6 +1,7 @@ import 'package:checks/checks.dart'; import 'package:html/parser.dart'; import 'package:test/scaffolding.dart'; +import 'package:zulip/model/code_block.dart'; import 'package:zulip/model/content.dart'; import 'content_checks.dart'; @@ -292,19 +293,91 @@ void main() { QuotationNode([ParagraphNode(links: null, nodes: [TextNode('words')])]), ]); - testParse('parse code blocks, no language', + testParse('parse code blocks, without syntax highlighting', // "```\nverb\natim\n```" '
verb\natim\n
', const [ - CodeBlockNode(text: 'verb\natim'), + CodeBlockNode([ + CodeBlockSpanNode(text: 'verb\natim', type: CodeBlockSpanType.text), + ]), ]); - testParse('parse code blocks, with highlighted language', + testParse('parse code blocks, with syntax highlighting', // "```dart\nclass A {}\n```" '
'
         'class '
         'A {}'
         '\n
', const [ - CodeBlockNode(text: 'class A {}'), + 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), + ]), + ]); + + testParse('parse code blocks, multiline, with syntax highlighting', + // '```rust\nfn main() {\n print!("Hello ");\n\n print!("world!\\n");\n}\n```' + '
'
+        'fn main'
+        '() {\n'
+        '    print!('
+        '"Hello ");\n\n'
+        '    print!('
+        '"world!\\n"'
+        ');\n}\n'
+        '
', const [ + 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), + ]), + ]); + + testParse('parse code blocks, 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'
+        '
'), + ]); + + testParse('parse code blocks, unknown span type', + // (no markdown; this test is for future Pygments versions adding new token types) + '
'
+        'class'
+        '\n
', [ + blockUnimplemented('
'
+        'class'
+        '\n
'), ]); testParse('parse image', @@ -328,7 +401,9 @@ void main() { ParagraphNode(wasImplicit: true, links: null, nodes: [TextNode('three')]), ]]), ]), - CodeBlockNode(text: 'four'), + CodeBlockNode([ + CodeBlockSpanNode(text: 'four', type: CodeBlockSpanType.text), + ]), ParagraphNode(wasImplicit: true, links: null, nodes: [TextNode('\n\n')]), // TODO avoid this; it renders wrong ]]), ]); diff --git a/test/widgets/content_test.dart b/test/widgets/content_test.dart index 5e9c401cd5..71b39a019a 100644 --- a/test/widgets/content_test.dart +++ b/test/widgets/content_test.dart @@ -18,6 +18,44 @@ import 'dialog_checks.dart'; void main() { TestZulipBinding.ensureInitialized(); + group("CodeBlock", () { + Future prepareContent(WidgetTester tester, String html) async { + await tester.pumpWidget(MaterialApp(home: BlockContentList(nodes: parseContent(html).nodes))); + } + + testWidgets('without syntax highlighting', (WidgetTester tester) async { + // "```\nverb\natim\n```" + await prepareContent(tester, + '
verb\natim\n
'); + tester.widget(find.text('verb\natim')); + }); + + testWidgets('with syntax highlighting', (WidgetTester tester) async { + // "```dart\nclass A {}\n```" + await prepareContent(tester, + '
'
+          'class '
+          'A {}'
+          '\n
'); + tester.widget(find.text('class A {}')); + }); + + testWidgets('multiline, with syntax highlighting', (WidgetTester tester) async { + // '```rust\nfn main() {\n print!("Hello ");\n\n print!("world!\\n");\n}\n```' + await prepareContent(tester, + '
'
+            'fn main'
+            '() {\n'
+            '    print!('
+            '"Hello ");\n\n'
+            '    print!('
+            '"world!\\n"'
+            ');\n}\n'
+            '
'); + tester.widget(find.text('fn main() {\n print!("Hello ");\n\n print!("world!\\n");\n}')); + }); + }); + group('LinkNode interactions', () { const expectedModeAndroid = LaunchMode.externalApplication;