Просмотр исходного кода

计算器改用结构化节点输入

Q28b 的第二阶段:表达式不再是字符串,而是一棵节点树。

为什么值得单独做一套:纯文本光标表达不了多参数结构。用户按了 ∫ 之后光标
该放哪?按 ^ 之后指数往哪写?字符串只能靠"插入 Integrate[, x] 再把光标
往回退 4 位"这种把戏,模板一复杂就散架,分数和上下标更是根本没法表达。

新增:
- lib/expr_node.dart  节点模型(字面量/函数/分式/幂/括号/后缀)、光标路径、
  编辑操作、序列化成 Symja 语法
- lib/expr_editor.dart 2D 渲染:分式竖排、指数上标、函数方括号、空槽占位、
  闪烁光标、点节点定位光标

手感上专门处理的几处(都有测试钉住):
- 数字键并入左边那个数字字面量,`42` 是一个数不是两个节点
- 退格对数字是**逐位删**(计算器直觉),不是整个数清掉
- 左边是**没填完的模板**就钻进去删,不是一按就整块没了
- 左边是**填完的模板**才整个删掉 —— 否则光标会永远在模板里打转、删不掉它
- `^` / `/` / `√` 把光标左边那个节点收成结构的一部分(x 再按 ^ 就是 x 的幂)

显示区同时露出**序列化后的 Symja 表达式**,用户能看到"到底会被求值成什么",
而不是猜。空槽序列化成 Null,求值会明确报错而不是静默算错。

纯文本输入保留在控制台(长表达式、粘贴还是文本方便)。

56 个测试通过(15 语法转换 + 25 节点模型 + 16 界面)。真机验证:∫ 模板的
空槽高亮可填、x^2 上标渲染、1/3 竖排分式、按 = 得 x^3/3。

Co-Authored-By: Claude Code <noreply@anthropic.com>
nonvioce 4 недель назад
Родитель
Сommit
5b87192789
5 измененных файлов с 1151 добавлено и 174 удалено
  1. 160 161
      lib/calculator_page.dart
  2. 322 0
      lib/expr_editor.dart
  3. 345 0
      lib/expr_node.dart
  4. 258 0
      test/expr_node_test.dart
  5. 66 13
      test/widget_test.dart

+ 160 - 161
lib/calculator_page.dart

@@ -5,14 +5,16 @@ import 'package:flutter_math_fork/flutter_math.dart';
 import 'package:provider/provider.dart';
 import 'package:provider/provider.dart';
 
 
 import 'curve_plot.dart';
 import 'curve_plot.dart';
+import 'expr_editor.dart';
+import 'expr_node.dart';
 import 'symja.dart';
 import 'symja.dart';
 import 'symja_functions.dart';
 import 'symja_functions.dart';
 
 
 /// 科学计算器(默认主界面)。
 /// 科学计算器(默认主界面)。
 ///
 ///
