ソースを参照

结构化编辑器加选区与剪切/复制/粘贴

之前的"复制粘贴"只能整条表达式进出,做不了"选中一段再搬位置"。
用户要的是文本编辑器那种:选中 2x+1 → 剪切 → 粘到 Log 的括号里。

## 选区模型
选区 = **同一个槽里**一段连续的子节点(SlotRange:path + start + end)。
不做跨槽的任意范围 —— 那要处理"从分子选到分母"这种语义模糊的情况,
而实际想剪的东西几乎总在同一层。

配套:selectNodeAt / extendSelectionTo / deleteSelection /
insertNodesAtCursor / selectionText。跨槽扩展会被忽略,不会串到别的槽。

## 手势
  · 长按节点 → 选中它,操作栏自动出现
  · 有选区时点另一个节点 → 扩展选区("按住选中一个再点另一个")
  · 点光标已经所在的位置(无选区)→ 弹出"粘贴"
  · 点槽的空白处 → 取消选区

## 浮动操作栏
显示区下面按需出现(不是常驻):有选区时给"已选中 <文本>"+复制/剪切/删除,
没有选区而点了光标处时给"粘贴到光标处"。

## 粘贴进去要先把文本解析回节点树
新增 lib/expr_parse.dart:一个递归下降解析器,支持数字(含科学计数法)、
四则与幂、一元负号、并置乘法、Name[...]、(…)、{…}、-> == :=。
**只解析这个子集,遇到不认识的就明确报错,绝不猜** —— 猜错了用户看到的是
算错的答案,比报错糟得多。粘贴失败时操作栏里直接说明原因。

顺带:
  · 空槽只剩光标那 2.5px 宽,手指点不到 —— 而"点光标处弹粘贴"最常在空槽里
    用(先按 ln 再粘参数)。给聚焦的空槽加了最小可点面积。
  · 序列化的括号规则改进:已经整体带括号的不再套成 ((x+1)),单个项不加括号
    (原来是 (-1)/2)。让"复制→粘贴"能闭环。

102 个测试通过(新增 7 个选区模型 + 7 个解析器 + 4 个界面)。
真机验证:长按 2 → 点 1 得"已选中 2x+1" → 剪切 → ln 建 Log[] →
点光标处 → 粘贴 → Log[2x+1]。

Co-Authored-By: Claude Code <noreply@anthropic.com>
nonvioce 4 週間 前
親
コミット
7fc352ed6d
9 ファイル変更、1027 行追加、141 行削除
  1. 173 18
      lib/calculator_page.dart
  2. 117 18
      lib/expr_editor.dart
  3. 147 52
      lib/expr_node.dart
  4. 273 0
      lib/expr_parse.dart
  5. 3 3
      pubspec.lock
  6. 1 0
      pubspec.yaml
  7. 49 39
      test/expr_node_test.dart
  8. 121 0
      test/expr_parse_test.dart
  9. 143 11
      test/widget_test.dart

+ 173 - 18
lib/calculator_page.dart

@@ -1,12 +1,14 @@
 import 'dart:async';
 
 import 'package:flutter/material.dart';
+import 'package:flutter/services.dart';
 import 'package:flutter_math_fork/flutter_math.dart';
 import 'package:provider/provider.dart';
 
 import 'curve_plot.dart';
 import 'expr_editor.dart';
 import 'expr_node.dart';
+import 'expr_parse.dart';
 import 'symja.dart';
 import 'symja_functions.dart';
 
