| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567 |
- 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,
- });
- final ExprDoc doc;
- final SeqNode seq;
- final SlotPath path;
- 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;
- // 用 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: const EdgeInsets.symmetric(horizontal: 2, vertical: 3),
- // 空槽只有一个光标那么宽(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) {
- 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,
- ),
- );
- }
- /// 常见函数用**数学记号**显示,而不是代码式的 `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);
- final upper = _slot(context, b.upper, parent.push(index, 3), scale: 0.5);
- if (b.kind == 'Limit') {
- // lim 下面写"x→0"
- final under = Row(
- mainAxisSize: MainAxisSize.min,
- children: [
- _slot(context, b.variable, parent.push(index, 1), scale: 0.6),
- Text('→', style: _mathStyle(context, 0.6)),
- lower,
- ],
- );
- return _row([
- Column(
- mainAxisSize: MainAxisSize.min,
- children: [
- Text('lim', style: _mathStyle(context, 0.95)),
- under,
- ],
- ),
- body,
- ]);
- }
- final glyph = switch (b.kind) {
- 'Integrate' => '∫',
- 'Sum' => '∑',
- 'Product' => '∏',
- _ => '∑',
- };
- final stack = Column(
- mainAxisSize: MainAxisSize.min,
- children: [
- // 上下限**始终渲染** —— 空的时候画成小占位框。
- // 不渲染的话不定积分的两个限槽就成了够不着的死结构,永远填不进去。
- upper,
- Text(glyph, style: _mathStyle(context, 1.7)),
- lower,
- ],
- );
- if (b.kind == 'Integrate') {
- return _row([
- stack,
- body,
- Text('d', style: _mathStyle(context, 0.9)),
- variable,
- ]);
- }
- return _row([stack, body]);
- }
- Widget _row(List<Widget> children) => Wrap(
- crossAxisAlignment: WrapCrossAlignment.center,
- children: children,
- );
- /// [scale] 是**真的把字号改小**,而不是 Transform 缩放。
- ///
- /// Transform 只改视觉、不改布局,占位仍是原尺寸 —— 结果就是"字看着小了
- /// 但离运算符老远"(用户实测反馈)。直接把 size 传下去,布局才会跟着缩。
- Widget _slot(BuildContext context, SeqNode seq, SlotPath path,
- {double scale = 1}) =>
- _SlotView(
- doc: doc,
- seq: seq,
- path: path,
- size: size * scale,
- 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,
- );
- }
- }
|