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, required this.onCaretTapped, this.fontSize = 22, }); final ExprDoc doc; /// 光标或内容变化后调用,让页面刷新。 final VoidCallback onChanged; /// 用户点了**光标已经所在的那个位置**(且没有选区)—— 页面据此弹出"粘贴"。 final VoidCallback onCaretTapped; final double fontSize; /// 点一个节点。 /// /// 三种语义: /// · 有选区时 → 扩展选区("按住选中一个,再点另一个") /// · 没选区、但点的是光标已经待着的地方 → 交给页面弹粘贴 /// · 其余 → 移动光标 void _tapNode(SlotPath parent, int index, bool before) { if (doc.selection != null) { doc.extendSelectionTo(parent, index); onChanged(); return; } final target = SlotPath(parent.steps, before ? index : index + 1); if (doc.cursor == target) { onCaretTapped(); return; } doc.cursor = target; onChanged(); } /// 长按一个节点 → 选中它,工具条随之出现。 void _longPressNode(SlotPath parent, int index) { doc.selectNodeAt(parent, index); onChanged(); } /// 点槽的空白处:有选区就取消选区,否则光标落到槽尾。 void _tapSlot(SlotPath path, SeqNode seq) { if (doc.selection != null) { doc.clearSelection(); onChanged(); return; } final p = SlotPath(path.steps, seq.children.length); if (doc.cursor == p) { onCaretTapped(); 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, onTapNode: _tapNode, onLongPressNode: _longPressNode, onTapSlot: _tapSlot, ), ), ), ), ); } } /// 一个槽的视图:把孩子按顺序排开,在光标位置插一根竖线。 class _SlotView extends StatelessWidget { const _SlotView({ required this.doc, required this.seq, required this.path, required this.size, required this.onTapNode, required this.onLongPressNode, required this.onTapSlot, this.vpad = 3, }); final ExprDoc doc; final SeqNode seq; final SlotPath path; final double size; /// 上下内边距。上下限这种要紧贴运算符的地方传 0。 final double vpad; final void Function(SlotPath parent, int index, bool before) onTapNode; final void Function(SlotPath parent, int index) onLongPressNode; final void Function(SlotPath path, SeqNode seq) onTapSlot; @override Widget build(BuildContext context) { final scheme = Theme.of(context).colorScheme; // 用 sameSlotAs 而不是 ==:offset 是槽内的插入位置,不属于槽的身份 final focused = doc.cursor.sameSlotAs(path); final sel = doc.selection; final selectedHere = sel != null && !sel.isEmpty && sel.path.sameSlotAs(path); return GestureDetector( onTap: () => onTapSlot(path, seq), behavior: HitTestBehavior.opaque, child: Container( padding: EdgeInsets.symmetric(horizontal: 2, vertical: vpad), // 空槽只有一个光标那么宽(2.5px),手指点不到 —— 而"点光标处弹粘贴" // 恰恰最常在空槽里用(先按 ln 再粘参数)。给个最小的可点面积。 constraints: focused && seq.isEmpty ? const BoxConstraints(minWidth: 24, minHeight: 30) : null, 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), _SelectedOrNot( highlighted: selectedHere && i >= sel.start && i < sel.end, child: _NodeView( doc: doc, node: seq.children[i], parent: path, index: i, size: size, onTapNode: onTapNode, onLongPressNode: onLongPressNode, onTapSlot: onTapSlot, ), ), ], 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.onTapNode, required this.onLongPressNode, required this.onTapSlot, }); final ExprDoc doc; final ExprNode node; /// 该节点所在槽的路径,以及它在槽里的下标。 final SlotPath parent; final int index; final double size; final void Function(SlotPath parent, int index, bool before) onTapNode; final void Function(SlotPath parent, int index) onLongPressNode; final void Function(SlotPath path, SeqNode seq) onTapSlot; @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), BraceNode b => _brace(context, b), BigOpNode b => _bigOp(context, b), 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, onLongPress: () => onLongPressNode(parent, index), onTapDown: (d) { final box = inner.findRenderObject() as RenderBox?; final before = d.localPosition.dx < (box?.size.width ?? 0) / 2; onTapNode(parent, index, before); }, child: body, ), ); } Widget _literal(LitNode l, ColorScheme scheme) { // 有显示名的(如 ANS)不加斜体 —— 它是缩写不是变量 final isLetter = l.display == null && RegExp(r'^[A-Za-z]+$').hasMatch(l.text); return Text( l.display ?? l.text, style: TextStyle( fontSize: size, fontFamily: 'serif', fontStyle: isLetter ? FontStyle.italic : FontStyle.normal, color: scheme.onSurface, ), ); } /// 常见函数用**数学记号**显示,而不是代码式的 `Name[...]`。 /// /// 关键点:这里只改**怎么画**,不改槽本身 —— 参数槽还是原来那些, /// 所以照样能点进去编辑、光标照样能进。 Widget _function(BuildContext context, FuncNode f) { switch (f.name) { case 'Sqrt': if (f.args.length == 1) return _radical(context, f); case 'Abs': if (f.args.length == 1) return _bars(context, f); case 'Integrate': if (f.args.length == 2) return _integral(context, f); case 'Sum': if (f.args.length == 2) return _bigOperator(context, f, '∑'); case 'Product': if (f.args.length == 2) return _bigOperator(context, f, '∏'); } return _plainFunction(context, f); } TextStyle _mathStyle(BuildContext context, double scale) => TextStyle( fontSize: size * scale, color: Theme.of(context).colorScheme.onSurface, ); /// √ 盖住被开方数。 /// /// 用画笔画成**一条连起来的折线**(下折 → 上挑 → 横线),而不是 /// "√ 字符 + 一条上边框" —— 后者两边各按各的字形对齐,中间必然断开 /// (用户实测反馈"根号是断开的")。 Widget _radical(BuildContext context, FuncNode f) => _Radical( size: size, color: Theme.of(context).colorScheme.onSurface, child: _slot(context, f.args.first, parent.push(index, 0)), ); Widget _bars(BuildContext context, FuncNode f) => _row([ Text('|', style: _mathStyle(context, 1.2)), _slot(context, f.args.first, parent.push(index, 0)), Text('|', style: _mathStyle(context, 1.2)), ]); /// `Integrate[f, x]` 画成 `∫ f dx`。 /// /// 参数顺序被重排了(数学写法就是 ∫ … d…),但**槽还是那两个槽**, /// 所以光标和点击命中都不受影响。 Widget _integral(BuildContext context, FuncNode f) => _row([ Text('∫', style: _mathStyle(context, 1.6)), _slot(context, f.args[0], parent.push(index, 0)), Text('d', style: _mathStyle(context, 0.9)), _slot(context, f.args[1], parent.push(index, 1)), ]); /// `Sum[f, {n,1,∞}]` 画成 `Σ f {n,1,∞}`(迭代器小一号)。 Widget _bigOperator(BuildContext context, FuncNode f, String glyph) => _row([ Text(glyph, style: _mathStyle(context, 1.6)), _slot(context, f.args[0], parent.push(index, 0)), Transform.translate( offset: Offset(0, size * 0.25), child: _slot(context, f.args[1], parent.push(index, 1), scale: 0.7), ), ]); Widget _plainFunction(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 _brace(BuildContext context, BraceNode b) { final style = TextStyle( fontSize: size, color: Theme.of(context).colorScheme.onSurface, ); return _row([ Text('{', style: style), _slot(context, b.inner, parent.push(index, 0)), Text('}', style: style), ]); } /// 大运算符:上下限画在**习惯位置**。 /// /// ∫ 上限定在上方、下限定在下方,后面接被积函数和 dx /// ∑ / ∏ 同理,下限在下、上限在上 /// lim 的条件写在 "lim" 正下方 /// /// 上下限为空时不占位(不定积分就是"∫ f dx",没有上下限)。 Widget _bigOp(BuildContext context, BigOpNode b) { final body = _slot(context, b.body, parent.push(index, 0)); final variable = _slot(context, b.variable, parent.push(index, 1)); // 上下限紧贴运算符:槽的上下内边距收掉,再往中间拽几像素 // 下限**不**上拽。字形在行框里的墨迹并不上下居中(∑ 偏上、基线以下留白多), // 上下对称地拽同样的量,上限正好、下限就压到字形上了。 final lower = _slot(context, b.lower, parent.push(index, 2), scale: 0.5, vpad: 0); final upper = Transform.translate( offset: const Offset(0, 3), child: _slot(context, b.upper, parent.push(index, 3), scale: 0.5, vpad: 0), ); if (b.kind == 'Limit') { // lim 下面写"x→0" // 三个元素**必须同一个字号**:框大小不同时居中对齐会让基线错开, // 横着的箭头就没法同时正对两端的 x 和数字。 final under = Row( mainAxisSize: MainAxisSize.min, children: [ _slot(context, b.variable, parent.push(index, 1), scale: 0.6, vpad: 0), Text('→', style: _mathStyle(context, 0.6).copyWith(height: 1.0)), _slot(context, b.lower, parent.push(index, 2), scale: 0.6, vpad: 0), ], ); return _row([ Column( mainAxisSize: MainAxisSize.min, children: [ Text('lim', style: _mathStyle(context, 0.95).copyWith(height: 1.0)), Transform.translate(offset: const Offset(0, -2), child: under), ], ), body, ]); } final glyph = switch (b.kind) { 'Integrate' => '∫', 'Sum' => '∑', 'Product' => '∏', _ => '∑', }; // height: 1.0 把字体自带的行距空白压掉 —— 默认行高约 1.16em, // 37 号字上下各多出好几个像素,看着就是"离运算符很远"。 final stack = Column( mainAxisSize: MainAxisSize.min, children: [ // 上下限**始终渲染** —— 空的时候画成小占位框。 // 不渲染的话不定积分的两个限槽就成了够不着的死结构,永远填不进去。 upper, Text(glyph, style: _mathStyle(context, 1.7).copyWith(height: 1.0)), lower, ], ); if (b.kind == 'Integrate') { return _row([ stack, body, Text('d', style: _mathStyle(context, 0.9)), variable, ]); } return _row([stack, body]); } Widget _row(List children) => Wrap( crossAxisAlignment: WrapCrossAlignment.center, children: children, ); /// [scale] 是**真的把字号改小**,而不是 Transform 缩放。 /// /// Transform 只改视觉、不改布局,占位仍是原尺寸 —— 结果就是"字看着小了 /// 但离运算符老远"(用户实测反馈)。直接把 size 传下去,布局才会跟着缩。 Widget _slot(BuildContext context, SeqNode seq, SlotPath path, {double scale = 1, double vpad = 3}) => _SlotView( doc: doc, seq: seq, path: path, size: size * scale, vpad: vpad, onTapNode: onTapNode, onLongPressNode: onLongPressNode, onTapSlot: onTapSlot, ); Widget _text(String t, double s) => Text(t, style: TextStyle(fontSize: s)); } /// 根号:画笔画出的折线 + 上划线,被开方数挂在横线下面。 class _Radical extends StatelessWidget { const _Radical({ required this.size, required this.color, required this.child, }); final double size; final Color color; final Widget child; @override Widget build(BuildContext context) { final arm = size * 0.42; // 左侧那一段"折"的宽度 return CustomPaint( painter: _RadicalPainter(color, arm), child: Padding( padding: EdgeInsets.only(left: arm, top: size * 0.30), child: child, ), ); } } class _RadicalPainter extends CustomPainter { _RadicalPainter(this.color, this.arm); final Color color; final double arm; @override void paint(Canvas canvas, Size s) { final paint = Paint() ..color = color ..strokeWidth = 1.4 ..style = PaintingStyle.stroke ..strokeJoin = StrokeJoin.miter ..strokeCap = StrokeCap.butt; // 从左下起笔 → 折到底 → 挑到顶 → 横着盖住被开方数 final path = Path() ..moveTo(0, s.height * 0.55) ..lineTo(arm * 0.4, s.height * 0.92) ..lineTo(arm, 0) ..lineTo(s.width, 0); canvas.drawPath(path, paint); } @override bool shouldRepaint(covariant _RadicalPainter old) => old.color != color || old.arm != arm; } /// 选中时给节点盖一层高亮背景。 class _SelectedOrNot extends StatelessWidget { const _SelectedOrNot({required this.highlighted, required this.child}); final bool highlighted; final Widget child; @override Widget build(BuildContext context) { if (!highlighted) return child; return Container( decoration: BoxDecoration( color: Theme.of(context).colorScheme.primary.withValues(alpha: 0.22), borderRadius: BorderRadius.circular(3), ), child: child, ); } }