overhaul ClideMarkdown renderer

Inline grouping collapses consecutive inline nodes (strong, em, code,
a, del) into a single RichText widget instead of rendering each as a
separate block — fixes tight list items like `builtin.git` appearing
on its own line.  HTML entity unescaping moved from pre-parse (useless)
to post-AST extraction where the markdown package actually introduces
the entities.  Explicit Josefin Sans Light font family and 1.25 line
height on all RichText spans.  Body text bumped to 16px; inline code
uses clideFontMono for visual weight parity.

Adds onRecordTap callback — markdown links matching [DQRT]-\d+ render
as tappable WidgetSpans with hover underline.  New typography constants
clideFontSmall (12), clideFontBadge (11), clideLineHeight (1.25).

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-04-23 18:53:18 +02:00
co-authored by Claude
parent 8b93ccd6d6
commit fbcfee20b7
2 changed files with 125 additions and 51 deletions
+122 -51
View File
@@ -3,14 +3,22 @@ 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/clide_tappable.dart';
import 'package:clide/widgets/src/typography.dart';
import 'package:flutter/widgets.dart';
import 'package:markdown/markdown.dart' as md;
typedef RecordTapCallback = void Function(String id);
class ClideMarkdown extends StatelessWidget {
const ClideMarkdown(this.source, {super.key});
const ClideMarkdown(this.source, {super.key, this.onRecordTap});
static const double _fontSize = 16;
static const double _lineHeight = clideLineHeight;
static final _recordPattern = RegExp(r'^[DQRT]-\d+$');
final String source;
final RecordTapCallback? onRecordTap;
static String _unescapeHtml(String s) {
return s
@@ -25,10 +33,9 @@ class ClideMarkdown extends StatelessWidget {
@override
Widget build(BuildContext context) {
final tokens = ClideTheme.of(context).surface;
final cleaned = _unescapeHtml(source);
final doc = md.Document(extensionSet: md.ExtensionSet.gitHubFlavored);
final nodes = doc.parseLines(cleaned.split('\n'));
final widgets = _buildNodes(nodes, tokens);
final nodes = doc.parseLines(source.split('\n'));
final widgets = _buildNodes(nodes, tokens, onRecordTap);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
@@ -36,82 +43,122 @@ class ClideMarkdown extends StatelessWidget {
);
}
static List<Widget> _buildNodes(List<md.Node> nodes, SurfaceTokens tokens) {
static const _inlineTags = {'strong', 'em', 'code', 'a', 'del', 'br', 'img', 'span'};
static bool _isInline(md.Node node) {
if (node is md.Text) return true;
if (node is md.Element) return _inlineTags.contains(node.tag);
return false;
}
static List<Widget> _buildNodes(List<md.Node> nodes, SurfaceTokens tokens, RecordTapCallback? onRecordTap) {
final out = <Widget>[];
final inlineRun = <md.Node>[];
void flushInline() {
if (inlineRun.isEmpty) return;
final spans = <InlineSpan>[];
for (final n in inlineRun) {
if (n is md.Text) {
spans.add(TextSpan(text: _unescapeHtml(n.text)));
} else if (n is md.Element) {
spans.add(_inlineElementSpan(n, tokens, onRecordTap));
}
}
out.add(RichText(
text: TextSpan(
style: TextStyle(
fontFamily: clideUiFamily,
fontFamilyFallback: clideUiFamilyFallback,
fontWeight: clideUiDefaultWeight,
color: tokens.globalForeground,
fontSize: _fontSize,
height: _lineHeight,
),
children: spans,
),
));
inlineRun.clear();
}
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));
if (_isInline(node)) {
inlineRun.add(node);
} else {
flushInline();
if (node is md.Element) {
out.add(_buildElement(node, tokens, onRecordTap));
}
}
}
flushInline();
return out;
}
static Widget _buildElement(md.Element el, SurfaceTokens tokens) {
static Widget _buildElement(md.Element el, SurfaceTokens tokens, RecordTapCallback? onRecordTap) {
switch (el.tag) {
case 'h1':
return Padding(
padding: const EdgeInsets.only(top: 20, bottom: 10),
child: _inlineText(el, tokens, fontSize: 22, fontWeight: FontWeight.w500),
child: _inlineText(el, tokens, onRecordTap, fontSize: 22, fontWeight: FontWeight.w500),
);
case 'h2':
return Padding(
padding: const EdgeInsets.only(top: 18, bottom: 8),
child: _inlineText(el, tokens, fontSize: 18, fontWeight: FontWeight.w500),
padding: const EdgeInsets.only(top: 18, bottom: 10),
child: _inlineText(el, tokens, onRecordTap, fontSize: 18, fontWeight: FontWeight.w500),
);
case 'h3':
return Padding(
padding: const EdgeInsets.only(top: 16, bottom: 6),
child: _inlineText(el, tokens, fontSize: 16, fontWeight: FontWeight.w500),
padding: const EdgeInsets.only(top: 16, bottom: 8),
child: _inlineText(el, tokens, onRecordTap, fontSize: 16, fontWeight: FontWeight.w500),
);
case 'h4':
case 'h5':
case 'h6':
return Padding(
padding: const EdgeInsets.only(top: 14, bottom: 6),
child: _inlineText(el, tokens, fontSize: clideFontBody, fontWeight: FontWeight.w600),
padding: const EdgeInsets.only(top: 14, bottom: 8),
child: _inlineText(el, tokens, onRecordTap, fontSize: clideFontBody, fontWeight: FontWeight.w600),
);
case 'p':
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: _inlineRichText(el, tokens),
padding: const EdgeInsets.only(bottom: 14),
child: _inlineRichText(el, tokens, onRecordTap),
);
case 'ul':
return Padding(
padding: const EdgeInsets.only(left: 8, bottom: 12),
padding: const EdgeInsets.only(left: 2, bottom: 14),
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)],
children: [for (final c in el.children ?? const []) if (c is md.Element) _buildListItem(c, tokens, onRecordTap, ordered: false)],
),
);
case 'ol':
return Padding(
padding: const EdgeInsets.only(left: 8, bottom: 12),
padding: const EdgeInsets.only(left: 2, bottom: 14),
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),
if (el.children![i] is md.Element) _buildListItem(el.children![i] as md.Element, tokens, onRecordTap, ordered: true, index: i + 1),
],
),
);
case 'blockquote':
return Container(
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.only(left: 8),
margin: const EdgeInsets.only(bottom: 14),
padding: const EdgeInsets.only(left: 6),
decoration: BoxDecoration(border: Border(left: BorderSide(color: tokens.globalTextMuted, width: 3))),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: _buildNodes(el.children?.cast<md.Node>() ?? const [], tokens),
children: _buildNodes(el.children?.cast<md.Node>() ?? const [], tokens, onRecordTap),
),
);
case 'pre':
final codeEl = el.children?.whereType<md.Element>().firstOrNull;
final code = codeEl?.textContent ?? el.textContent;
final code = _unescapeHtml(codeEl?.textContent ?? el.textContent);
String? lang;
final cls = codeEl?.attributes['class'];
if (cls != null && cls.startsWith('language-')) {
@@ -126,26 +173,26 @@ class ClideMarkdown extends StatelessWidget {
case 'table':
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: _buildTable(el, tokens),
child: _buildTable(el, tokens, onRecordTap),
);
default:
return _inlineRichText(el, tokens);
return _inlineRichText(el, tokens, onRecordTap);
}
}
static Widget _buildListItem(md.Element el, SurfaceTokens tokens, {bool ordered = false, int index = 1}) {
static Widget _buildListItem(md.Element el, SurfaceTokens tokens, RecordTapCallback? onRecordTap, {bool ordered = false, int index = 1}) {
final bullet = ordered ? '$index. ' : '';
return Padding(
padding: const EdgeInsets.only(bottom: 6),
padding: const EdgeInsets.only(bottom: 10),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ClideText(bullet, color: tokens.globalTextMuted, fontSize: clideFontBody),
ClideText(bullet, color: tokens.globalTextMuted, fontSize: _fontSize),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: _buildNodes(el.children?.cast<md.Node>() ?? const [], tokens),
children: _buildNodes(el.children?.cast<md.Node>() ?? const [], tokens, onRecordTap),
),
),
],
@@ -153,7 +200,7 @@ class ClideMarkdown extends StatelessWidget {
);
}
static Widget _buildTable(md.Element table, SurfaceTokens tokens) {
static Widget _buildTable(md.Element table, SurfaceTokens tokens, RecordTapCallback? onRecordTap) {
final rows = <TableRow>[];
for (final child in table.children ?? const []) {
if (child is! md.Element) continue;
@@ -165,7 +212,7 @@ class ClideMarkdown extends StatelessWidget {
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),
child: _inlineText(cell, tokens, onRecordTap, fontWeight: isHeader ? FontWeight.w600 : null),
));
}
if (cells.isNotEmpty) {
@@ -184,62 +231,86 @@ class ClideMarkdown extends StatelessWidget {
);
}
static Widget _inlineText(md.Element el, SurfaceTokens tokens, {double? fontSize, FontWeight? fontWeight}) {
return RichText(text: _buildInlineSpan(el, tokens, fontSize: fontSize, fontWeight: fontWeight));
static Widget _inlineText(md.Element el, SurfaceTokens tokens, RecordTapCallback? onRecordTap, {double? fontSize, FontWeight? fontWeight}) {
return RichText(text: _buildInlineSpan(el, tokens, onRecordTap, fontSize: fontSize, fontWeight: fontWeight));
}
static Widget _inlineRichText(md.Element el, SurfaceTokens tokens) {
return RichText(text: _buildInlineSpan(el, tokens));
static Widget _inlineRichText(md.Element el, SurfaceTokens tokens, RecordTapCallback? onRecordTap) {
return RichText(text: _buildInlineSpan(el, tokens, onRecordTap));
}
static TextSpan _buildInlineSpan(md.Element el, SurfaceTokens tokens, {double? fontSize, FontWeight? fontWeight}) {
static TextSpan _buildInlineSpan(md.Element el, SurfaceTokens tokens, RecordTapCallback? onRecordTap, {double? fontSize, FontWeight? fontWeight}) {
final children = <InlineSpan>[];
for (final child in el.children ?? const []) {
if (child is md.Text) {
children.add(TextSpan(text: child.text));
children.add(TextSpan(text: _unescapeHtml(child.text)));
} else if (child is md.Element) {
children.add(_inlineElementSpan(child, tokens));
children.add(_inlineElementSpan(child, tokens, onRecordTap));
}
}
return TextSpan(
style: TextStyle(
fontFamily: clideUiFamily,
fontFamilyFallback: clideUiFamilyFallback,
fontWeight: fontWeight ?? clideUiDefaultWeight,
color: tokens.globalForeground,
fontSize: fontSize ?? clideFontBody,
fontWeight: fontWeight,
fontSize: fontSize ?? _fontSize,
height: _lineHeight,
),
children: children,
);
}
static TextSpan _inlineElementSpan(md.Element el, SurfaceTokens tokens) {
static InlineSpan _inlineElementSpan(md.Element el, SurfaceTokens tokens, RecordTapCallback? onRecordTap) {
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)],
children: [for (final c in el.children ?? const []) if (c is md.Text) TextSpan(text: _unescapeHtml(c.text)) else if (c is md.Element) _inlineElementSpan(c, tokens, onRecordTap)],
);
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)],
children: [for (final c in el.children ?? const []) if (c is md.Text) TextSpan(text: _unescapeHtml(c.text)) else if (c is md.Element) _inlineElementSpan(c, tokens, onRecordTap)],
);
case 'code':
return TextSpan(
text: el.textContent,
text: _unescapeHtml(el.textContent),
style: TextStyle(fontFamily: clideMonoFamily, fontSize: clideFontMono, color: tokens.syntaxString, backgroundColor: tokens.panelBackground),
);
case 'a':
final text = _unescapeHtml(el.textContent);
if (onRecordTap != null && _recordPattern.hasMatch(text)) {
return WidgetSpan(
alignment: PlaceholderAlignment.baseline,
baseline: TextBaseline.alphabetic,
child: ClideTappable(
onTap: () => onRecordTap(text),
builder: (_, hovered, __) => Text(
text,
style: TextStyle(
color: tokens.globalFocus,
fontSize: _fontSize,
height: _lineHeight,
fontFamily: clideMonoFamily,
decoration: hovered ? TextDecoration.underline : null,
decorationColor: tokens.globalFocus,
),
),
),
);
}
return TextSpan(
text: el.textContent,
text: text,
style: TextStyle(color: tokens.globalFocus),
);
case 'del':
return TextSpan(
text: el.textContent,
text: _unescapeHtml(el.textContent),
style: TextStyle(decoration: TextDecoration.lineThrough, color: tokens.globalTextMuted),
);
default:
return TextSpan(text: el.textContent);
return TextSpan(text: _unescapeHtml(el.textContent));
}
}
}
+3
View File
@@ -56,6 +56,9 @@ const String clideMonoFamily = 'JetBrainsMono';
const double clideFontBody = 15;
const double clideFontCaption = 14;
const double clideFontMono = 14;
const double clideFontSmall = 12;
const double clideFontBadge = 11;
const double clideLineHeight = 1.25;
/// System fallback chain. Ordered by platform prevalence + quality of
/// programming-ligature / box-drawing coverage.