@@ -32,6 +34,9 @@ class _CalculatorPageState extends State<CalculatorPage> {
   Timer? _debounce;
   int _seq = 0;
 
+  /// 用户点了"光标已经在的地方"—— 显示粘贴入口。
+  bool _pastePrompt = false;
+
   @override
   void dispose() {
     _debounce?.cancel();
@@ -45,6 +50,7 @@ class _CalculatorPageState extends State<CalculatorPage> {
     setState(() {
       _committed = null;
       _error = null;
+      _pastePrompt = false; // 动过内容/光标就收起操作栏
     });
     _debounce?.cancel();
     _debounce = Timer(const Duration(milliseconds: 500), _runPreview);
@@ -52,6 +58,148 @@ class _CalculatorPageState extends State<CalculatorPage> {
 
   String get _source => _doc.toSymja();
 
+  void _snack(String msg) {
+    if (!mounted) return;
+    ScaffoldMessenger.of(context)
+      ..hideCurrentSnackBar()
+      ..showSnackBar(SnackBar(content: Text(msg)));
+  }
+
+  // ------------------------------------------------------------------ 复制 / 粘贴
+
+  Future<void> _copy(String text, String what) async {
+    await Clipboard.setData(ClipboardData(text: text));
+    _snack('已复制$what:$text');
+  }
+
+  /// 从剪贴板读一段 Symja 表达式,解析成节点树后整体替换。
+  ///
+  /// 解析器只认一个明确的子集,**认不出来就报错**,不会猜着解析 ——
+  /// 猜错了你会看到算错的结果,那比报错糟糕得多。
+  Future<void> _paste() async {
+    final data = await Clipboard.getData(Clipboard.kTextPlain);
+    final text = (data?.text ?? '').trim();
+    if (text.isEmpty) {
+      _snack('剪贴板里没有文本');
+      return;
+    }
+    try {
+      final parsed = parseSymjaExpr(text);
+      setState(() {
+        _pastePrompt = false;
+        if (_doc.selection != null) _doc.deleteSelection(); // 有选区就替换掉
+        _doc.insertNodesAtCursor(parsed.children); // 否则插到光标处
+        _preview = null;
+        _committed = null;
+        _error = null;
+      });
+      _onDocChanged();
+      _snack('已粘贴到光标处:${_doc.toSymja()}');
+    } on ExprParseException catch (e) {
+      _snack('这段表达式没法转成按钮式输入 —— $e');
+    }
+  }
+
+  // ------------------------------------------------------------------ 选区操作
+
+  Future<void> _copySelection() async {
+    final t = _doc.selectionText;
+    if (t.isEmpty) return;
+    await _copy(t, '选中内容');
+    setState(() => _doc.clearSelection());
+  }
+
+  Future<void> _cutSelection() async {
+    final t = _doc.selectionText;
+    if (t.isEmpty) return;
+    await Clipboard.setData(ClipboardData(text: t));
+    setState(() {
+      _doc.deleteSelection();
+      _pastePrompt = false;
+    });
+    _onDocChanged();
+    _snack('已剪切:$t');
+  }
+
+  void _deleteSelection() {
+    setState(() {
+      _doc.deleteSelection();
+      _pastePrompt = false;
+    });
+    _onDocChanged();
+  }
+
+  void _dismissBar() {
+    setState(() {
+      _pastePrompt = false;
+      _doc.clearSelection();
+    });
+  }
+
+  /// 选中部分内容后自动出现的操作栏;或点了光标处时的粘贴入口。
+  Widget? _contextBar() {
+    final sel = _doc.selection;
+    final hasSel = sel != null && !sel.isEmpty;
+    if (!hasSel && !_pastePrompt) return null;
+
+    final scheme = Theme.of(context).colorScheme;
+    return Material(
+      color: scheme.surfaceContainerHighest,
+      child: Padding(
+        padding: const EdgeInsets.only(left: 12, right: 4),
+        child: Row(
+          children: [
+            Expanded(
+              child: Text(
+                hasSel ? '已选中  ${_doc.selectionText}' : '粘贴到光标处',
+                maxLines: 1,
+                overflow: TextOverflow.ellipsis,
+                style: const TextStyle(fontFamily: 'monospace', fontSize: 12),
+              ),
+            ),
+            if (hasSel) ...[
+              TextButton(onPressed: _copySelection, child: const Text('复制')),
+              TextButton(onPressed: _cutSelection, child: const Text('剪切')),
+              TextButton(onPressed: _deleteSelection, child: const Text('删除')),
+            ] else
+              TextButton(onPressed: _paste, child: const Text('粘贴')),
+            IconButton(
+              tooltip: '关闭',
+              icon: const Icon(Icons.close, size: 18),
+              onPressed: _dismissBar,
+            ),
+          ],
+        ),
+      ),
+    );
+  }
+
+  Future<void> _menu(_Menu item) async {
+    switch (item) {
+      case _Menu.palette:
+        await _showPalette();
+      case _Menu.copyExpr:
+        final s = _source;
+        if (s.isEmpty || s == 'Null') {
+          _snack('还没有表达式');
+        } else {
+          await _copy(s, '表达式');
+        }
+      case _Menu.copyResult:
+        final r = _committed ?? _preview;
+        if (r == null) {
+          _snack('还没有结果');
+        } else {
+          await _copy(r.text, '结果');
+        }
+      case _Menu.paste:
+        await _paste();
+      case _Menu.clear:
+        _doc.clear();
+        _onDocChanged();
+    }
+  }
+
   Future<void> _runPreview() async {
     final expr = _source;
     if (expr.isEmpty || expr == 'Null') {
@@ -141,12 +289,12 @@ class _CalculatorPageState extends State<CalculatorPage> {
           _doc.insert(LitNode(k.payload));
         }
       case _Act.fn:
-        _doc.applyFunction(k.payload);
+        _doc.makeFunction(k.payload);
       case _Act.fnPrefixed:
-        // 像 Integrate 这种:包的时候整槽当第一个参数,其余按 prefill 预置
-        _doc.applyFunction(k.payload, prefill: k.prefill);
+        // 像 Integrate 这种,第一个参数留空给用户填,其余预置好
+        _doc.makeFunction(k.payload, prefill: k.prefill);
       case _Act.sqrt:
-        _doc.applyFunction('Sqrt');
+        _doc.makeFunction('Sqrt');
       case _Act.group:
         _doc.makeGroup();
       case _Act.power:
@@ -181,7 +329,7 @@ class _CalculatorPageState extends State<CalculatorPage> {
     );
     if (picked == null) return;
     if (picked.isFn) {
-      _doc.applyFunction(picked.value);
+      _doc.makeFunction(picked.value);
     } else {
       _doc.insert(LitNode(picked.value));
     }
@@ -193,6 +341,7 @@ class _CalculatorPageState extends State<CalculatorPage> {
   @override
   Widget build(BuildContext context) {
     final session = context.watch<SymjaSession>();
+    final bar = _contextBar();
 
     return Scaffold(
       appBar: AppBar(
@@ -218,18 +367,16 @@ class _CalculatorPageState extends State<CalculatorPage> {
             icon: const Icon(Icons.show_chart),
             onPressed: _plotCurrent,
           ),
-          IconButton(
-            tooltip: '函数面板',
-            icon: const Icon(Icons.functions),
-            onPressed: _showPalette,
-          ),
-          IconButton(
-            tooltip: '清空',
-            icon: const Icon(Icons.refresh),
-            onPressed: () {
-              _doc.clear();
-              _onDocChanged();
-            },
+          PopupMenuButton<_Menu>(
+            tooltip: '更多',
+            onSelected: _menu,
+            itemBuilder: (_) => const [
+              PopupMenuItem(value: _Menu.palette, child: Text('函数面板')),
+              PopupMenuItem(value: _Menu.copyExpr, child: Text('复制表达式')),
+              PopupMenuItem(value: _Menu.copyResult, child: Text('复制结果')),
+              PopupMenuItem(value: _Menu.paste, child: Text('从剪贴板粘贴')),
+              PopupMenuItem(value: _Menu.clear, child: Text('清空')),
+            ],
           ),
         ],
       ),
@@ -237,6 +384,7 @@ class _CalculatorPageState extends State<CalculatorPage> {
         child: Column(
           children: [
             _display(context),
+            ?bar,
             const Divider(height: 1),
             Expanded(child: _keypad(context)),
           ],
@@ -257,7 +405,11 @@ class _CalculatorPageState extends State<CalculatorPage> {
       child: Column(
         crossAxisAlignment: CrossAxisAlignment.end,
         children: [
-          ExprEditor(doc: _doc, onChanged: _onDocChanged),
+          ExprEditor(
+            doc: _doc,
+            onChanged: _onDocChanged,
+            onCaretTapped: () => setState(() => _pastePrompt = true),
+          ),
           const SizedBox(height: 6),
           // 序列化结果摆在明面上:用户能看到"到底会被求值成什么"
           Text(
@@ -447,6 +599,9 @@ class _CalculatorPageState extends State<CalculatorPage> {
   }
 }
 
+/// 计算器右上角「⋮」菜单的项。
+enum _Menu { palette, copyExpr, copyResult, paste, clear }
+
 enum _Act {
   char,
   fn,

+ 117 - 18
lib/expr_editor.dart

@@ -12,6 +12,7 @@ class ExprEditor extends StatelessWidget {
     super.key,
     required this.doc,
     required this.onChanged,
+    required this.onCaretTapped,
     this.fontSize = 22,
   });
 
@@ -20,10 +21,50 @@ class ExprEditor extends StatelessWidget {
   /// 光标或内容变化后调用,让页面刷新。
   final VoidCallback onChanged;
 
+  /// 用户点了**光标已经所在的那个位置**(且没有选区)—— 页面据此弹出"粘贴"。
+  final VoidCallback onCaretTapped;
+
   final double fontSize;
 
-  void _setCursor(BuildContext context, SlotPath p) {
-    if (doc.cursor == p) return;
+  /// 点一个节点。
+  ///
+  /// 三种语义:
+  ///  · 有选区时 → 扩展选区("按住选中一个,再点另一个")
+  ///  · 没选区、但点的是光标已经待着的地方 → 交给页面弹粘贴
+  ///  · 其余 → 移动光标
+  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();
   }
@@ -46,7 +87,9 @@ class ExprEditor extends StatelessWidget {
               seq: doc.root,
               path: SlotPath.root,
               size: fontSize,
-              onCursor: (p) => _setCursor(context, p),
+              onTapNode: _tapNode,
+              onLongPressNode: _longPressNode,
+              onTapSlot: _tapSlot,
             ),
           ),
         ),
@@ -62,14 +105,18 @@ class _SlotView extends StatelessWidget {
     required this.seq,
     required this.path,
     required this.size,
-    required this.onCursor,
+    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) onCursor;
+  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) {
@@ -77,12 +124,19 @@ class _SlotView extends StatelessWidget {
     // 用 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: () => onCursor(SlotPath(path.steps, seq.children.length)),
+      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,
@@ -92,13 +146,18 @@ class _SlotView extends StatelessWidget {
           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,
+              _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)
@@ -146,7 +205,9 @@ class _NodeView extends StatelessWidget {
     required this.parent,
     required this.index,
     required this.size,
-    required this.onCursor,
+    required this.onTapNode,
+    required this.onLongPressNode,
+    required this.onTapSlot,
   });
 
   final ExprDoc doc;
@@ -157,7 +218,9 @@ class _NodeView extends StatelessWidget {
   final int index;
 
   final double size;
-  final void Function(SlotPath) onCursor;
+  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) {
@@ -168,6 +231,7 @@ class _NodeView extends StatelessWidget {
       FracNode f => _fraction(context, f),
       PowNode p => _power(context, p),
       GroupNode g => _group(context, g),
+      BraceNode b => _brace(context, b),
       PostfixNode p => _row([
           _slot(context, p.operand, parent.push(index, 0)),
           _text(p.op, size),
@@ -184,10 +248,11 @@ class _NodeView extends StatelessWidget {
     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;
-          onCursor(SlotPath(parent.steps, before ? index : index + 1));
+          onTapNode(parent, index, before);
         },
         child: body,
       ),
@@ -263,6 +328,18 @@ class _NodeView extends StatelessWidget {
     ]);
   }
 
+  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),
+    ]);
+  }
+
   Widget _row(List<Widget> children) => Wrap(
         crossAxisAlignment: WrapCrossAlignment.center,
         children: children,
@@ -277,13 +354,35 @@ class _NodeView extends StatelessWidget {
           seq: seq,
           path: path,
           size: size,
-          onCursor: onCursor,
+          onTapNode: onTapNode,
+          onLongPressNode: onLongPressNode,
+          onTapSlot: onTapSlot,
         ),
       );
 
   Widget _text(String t, double s) => Text(t, style: TextStyle(fontSize: s));
 }
 
+/// 选中时给节点盖一层高亮背景。
+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,
+    );
+  }
+}
+
 /// 给槽套一个缩放(指数要用小一号字)。
 class _ScaledSlot extends StatelessWidget {
   const _ScaledSlot({required this.scale, required this.child});

+ 147 - 52
lib/expr_node.dart

@@ -9,6 +9,8 @@
 /// 脚本式输入还是文本方便)。两者序列化后交给同一个求值内核。
 library;
 
+import 'package:meta/meta.dart';
+
 /// 表达式节点。
 sealed class ExprNode {
   const ExprNode();
@@ -50,6 +52,15 @@ class GroupNode extends ExprNode {
   GroupNode(this.inner);
 }
 
+/// 花括号列表 `{ … }`。
+///
+/// 计算器自己不用它(Sum 的迭代范围是一个字面量),但**粘贴**进来的表达式
+/// 可能带花括号(`Sum[f, {n, 1, Infinity}]`),没有这个节点就解析不了。
+class BraceNode extends ExprNode {
+  SeqNode inner;
+  BraceNode(this.inner);
+}
+
 /// 后缀运算符,目前只有阶乘 `!`。
 class PostfixNode extends ExprNode {
   SeqNode operand;
@@ -122,6 +133,33 @@ class SlotPath {
       Object.hash(offset, Object.hashAll(steps.map((s) => Object.hash(s.child, s.slot))));
 }
 
+/// 把一个槽序列化成 Symja 表达式。
+///
+/// 序列化不依赖任何文档状态,所以给出这个顶层入口,
+/// 免得为了转个字符串还要 new 一个 ExprDoc。
+String symjaOf(SeqNode seq) => ExprDoc().serializeSeq(seq);
+
+/// 选区:**同一个槽里**一段连续的子节点。
+///
+/// 不做跨槽的任意范围 —— 那要处理"从分子选到分母"这种语义模糊的情况,
+/// 而实际用起来,想剪切的东西几乎总在同一层(`2x+1` 就是根槽里连续的四个节点)。
+@immutable
+class SlotRange {
+  final SlotPath path;
+  final int start; // 含
+  final int end; // 不含
+  const SlotRange(this.path, this.start, this.end);
+
+  bool get isEmpty => end <= start;
+
+  @override
+  bool operator ==(Object other) =>
+      other is SlotRange && other.path == path && other.start == start && other.end == end;
+
+  @override
+  int get hashCode => Object.hash(path, start, end);
+}
+
 /// 编辑器文档:持有一棵树和一个光标,并实现所有编辑操作。
 ///
 /// 可变对象,调用方自己 setState。刻意不做成 ChangeNotifier —— 操作很密集
@@ -130,6 +168,64 @@ class ExprDoc {
   final SeqNode root = SeqNode();
   SlotPath cursor = SlotPath.root;
 
+  /// 当前选区。null = 没选中(此时操作都作用在光标处)。
+  SlotRange? selection;
+
+  // ------------------------------------------------------------------ 选区
+
+  void selectNodeAt(SlotPath path, int index) {
+    selection = SlotRange(path, index, index + 1);
+  }
+
+  /// 把选区扩展到包含第 [index] 个节点(按住不放再点另一个节点的语义)。
+  /// 只支持同一个槽内,跨槽忽略。
+  void extendSelectionTo(SlotPath path, int index) {
+    final s = selection;
+    if (s == null || !s.path.sameSlotAs(path)) return;
+    selection = SlotRange(
+      s.path,
+      index < s.start ? index : s.start,
+      index + 1 > s.end ? index + 1 : s.end,
+    );
+  }
+
+  void clearSelection() => selection = null;
+
+  /// 选中的节点(按原位序)。
+  List<ExprNode> get selectedNodes {
+    final s = selection;
+    if (s == null || s.isEmpty) return const <ExprNode>[];
+    final children = slotAt(s.path).children;
+    final from = s.start.clamp(0, children.length);
+    final to = s.end.clamp(0, children.length);
+    return children.sublist(from, to);
+  }
+
+  /// 选区的 Symja 文本(复制到剪贴板的内容)。
+  String get selectionText => symjaOf(SeqNode(selectedNodes));
+
+  /// 删掉选区,光标落到选区起点。
+  void deleteSelection() {
+    final s = selection;
+    if (s == null || s.isEmpty) return;
+    final children = slotAt(s.path).children;
+    final from = s.start.clamp(0, children.length);
+    final to = s.end.clamp(0, children.length);
+    children.removeRange(from, to);
+    cursor = SlotPath(s.path.steps, from);
+    selection = null;
+  }
+
+  /// 在光标处插入一串节点(粘贴用),插完光标落在它们之后。
+  void insertNodesAtCursor(List<ExprNode> nodes) {
+    if (nodes.isEmpty) return;
+    final seq = current;
+    final at = cursor.offset.clamp(0, seq.children.length);
+    seq.children.insertAll(at, nodes);
+    cursor = SlotPath(cursor.steps, at + nodes.length);
+    selection = null;
+  }
+
   // ------------------------------------------------------------------ 取槽
 
   /// 按路径找到对应的槽。路径失效时返回根槽。
@@ -150,6 +246,7 @@ class ExprDoc {
         FracNode f => [f.num, f.den], // 0=分子 1=分母
         PowNode p => [p.base, p.exp], // 0=底 1=指数
         GroupNode g => [g.inner],
+        BraceNode b => [b.inner],
         PostfixNode p => [p.operand],
         LitNode _ => const <SeqNode>[],
       };
@@ -235,55 +332,6 @@ class ExprDoc {
     );
   }
 
-  /// 槽尾是不是运算符 —— 说明用户这句还没写完。
-  static const _operators = {'+', '-', '*', '/', '^', ','};
-
-  static bool _endsWithOperator(SeqNode seq) {
-    if (seq.children.isEmpty) return false;
-    final last = seq.children.last;
-    return last is LitNode && _operators.contains(last.text);
-  }
-
-  /// 单参数(或多参数带预置)函数键的行为。
-  ///
-  /// 两种情况,按"用户此刻想干什么"分:
-  ///
-  ///  1. **光标在槽尾、槽里有内容、且最后不是运算符** → 把**整槽内容**包进函数。
-  ///     用户打完 `2x+1` 按 `ln`,要的是 `Log[2x+1]`,不是 `Log[]` 再让他重打。
-  ///     (注意是**整槽**不是"左边那个节点" —— `2x+1` 是四个节点,
-  ///     只包最后一个会变成 `2x+Log[1]`。)
-  ///
-  ///  2. **其余情况** → 插一个空模板,光标进括号里。
-  ///     空槽时按 `ln` 就是这种情况;光标停在中间时也是。
-  ///
-  /// 一个刻意的例外:槽尾是运算符(用户刚打完 `1+`)**不包**。
-  /// 那时他想要的是 `1+Log[...]`,包起来会变成 `Log[1+]`。
-  ///
-  /// [prefill] 里的第一项留空表示"由上面两种情况决定",其余项原样预置。
-  void applyFunction(String name, {List<String> prefill = const []}) {
-    final seq = current;
-    final atEnd = cursor.offset >= seq.children.length;
-    final shouldWrap =
-        atEnd && seq.children.isNotEmpty && !_endsWithOperator(seq);
-
-    // prefill 的第一项是"要填的那个参数",包裹时被整槽内容顶替;
-    // 没有 prefill 的函数(Sin、Log…)就只有一个参数。
-    final rest = prefill.isEmpty ? const <String>[] : prefill.sublist(1);
-
-    if (!shouldWrap) {
-      makeFunction(name, prefill: prefill);
-      return;
-    }
-
-    final inner = SeqNode(List<ExprNode>.from(seq.children));
-    seq.children.clear();
-    seq.children.add(FuncNode(name, [
-      inner,
-      for (final p in rest) p.isEmpty ? SeqNode() : SeqNode([LitNode(p)]),
-    ]));
-    cursor = SlotPath(cursor.steps, 1);
-  }
-
   /// 切到当前函数调用的下一个参数;已经是最后一个就新建一个参数槽。
   ///
   /// 只能用在函数参数里(光标路径的最后一跳指向某个 FuncNode 的参数槽时)。
@@ -367,6 +415,14 @@ class ExprDoc {
     cursor = up;
   }
 
+  /// 用另一个槽的内容整体替换(粘贴用)。光标落到末尾 —— 粘完接着打最自然。
+  void replaceWith(SeqNode other) {
+    root.children
+      ..clear()
+      ..addAll(other.children);
+    cursor = SlotPath(const <SlotStep>[], root.children.length);
+  }
+
   void clear() {
     root.children.clear();
     cursor = SlotPath.root;
@@ -390,14 +446,53 @@ class ExprDoc {
         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)})',
+        BraceNode b => '{${serializeSeq(b.inner)}}',
         PostfixNode p => '${_grouped(serializeSeq(p.operand))}${p.op}',
       };
 
   /// 复合结构当操作数时要加括号,否则 `x+1` 当分母会算错优先级。
+  ///
+  /// 但**不多加**:整体已经被一对括号包住的(`(x+1)`)不再套一层成 `((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;
+    if (_isFullyParenthesized(s)) return s;
+    if (_isSimpleTerm(s)) return s;
+    return '($s)';
+  }
+
+  /// 整个串恰好被一对括号包住(括号配平、且中间没有提前闭合)。
+  static bool _isFullyParenthesized(String s) {
+    if (!s.startsWith('(') || !s.endsWith(')')) return false;
+    var depth = 0;
+    for (var i = 0; i < s.length; i++) {
+      if (s[i] == '(') {
+        depth++;
+      } else if (s[i] == ')') {
+        depth--;
+        if (depth == 0) return i == s.length - 1;
+      }
+    }
+    return false;
+  }
+
+  /// 顶层没有运算符 —— 单个字面量、函数调用或括号组,当操作数不需要括号。
+  ///
+  /// `[` `]` `{` `}` 也算一层,所以 `Sin[x+1]` 里的 `+` 不算顶层运算符 ——
+  /// 它本来就不用括号。开头的正负号是正负号不是运算符,跳过。
+  static bool _isSimpleTerm(String s) {
+    var depth = 0;
+    for (var i = 0; i < s.length; i++) {
+      final c = s[i];
+      if (c == '(' || c == '[' || c == '{') {
+        depth++;
+      } else if (c == ')' || c == ']' || c == '}') {
+        depth--;
+      } else if (depth == 0 && '+-*/^'.contains(c)) {
+        if (i == 0 && (c == '-' || c == '+')) continue;
+        return false;
+      }
+    }
+    return true;
   }
 }

+ 273 - 0
lib/expr_parse.dart

@@ -0,0 +1,273 @@
+/// 把 Symja 语法的表达式文本解析成节点树。
+///
+/// 用途是**粘贴**:计算器的输入是结构化的,从剪贴板进来的却是一串文本,
+/// 得先转成树才能继续用按钮编辑。
+///
+/// 设计原则:**只解析一个明确的子集,遇到不认识的东西就明确报错,绝不猜。**
+/// 猜错了用户看到的是算错的答案,比直接报错糟糕得多。
+///
+/// 支持的语法:
+///   数字(含小数与 `1e-3` 科学计数法)
+///   `+ - * / ^`、一元负号、并置乘法(`2x`、`2(x+1)`)
+///   `Name[...]`、`( … )`、`{ … }`
+///   `-> == :=`(当普通运算符节点)
+///   标识符可以带 `_` 和 `$`(`x_`、`$ans`)
+///
+/// 不支持(会报错):`@ /;; & 过长的上下文反引号` 等等。
+library;
+
+import 'expr_node.dart';
+
+/// 解析失败。带上出错位置,方便定位。
+class ExprParseException implements Exception {
+  final String message;
+  final int position;
+  const ExprParseException(this.message, this.position);
+
+  @override
+  String toString() => position >= 0 ? '第 ${position + 1} 个字符:$message' : message;
+}
+
+/// 解析成功返回根槽;失败抛 [ExprParseException]。
+SeqNode parseSymjaExpr(String source) => _Parser(source).parse();
+
+class _Parser {
+  final String src;
+  int i = 0;
+
+  _Parser(this.src);
+
+  bool get _eof => i >= src.length;
+  String get _cur => src[i];
+
+  void _ws() {
+    while (!_eof && const {' ', '\t', '\n', '\r'}.contains(_cur)) {
+      i++;
+    }
+  }
+
+  void _expect(String ch) {
+    _ws();
+    if (_eof) throw ExprParseException('表达式不完整,缺少「$ch」', i);
+    if (_cur != ch) throw ExprParseException('期望「$ch」,实际是「$_cur」', i);
+    i++;
+  }
+
+  SeqNode parse() {
+    final seq = SeqNode(_expr());
+    _ws();
+    if (!_eof) {
+      throw ExprParseException('多余的内容「${src.substring(i)}」', i);
+    }
+    return seq;
+  }
+
+  // ---------------------------------------------------------------- 加减层
+  //
+  // 树里没有"加法节点"——加减乘都是序列里的字面量运算符,优先级交给 Symja
+  // 求值时处理。只有 / 和 ^ 做成结构(分式、幂),因为它们要竖排/上标显示。
+
+  List<ExprNode> _expr() {
+    final out = <ExprNode>[..._term()];
+    while (true) {
+      _ws();
+      if (_eof) break;
+
+      // 多字符运算符要先判,否则 `x->0` 会被当成 `x` 减 `>0`
+      if (src.startsWith('->', i)) {
+        i += 2;
+        out.add(LitNode('->'));
+        out.addAll(_term());
+      } else if (src.startsWith('==', i)) {
+        i += 2;
+        out.add(LitNode('=='));
+        out.addAll(_term());
+      } else if (src.startsWith(':=', i)) {
+        i += 2;
+        out.add(LitNode(':='));
+        out.addAll(_term());
+      } else if (_cur == '+' || _cur == '-') {
+        out.add(LitNode(_cur));
+        i++;
+        out.addAll(_term());
+      } else {
+        break;
+      }
+    }
+    return out;
+  }
+
+  // ---------------------------------------------------------------- 乘除层
+
+  List<ExprNode> _term() {
+    var out = <ExprNode>[..._unary()];
+    while (true) {
+      _ws();
+      if (_eof) break;
+
+      if (_cur == '*') {
+        i++;
+        out = [...out, LitNode('*'), ..._unary()];
+      } else if (_cur == '/') {
+        i++;
+        out = [
+          FracNode(num: SeqNode(out), den: SeqNode(_unary())),
+        ];
+      } else if (_startsValue(_cur)) {
+        // 并置即乘法:2x、2(x+1)。不插 `*` 节点 —— 序列化出来还是 `2x`,
+        // Symja 认这个写法,而且用户看到的和打进去的一样。
+        out = [...out, ..._unary()];
+      } else {
+        break;
+      }
+    }
+    return out;
+  }
+
+  bool _startsValue(String c) =>
+      _isDigit(c) || c == '.' || _isIdentStart(c) || c == '(' || c == '{';
+
+  // ---------------------------------------------------------------- 一元 / 幂
+
+  List<ExprNode> _unary() {
+    _ws();
+    if (_eof) throw ExprParseException('表达式不完整', i);
+    if (_cur == '-') {
+      i++;
+      return [LitNode('-'), ..._unary()];
+    }
+    if (_cur == '+') {
+      i++;
+      return _unary();
+    }
+    return _power();
+  }
+
+  List<ExprNode> _power() {
+    final base = _primary();
+    _ws();
+    if (!_eof && _cur == '^') {
+      i++;
+      final exp = _unary(); // 右结合
+      return [PowNode(base: SeqNode(base), exp: SeqNode(exp))];
+    }
+    return base;
+  }
+
+  // ---------------------------------------------------------------- 原子
+
+  List<ExprNode> _primary() {
+    _ws();
+    if (_eof) throw ExprParseException('表达式不完整', i);
+    final c = _cur;
+
+    if (c == '(') {
+      i++;
+      final inner = _expr();
+      _expect(')');
+      return [GroupNode(SeqNode(inner))];
+    }
+
+    if (c == '{') {
+      i++;
+      // 花括号里是**逗号分隔的列表**(`{n, 1, Infinity}`),不是单个表达式。
+      // 逗号当成字面量节点留着,序列化时拼回去。
+      final inner = <ExprNode>[];
+      _ws();
+      if (!_eof && _cur != '}') {
+        while (true) {
+          if (inner.isNotEmpty) inner.add(LitNode(', '));
+          inner.addAll(_expr());
+          _ws();
+          if (!_eof && _cur == ',') {
+            i++;
+            continue;
+          }
+          break;
+        }
+      }
+      _expect('}');
+      return [BraceNode(SeqNode(inner))];
+    }
+
+    if (_isDigit(c) || c == '.') return [LitNode(_number())];
+
+    if (_isIdentStart(c)) {
+      final name = _ident();
+      _ws();
+      if (!_eof && _cur == '[') {
+        i++;
+        final args = _args();
+        _expect(']');
+        return [FuncNode(name, args)];
+      }
+      return [LitNode(name)];
+    }
+
+    throw ExprParseException('看不懂的字符「$c」', i);
+  }
+
+  List<SeqNode> _args() {
+    final out = <SeqNode>[];
+    _ws();
+    if (!_eof && _cur == ']') return out; // Sin[] 这种空参数
+
+    while (true) {
+      final nodes = _expr();
+      // 空槽序列化出来是 Null;解析时还原成空槽,这样"复制→粘贴"能闭环
+      final isNull = nodes.length == 1 &&
+          nodes.first is LitNode &&
+          (nodes.first as LitNode).text == 'Null';
+      out.add(isNull ? SeqNode() : SeqNode(nodes));
+
+      _ws();
+      if (!_eof && _cur == ',') {
+        i++;
+        continue;
+      }
+      break;
+    }
+    return out;
+  }
+
+  String _number() {
+    final start = i;
+    while (!_eof && (_isDigit(_cur) || _cur == '.')) {
+      i++;
+    }
+    // 科学计数法
+    if (!_eof && (_cur == 'e' || _cur == 'E')) {
+      var k = i + 1;
+      if (k < src.length && (src[k] == '+' || src[k] == '-')) k++;
+      if (k < src.length && _isDigit(src[k])) {
+        i = k;
+        while (!_eof && _isDigit(_cur)) {
+          i++;
+        }
+      }
+    }
+    return src.substring(start, i);
+  }
+
+  String _ident() {
+    final start = i;
+    while (!_eof && _isIdentPart(_cur)) {
+      i++;
+    }
+    return src.substring(start, i);
+  }
+}
+
+bool _isDigit(String c) {
+  final u = c.codeUnitAt(0);
+  return u >= 0x30 && u <= 0x39;
+}
+
+bool _isLetter(String c) {
+  final u = c.codeUnitAt(0);
+  return (u >= 0x41 && u <= 0x5A) || (u >= 0x61 && u <= 0x7A) || c == '√';
+}
+
+bool _isIdentStart(String c) => _isLetter(c) || c == '_' || c == r'$';
+
+bool _isIdentPart(String c) => _isLetter(c) || _isDigit(c) || c == '_' || c == r'$';

+ 3 - 3
pubspec.lock

@@ -252,13 +252,13 @@ packages:
     source: hosted
     version: "3.2.0"
   meta:
-    dependency: transitive
+    dependency: "direct main"
     description:
       name: meta
-      sha256: c82594181e3312f3d0695fc95aaaf7758d75b8d4ae2bbecf223b9fd5109a059d
+      sha256: "307249ce4ff29d58a18e97f6345f539382eb9c9c29ecda628900f31de0443dd9"
       url: "https://pub.dev"
     source: hosted
-    version: "1.18.3"
+    version: "1.19.0"
   nested:
     dependency: transitive
     description:

+ 1 - 0
pubspec.yaml

@@ -40,6 +40,7 @@ dependencies:
   rational: ^2.2.3
   fl_chart: ^1.2.0
   math_expressions: ^3.2.0
+  meta: ^1.19.0
 
 dev_dependencies:
   flutter_test:

+ 49 - 39
test/expr_node_test.dart

@@ -150,9 +150,11 @@ void main() {
       expect(d.toSymja(), 'f[1, 2]');
     });
 
-    test('根式把左边的节点收进被开方数', () {
-      final d = docWith([LitNode('2')]);
-      d.applyFunction('Sqrt');
+    test('根号键插空模板,往里填即得', () {
+      final d = ExprDoc();
+      d.makeFunction('Sqrt');
+      expect(d.toSymja(), 'Sqrt[Null]');
+      d.insertChar('2');
       expect(d.toSymja(), 'Sqrt[2]');
     });
   });
@@ -245,7 +247,7 @@ void main() {
     });
   });
 
-  group('函数键:把整个槽包进去', () {
+  group('选区与剪切粘贴', () {
     ExprDoc twoXPlusOne() {
       final d = ExprDoc();
       d.insertChar('2');
@@ -255,55 +257,63 @@ void main() {
       return d;
     }
 
-    test('打完 2x+1 按 ln → Log[2x+1](整槽,不是只包最后一个节点)', () {
+    test('选中一个节点', () {
       final d = twoXPlusOne();
-      expect(d.toSymja(), '2x+1');
-      d.applyFunction('Log');
-      expect(d.toSymja(), 'Log[2x+1]');
+      d.selectNodeAt(SlotPath.root, 0);
+      expect(d.selectionText, '2');
     });
 
-    test('单参数函数包完只带一个参数(不能多塞空槽)', () {
-      final d = ExprDoc();
-      d.insertChar('2');
-      d.applyFunction('Sqrt');
-      expect(d.toSymja(), 'Sqrt[2]');
+    test('扩展选区覆盖连续节点', () {
+      final d = twoXPlusOne();
+      d.selectNodeAt(SlotPath.root, 1); // x
+      d.extendSelectionTo(SlotPath.root, 3); // 1
+      expect(d.selectionText, 'x+1');
     });
 
-    test('带预置参数的:整槽当第一个参数,其余照旧', () {
+    test('往左扩展也成立(start 取更小的那个)', () {
       final d = twoXPlusOne();
-      d.applyFunction('Integrate', prefill: ['', 'x']);
-      expect(d.toSymja(), 'Integrate[2x+1, x]');
+      d.selectNodeAt(SlotPath.root, 3);
+      d.extendSelectionTo(SlotPath.root, 0);
+      expect(d.selectionText, '2x+1');
     });
 
-    test('空槽时按 ln → 空模板,不是 Log[Null] 包 Null', () {
-      final d = ExprDoc();
-      d.applyFunction('Log');
-      expect(d.toSymja(), 'Log[Null]');
-      d.insertChar('x');
-      expect(d.toSymja(), 'Log[x]');
-    });
+    test('剪切:返回文本、删掉内容、光标落到原位', () {
+      final d = twoXPlusOne();
+      d.selectNodeAt(SlotPath.root, 0);
+      d.extendSelectionTo(SlotPath.root, 3);
+      expect(d.selectionText, '2x+1');
 
-    test('槽尾是运算符时不包 —— 用户刚打完 1+ ,要的是 1+Log[...]', () {
-      final d = ExprDoc();
-      d.insertChar('1');
-      d.insert(LitNode('+'));
-      d.applyFunction('Log');
-      expect(d.toSymja(), '1+Log[Null]');
+      d.deleteSelection();
+      expect(d.toSymja(), 'Null');
+      expect(d.selection, isNull);
+      expect(d.cursor.offset, 0);
     });
 
-    test('光标停在中间时也插模板,不把整槽包走', () {
+    test('只删中间一段,两边保留', () {
       final d = twoXPlusOne();
-      d.cursor = const SlotPath(<SlotStep>[], 2); // 停在 x 之后、+ 之前
-      d.applyFunction('Log');
-      expect(d.toSymja(), '2xLog[Null]+1');
+      d.cursor = const SlotPath(<SlotStep>[], 0);
+      d.selectNodeAt(SlotPath.root, 1);
+      // 选中 x 与 +
+      d.extendSelectionTo(SlotPath.root, 2);
+      expect(d.selectionText, 'x+');
+      d.deleteSelection();
+      expect(d.toSymja(), '21');
     });
 
-    test('包完之后光标在函数外面(能继续往后打)', () {
-      final d = twoXPlusOne();
-      d.applyFunction('Log');
-      d.insert(LitNode('+'));
-      d.insertChar('3');
-      expect(d.toSymja(), 'Log[2x+1]+3');
+    test('插入到光标处(粘贴进 Log 的括号里)', () {
+      final d = ExprDoc();
+      d.makeFunction('Log'); // Log[□],光标在参数槽里
+      d.insertNodesAtCursor([LitNode('2'), LitNode('x'), LitNode('+'), LitNode('1')]);
+      expect(d.toSymja(), 'Log[2x+1]');
+      expect(d.cursor.offset, 4);
+    });
+
+    test('跨槽扩展会被忽略,不会串到别的槽', () {
+      final d = ExprDoc();
+      d.makeFunction('Log'); // 光标在 Log 的参数槽
+      d.selectNodeAt(SlotPath.root, 0); // 选中根槽里的 Log
+      d.extendSelectionTo(const SlotPath(<SlotStep>[SlotStep(0, 0)], 0), 0);
+      expect(d.selectionText, 'Log[Null]'); // 还是原来那个选区
     });
   });
 

+ 121 - 0
test/expr_parse_test.dart

@@ -0,0 +1,121 @@
+// 粘贴用的解析器:把 Symja 文本转回节点树。
+//
+// 重点是**往返一致**:复制出来的东西粘回去应当还是同一个表达式;
+// 以及**不认识就报错**,绝不猜着解析(猜错等于给用户一个算错的答案)。
+import 'package:flutter_test/flutter_test.dart';
+
+import 'package:cal/expr_node.dart';
+import 'package:cal/expr_parse.dart';
+
+/// 解析再序列化,应当和输入一模一样。
+String rt(String s) => symjaOf(parseSymjaExpr(s));
+
+void main() {
+  group('基本往返', () {
+    test('数字与四则', () {
+      expect(rt('2'), '2');
+      expect(rt('2+3'), '2+3');
+      expect(rt('2x+1'), '2x+1');
+      expect(rt('2*x+1'), '2*x+1');
+      expect(rt('2.5'), '2.5');
+    });
+
+    test('函数调用', () {
+      expect(rt('Sin[x]'), 'Sin[x]');
+      expect(rt('Log[2x+1]'), 'Log[2x+1]');
+      expect(rt('Integrate[x^2, x]'), 'Integrate[x^2, x]');
+      expect(rt('Sin[]'), 'Sin[]');
+    });
+
+    test('幂与分式做成结构(要竖排/上标显示)', () {
+      final doc = parseSymjaExpr('x^2');
+      expect(doc.children.single, isA<PowNode>());
+      final frac = parseSymjaExpr('1/3');
+      expect(frac.children.single, isA<FracNode>());
+    });
+
+    test('分式分母是复合表达式时序列化补括号', () {
+      expect(rt('1/(x+1)'), '1/(x+1)');
+    });
+
+    test('括号与花括号', () {
+      expect(rt('(x+1)'), '(x+1)');
+      expect(rt('2(x+1)'), '2(x+1)');  // 并置乘法原样保留,Symja 认这个写法
+      expect(rt('{n, 1, Infinity}'), '{n, 1, Infinity}');
+      expect(rt('Sum[f, {n, 1, Infinity}]'), 'Sum[f, {n, 1, Infinity}]');
+    });
+
+    test('空槽写成 Null,解析回来还是空槽', () {
+      final t = parseSymjaExpr('Integrate[Null, x]');
+      final fn = t.children.single as FuncNode;
+      expect(fn.args.first.isEmpty, isTrue);
+      expect(symjaOf(t), 'Integrate[Null, x]');
+    });
+  });
+
+  group('运算符与词法', () {
+    test('多字符运算符', () {
+      expect(rt('x->0'), 'x->0');
+      expect(rt('x^2-1==0'), 'x^2-1==0');
+      expect(rt('f[x_]:=x^2'), 'f[x_]:=x^2');
+    });
+
+    test('一元负号', () {
+      expect(rt('-x'), '-x');
+      expect(rt('-1/2'), '-1/2');
+    });
+
+    test('科学计数法不能被拆成并置乘法', () {
+      expect(rt('1e-3'), '1e-3');
+      expect(rt('2.5E10'), '2.5E10');
+    });
+
+    test(r'标识符里的下划线和 $', () {
+      expect(rt('x_'), 'x_');
+      expect(rt(r'$ans'), r'$ans');
+    });
+
+    test('空白随便加', () {
+      expect(rt('  2  x  +  1 '), '2x+1');
+    });
+  });
+
+  group('不认识就报错,不猜', () {
+    void bad(String s) {
+      expect(() => parseSymjaExpr(s), throwsA(isA<ExprParseException>()),
+          reason: '「$s」应当解析失败');
+    }
+
+    test('不支持的运算符', () {
+      bad('a@b');
+      bad('a&b');
+      bad('x;;y');
+      bad('a//b');
+    });
+
+    test('括号不配平', () {
+      bad('(x+1');
+      bad('Sin[x');
+      bad('Abs[x]]');
+    });
+
+    test('多余的尾巴', () {
+      bad('x y z ]');
+      bad('1+2)');
+    });
+
+    test('空串', () {
+      bad('');
+    });
+
+    test('报错信息带位置', () {
+      try {
+        parseSymjaExpr('x@y');
+        fail('应当抛异常');
+      } on ExprParseException catch (e) {
+        expect(e.position, 1);
+        expect(e.toString(), contains('第 2 个字符'));
+      }
+    });
+  });
+}

+ 143 - 11
test/widget_test.dart

@@ -191,17 +191,6 @@ void main() {
       expect(find.byKey(const ValueKey('expr-caret')), findsOneWidget);
     });
 
-    testWidgets('打完 2x+1 按 ln,整个式子被包成 Log[2x+1]', (tester) async {
-      await pumpApp(tester);
-      for (final k in ['2', 'x', '+', '1']) {
-        await press(tester, k);
-      }
-      expect(find.text('2x+1'), findsOneWidget);
-
-      await press(tester, 'ln');
-      expect(find.text('Log[2x+1]'), findsOneWidget);
-    });
-
     testWidgets('点节点左半边能把光标放回最前面', (tester) async {
       await pumpApp(tester);
       await press(tester, 'x');
@@ -287,6 +276,149 @@ void main() {
     });
   });
 
+  group('复制粘贴', () {
+    /// 把系统通道的剪贴板换成内存版,并记录写入。
+    ({List<String> copied, void Function(String?) feed}) mockClipboard(
+        WidgetTester tester) {
+      final copied = <String>[];
+      String? available;
+      TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
+          .setMockMethodCallHandler(SystemChannels.platform, (call) async {
+        switch (call.method) {
+          case 'Clipboard.setData':
+            final t = (call.arguments as Map)['text'] as String;
+            copied.add(t);
+            available = t; // 有写有读,跟真剪贴板一样
+            return null;
+          case 'Clipboard.getData':
+            return available == null ? null : <String, dynamic>{'text': available};
+          default:
+            return null;
+        }
+      });
+      addTearDown(() => TestDefaultBinaryMessengerBinding
+          .instance.defaultBinaryMessenger
+          .setMockMethodCallHandler(SystemChannels.platform, null));
+      return (copied: copied, feed: (v) => available = v);
+    }
+
+    Future<void> pickMenu(WidgetTester tester, String item) async {
+      await tester.tap(find.byIcon(Icons.more_vert));
+      await tester.pumpAndSettle();
+      await tester.tap(find.text(item));
+      await tester.pumpAndSettle();
+    }
+
+    Finder inEditor(String text) => find.descendant(
+        of: find.byType(ExprEditor), matching: find.text(text));
+
+    testWidgets('长按选中一段 → 操作栏自动出现 → 剪切', (tester) async {
+      final clip = mockClipboard(tester);
+      await pumpApp(tester);
+      for (final k in ['2', 'x', '+', '1']) {
+        await press(tester, k);
+      }
+
+      await tester.longPress(inEditor('2'));
+      await tester.pumpAndSettle();
+      // 选中后操作栏自动出现
+      expect(find.textContaining('已选中'), findsOneWidget);
+
+      // 再点另一个节点 → 扩展选区
+      await tester.tap(inEditor('1'));
+      await tester.pumpAndSettle();
+      expect(find.textContaining('已选中  2x+1'), findsOneWidget);
+
+      await tester.tap(find.text('剪切'));
+      await tester.pumpAndSettle();
+      expect(clip.copied, ['2x+1']);
+      expect(find.text('Null'), findsOneWidget); // 全剪掉了
+    });
+
+    testWidgets('剪切后粘到 Log 的括号里', (tester) async {
+      final clip = mockClipboard(tester);
+      await pumpApp(tester);
+      for (final k in ['2', 'x', '+', '1']) {
+        await press(tester, k);
+      }
+      await tester.longPress(inEditor('2'));
+      await tester.pumpAndSettle();
+      await tester.tap(inEditor('1'));
+      await tester.pumpAndSettle();
+      await tester.tap(find.text('剪切'));
+      await tester.pumpAndSettle();
+
+      // 建一个空的 Log,光标在参数槽里
+      await press(tester, 'ln');
+      expect(find.text('Log[Null]'), findsOneWidget);
+
+      await pickMenu(tester, '从剪贴板粘贴');
+      expect(find.text('Log[2x+1]'), findsWidgets);
+    });
+
+    testWidgets('点光标所在位置会弹出粘贴入口', (tester) async {
+      final clip = mockClipboard(tester);
+      clip.feed('7');
+      await pumpApp(tester);
+      await press(tester, 'x'); // 光标停在 x 之后
+
+      // 点 x 的右半边 = 光标已经在的地方
+      final r = tester.getRect(inEditor('x'));
+      await tester.tapAt(Offset(r.right - 1, r.center.dy));
+      await tester.pumpAndSettle();
+      expect(find.text('粘贴到光标处'), findsOneWidget);
+
+      await tester.tap(find.text('粘贴'));
+      await tester.pumpAndSettle();
+      expect(find.textContaining('已粘贴到光标处'), findsOneWidget);
+    });
+
+    testWidgets('复制表达式进剪贴板', (tester) async {
+      final clip = mockClipboard(tester);
+      await pumpApp(tester);
+      await press(tester, 'x');
+      await press(tester, '^');
+      await press(tester, '2');
+
+      await pickMenu(tester, '复制表达式');
+      expect(clip.copied, ['x^2']);
+    });
+
+    testWidgets('粘贴一段 Symja 表达式,转成结构化输入', (tester) async {
+      final clip = mockClipboard(tester);
+      clip.feed('Integrate[x^2, x]');
+      await pumpApp(tester);
+
+      await pickMenu(tester, '从剪贴板粘贴');
+      // 编辑器显示区和序列化行都会出现它
+      expect(find.text('Integrate[x^2, x]'), findsWidgets);
+    });
+
+    testWidgets('粘贴解析不了的东西会明确报错,不会乱猜', (tester) async {
+      final clip = mockClipboard(tester);
+      clip.feed('x@y');
+      await pumpApp(tester);
+
+      await pickMenu(tester, '从剪贴板粘贴');
+      expect(find.textContaining('没法转成按钮式输入'), findsOneWidget);
+    });
+
+    testWidgets('复制粘贴走一圈,结果一致', (tester) async {
+      final clip = mockClipboard(tester);
+      await pumpApp(tester);
+      for (final k in ['2', 'x', '+', '1']) {
+        await press(tester, k);
+      }
+      await pickMenu(tester, '复制表达式');
+      expect(clip.copied.single, '2x+1');
+
+      clip.feed(clip.copied.single);
+      await pickMenu(tester, '清空');
+      await pickMenu(tester, '从剪贴板粘贴');
+      expect(find.text('2x+1'), findsWidgets);
+    });
+  });
+
   group('控制台', () {
     testWidgets('提交表达式会送到通道并渲染结果', (tester) async {
       await pumpApp(tester);