From 331dd7942b233d014025c89020f1799e815b763a Mon Sep 17 00:00:00 2001 From: rajveermalviya Date: Tue, 15 Aug 2023 08:23:52 +0530 Subject: [PATCH 1/3] content: Implement syntax highlighting in CodeBlock - Implement parsing of all the pygments token types that can be emitted by pygments as span classes. - Add styles adapted from web frontend's css for the code block spans. Fixes: #191 --- lib/model/code_block.dart | 259 ++++++++++++++++++++++++++++++++ lib/model/content.dart | 73 ++++++--- lib/widgets/code_block.dart | 265 +++++++++++++++++++++++++++++++++ lib/widgets/content.dart | 17 ++- test/model/content_test.dart | 85 ++++++++++- test/widgets/content_test.dart | 38 +++++ 6 files changed, 708 insertions(+), 29 deletions(-) create mode 100644 lib/model/code_block.dart create mode 100644 lib/widgets/code_block.dart 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..c938d318ca 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,8 +256,6 @@ 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( @@ -266,7 +265,17 @@ class CodeBlock extends StatelessWidget { color: const HSLColor.fromAHSL(0.15, 0, 0, 0).toColor())), child: SingleChildScrollViewWithScrollbar( scrollDirection: Axis.horizontal, - child: Text(text, style: _kCodeBlockStyle))); + 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; From 863adbce4d55b757a8f7ca4cacc57175a394b804 Mon Sep 17 00:00:00 2001 From: rajveermalviya Date: Tue, 15 Aug 2023 08:24:25 +0530 Subject: [PATCH 2/3] content: Match CodeBlock border-radius with web --- lib/widgets/content.dart | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/widgets/content.dart b/lib/widgets/content.dart index c938d318ca..547b991635 100644 --- a/lib/widgets/content.dart +++ b/lib/widgets/content.dart @@ -262,7 +262,8 @@ class CodeBlock extends StatelessWidget { 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.rich(_buildNodes(node.spans)))); From 5e6a0ec4aa1b91298ac4f9b10ae2a0d8d23bd4ea Mon Sep 17 00:00:00 2001 From: rajveermalviya Date: Tue, 15 Aug 2023 08:25:25 +0530 Subject: [PATCH 3/3] content: Move CodeBlock padding to inside scroll view This way, when the text is wide and needs to scroll, the padding scrolls with the text, rather than reducing the size of the viewport. See screenshots: https://github.com/zulip/zulip-flutter/pull/242#discussion_r1285096855 --- lib/widgets/content.dart | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/lib/widgets/content.dart b/lib/widgets/content.dart index 547b991635..a61cc0ce4d 100644 --- a/lib/widgets/content.dart +++ b/lib/widgets/content.dart @@ -257,7 +257,6 @@ class CodeBlock extends StatelessWidget { @override Widget build(BuildContext context) { return Container( - padding: const EdgeInsets.fromLTRB(7, 5, 7, 3), decoration: BoxDecoration( color: Colors.white, border: Border.all( @@ -266,7 +265,9 @@ class CodeBlock extends StatelessWidget { borderRadius: BorderRadius.circular(4)), child: SingleChildScrollViewWithScrollbar( scrollDirection: Axis.horizontal, - child: Text.rich(_buildNodes(node.spans)))); + child: Padding( + padding: const EdgeInsets.fromLTRB(7, 5, 7, 3), + child: Text.rich(_buildNodes(node.spans))))); } InlineSpan _buildNodes(List nodes) {