-/// 表达式用**字符串**表示,按键在光标处插入 —— 这是 Q28b 定的第一阶段。
-/// 结构化节点输入(节点树 / 槽 / 选区)是后续独立的升级,但下游接口不变:
-/// 求值、历史、双界面看到的始终是一个表达式字符串。
+/// 输入用**结构化节点树**(见 lib/expr_node.dart),不是字符串:
+/// 按键把节点插进当前光标槽,分数竖排、指数上标、多参数函数各有自己的槽。
+/// 纯文本输入留给控制台。
 class CalculatorPage extends StatefulWidget {
 class CalculatorPage extends StatefulWidget {
   const CalculatorPage({super.key});
   const CalculatorPage({super.key});
 
 
@@ -21,13 +23,9 @@ class CalculatorPage extends StatefulWidget {
 }
 }
 
 
 class _CalculatorPageState extends State<CalculatorPage> {
 class _CalculatorPageState extends State<CalculatorPage> {
-  final _ctrl = TextEditingController();
-  final _focus = FocusNode();
+  final _doc = ExprDoc();
 
 
-  /// 边输入边算的结果(不算历史、不动 `%`)。
   SymjaResult? _preview;
   SymjaResult? _preview;
-
-  /// 按 = 提交后的结果。
   SymjaResult? _committed;
   SymjaResult? _committed;
   String? _error;
   String? _error;
 
 
@@ -37,66 +35,26 @@ class _CalculatorPageState extends State<CalculatorPage> {
   @override
   @override
   void dispose() {
   void dispose() {
     _debounce?.cancel();
     _debounce?.cancel();
-    _ctrl.dispose();
-    _focus.dispose();
     super.dispose();
     super.dispose();
   }
   }
 
 
-  // ------------------------------------------------------------------ 表达式编辑
-
-  /// 在光标处插入 [text],并把光标往回退 [back] 位(用于 `Integrate[□, x]` 这类模板)。
-  void _insert(String text, {int back = 0}) {
-    final v = _ctrl.value;
-    final sel = v.selection;
-    final start = sel.isValid ? sel.start : v.text.length;
-    final end = sel.isValid ? sel.end : v.text.length;
-
-    final newText = v.text.replaceRange(start, end, text);
-    _ctrl.value = TextEditingValue(
-      text: newText,
-      selection:
-          TextSelection.collapsed(offset: start + text.length - back),
-    );
-    _onChanged();
-  }
-
-  void _backspace() {
-    final v = _ctrl.value;
-    final sel = v.selection;
-    final start = sel.isValid ? sel.start : v.text.length;
-    final end = sel.isValid ? sel.end : v.text.length;
-    if (start == end && start == 0) return;
-    final from = start == end ? start - 1 : start;
-    final newText = v.text.replaceRange(from, end, '');
-    _ctrl.value = TextEditingValue(
-      text: newText,
-      selection: TextSelection.collapsed(offset: from),
-    );
-    _onChanged();
-  }
-
-  void _clear() {
-    _ctrl.clear();
-    setState(() {
-      _preview = null;
-      _committed = null;
-      _error = null;
-    });
-  }
+  // ------------------------------------------------------------------ 求值
 
 
-  void _onChanged() {
+  /// 编辑器每次变化都走这里:清掉上次的提交态,防抖后重算预览。
+  void _onDocChanged() {
     setState(() {
     setState(() {
       _committed = null;
       _committed = null;
       _error = null;
       _error = null;
     });
     });
     _debounce?.cancel();
     _debounce?.cancel();
-    // 防抖:按键连打时不要每个字符都过一次通道
     _debounce = Timer(const Duration(milliseconds: 500), _runPreview);
     _debounce = Timer(const Duration(milliseconds: 500), _runPreview);
   }
   }
 
 
+  String get _source => _doc.toSymja();
+
   Future<void> _runPreview() async {
   Future<void> _runPreview() async {
-    final expr = _ctrl.text.trim();
-    if (expr.isEmpty) {
+    final expr = _source;
+    if (expr.isEmpty || expr == 'Null') {
       if (mounted) setState(() => _preview = null);
       if (mounted) setState(() => _preview = null);
       return;
       return;
     }
     }
@@ -107,11 +65,9 @@ class _CalculatorPageState extends State<CalculatorPage> {
     setState(() => _preview = r);
     setState(() => _preview = r);
   }
   }
 
 
-  // ------------------------------------------------------------------ 提交
-
   Future<void> _commit() async {
   Future<void> _commit() async {
-    final expr = _ctrl.text.trim();
-    if (expr.isEmpty) return;
+    final expr = _source;
+    if (expr.isEmpty || expr == 'Null') return;
     _debounce?.cancel();
     _debounce?.cancel();
     final session = context.read<SymjaSession>();
     final session = context.read<SymjaSession>();
     final id = ++_seq; // 让在途的预览作废
     final id = ++_seq; // 让在途的预览作废
@@ -134,10 +90,9 @@ class _CalculatorPageState extends State<CalculatorPage> {
     await session.refreshVariables();
     await session.refreshVariables();
   }
   }
 
 
-  /// 把当前表达式在 x ∈ [-6.3, 6.3] 上采样并画出来。
   Future<void> _plotCurrent() async {
   Future<void> _plotCurrent() async {
-    final expr = _ctrl.text.trim();
-    if (expr.isEmpty) return;
+    final expr = _source;
+    if (expr.isEmpty || expr == 'Null') return;
     final session = context.read<SymjaSession>();
     final session = context.read<SymjaSession>();
     final messenger = ScaffoldMessenger.of(context);
     final messenger = ScaffoldMessenger.of(context);
     try {
     try {
@@ -174,16 +129,64 @@ class _CalculatorPageState extends State<CalculatorPage> {
     }
     }
   }
   }
 
 
+  // ------------------------------------------------------------------ 按键
+
+  void _press(_Key k) {
+    switch (k.act) {
+      case _Act.char:
+        if (k.mergeDigit) {
+          // 数字键并入左边那个数字字面量,`42` 才是一个数而不是两个节点
+          _doc.insertChar(k.payload);
+        } else {
+          _doc.insert(LitNode(k.payload));
+        }
+      case _Act.fn:
+        _doc.makeFunction(k.payload);
+      case _Act.fnPrefixed:
+        // 像 Integrate 这种,第一个参数是要填的,后面预置好
+        _doc.makeFunction(k.payload, prefill: k.prefill);
+        // prefill 之后光标停在第一个槽(空的),正是要填的位置
+      case _Act.sqrt:
+        _doc.makeSqrt();
+      case _Act.group:
+        _doc.makeGroup();
+      case _Act.power:
+        _doc.makePower();
+      case _Act.frac:
+        _doc.makeFraction();
+      case _Act.postfix:
+        // 阶乘是后缀:把光标左边那个节点收进来当操作数
+        final operand = _doc.takeLeft();
+        _doc.insert(PostfixNode(
+          operand: SeqNode(operand == null ? null : [operand]),
+        ));
+      case _Act.comma:
+        _doc.nextArgument();
+      case _Act.back:
+        _doc.backspace();
+      case _Act.clear:
+        _doc.clear();
+      case _Act.commit:
+        _commit();
+        return; // 提交不改文档,不用刷新
+    }
+    _onDocChanged();
+  }
+
   Future<void> _showPalette() async {
   Future<void> _showPalette() async {
-    // 面板返回「要插入什么」+「光标从末尾回退几位」:
-    // 函数是 `Sin[]` 退 1(光标在括号里),常量是 `Pi` 退 0。
-    final picked = await showModalBottomSheet<({String text, int back})>(
+    final picked = await showModalBottomSheet<({String value, bool isFn})>(
       context: context,
       context: context,
       isScrollControlled: true,
       isScrollControlled: true,
       showDragHandle: true,
       showDragHandle: true,
       builder: (_) => const _FunctionPalette(),
       builder: (_) => const _FunctionPalette(),
     );
     );
-    if (picked != null) _insert(picked.text, back: picked.back);
+    if (picked == null) return;
+    if (picked.isFn) {
+      _doc.makeFunction(picked.value);
+    } else {
+      _doc.insert(LitNode(picked.value));
+    }
+    _onDocChanged();
   }
   }
 
 
   // ------------------------------------------------------------------ UI
   // ------------------------------------------------------------------ UI
@@ -207,7 +210,10 @@ class _CalculatorPageState extends State<CalculatorPage> {
           IconButton(
           IconButton(
             tooltip: '清空',
             tooltip: '清空',
             icon: const Icon(Icons.refresh),
             icon: const Icon(Icons.refresh),
-            onPressed: _clear,
+            onPressed: () {
+              _doc.clear();
+              _onDocChanged();
+            },
           ),
           ),
         ],
         ],
       ),
       ),
@@ -229,29 +235,27 @@ class _CalculatorPageState extends State<CalculatorPage> {
 
 
     return Container(
     return Container(
       width: double.infinity,
       width: double.infinity,
-      padding: const EdgeInsets.fromLTRB(16, 12, 16, 12),
+      constraints: const BoxConstraints(minHeight: 132),
+      padding: const EdgeInsets.fromLTRB(12, 10, 12, 10),
       color: scheme.surfaceContainerLow,
       color: scheme.surfaceContainerLow,
       child: Column(
       child: Column(
         crossAxisAlignment: CrossAxisAlignment.end,
         crossAxisAlignment: CrossAxisAlignment.end,
         children: [
         children: [
-          TextField(
-            controller: _ctrl,
-            focusNode: _focus,
-            maxLines: 3,
-            minLines: 1,
+          ExprEditor(doc: _doc, onChanged: _onDocChanged),
+          const SizedBox(height: 6),
+          // 序列化结果摆在明面上:用户能看到"到底会被求值成什么"
+          Text(
+            _source,
             textAlign: TextAlign.right,
             textAlign: TextAlign.right,
-            style: const TextStyle(fontFamily: 'monospace', fontSize: 18),
-            decoration: const InputDecoration(
-              isDense: true,
-              border: InputBorder.none,
-              hintText: '按下面的键,或点这里用键盘输入',
+            style: TextStyle(
+              fontFamily: 'monospace',
+              fontSize: 11,
+              color: scheme.outline,
             ),
             ),
-            onChanged: (_) => _onChanged(),
-            onSubmitted: (_) => _commit(),
           ),
           ),
-          const SizedBox(height: 8),
+          const SizedBox(height: 6),
           SizedBox(
           SizedBox(
-            height: 56,
+            height: 52,
             child: Align(
             child: Align(
               alignment: Alignment.centerRight,
               alignment: Alignment.centerRight,
               child: SingleChildScrollView(
               child: SingleChildScrollView(
@@ -279,12 +283,7 @@ class _CalculatorPageState extends State<CalculatorPage> {
         ),
         ),
       );
       );
     }
     }
-    if (shown == null) {
-      return Text(
-        _ctrl.text.trim().isEmpty ? '' : '…',
-        style: TextStyle(fontSize: 22, color: scheme.outline),
-      );
-    }
+    if (shown == null) return const SizedBox.shrink();
 
 
     final plain = SelectableText(
     final plain = SelectableText(
       shown.text,
       shown.text,
@@ -318,65 +317,62 @@ class _CalculatorPageState extends State<CalculatorPage> {
   Widget _keypad(BuildContext context) {
   Widget _keypad(BuildContext context) {
     return LayoutBuilder(
     return LayoutBuilder(
       builder: (context, box) {
       builder: (context, box) {
-        const rows = <List<_Key>>[
+        final rows = <List<_Key>>[
           [
           [
-            _Key('∫', insert: 'Integrate[', after: ', x]'),
-            _Key('Σ', insert: 'Sum[', after: ', {n, 1, Infinity}]'),
-            _Key('d/dx', insert: 'D[', after: ', x]'),
-            _Key('lim', insert: 'Limit[', after: ', x->0]'),
-            _Key('solve', insert: 'Solve[', after: ', x]'),
+            _Key('∫', _Act.fnPrefixed, payload: 'Integrate', prefill: ['', 'x']),
+            _Key('Σ', _Act.fnPrefixed,
+                payload: 'Sum', prefill: ['', '{n, 1, Infinity}']),
+            _Key('d/dx', _Act.fnPrefixed, payload: 'D', prefill: ['', 'x']),
+            _Key('lim', _Act.fnPrefixed, payload: 'Limit', prefill: ['', 'x->0']),
+            _Key('solve', _Act.fnPrefixed, payload: 'Solve', prefill: ['', 'x']),
           ],
           ],
           [
           [
-            _Key('sin', insert: 'Sin[', after: ']'),
-            _Key('cos', insert: 'Cos[', after: ']'),
-            _Key('tan', insert: 'Tan[', after: ']'),
-            _Key('ln', insert: 'Log[', after: ']'),
-            _Key('√', insert: 'Sqrt[', after: ']'),
+            _Key('sin', _Act.fn, payload: 'Sin'),
+            _Key('cos', _Act.fn, payload: 'Cos'),
+            _Key('tan', _Act.fn, payload: 'Tan'),
+            _Key('ln', _Act.fn, payload: 'Log'),
+            _Key('√', _Act.sqrt),
           ],
           ],
           [
           [
-            _Key('7', insert: '7'),
-            _Key('8', insert: '8'),
-            _Key('9', insert: '9'),
-            _Key('(', insert: '('),
-            _Key(')', insert: ')'),
+            _Key('7', _Act.char, payload: '7', mergeDigit: true),
+            _Key('8', _Act.char, payload: '8', mergeDigit: true),
+            _Key('9', _Act.char, payload: '9', mergeDigit: true),
+            _Key('(', _Act.group),
+            _Key('/', _Act.frac),
           ],
           ],
           [
           [
-            _Key('4', insert: '4'),
-            _Key('5', insert: '5'),
-            _Key('6', insert: '6'),
-            _Key('×', insert: '*'),
-            _Key('÷', insert: '/'),
+            _Key('4', _Act.char, payload: '4', mergeDigit: true),
+            _Key('5', _Act.char, payload: '5', mergeDigit: true),
+            _Key('6', _Act.char, payload: '6', mergeDigit: true),
+            _Key('×', _Act.char, payload: '*'),
+            _Key('!', _Act.postfix),
           ],
           ],
           [
           [
-            _Key('1', insert: '1'),
-            _Key('2', insert: '2'),
-            _Key('3', insert: '3'),
-            _Key('+', insert: '+'),
-            _Key('−', insert: '-'),
+            _Key('1', _Act.char, payload: '1', mergeDigit: true),
+            _Key('2', _Act.char, payload: '2', mergeDigit: true),
+            _Key('3', _Act.char, payload: '3', mergeDigit: true),
+            _Key('+', _Act.char, payload: '+'),
+            _Key('−', _Act.char, payload: '-'),
           ],
           ],
           [
           [
-            _Key('0', insert: '0'),
-            _Key('.', insert: '.'),
-            _Key('^', insert: '^'),
-            // 变量键:没有它们就没法只用按钮写出 Integrate[x^2, x]。
-            // π / e 挪进了函数面板(那里的常量是可以点的)。
-            _Key('x', insert: 'x'),
-            _Key('n', insert: 'n'),
+            _Key('0', _Act.char, payload: '0', mergeDigit: true),
+            _Key('.', _Act.char, payload: '.', mergeDigit: true),
+            _Key('^', _Act.power),
+            _Key('x', _Act.char, payload: 'x'),
+            _Key('n', _Act.char, payload: 'n'),
           ],
           ],
           [
           [
-            _Key('⌫', action: _KeyAction.backspace, tint: true),
-            _Key('CLR', action: _KeyAction.clear, tint: true),
-            _Key(',', insert: ', '),
-            _Key('ANS', insert: '%', tint: true),
-            _Key('=', action: _KeyAction.commit, primary: true),
+            _Key('⌫', _Act.back, tint: true),
+            _Key('CLR', _Act.clear, tint: true),
+            _Key(',', _Act.comma),
+            _Key('ANS', _Act.char, payload: '%', tint: true),
+            _Key('=', _Act.commit, primary: true),
           ],
           ],
         ];
         ];
 
 
-        // 7 行 + 6 个 6px 间隙,再扣掉 Padding 的上下各 6px ——
-        // box.maxHeight 是 padding **之前**的高度,漏掉这一项会溢出 12px。
-        const gaps = 6 * 6.0;
-        const padding = 12.0;
-        final h = (box.maxHeight - padding - gaps) / 7;
+        // 7 行 + 6 个间隙,再扣掉 Padding 上下各 6 ——
+        // box.maxHeight 是 padding **之前**的高度,漏掉会溢出。
+        final h = (box.maxHeight - 12 - 6 * 6) / 7;
         return Padding(
         return Padding(
           padding: const EdgeInsets.all(6),
           padding: const EdgeInsets.all(6),
           child: Column(
           child: Column(
@@ -412,25 +408,14 @@ class _CalculatorPageState extends State<CalculatorPage> {
     final fg = k.primary ? scheme.onPrimary : scheme.onSurface;
     final fg = k.primary ? scheme.onPrimary : scheme.onSurface;
 
 
     return Material(
     return Material(
+      // 加 key 是为了让测试能精确定位按键 —— 编辑器里渲染出的字面量(比如 'x')
+      // 和键盘上同名的按键都符合 find.text,会撞车。
+      key: ValueKey('key-${k.label}'),
       color: bg,
       color: bg,
       borderRadius: BorderRadius.circular(10),
       borderRadius: BorderRadius.circular(10),
       child: InkWell(
       child: InkWell(
         borderRadius: BorderRadius.circular(10),
         borderRadius: BorderRadius.circular(10),
-        onTap: () {
-          switch (k.action) {
-            case _KeyAction.insert:
-              // 模板形如 Insert[□, x]:先插前半段,再插后半段,光标夹在中间
-              _insert(k.insert + (k.after ?? ''), back: (k.after ?? '').length);
-            case _KeyAction.backspace:
-              _backspace();
-            case _KeyAction.clear:
-              _clear();
-            case _KeyAction.commit:
-              _commit();
-            case null:
-              _insert(k.insert);
-          }
-        },
+        onTap: () => _press(k),
         child: Center(
         child: Center(
           child: Text(
           child: Text(
             k.label,
             k.label,
@@ -446,33 +431,48 @@ class _CalculatorPageState extends State<CalculatorPage> {
   }
   }
 }
 }
 
 
-enum _KeyAction { insert, backspace, clear, commit }
+enum _Act {
+  char,
+  fn,
+  fnPrefixed,
+  sqrt,
+  group,
+  power,
+  frac,
+  postfix,
+  comma,
+  back,
+  clear,
+  commit,
+}
 
 
 @immutable
 @immutable
 class _Key {
 class _Key {
   final String label;
   final String label;
+  final _Act act;
+  final String payload;
 
 
-  /// 插入的文本(模板时是前半段)。
-  final String insert;
+  /// fnPrefixed 用:第一个参数留空给用户填,其余的预置。
+  final List<String> prefill;
 
 
-  /// 模板的后半段;光标会停在 [insert] 与 [after] 之间。
-  final String? after;
+  /// 数字键:并入左边那个数字字面量,而不是各建一个节点。
+  final bool mergeDigit;
 
 
-  final _KeyAction? action;
   final bool tint;
   final bool tint;
   final bool primary;
   final bool primary;
 
 
   const _Key(
   const _Key(
-    this.label, {
-    this.insert = '',
-    this.after,
-    this.action = _KeyAction.insert,
+    this.label,
+    this.act, {
+    this.payload = '',
+    this.prefill = const [],
+    this.mergeDigit = false,
     this.tint = false,
     this.tint = false,
     this.primary = false,
     this.primary = false,
   });
   });
 }
 }
 
 
-/// 函数面板:检索 Symja 的两百多个函数,选中后插入 `Name[]`。
+/// 函数面板:检索 Symja 的两百多个函数名,选中后插入一个函数节点。
 class _FunctionPalette extends StatefulWidget {
 class _FunctionPalette extends StatefulWidget {
   const _FunctionPalette();
   const _FunctionPalette();
 
 
@@ -525,8 +525,7 @@ class _FunctionPaletteState extends State<_FunctionPalette> {
               runSpacing: 4,
               runSpacing: 4,
               crossAxisAlignment: WrapCrossAlignment.center,
               crossAxisAlignment: WrapCrossAlignment.center,
               children: [
               children: [
-                Text('常量',
-                    style: Theme.of(context).textTheme.bodySmall),
+                Text('常量', style: Theme.of(context).textTheme.bodySmall),
                 for (final c in kSymjaConstants)
                 for (final c in kSymjaConstants)
                   ActionChip(
                   ActionChip(
                     label: Text(
                     label: Text(
@@ -537,7 +536,7 @@ class _FunctionPaletteState extends State<_FunctionPalette> {
                     visualDensity: VisualDensity.compact,
                     visualDensity: VisualDensity.compact,
                     materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
                     materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
                     onPressed: () =>
                     onPressed: () =>
-                        Navigator.pop(context, (text: c, back: 0)),
+                        Navigator.pop(context, (value: c, isFn: false)),
                   ),
                   ),
               ],
               ],
             ),
             ),
@@ -556,7 +555,7 @@ class _FunctionPaletteState extends State<_FunctionPalette> {
                         style: const TextStyle(fontFamily: 'monospace'),
                         style: const TextStyle(fontFamily: 'monospace'),
                       ),
                       ),
                       onTap: () => Navigator.pop(
                       onTap: () => Navigator.pop(
-                          context, (text: '${fns[i]}[]', back: 1)),
+                          context, (value: fns[i], isFn: true)),
                     ),
                     ),
                   ),
                   ),
           ),
           ),

+ 322 - 0
lib/expr_editor.dart

@@ -0,0 +1,322 @@
+import 'package:flutter/material.dart';
+
+import 'expr_node.dart';
+
+/// 结构化表达式的 2D 编辑器视图。
+///
+/// 渲染方式:直接把节点树翻译成 widget 树 —— 分式用 Column(分子/横线/分母)、
+/// 幂用 Row + 抬高的上标、函数用 `名字[ 参数 ]`。没有自己写排版引擎,
+/// 因为 Flutter 的布局本来就够用,而且这样点击命中、无障碍都能白拿。
+class ExprEditor extends StatefulWidget {
+  const ExprEditor({
+    super.key,
+    required this.doc,
+    required this.onChanged,
+    this.fontSize = 22,
+  });
+
+  final ExprDoc doc;
+
+  /// 光标或内容变化后调用,让页面刷新。
+  final VoidCallback onChanged;
+
+  final double fontSize;
+
+  /// 光标是否闪烁。
+  ///
+  /// **widget 测试里必须设成 false**:闪烁是无限循环动画,而计算器页面常驻在
+  /// IndexedStack 里,于是 `pumpAndSettle` 永远等不到"没有待处理帧"而超时。
+  static bool blinkEnabled = true;
+
+  @override
+  State<ExprEditor> createState() => _ExprEditorState();
+}
+
+class _ExprEditorState extends State<ExprEditor>
+    with SingleTickerProviderStateMixin {
+  late final AnimationController _blink = AnimationController(
+    vsync: this,
+    duration: const Duration(milliseconds: 1100),
+    value: 1,
+  );
+
+  @override
+  void initState() {
+    super.initState();
+    if (ExprEditor.blinkEnabled) _blink.repeat(reverse: true);
+  }
+
+  @override
+  void dispose() {
+    _blink.dispose();
+    super.dispose();
+  }
+
+  void _setCursor(SlotPath p) {
+    if (widget.doc.cursor == p) return;
+    widget.doc.cursor = p;
+    widget.onChanged();
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    return AnimatedBuilder(
+      animation: _blink,
+      builder: (context, _) => 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: widget.doc,
+                seq: widget.doc.root,
+                path: SlotPath.root,
+                size: widget.fontSize,
+                blink: _blink.value,
+                onCursor: _setCursor,
+              ),
+            ),
+          ),
+        ),
+      ),
+    );
+  }
+}
+
+/// 一个槽的视图:把孩子按顺序排开,在光标位置插一根竖线。
+class _SlotView extends StatelessWidget {
+  const _SlotView({
+    required this.doc,
+    required this.seq,
+    required this.path,
+    required this.size,
+    required this.blink,
+    required this.onCursor,
+  });
+
+  final ExprDoc doc;
+  final SeqNode seq;
+  final SlotPath path;
+  final double size;
+  final double blink;
+  final void Function(SlotPath) onCursor;
+
+  @override
+  Widget build(BuildContext context) {
+    final scheme = Theme.of(context).colorScheme;
+    final focused = doc.cursor == 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,
+                blink: blink,
+                onCursor: onCursor,
+              ),
+            ],
+            if (focused && doc.cursor.offset >= seq.children.length)
+              _caret(scheme),
+            // 空槽给个占位,否则用户不知道能往里点
+            if (seq.isEmpty && !focused) _placeholder(scheme),
+          ],
+        ),
+      ),
+    );
+  }
+
+  Widget _caret(ColorScheme scheme) => Container(
+        width: 2.5,
+        height: size * 1.25,
+        margin: const EdgeInsets.symmetric(horizontal: 1),
+        // 直接用动画值当透明度:收到的是 0..1 的呼吸值,比硬闪好看
+        color: scheme.primary.withValues(alpha: 0.15 + blink * 0.85),
+      );
+
+  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.blink,
+    required this.onCursor,
+  });
+
+  final ExprDoc doc;
+  final ExprNode node;
+
+  /// 该节点所在槽的路径,以及它在槽里的下标。
+  final SlotPath parent;
+  final int index;
+
+  final double size;
+  final double blink;
+  final void Function(SlotPath) onCursor;
+
+  @override
+  Widget build(BuildContext context) {
+    final scheme = Theme.of(context).colorScheme;
+    // 点中某个节点 → 光标落到它后面(符合"接着往下打"的直觉)
+    return GestureDetector(
+      onTap: () => onCursor(SlotPath(parent.steps, index + 1)),
+      behavior: HitTestBehavior.opaque,
+      child: 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),
+          ]),
+      },
+    );
+  }
+
+  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<Widget> 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,
+          blink: blink,
+          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,
+    );
+  }
+}

+ 345 - 0
lib/expr_node.dart

@@ -0,0 +1,345 @@
+/// 结构化表达式模型 —— 计算器的输入不再是字符串,而是一棵树。
+///
+/// 为什么值得单独做一套:纯文本光标表达不了多参数结构。用户按了 `∫` 之后
+/// 光标该放哪?按 `^` 之后指数该往哪写?用字符串只能靠"插入 `Integrate[, x]`
+/// 再把光标往回退 4 位"这种把戏,模板一复杂就散架,分数、上下标更是没法表达。
+/// 树 + 槽就没有这个问题。
+///
+/// 分工:**计算器用这套结构化输入,控制台继续用纯文本**(长表达式、粘贴、
+/// 脚本式输入还是文本方便)。两者序列化后交给同一个求值内核。
+library;
+
+/// 表达式节点。
+sealed class ExprNode {
+  const ExprNode();
+}
+
+/// 字面量:数字、变量名、常量。用字符串存,因为 `3.14`、`x`、`Pi` 都是文本。
+class LitNode extends ExprNode {
+  final String text;
+  const LitNode(this.text);
+}
+
+/// 函数调用:`name[arg0, arg1, …]`。每个参数是一个槽。
+class FuncNode extends ExprNode {
+  final String name;
+  final List<SeqNode> args;
+  FuncNode(this.name, this.args);
+}
+
+/// 分式:分子 / 分母。
+class FracNode extends ExprNode {
+  SeqNode num;
+  SeqNode den;
+  FracNode({required this.num, required this.den});
+}
+
+/// 幂:底 ^ 指数。
+class PowNode extends ExprNode {
+  SeqNode base;
+  SeqNode exp;
+  PowNode({required this.base, required this.exp});
+}
+
+/// 括号分组 `( … )`。
+///
+/// 用独立节点而不是把括号当字面量:括号必须成对且包住一整段,
+/// 当字面量的话用户能删掉半个括号,表达式就废了。
+class GroupNode extends ExprNode {
+  SeqNode inner;
+  GroupNode(this.inner);
+}
+
+/// 后缀运算符,目前只有阶乘 `!`。
+class PostfixNode extends ExprNode {
+  SeqNode operand;
+  final String op;
+  PostfixNode({required this.operand, this.op = '!'});
+}
+
+/// 一个槽 = 一串节点。分式的分子分母、函数的参数、幂的指数都是槽。
+class SeqNode {
+  final List<ExprNode> children;
+  SeqNode([List<ExprNode>? children]) : children = children ?? <ExprNode>[];
+
+  bool get isEmpty => children.isEmpty;
+}
+
+/// 光标路径里的一跳:进入第 [child] 个孩子的第 [slot] 个槽。
+class SlotStep {
+  final int child;
+  final int slot;
+  const SlotStep(this.child, this.slot);
+}
+
+/// 光标位置。空路径 = 根槽;[offset] 是在最终那个槽里的插入位置。
+class SlotPath {
+  final List<SlotStep> steps;
+  final int offset;
+
+  const SlotPath(this.steps, this.offset);
+
+  static const root = SlotPath(<SlotStep>[], 0);
+
+  SlotPath push(int child, int slot) =>
+      SlotPath([...steps, SlotStep(child, slot)], 0);
+
+  /// 回到父槽,并把光标停在刚才那个孩子之后。
+  SlotPath? get parent {
+    if (steps.isEmpty) return null;
+    final last = steps.last;
+    return SlotPath(steps.sublist(0, steps.length - 1), last.child + 1);
+  }
+
+  @override
+  bool operator ==(Object other) {
+    if (other is! SlotPath || other.offset != offset) return false;
+    if (other.steps.length != steps.length) return false;
+    for (var i = 0; i < steps.length; i++) {
+      if (steps[i].child != other.steps[i].child) return false;
+      if (steps[i].slot != other.steps[i].slot) return false;
+    }
+    return true;
+  }
+
+  @override
+  int get hashCode =>
+      Object.hash(offset, Object.hashAll(steps.map((s) => Object.hash(s.child, s.slot))));
+}
+
+/// 编辑器文档:持有一棵树和一个光标,并实现所有编辑操作。
+///
+/// 可变对象,调用方自己 setState。刻意不做成 ChangeNotifier —— 操作很密集
+/// (每敲一个键一次),让页面统一刷新比分发通知更简单也更快。
+class ExprDoc {
+  final SeqNode root = SeqNode();
+  SlotPath cursor = SlotPath.root;
+
+  // ------------------------------------------------------------------ 取槽
+
+  /// 按路径找到对应的槽。路径失效时返回根槽。
+  SeqNode slotAt(SlotPath path) {
+    var seq = root;
+    for (final step in path.steps) {
+      if (step.child < 0 || step.child >= seq.children.length) return root;
+      final node = seq.children[step.child];
+      final slots = _slotsOf(node);
+      if (step.slot < 0 || step.slot >= slots.length) return seq;
+      seq = slots[step.slot];
+    }
+    return seq;
+  }
+
+  static List<SeqNode> _slotsOf(ExprNode node) => switch (node) {
+        FuncNode f => f.args,
+        FracNode f => [f.num, f.den], // 0=分子 1=分母
+        PowNode p => [p.base, p.exp], // 0=底 1=指数
+        GroupNode g => [g.inner],
+        PostfixNode p => [p.operand],
+        LitNode _ => const <SeqNode>[],
+      };
+
+  SeqNode get current => slotAt(cursor);
+
+  // ------------------------------------------------------------------ 插入
+
+  /// 在当前光标处插入一个节点;[enterSlot] 不为空则顺便把光标移进它的某个槽。
+  void insert(ExprNode node, {int? enterSlot}) {
+    final seq = current;
+    final at = cursor.offset.clamp(0, seq.children.length);
+    seq.children.insert(at, node);
+    cursor = enterSlot == null
+        ? SlotPath(cursor.steps, at + 1)
+        : _descend(cursor, at, enterSlot);
+  }
+
+  SlotPath _descend(SlotPath from, int child, int slot) =>
+      from.push(child, slot);
+
+  /// 插入一个字符。
+  ///
+  /// 数字和小数点会**并进左边那个数字字面量**,而不是各建一个节点 ——
+  /// 否则 `42` 是两个节点,退格只删最后一位、显示上还分不出是一个数还是两个。
+  void insertChar(String ch) {
+    final seq = current;
+    if (cursor.offset > 0 && seq.children.isNotEmpty) {
+      final left = seq.children[cursor.offset - 1];
+      if (left is LitNode && _isNumberText(left.text) && _isNumberChar(ch)) {
+        seq.children[cursor.offset - 1] = LitNode(left.text + ch);
+        return;
+      }
+    }
+    insert(LitNode(ch));
+  }
+
+  static bool _isNumberText(String s) =>
+      s.isNotEmpty && RegExp(r'^[0-9.]+$').hasMatch(s);
+
+  static bool _isNumberChar(String c) => RegExp(r'^[0-9.]$').hasMatch(c);
+
+  /// 插入一对括号(光标停在里面)。
+  void makeGroup() => insert(GroupNode(SeqNode()), enterSlot: 0);
+
+  /// 把当前槽里光标左边那个节点取出来(没有则返回 null),并把它从槽里摘掉。
+  ///
+  /// `^`、`/`、`√` 这些"把前面那个东西变成结构的一部分"的操作靠它实现 ——
+  /// 这是结构化编辑器和纯文本手感最接近的地方:先打 `x` 再按 `^`,就成了 x 的幂。
+  ExprNode? takeLeft() {
+    final seq = current;
+    if (cursor.offset <= 0 || cursor.offset > seq.children.length) return null;
+    cursor = SlotPath(cursor.steps, cursor.offset - 1);
+    return seq.children.removeAt(cursor.offset);
+  }
+
+  /// 制造一个幂:底 = 光标左边那个节点(没有就用空底),光标落到指数上。
+  void makePower() {
+    final base = takeLeft();
+    final node = PowNode(base: SeqNode(base == null ? null : [base]), exp: SeqNode());
+    insert(node, enterSlot: 1);
+  }
+
+  /// 制造一个分式:分子 = 光标左边那个节点(没有就空着),光标落到分母上。
+  void makeFraction() {
+    final num = takeLeft();
+    final node = FracNode(num: SeqNode(num == null ? null : [num]), den: SeqNode());
+    insert(node, enterSlot: 1);
+  }
+
+  /// 制造一个根式:被开方数 = 光标左边那个节点(没有就空着)。
+  void makeSqrt() {
+    final inner = takeLeft();
+    insert(FuncNode('Sqrt', [SeqNode(inner == null ? null : [inner])]), enterSlot: 0);
+  }
+
+  /// 制造一个函数调用,光标落在第一个参数槽。
+  ///
+  /// [prefill] 每一项对应一个参数:[String] 为空表示**留一个空槽给用户填**,
+  /// 非空则预置成字面量(例如 `Sum` 的 `{n, 1, Infinity}`)。
+  void makeFunction(String name, {List<String> prefill = const []}) {
+    insert(
+      FuncNode(name, [
+        for (final p in prefill)
+          p.isEmpty ? SeqNode() : SeqNode([LitNode(p)]),
+        if (prefill.isEmpty) SeqNode(),
+      ]),
+      enterSlot: 0,
+    );
+  }
+
+  /// 切到当前函数调用的下一个参数;已经是最后一个就新建一个参数槽。
+  ///
+  /// 只能用在函数参数里(光标路径的最后一跳指向某个 FuncNode 的参数槽时)。
+  void nextArgument() {
+    if (cursor.steps.isEmpty) return;
+    final here = cursor.steps.last;
+    final parent = SlotPath(cursor.steps.sublist(0, cursor.steps.length - 1), 0);
+    final siblings = slotAt(parent);
+    if (here.child >= siblings.children.length) return;
+
+    final node = siblings.children[here.child];
+    if (node is! FuncNode) return;
+
+    var target = here.slot + 1;
+    if (target >= node.args.length) {
+      node.args.add(SeqNode());
+      target = node.args.length - 1;
+    }
+    cursor = parent.push(here.child, target);
+  }
+
+  // ------------------------------------------------------------------ 删除
+
+  /// 槽里还有没填的空位("还没填完的模板")。
+  static bool _incomplete(ExprNode n) => _slotsOf(n).any((s) => s.isEmpty);
+
+  /// 退格。
+  ///
+  /// 三条规则,都是为了"手感":
+  ///  1. 数字字面量**逐位删** —— 计算器按退格是删一位,不是把整个数清掉。
+  ///  2. 左边是**还没填完的模板**(还有空槽)就钻进去删,而不是整个删掉 ——
+  ///     用户按了 `∫` 后悔,一按退格整块模板没了会很难受。
+  ///  3. 左边是**已经填完的模板**就整个删掉 —— 否则光标会永远在模板内部打转,
+  ///     根本删不掉它。
+  void backspace() {
+    final seq = current;
+
+    if (cursor.offset > 0 && seq.children.isNotEmpty) {
+      final node = seq.children[cursor.offset - 1];
+
+      // 规则 1
+      if (node is LitNode && _isNumberText(node.text) && node.text.length > 1) {
+        seq.children[cursor.offset - 1] =
+            LitNode(node.text.substring(0, node.text.length - 1));
+        return;
+      }
+
+      final slots = _slotsOf(node);
+      if (slots.isEmpty) {
+        seq.children.removeAt(cursor.offset - 1);
+        cursor = SlotPath(cursor.steps, cursor.offset - 1);
+        return;
+      }
+      if (_incomplete(node)) {
+        // 规则 2:钻进最后一个空槽
+        final i = slots.lastIndexWhere((s) => s.isEmpty);
+        cursor = cursor.push(cursor.offset - 1, i);
+        return;
+      }
+      // 规则 3
+      seq.children.removeAt(cursor.offset - 1);
+      cursor = SlotPath(cursor.steps, cursor.offset - 1);
+      return;
+    }
+
+    // 已经在槽的最左边:退回父槽
+    final up = cursor.parent;
+    if (up == null) return; // 根槽最左边,没得删
+
+    final parentSeq = slotAt(SlotPath(up.steps, up.offset));
+    final idx = up.offset - 1;
+    if (idx >= 0 && idx < parentSeq.children.length) {
+      final child = parentSeq.children[idx];
+      // 从"还没填完的模板"里退出来时,顺手把它也删掉
+      if (_incomplete(child)) {
+        parentSeq.children.removeAt(idx);
+        cursor = SlotPath(up.steps, idx);
+        return;
+      }
+    }
+    cursor = up;
+  }
+
+  void clear() {
+    root.children.clear();
+    cursor = SlotPath.root;
+  }
+
+  // ------------------------------------------------------------------ 序列化成 Symja
+
+  /// 整棵树 → 一条 Symja 表达式。
+  String toSymja() => serializeSeq(root);
+
+  /// 槽 → 表达式。空槽在 Symja 里写成 `Null`,求值时会明确报错而不是静默算错。
+  String serializeSeq(SeqNode seq) {
+    if (seq.isEmpty) return 'Null';
+    return seq.children.map(serialize).join();
+  }
+
+  String serialize(ExprNode node) => switch (node) {
+        LitNode l => l.text,
+        FuncNode f =>
+          '${f.name}[${f.args.map(serializeSeq).join(', ')}]',
+        FracNode f => '${_grouped(serializeSeq(f.num))}/${_grouped(serializeSeq(f.den))}',
+        PowNode p => '${_grouped(serializeSeq(p.base))}^${_grouped(serializeSeq(p.exp))}',
+        GroupNode g => '(${serializeSeq(g.inner)})',
+        PostfixNode p => '${_grouped(serializeSeq(p.operand))}${p.op}',
+      };
+
+  /// 复合结构当操作数时要加括号,否则 `x+1` 当分母会算错优先级。
+  String _grouped(String s) {
+    if (s.isEmpty) return s;
+    final needsParens = s.contains(RegExp(r'[+\-*/^]')) ||
+        (s.contains(' ') && s.contains(','));
+    return needsParens ? '($s)' : s;
+  }
+}

+ 258 - 0
test/expr_node_test.dart

@@ -0,0 +1,258 @@
+// 结构化表达式模型的测试。
+//
+// 这套东西的难点全在编辑手感上(光标往哪落、退格先钻还是先删、括号什么时候要加),
+// 而这些"手感"出了问题在界面上看不出来,只会悄悄算错。所以边界都钉在这里。
+import 'package:flutter_test/flutter_test.dart';
+
+import 'package:cal/expr_node.dart';
+
+ExprDoc docWith(List<ExprNode> nodes) {
+  final d = ExprDoc();
+  for (final n in nodes) {
+    d.insert(n);
+  }
+  return d;
+}
+
+void main() {
+  group('插入与序列化', () {
+    test('挨着插入的节点直接拼接', () {
+      final d = docWith([LitNode('x'), LitNode('+'), LitNode('1')]);
+      expect(d.toSymja(), 'x+1');
+    });
+
+    test('数字键并入左边那个数字,42 是一个节点不是两个', () {
+      final d = ExprDoc();
+      d.insertChar('4');
+      d.insertChar('2');
+      expect(d.root.children.length, 1);
+      expect(d.toSymja(), '42');
+    });
+
+    test('小数点也并入', () {
+      final d = ExprDoc();
+      d.insertChar('3');
+      d.insertChar('.');
+      d.insertChar('1');
+      d.insertChar('4');
+      expect(d.toSymja(), '3.14');
+    });
+
+    test('数字后面的字母不会被并进去', () {
+      final d = ExprDoc();
+      d.insertChar('2');
+      d.insertChar('x');
+      expect(d.root.children.length, 2);
+      expect(d.toSymja(), '2x');
+    });
+
+    test('空槽序列化成 Null(让求值明确报错,而不是静默算错)', () {
+      final d = ExprDoc();
+      expect(d.toSymja(), 'Null');
+    });
+  });
+
+  group('幂', () {
+    test('先打 x 再按 ^ ,x 成为底、光标落到指数', () {
+      final d = docWith([LitNode('x')]);
+      d.makePower();
+      d.insertChar('2');
+      expect(d.toSymja(), 'x^2');
+    });
+
+    test('没有左边的节点时造一个空底的幂', () {
+      final d = ExprDoc();
+      d.makePower();
+      d.insertChar('2');
+      expect(d.toSymja(), 'Null^2');
+    });
+
+    test('底是复合表达式时要加括号', () {
+      final d = ExprDoc();
+      d.insert(PowNode(
+        base: SeqNode([LitNode('x'), LitNode('+'), LitNode('1')]),
+        exp: SeqNode([LitNode('2')]),
+      ));
+      expect(d.toSymja(), '(x+1)^2');
+    });
+
+    test('底是单个字面量时不加多余括号', () {
+      final d = ExprDoc();
+      d.insert(PowNode(base: SeqNode([LitNode('x')]), exp: SeqNode([LitNode('2')])));
+      expect(d.toSymja(), 'x^2');
+    });
+  });
+
+  group('分式与括号', () {
+    test('1 再按 / 再打 3 得 1/3', () {
+      final d = ExprDoc();
+      d.insertChar('1');
+      d.makeFraction();
+      d.insertChar('3');
+      expect(d.toSymja(), '1/3');
+    });
+
+    test('分母是 x+1 时要加括号', () {
+      final d = ExprDoc();
+      d.insertChar('1');
+      d.makeFraction();
+      d.insertChar('x');
+      d.insertChar('+');
+      d.insertChar('1');
+      expect(d.toSymja(), '1/(x+1)');
+    });
+
+    test('括号分组', () {
+      final d = ExprDoc();
+      d.insertChar('2');
+      d.insertChar('*');
+      d.makeGroup();
+      d.insertChar('x');
+      d.insertChar('+');
+      d.insertChar('1');
+      expect(d.toSymja(), '2*(x+1)');
+    });
+  });
+
+  group('函数模板', () {
+    test('空的 prefill 就是空槽,光标落在第一个槽', () {
+      final d = ExprDoc();
+      d.makeFunction('Integrate', prefill: ['', 'x']);
+      expect(d.toSymja(), 'Integrate[Null, x]');
+      // 光标应当在第一个参数里,所以接着打的字进那个槽
+      d.insertChar('x');
+      expect(d.toSymja(), 'Integrate[x, x]');
+    });
+
+    test('填出 Integrate[x^2, x]', () {
+      final d = ExprDoc();
+      d.makeFunction('Integrate', prefill: ['', 'x']);
+      d.insertChar('x');
+      d.makePower();
+      d.insertChar('2');
+      expect(d.toSymja(), 'Integrate[x^2, x]');
+    });
+
+    test('无参模板', () {
+      final d = ExprDoc();
+      d.makeFunction('Sin');
+      expect(d.toSymja(), 'Sin[Null]');
+      d.insertChar('x');
+      expect(d.toSymja(), 'Sin[x]');
+    });
+
+    test('逗号切到下一个参数,没有就新建', () {
+      final d = ExprDoc();
+      d.makeFunction('f');
+      d.insertChar('1');
+      d.nextArgument();
+      d.insertChar('2');
+      expect(d.toSymja(), 'f[1, 2]');
+    });
+
+    test('根式把左边的节点收进被开方数', () {
+      final d = docWith([LitNode('2')]);
+      d.makeSqrt();
+      expect(d.toSymja(), 'Sqrt[2]');
+    });
+  });
+
+  group('退格', () {
+    test('删掉左边那个字面量', () {
+      final d = docWith([LitNode('1'), LitNode('2')]);
+      d.backspace();
+      expect(d.toSymja(), '1');
+    });
+
+    test('光标在最左边时按退格什么都不发生', () {
+      final d = docWith([LitNode('1')]);
+      d.cursor = SlotPath.root; // 手动把光标移到最左
+      d.backspace();
+      expect(d.toSymja(), '1');
+    });
+
+    test('数字逐位删,不是整个数清掉', () {
+      final d = ExprDoc();
+      d.insertChar('1');
+      d.insertChar('2');
+      d.insertChar('3');
+      d.backspace();
+      expect(d.toSymja(), '12');
+      d.backspace();
+      expect(d.toSymja(), '1');
+    });
+
+    test('住进空槽里退格:先退回父槽,顺手把没填完的模板也删掉', () {
+      final d = ExprDoc();
+      d.makeFunction('Integrate', prefill: ['', 'x']);
+      expect(d.toSymja(), 'Integrate[Null, x]');
+      d.backspace(); // 空槽最左边 → 退回根槽,并把没填完的模板一起删掉
+      expect(d.toSymja(), 'Null');
+    });
+
+    test('左边是没填完的模板:先进去,再退一次才删掉', () {
+      final d = ExprDoc();
+      d.makeFunction('Integrate', prefill: ['', 'x']);
+      d.cursor = const SlotPath(<SlotStep>[], 1); // 根槽 offset 1 = 模板右边
+      expect(d.toSymja(), 'Integrate[Null, x]');
+
+      d.backspace(); // 钻进那个空槽,内容不变
+      expect(d.toSymja(), 'Integrate[Null, x]');
+
+      d.backspace(); // 退回根槽,顺手把没填完的模板删掉
+      expect(d.toSymja(), 'Null');
+    });
+
+    test('填完参数的过程里模板不会被误删', () {
+      final d = ExprDoc();
+      d.makeFunction('Sin');
+      d.insertChar('x');
+      expect(d.toSymja(), 'Sin[x]');
+      // 回到参数里,且把光标停在 x **之后**(offset 1)
+      d.cursor = const SlotPath(<SlotStep>[SlotStep(0, 0)], 1);
+      d.backspace(); // 删参数里的 x
+      expect(d.toSymja(), 'Sin[Null]');
+    });
+
+    test('左边是填完的模板:整个删掉(否则光标永远出不来)', () {
+      final d = ExprDoc();
+      d.makeFunction('Integrate', prefill: ['', 'x']);
+      d.insertChar('x'); // Integrate[x, x],已经填完
+      d.cursor = const SlotPath(<SlotStep>[], 1); // 模板右边
+      expect(d.toSymja(), 'Integrate[x, x]');
+      d.backspace();
+      expect(d.toSymja(), 'Null');
+    });
+  });
+
+  group('光标路径', () {
+    test('插入带占位槽的节点后光标在正确的槽里', () {
+      final d = ExprDoc();
+      d.makeFraction();
+      expect(d.cursor.steps.length, 1);
+      expect(d.cursor.steps.first.slot, 1); // 分母
+      expect(d.cursor.offset, 0);
+    });
+
+    test('parent 回到父槽并停在刚退出的那个孩子之后', () {
+      final d = ExprDoc();
+      d.makeFunction('Sin');
+      d.insertChar('x');
+      final up = d.cursor.parent;
+      expect(up, isNotNull);
+      expect(up!.steps, isEmpty);
+      expect(up.offset, 1); // Sin 之后
+    });
+  });
+
+  group('clear', () {
+    test('清空后回到根槽', () {
+      final d = ExprDoc();
+      d.makeFunction('Integrate', prefill: ['', 'x']);
+      d.clear();
+      expect(d.toSymja(), 'Null');
+      expect(d.cursor.steps, isEmpty);
+      expect(d.root.children, isEmpty);
+    });
+  });
+}

+ 66 - 13
test/widget_test.dart

@@ -6,6 +6,7 @@ import 'package:flutter_test/flutter_test.dart';
 
 
 import 'package:cal/calculator_page.dart';
 import 'package:cal/calculator_page.dart';
 import 'package:cal/console_page.dart';
 import 'package:cal/console_page.dart';
+import 'package:cal/expr_editor.dart';
 import 'package:cal/main.dart';
 import 'package:cal/main.dart';
 import 'package:cal/tools_page.dart';
 import 'package:cal/tools_page.dart';
 
 
@@ -15,6 +16,8 @@ void main() {
   final calls = <MethodCall>[];
   final calls = <MethodCall>[];
 
 
   setUp(() {
   setUp(() {
+    // 光标闪烁是无限循环动画,会让 pumpAndSettle 永远等不到静止
+    ExprEditor.blinkEnabled = false;
     calls.clear();
     calls.clear();
     TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
     TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
         .setMockMethodCallHandler(symja, (call) async {
         .setMockMethodCallHandler(symja, (call) async {
@@ -100,6 +103,12 @@ void main() {
     await tester.pumpAndSettle();
     await tester.pumpAndSettle();
   }
   }
 
 
+  /// 按计算器上的某个键。按键带 ValueKey,避免和编辑器里的字面量撞车。
+  Future<void> press(WidgetTester tester, String label) async {
+    await tester.tap(find.byKey(ValueKey('key-$label')));
+    await tester.pumpAndSettle();
+  }
+
   group('外壳', () {
   group('外壳', () {
     testWidgets('三个界面:计算器 / 工具 / 控制台', (tester) async {
     testWidgets('三个界面:计算器 / 工具 / 控制台', (tester) async {
       await pumpApp(tester);
       await pumpApp(tester);
@@ -111,30 +120,74 @@ void main() {
     });
     });
   });
   });
 
 
-  group('计算器', () {
-    testWidgets('按键把表达式插进显示区,并触发预览', (tester) async {
+  group('计算器(结构化输入)', () {
+    testWidgets('按 ∫ 插出 Integrate 模板,并在显示区露出序列化结果', (tester) async {
       await pumpApp(tester);
       await pumpApp(tester);
+      await press(tester, '∫');
 
 
-      await tester.tap(find.text('∫')); // Integrate[□, x] 模板
-      await tester.pumpAndSettle();
+      // 空槽序列化成 Null,用户一眼能看出还没填
+      expect(find.text('Integrate[Null, x]'), findsOneWidget);
+    });
+
+    testWidgets('模板槽里能接着打字(光标落在第一个参数)', (tester) async {
+      await pumpApp(tester);
+      await press(tester, '∫');
+      await press(tester, 'x');
+
+      expect(find.text('Integrate[x, x]'), findsOneWidget);
+    });
 
 
-      final tf = tester.widget<TextField>(
-          inPage(CalculatorPage, find.byType(TextField)));
-      expect(tf.controller!.text, 'Integrate[, x]');
-      // 光标应停在方括号里
-      expect(tf.controller!.selection.baseOffset, 'Integrate['.length);
+    testWidgets('^ 把左边的节点收成底,光标落到指数', (tester) async {
+      await pumpApp(tester);
+      await press(tester, 'x');
+      await press(tester, '^');
+      await press(tester, '2');
+
+      expect(find.text('x^2'), findsOneWidget);
     });
     });
 
 
-    testWidgets('预览走 preview 标志,不污染 %', (tester) async {
+    testWidgets('数字键并入同一个字面量', (tester) async {
       await pumpApp(tester);
       await pumpApp(tester);
-      await tester.enterText(
-          inPage(CalculatorPage, find.byType(TextField)), 'Sin[x]');
+      await press(tester, '4');
+      await press(tester, '2');
+
+      // 编辑器里的字面量 42 和下面那行序列化结果都是 '42',所以是 findsWidgets
+      expect(find.text('42'), findsWidgets);
+    });
+
+    testWidgets('实时预览走 preview 标志,不污染 %', (tester) async {
+      await pumpApp(tester);
+      await press(tester, 'sin');
+      await press(tester, 'x');
       // 等防抖
       // 等防抖
       await tester.pump(const Duration(milliseconds: 700));
       await tester.pump(const Duration(milliseconds: 700));
       await tester.pumpAndSettle();
       await tester.pumpAndSettle();
 
 
-      final c = calls.firstWhere((c) => c.method == 'evaluate');
+      // 按 sin 的那次也会触发预览(expr = Sin[Null]),所以取最后一次
+      final c = calls.lastWhere((c) => c.method == 'evaluate');
       expect(c.arguments['preview'], true);
       expect(c.arguments['preview'], true);
+      expect(c.arguments['expr'], 'Sin[x]');
+    });
+
+    testWidgets('按 = 提交时把 preview 置 false(正式求值要更新 %)', (tester) async {
+      await pumpApp(tester);
+      await press(tester, 'sin');
+      await press(tester, 'x');
+      await press(tester, '=');
+
+      final evals = calls.where((c) => c.method == 'evaluate').toList();
+      final committed = evals.where((c) => c.arguments['preview'] == false);
+      expect(committed, isNotEmpty);
+      expect(committed.last.arguments['expr'], 'Sin[x]');
+    });
+
+    testWidgets('CLR 清空', (tester) async {
+      await pumpApp(tester);
+      await press(tester, 'sin');
+      expect(find.text('Sin[Null]'), findsOneWidget);
+
+      await press(tester, 'CLR');
+      expect(find.text('Null'), findsOneWidget);
     });
     });
   });
   });