From 227d787bc68422b0f5ea7df94515d685a1e46082 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Thu, 23 Apr 2026 17:36:40 +0200 Subject: [PATCH] add ClideMarkdown, ClideCodeBlock, ClideSvgView primitives Three reusable rendering widgets in lib/widgets/: - ClideMarkdown: walks markdown AST (GFM tables, task lists, fenced code), renders headings, lists, blockquotes, tables, inline bold/italic/code/links. Delegates code blocks to ClideCodeBlock. - ClideCodeBlock: syntax-highlighted code via TreeSitterService. Async highlight, byte-to-char offset mapping, theme-aware colors from SurfaceTokens. - ClideSvgView: wraps jovial_svg with asset/string constructors and optional sizing. Retrofitted: decision detail body renders as markdown instead of raw text. Markdown viewer uses ClideMarkdown. Welcome logo uses ClideSvgView. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../decisions/src/decision_detail_view.dart | 10 +- lib/builtin/markdown/src/markdown_viewer.dart | 6 +- lib/builtin/welcome/src/welcome_view.dart | 10 +- lib/widgets/src/clide_code_block.dart | 133 ++++++++++ lib/widgets/src/clide_markdown.dart | 234 ++++++++++++++++++ lib/widgets/src/clide_svg_view.dart | 33 +++ lib/widgets/widgets.dart | 3 + 7 files changed, 406 insertions(+), 23 deletions(-) create mode 100644 lib/widgets/src/clide_code_block.dart create mode 100644 lib/widgets/src/clide_markdown.dart create mode 100644 lib/widgets/src/clide_svg_view.dart diff --git a/lib/builtin/decisions/src/decision_detail_view.dart b/lib/builtin/decisions/src/decision_detail_view.dart index 62ec350f..597e077b 100644 --- a/lib/builtin/decisions/src/decision_detail_view.dart +++ b/lib/builtin/decisions/src/decision_detail_view.dart @@ -108,15 +108,7 @@ class _DecisionDetailViewState extends State { ), if (body != null && body.isNotEmpty) ...[ const SizedBox(height: 12), - Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: tokens.panelBackground, - borderRadius: BorderRadius.circular(4), - border: Border.all(color: tokens.panelBorder), - ), - child: ClideText(body, fontSize: 13, fontFamily: clideMonoFamily), - ), + ClideMarkdown(body), ], if (refs.isNotEmpty) ...[ const SizedBox(height: 16), diff --git a/lib/builtin/markdown/src/markdown_viewer.dart b/lib/builtin/markdown/src/markdown_viewer.dart index 19ccea07..39f19710 100644 --- a/lib/builtin/markdown/src/markdown_viewer.dart +++ b/lib/builtin/markdown/src/markdown_viewer.dart @@ -78,16 +78,12 @@ class _MarkdownViewerState extends State { child: ClideText('Open a .md file to preview it here.', muted: true), ); } - final tokens = ClideTheme.of(context).surface; return ClidePaneChrome( title: _path ?? 'viewer', subtitle: '${_content!.split('\n').length} lines', child: SingleChildScrollView( padding: const EdgeInsets.all(12), - child: Text( - _content!, - style: TextStyle(color: tokens.globalForeground, fontSize: 13, fontFamily: clideMonoFamily, fontFamilyFallback: clideMonoFamilyFallback), - ), + child: ClideMarkdown(_content!), ), ); } diff --git a/lib/builtin/welcome/src/welcome_view.dart b/lib/builtin/welcome/src/welcome_view.dart index 87b27e61..c458b2de 100644 --- a/lib/builtin/welcome/src/welcome_view.dart +++ b/lib/builtin/welcome/src/welcome_view.dart @@ -2,9 +2,7 @@ import 'dart:async'; import 'package:clide/kernel/kernel.dart'; import 'package:clide/widgets/widgets.dart'; -import 'package:flutter/services.dart' show rootBundle; import 'package:flutter/widgets.dart'; -import 'package:jovial_svg/jovial_svg.dart'; class WelcomeView extends StatelessWidget { const WelcomeView({super.key}); @@ -56,13 +54,7 @@ class _Header extends StatelessWidget { return Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ - SizedBox( - width: 144, - height: 144, - child: ScalableImageWidget.fromSISource( - si: ScalableImageSource.fromSvg(rootBundle, 'assets/logo/logo.svg'), - ), - ), + const ClideSvgView.asset('assets/logo/logo.svg', width: 144, height: 144), const SizedBox(width: 24), Column( crossAxisAlignment: CrossAxisAlignment.start, diff --git a/lib/widgets/src/clide_code_block.dart b/lib/widgets/src/clide_code_block.dart new file mode 100644 index 00000000..7ca8d08d --- /dev/null +++ b/lib/widgets/src/clide_code_block.dart @@ -0,0 +1,133 @@ +import 'dart:convert'; + +import 'package:clide/kernel/src/syntax/tree_sitter_service.dart'; +import 'package:clide/kernel/src/theme/controller.dart'; +import 'package:clide/widgets/src/typography.dart'; +import 'package:flutter/widgets.dart'; + +class ClideCodeBlock extends StatefulWidget { + const ClideCodeBlock({super.key, required this.source, this.language}); + + final String source; + final String? language; + + @override + State createState() => _ClideCodeBlockState(); +} + +class _ClideCodeBlockState extends State { + final TreeSitterService _syntax = TreeSitterService(); + List? _spans; + + @override + void initState() { + super.initState(); + _highlight(); + } + + @override + void didUpdateWidget(ClideCodeBlock old) { + super.didUpdateWidget(old); + if (old.source != widget.source || old.language != widget.language) { + _highlight(); + } + } + + @override + void dispose() { + _syntax.dispose(); + super.dispose(); + } + + Future _highlight() async { + final lang = widget.language; + if (lang == null || lang.isEmpty) { + setState(() => _spans = null); + return; + } + final path = 'code.$lang'; + if (!await _syntax.hasGrammar(path)) { + setState(() => _spans = null); + return; + } + final result = await _syntax.highlight(path, widget.source); + if (mounted) setState(() => _spans = result.spans); + } + + @override + Widget build(BuildContext context) { + final tokens = ClideTheme.of(context).surface; + final style = TextStyle( + fontFamily: clideMonoFamily, + fontFamilyFallback: clideMonoFamilyFallback, + fontSize: clideFontMono, + color: tokens.globalForeground, + ); + + final spans = _spans; + TextSpan textSpan; + + if (spans == null || spans.isEmpty) { + textSpan = TextSpan(text: widget.source, style: style); + } else { + textSpan = _buildHighlightedSpan(widget.source, spans, style, tokens); + } + + return Container( + width: double.infinity, + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: tokens.panelBackground, + borderRadius: BorderRadius.circular(4), + border: Border.all(color: tokens.panelBorder), + ), + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: RichText(text: textSpan), + ), + ); + } + + static TextSpan _buildHighlightedSpan(String source, List spans, TextStyle base, dynamic tokens) { + final bytes = utf8.encode(source); + final byteToChar = List.filled(bytes.length + 1, source.length); + var bi = 0; + for (var ci = 0; ci < source.length; ci++) { + byteToChar[bi] = ci; + final rune = source.codeUnitAt(ci); + if (rune < 0x80) { + bi += 1; + } else if (rune < 0x800) { + bi += 2; + } else if (rune >= 0xD800 && rune <= 0xDBFF) { + bi += 4; + ci++; + } else { + bi += 3; + } + } + byteToChar[bi] = source.length; + + final sorted = List.of(spans)..sort((a, b) => a.start.compareTo(b.start)); + final children = []; + var lastChar = 0; + + for (final span in sorted) { + final sChar = span.start < byteToChar.length ? byteToChar[span.start] : source.length; + final eChar = span.end < byteToChar.length ? byteToChar[span.end] : source.length; + if (sChar > lastChar) { + children.add(TextSpan(text: source.substring(lastChar, sChar))); + } + if (eChar > sChar) { + final color = TreeSitterService.colorForRole(span.role, tokens); + children.add(TextSpan(text: source.substring(sChar, eChar), style: base.copyWith(color: color))); + } + lastChar = eChar; + } + if (lastChar < source.length) { + children.add(TextSpan(text: source.substring(lastChar))); + } + + return TextSpan(style: base, children: children); + } +} diff --git a/lib/widgets/src/clide_markdown.dart b/lib/widgets/src/clide_markdown.dart new file mode 100644 index 00000000..4daaeb72 --- /dev/null +++ b/lib/widgets/src/clide_markdown.dart @@ -0,0 +1,234 @@ +import 'package:clide/kernel/src/theme/controller.dart'; +import 'package:clide/kernel/src/theme/tokens.dart'; +import 'package:clide/widgets/src/clide_code_block.dart'; +import 'package:clide/widgets/src/clide_divider.dart'; +import 'package:clide/widgets/src/clide_text.dart'; +import 'package:clide/widgets/src/typography.dart'; +import 'package:flutter/widgets.dart'; +import 'package:markdown/markdown.dart' as md; + +class ClideMarkdown extends StatelessWidget { + const ClideMarkdown(this.source, {super.key}); + + final String source; + + @override + Widget build(BuildContext context) { + final tokens = ClideTheme.of(context).surface; + final doc = md.Document(extensionSet: md.ExtensionSet.gitHubFlavored); + final nodes = doc.parseLines(source.split('\n')); + final widgets = _buildNodes(nodes, tokens); + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: widgets, + ); + } + + static List _buildNodes(List nodes, SurfaceTokens tokens) { + final out = []; + for (final node in nodes) { + if (node is md.Element) { + out.add(_buildElement(node, tokens)); + } else if (node is md.Text) { + out.add(ClideText(node.text, fontSize: clideFontBody)); + } + } + return out; + } + + static Widget _buildElement(md.Element el, SurfaceTokens tokens) { + switch (el.tag) { + case 'h1': + return Padding( + padding: const EdgeInsets.only(top: 16, bottom: 8), + child: _inlineText(el, tokens, fontSize: 22, fontWeight: FontWeight.w500), + ); + case 'h2': + return Padding( + padding: const EdgeInsets.only(top: 14, bottom: 6), + child: _inlineText(el, tokens, fontSize: 18, fontWeight: FontWeight.w500), + ); + case 'h3': + return Padding( + padding: const EdgeInsets.only(top: 12, bottom: 4), + child: _inlineText(el, tokens, fontSize: 16, fontWeight: FontWeight.w500), + ); + case 'h4': + case 'h5': + case 'h6': + return Padding( + padding: const EdgeInsets.only(top: 10, bottom: 4), + child: _inlineText(el, tokens, fontSize: clideFontBody, fontWeight: FontWeight.w600), + ); + case 'p': + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: _inlineRichText(el, tokens), + ); + case 'ul': + return Padding( + padding: const EdgeInsets.only(left: 16, bottom: 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [for (final c in el.children ?? const []) if (c is md.Element) _buildListItem(c, tokens, ordered: false)], + ), + ); + case 'ol': + return Padding( + padding: const EdgeInsets.only(left: 16, bottom: 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + for (var i = 0; i < (el.children?.length ?? 0); i++) + if (el.children![i] is md.Element) _buildListItem(el.children![i] as md.Element, tokens, ordered: true, index: i + 1), + ], + ), + ); + case 'blockquote': + return Container( + margin: const EdgeInsets.only(bottom: 8), + padding: const EdgeInsets.only(left: 12), + decoration: BoxDecoration(border: Border(left: BorderSide(color: tokens.globalTextMuted, width: 3))), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: _buildNodes(el.children?.cast() ?? const [], tokens), + ), + ); + case 'pre': + final codeEl = el.children?.whereType().firstOrNull; + final code = codeEl?.textContent ?? el.textContent; + String? lang; + final cls = codeEl?.attributes['class']; + if (cls != null && cls.startsWith('language-')) { + lang = cls.substring(9); + } + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: ClideCodeBlock(source: code, language: lang), + ); + case 'hr': + return Padding(padding: const EdgeInsets.symmetric(vertical: 8), child: ClideDivider()); + case 'table': + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: _buildTable(el, tokens), + ); + default: + return _inlineRichText(el, tokens); + } + } + + static Widget _buildListItem(md.Element el, SurfaceTokens tokens, {bool ordered = false, int index = 1}) { + final bullet = ordered ? '$index. ' : '• '; + return Padding( + padding: const EdgeInsets.only(bottom: 2), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ClideText(bullet, color: tokens.globalTextMuted, fontSize: clideFontBody), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: _buildNodes(el.children?.cast() ?? const [], tokens), + ), + ), + ], + ), + ); + } + + static Widget _buildTable(md.Element table, SurfaceTokens tokens) { + final rows = []; + for (final child in table.children ?? const []) { + if (child is! md.Element) continue; + for (final row in child.children ?? const []) { + if (row is! md.Element) continue; + final cells = []; + final isHeader = row.tag == 'tr' && (child.tag == 'thead'); + for (final cell in row.children ?? const []) { + if (cell is! md.Element) continue; + cells.add(Padding( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 3), + child: _inlineText(cell, tokens, fontWeight: isHeader ? FontWeight.w600 : null), + )); + } + if (cells.isNotEmpty) { + rows.add(TableRow( + decoration: isHeader ? BoxDecoration(border: Border(bottom: BorderSide(color: tokens.dividerColor))) : null, + children: cells, + )); + } + } + } + if (rows.isEmpty) return const SizedBox.shrink(); + return Table( + border: TableBorder.all(color: tokens.panelBorder, width: 1), + defaultVerticalAlignment: TableCellVerticalAlignment.top, + children: rows, + ); + } + + static Widget _inlineText(md.Element el, SurfaceTokens tokens, {double? fontSize, FontWeight? fontWeight}) { + return RichText(text: _buildInlineSpan(el, tokens, fontSize: fontSize, fontWeight: fontWeight)); + } + + static Widget _inlineRichText(md.Element el, SurfaceTokens tokens) { + return RichText(text: _buildInlineSpan(el, tokens)); + } + + static TextSpan _buildInlineSpan(md.Element el, SurfaceTokens tokens, {double? fontSize, FontWeight? fontWeight}) { + final children = []; + for (final child in el.children ?? const []) { + if (child is md.Text) { + children.add(TextSpan(text: child.text)); + } else if (child is md.Element) { + children.add(_inlineElementSpan(child, tokens)); + } + } + return TextSpan( + style: TextStyle( + color: tokens.globalForeground, + fontSize: fontSize ?? clideFontBody, + fontWeight: fontWeight, + ), + children: children, + ); + } + + static TextSpan _inlineElementSpan(md.Element el, SurfaceTokens tokens) { + switch (el.tag) { + case 'strong': + return TextSpan( + style: const TextStyle(fontWeight: FontWeight.w700), + children: [for (final c in el.children ?? const []) if (c is md.Text) TextSpan(text: c.text) else if (c is md.Element) _inlineElementSpan(c, tokens)], + ); + case 'em': + return TextSpan( + style: const TextStyle(fontStyle: FontStyle.italic), + children: [for (final c in el.children ?? const []) if (c is md.Text) TextSpan(text: c.text) else if (c is md.Element) _inlineElementSpan(c, tokens)], + ); + case 'code': + return TextSpan( + text: el.textContent, + style: TextStyle(fontFamily: clideMonoFamily, fontSize: clideFontMono, color: tokens.syntaxString, backgroundColor: tokens.panelBackground), + ); + case 'a': + return TextSpan( + text: el.textContent, + style: TextStyle(color: tokens.globalFocus), + ); + case 'del': + return TextSpan( + text: el.textContent, + style: TextStyle(decoration: TextDecoration.lineThrough, color: tokens.globalTextMuted), + ); + default: + return TextSpan(text: el.textContent); + } + } +} diff --git a/lib/widgets/src/clide_svg_view.dart b/lib/widgets/src/clide_svg_view.dart new file mode 100644 index 00000000..122ef889 --- /dev/null +++ b/lib/widgets/src/clide_svg_view.dart @@ -0,0 +1,33 @@ +import 'package:flutter/services.dart' show rootBundle; +import 'package:flutter/widgets.dart'; +import 'package:jovial_svg/jovial_svg.dart'; + +class ClideSvgView extends StatelessWidget { + const ClideSvgView.asset(this.assetPath, {super.key, this.width, this.height}) : svgString = null; + const ClideSvgView.string(this.svgString, {super.key, this.width, this.height}) : assetPath = null; + + final String? assetPath; + final String? svgString; + final double? width; + final double? height; + + @override + Widget build(BuildContext context) { + Widget child; + if (assetPath != null) { + child = ScalableImageWidget.fromSISource( + si: ScalableImageSource.fromSvg(rootBundle, assetPath!), + ); + } else if (svgString != null) { + final si = ScalableImage.fromSvgString(svgString!); + child = ScalableImageWidget(si: si); + } else { + return const SizedBox.shrink(); + } + + if (width != null || height != null) { + child = SizedBox(width: width, height: height, child: child); + } + return child; + } +} diff --git a/lib/widgets/widgets.dart b/lib/widgets/widgets.dart index 0c2566a1..836a1faf 100644 --- a/lib/widgets/widgets.dart +++ b/lib/widgets/widgets.dart @@ -7,8 +7,11 @@ library; export 'src/clide_button.dart'; export 'src/clide_column_hat.dart'; +export 'src/clide_code_block.dart'; export 'src/clide_divider.dart'; export 'src/clide_filter_box.dart'; +export 'src/clide_markdown.dart'; +export 'src/clide_svg_view.dart'; export 'src/clide_icon.dart'; export 'src/clide_icon_rail.dart'; export 'src/clide_palette.dart';