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 match = RegExp(r'^(\d+(?:\.\d+)?)(px|rem|em|%)$').firstMatch(raw); if (match == null) return null; final value = double.parse(match.group(1)!); final double size; switch (match.group(2)) { case 'px': size = value; case 'rem': size = value * 16; case 'em': size = base * value; case '%': size = base * value / 100; default: return null; } 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. 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(); } if (hex.length == 6) hex = 'ff$hex'; if (hex.length == 8) { final rgba = int.tryParse(hex, radix: 16); if (rgba != null) return Color(rgba); } return null; } 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), };