import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'pm_node.dart'; import 'pm_render_scope.dart'; /// Renders a run of inline nodes ([PmText] / [PmHardBreak]) as a single /// `Text.rich`, composing marks into `TextStyle`s. /// /// Link marks need a [TapGestureRecognizer], which must be disposed to avoid /// leaks. The recognizers are therefore built in [didChangeDependencies] / /// [didUpdateWidget] and released in [dispose] — never allocated inside /// [build]. class PmRichText extends StatefulWidget { final List inlines; final TextStyle baseStyle; final TextAlign textAlign; const PmRichText({ required this.inlines, required this.baseStyle, this.textAlign = TextAlign.start, super.key, }); @override State createState() => _PmRichTextState(); } class _PmRichTextState extends State { final List _recognizers = []; InlineSpan _span = const TextSpan(); @override void didChangeDependencies() { super.didChangeDependencies(); _rebuild(); } @override void didUpdateWidget(PmRichText oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.inlines != widget.inlines || oldWidget.baseStyle != widget.baseStyle || oldWidget.textAlign != widget.textAlign) { _rebuild(); } } @override void dispose() { _disposeRecognizers(); super.dispose(); } void _disposeRecognizers() { for (final recognizer in _recognizers) { recognizer.dispose(); } _recognizers.clear(); } void _rebuild() { _disposeRecognizers(); final theme = Theme.of(context); final onLinkTap = PmRenderScope.maybeOf(context)?.onLinkTap; final spans = []; for (final node in widget.inlines) { if (node is PmHardBreak) { spans.add(const TextSpan(text: '\n')); } else if (node is PmText) { spans.add(_textSpan(node, theme, onLinkTap)); } } _span = TextSpan(style: widget.baseStyle, children: spans); } TextSpan _textSpan( PmText node, ThemeData theme, void Function(String href)? onLinkTap, ) { String? href; final style = _styleForMarks( node.marks, widget.baseStyle, theme, onLink: (value) => href = value, ); TapGestureRecognizer? recognizer; if (href != null && onLinkTap != null) { final target = href!; recognizer = TapGestureRecognizer()..onTap = () => onLinkTap(target); _recognizers.add(recognizer); } return TextSpan(text: node.text, style: style, recognizer: recognizer); } TextStyle _styleForMarks( List marks, TextStyle base, ThemeData theme, { required void Function(String href) onLink, }) { var style = base; final decorations = []; for (final mark in marks) { switch (mark.type) { case 'bold': style = style.copyWith(fontWeight: FontWeight.bold); case 'italic': style = style.copyWith(fontStyle: FontStyle.italic); case 'strike': decorations.add(TextDecoration.lineThrough); case 'underline': decorations.add(TextDecoration.underline); case 'code': style = style.copyWith( fontFamily: 'monospace', backgroundColor: theme.colorScheme.surfaceContainerHighest, ); case 'highlight': style = style.copyWith( backgroundColor: _highlightColor(mark.attrs['color'], theme), ); case 'textStyle': final size = _fontSize(mark.attrs['fontSize'], base.fontSize ?? 14); if (size != null) style = style.copyWith(fontSize: size); case 'link': final rawHref = mark.attrs['href']; if (rawHref is String && rawHref.isNotEmpty) { onLink(rawHref); style = style.copyWith(color: theme.colorScheme.primary); decorations.add(TextDecoration.underline); } } } if (decorations.isNotEmpty) { style = style.copyWith(decoration: TextDecoration.combine(decorations)); } return style; } Color _highlightColor(dynamic color, ThemeData theme) { final parsed = color is String ? parseCssColor(color) : null; final base = parsed ?? theme.colorScheme.tertiaryContainer; // Full-opacity highlights swamp the text in dark mode, so dial the alpha // down further there while keeping the accent readable in light mode. final dark = theme.brightness == Brightness.dark; return base.withValues(alpha: dark ? 0.30 : 0.45); } double? _fontSize(dynamic raw, double base) { if (raw is! String) return null; final length = parsePmCssLength(raw); if (length == null) return null; final value = length.value; final size = switch (length.unit) { 'px' => value, 'rem' => value * 16, 'em' => base * value, _ => base * value / 100, }; return size.clamp(8, 72); } @override Widget build(BuildContext context) { final softWrap = PmCellTextFlow.softWrapOf(context); return Text.rich( _span, textAlign: widget.textAlign, softWrap: softWrap, overflow: softWrap ? TextOverflow.clip : TextOverflow.visible, ); } } /// Parses a CSS `#rgb`/`#rrggbb`/`#rrggbbaa` hex or a small set of named colors. /// Returns `null` for anything unrecognised so the caller can fall back. @visibleForTesting Color? parseCssColor(String raw) { final value = raw.trim().toLowerCase(); if (value.startsWith('#')) { var hex = value.substring(1); if (hex.length == 3) { hex = hex.split('').map((c) => '$c$c').join(); } // CSS puts alpha last, Flutter's Color expects it first. final argb = switch (hex.length) { 6 => 'ff$hex', 8 => hex.substring(6) + hex.substring(0, 6), _ => null, }; final parsed = argb == null ? null : int.tryParse(argb, radix: 16); return parsed == null ? null : Color(parsed); } return _namedColors[value]; } const _namedColors = { 'red': Color(0xFFEF4444), 'orange': Color(0xFFF97316), 'yellow': Color(0xFFFACC15), 'green': Color(0xFF22C55E), 'blue': Color(0xFF3B82F6), 'purple': Color(0xFFA855F7), 'pink': Color(0xFFEC4899), 'gray': Color(0xFF9CA3AF), 'grey': Color(0xFF9CA3AF), };