import 'package:flutter/material.dart'; import 'expr_node.dart'; /// 结构化表达式的 2D 编辑器视图。 /// /// 渲染方式:直接把节点树翻译成 widget 树 —— 分式用 Column(分子/横线/分母)、 /// 幂用 Row + 抬高的上标、函数用 `名字[ 参数 ]`。没有自己写排版引擎, /// 因为 Flutter 的布局本来就够用,而且这样点击命中、无障碍都能白拿。 class ExprEditor extends StatelessWidget { const ExprEditor({ super.key, required this.doc, required this.onChanged, this.fontSize = 22, }); final ExprDoc doc; /// 光标或内容变化后调用,让页面刷新。 final VoidCallback onChanged; final double fontSize; void _setCursor(BuildContext context, SlotPath p) { if (doc.cursor == p) return; doc.cursor = p; onChanged(); } @override Widget build(BuildContext context) { return SingleChildScrollView( scrollDirection: Axis.horizontal, reverse: true, // 长表达式总是把光标一侧露出来 child: ConstrainedBox( constraints: BoxConstraints( minWidth: MediaQuery.sizeOf(context).width - 32, ), child: Align( alignment: Alignment.centerRight, child: Padding( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), child: _SlotView( doc: doc, seq: doc.root, path: SlotPath.root, size: fontSize, onCursor: (p) => _setCursor(context, p), ), ), ), ), ); } } /// 一个槽的视图:把孩子按顺序排开,在光标位置插一根竖线。 class _SlotView extends StatelessWidget { const _SlotView({ required this.doc, required this.seq, required this.path, required this.size, required this.onCursor, }); final ExprDoc doc; final SeqNode seq; final SlotPath path; final double size; final void Function(SlotPath) onCursor; @override Widget build(BuildContext context) { final scheme = Theme.of(context).colorScheme; // 用 sameSlotAs 而不是 ==:offset 是槽内的插入位置,不属于槽的身份 final focused = doc.cursor.sameSlotAs(path); return GestureDetector( // 点槽的空白处 → 光标落到槽尾 onTap: () => onCursor(SlotPath(path.steps, seq.children.length)), behavior: HitTestBehavior.opaque, child: Container( padding: const EdgeInsets.symmetric(horizontal: 2, vertical: 3), decoration: BoxDecoration( borderRadius: BorderRadius.circular(4), color: focused ? scheme.primary.withValues(alpha: 0.06) : null, ), child: Wrap( crossAxisAlignment: WrapCrossAlignment.center, children: [ for (var i = 0; i < seq.children.length; i++) ...[ if (focused && doc.cursor.offset == i) _caret(scheme), _NodeView( doc: doc, node: seq.children[i], parent: path, index: i, size: size, onCursor: onCursor, ), ], if (focused && doc.cursor.offset >= seq.children.length) _caret(scheme), // 空槽给个占位,否则用户不知道能往里点 if (seq.isEmpty && !focused) _placeholder(scheme), ], ), ), ); } /// 光标。**常亮、不闪烁**。 /// /// 之前做成 0.15~1.0 的平滑呼吸,最暗那半程看着就跟没有一样(用户反馈 /// "有时能看到有时看不到")。计算器不是文本编辑器,聚焦的槽本来就有底色 /// 高亮,这里再常亮一根实心竖线,位置一眼就能看到。 Widget _caret(ColorScheme scheme) => Container( // 整个编辑区同时最多只有一根光标,所以固定 key 不会冲突。 // 加它是为了能写"打完字光标还在不在"的回归测试 —— 这个 bug // 之前就是靠人眼看截图才发现的。 key: const ValueKey('expr-caret'), width: 2.5, height: size * 1.25, margin: const EdgeInsets.symmetric(horizontal: 1), color: scheme.primary, ); Widget _placeholder(ColorScheme scheme) => Container( width: 12, height: size * 1.05, margin: const EdgeInsets.symmetric(horizontal: 1), decoration: BoxDecoration( border: Border.all(color: scheme.outlineVariant, width: 1), borderRadius: BorderRadius.circular(2), ), ); } /// 单个节点的视图。 class _NodeView extends StatelessWidget { const _NodeView({ required this.doc, required this.node, required this.parent, required this.index, required this.size, required this.onCursor, }); final ExprDoc doc; final ExprNode node; /// 该节点所在槽的路径,以及它在槽里的下标。 final SlotPath parent; final int index; final double size; final void Function(SlotPath) onCursor; @override Widget build(BuildContext context) { final scheme = Theme.of(context).colorScheme; final body = switch (node) { LitNode l => _literal(l, scheme), FuncNode f => _function(context, f), FracNode f => _fraction(context, f), PowNode p => _power(context, p), GroupNode g => _group(context, g), PostfixNode p => _row([ _slot(context, p.operand, parent.push(index, 0)), _text(p.op, size), ]), }; // 点节点的**左半边**把光标放到它前面,右半边放到它后面。 // // 之前无论点哪都落到"后面",于是偏移 0(表达式最前面)那个位置永远够不着 —— // 用户只能把光标停在第一个字符之后,回不到行首。 // // 宽度用 Builder 拿:Builder 自身不是 RenderObject,findRenderObject() // 会往下找到 GestureDetector 那个 box,尺寸正是节点的尺寸。 return Builder( builder: (inner) => GestureDetector( behavior: HitTestBehavior.opaque, onTapDown: (d) { final box = inner.findRenderObject() as RenderBox?; final before = d.localPosition.dx < (box?.size.width ?? 0) / 2; onCursor(SlotPath(parent.steps, before ? index : index + 1)); }, child: body, ), ); } Widget _literal(LitNode l, ColorScheme scheme) { final isLetter = RegExp(r'^[A-Za-z]+$').hasMatch(l.text); return Text( l.text, style: TextStyle( fontSize: size, fontFamily: 'serif', fontStyle: isLetter ? FontStyle.italic : FontStyle.normal, color: scheme.onSurface, ), ); } Widget _function(BuildContext context, FuncNode f) { final scheme = Theme.of(context).colorScheme; return _row([ Text( f.name, style: TextStyle(fontSize: size * 0.85, color: scheme.primary), ), Text('[', style: TextStyle(fontSize: size, color: scheme.onSurface)), for (var i = 0; i < f.args.length; i++) ...[ if (i > 0) Text(', ', style: TextStyle(fontSize: size, color: scheme.onSurface)), _slot(context, f.args[i], parent.push(index, i)), ], Text(']', style: TextStyle(fontSize: size, color: scheme.onSurface)), ]); } Widget _fraction(BuildContext context, FracNode f) { final scheme = Theme.of(context).colorScheme; return Padding( padding: const EdgeInsets.symmetric(horizontal: 2), // IntrinsicWidth 让横线刚好横跨分子分母里较宽的那个。 // 不能直接 width: double.infinity —— 这里是 Wrap 里的无界宽度,会报错。 child: IntrinsicWidth( child: Column( mainAxisSize: MainAxisSize.min, children: [ _slot(context, f.num, parent.push(index, 0)), Container(height: 1.4, color: scheme.onSurface), _slot(context, f.den, parent.push(index, 1)), ], ), ), ); } Widget _power(BuildContext context, PowNode p) { return _row([ _slot(context, p.base, parent.push(index, 0)), Transform.translate( offset: Offset(0, -size * 0.35), child: _slot(context, p.exp, parent.push(index, 1), scale: 0.7), ), ]); } Widget _group(BuildContext context, GroupNode g) { final scheme = Theme.of(context).colorScheme; final style = TextStyle(fontSize: size, color: scheme.onSurface); return _row([ Text('(', style: style), _slot(context, g.inner, parent.push(index, 0)), Text(')', style: style), ]); } Widget _row(List children) => Wrap( crossAxisAlignment: WrapCrossAlignment.center, children: children, ); Widget _slot(BuildContext context, SeqNode seq, SlotPath path, {double scale = 1}) => _ScaledSlot( scale: scale, child: _SlotView( doc: doc, seq: seq, path: path, size: size, onCursor: onCursor, ), ); Widget _text(String t, double s) => Text(t, style: TextStyle(fontSize: s)); } /// 给槽套一个缩放(指数要用小一号字)。 class _ScaledSlot extends StatelessWidget { const _ScaledSlot({required this.scale, required this.child}); final double scale; final Widget child; @override Widget build(BuildContext context) { if (scale == 1) return child; // 用 Transform 缩放而不是改字号:字号变了槽的布局参数也得跟着变, // 缩放一行搞定且视觉上等价。 return Transform.scale( scale: scale, alignment: Alignment.bottomLeft, child: child, ); } }