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

符号显示形式、大运算符上下限、绘图滑动条、复制粘贴入口调整等一批改动

## 常见函数用数学记号显示(不再一律 Name[...])
  √  用画笔画成一条连起来的折线(下折→上挑→横线)。
     原来是"√ 字符 + 一条上边框",两边各按各的字形对齐,中间必然断开。
  ∫  画成 ∫ f dx;定积分时下限在下、上限在上
  ∑ ∏ 下限在下、上限在上,后面跟求和项
  lim 条件写在 "lim" 正下方
  只改画法不改槽 —— 参数槽还是那些,照样能点进去编辑。
  上下限槽**始终渲染**(空的画成小占位框),否则不定积分的两个限槽
  就成了够不着的死结构。

## 缩放改成真的改字号
之前用 Transform.scale 缩上下限:它只改视觉不改布局,占位仍是原尺寸,
于是"字看着小了但离运算符老远"。改成把 size 传下去,布局跟着缩。

## 编辑区高度固定
表达式一变高(分式、Σ 带上下限)编辑区就撑大,下面的键盘整体往下跳,
手指刚瞄好的键就换位置。改成固定高度 + 超高纵向滚动。

## 绘图不再用手势,改滑动条
图在手机上很小,双指捏合容易误触、缩过头还找不回来。改成横向/纵向两条
RangeSlider + 复位按钮,能看出当前窗口在哪、也能一键回到自动定界。

## 工具里的表达式直接交给 Symja 解析
二分法求根原来用 math_expressions(另一套语法、另一套优先级,同一个式子
可能"在这里算得出来、在别处又不对"),改成 `N[f /. x -> a]` 走 Symja。
函数图像绘制/参数方程绘图/三点法向量也去掉了我自己的语法转换层 —— 谁解析
谁负责,不再做中间翻译。因此删掉 lib/expr_syntax.dart 及其测试。

## 交互与文案
  · 函数面板按钮加回 AppBar,"⋮"菜单去掉(复制/粘贴改由选区操作栏承担)
  · 点结果直接复制结果
  · 函数面板 242 个函数后面各加一句中文说明
  · 控制台 help 去掉分栏排版(中英文混排在等宽字体里根本对不齐)

79 个测试通过。真机验证:根号连成一体、∫ x² dx、Σ 的 ∞ 在上 1 在下、
lim 的 x→0 在下、绘图滑动条、点结果复制、函数面板中文说明。

Co-Authored-By: Claude Code <noreply@anthropic.com>
nonvioce 4 недель назад
Родитель
Сommit
1a823dbe59

+ 56 - 59
lib/calculator_page.dart

@@ -65,8 +65,6 @@ class _CalculatorPageState extends State<CalculatorPage> {
       ..showSnackBar(SnackBar(content: Text(msg)));
   }
 
-  // ------------------------------------------------------------------ 复制 / 粘贴
-
   Future<void> _copy(String text, String what) async {
     await Clipboard.setData(ClipboardData(text: text));
     _snack('已复制$what:$text');
@@ -174,32 +172,6 @@ class _CalculatorPageState extends State<CalculatorPage> {
     );
   }
 
-  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') {
@@ -252,7 +224,7 @@ class _CalculatorPageState extends State<CalculatorPage> {
           title: Text('$expr   (${s.ys.length} 点,${s.elapsedMs} ms)'),
           content: SizedBox(
             width: 360,
-            height: 280,
+            height: 420,
             child: CurvePlot(series: [
               CurveSeries(
                 label: expr,
@@ -291,8 +263,17 @@ class _CalculatorPageState extends State<CalculatorPage> {
       case _Act.fn:
         _doc.makeFunction(k.payload);
       case _Act.fnPrefixed:
-        // 像 Integrate 这种,第一个参数留空给用户填,其余预置好
+        // 像 D 这种,第一个参数留空给用户填,其余预置好
         _doc.makeFunction(k.payload, prefill: k.prefill);
+      case _Act.bigOp:
+        // ∫ / Σ / lim:上下限单独成槽,才画得到习惯位置
+        final p = k.prefill;
+        _doc.makeBigOp(
+          k.payload,
+          variable: p.isNotEmpty ? p[0] : 'x',
+          lower: p.length > 1 ? p[1] : '',
+          upper: p.length > 2 ? p[2] : '',
+        );
       case _Act.sqrt:
         _doc.makeFunction('Sqrt');
       case _Act.group:
@@ -367,16 +348,10 @@ class _CalculatorPageState extends State<CalculatorPage> {
             icon: const Icon(Icons.show_chart),
             onPressed: _plotCurrent,
           ),
-          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('清空')),
-            ],
+          IconButton(
+            tooltip: '函数面板',
+            icon: const Icon(Icons.functions),
+            onPressed: _showPalette,
           ),
         ],
       ),
@@ -405,16 +380,29 @@ class _CalculatorPageState extends State<CalculatorPage> {
       child: Column(
         crossAxisAlignment: CrossAxisAlignment.end,
         children: [
-          ExprEditor(
-            doc: _doc,
-            onChanged: _onDocChanged,
-            onCaretTapped: () => setState(() => _pastePrompt = true),
+          // 固定高度 + 超高时纵向滚动。
+          // 不固定的话,表达式一变高(分式、Σ 带上下限)编辑区就撑大,
+          // 下面的键盘整体往下跳,手指刚瞄好的键就换位置了。
+          SizedBox(
+            height: 104,
+            child: SingleChildScrollView(
+              scrollDirection: Axis.vertical,
+              reverse: true, // 内容超高时把底部(光标那侧)留在视野里
+              child: ExprEditor(
+                doc: _doc,
+                onChanged: _onDocChanged,
+                onCaretTapped: () => setState(() => _pastePrompt = true),
+              ),
+            ),
           ),
           const SizedBox(height: 6),
-          // 序列化结果摆在明面上:用户能看到"到底会被求值成什么"
+          // 常显。它就是真正送进引擎的那串文本(也是"复制表达式"复制走的内容),
+          // 空槽会写成 Null、隐含括号也会露出来。
           Text(
             _source,
             textAlign: TextAlign.right,
+            maxLines: 2,
+            overflow: TextOverflow.ellipsis,
             style: TextStyle(
               fontFamily: 'monospace',
               fontSize: 11,
@@ -425,12 +413,16 @@ class _CalculatorPageState extends State<CalculatorPage> {
           SizedBox(
             height: 52,
             width: double.infinity,
-            // 等比缩到放得下,**不横向滚动** ——
-            // 滚动的话长结果只能看到尾巴,"显示不全"就是这么来的。
-            child: FittedBox(
-              fit: BoxFit.scaleDown,
-              alignment: Alignment.centerRight,
-              child: _resultView(context, shown),
+            child: GestureDetector(
+              key: const ValueKey('calc-result'),
+              // 点结果 = 复制结果
+              onTap: shown == null ? null : () => _copy(shown.text, '结果'),
+              behavior: HitTestBehavior.opaque,
+              child: FittedBox(
+                fit: BoxFit.scaleDown,
+                alignment: Alignment.centerRight,
+                child: _resultView(context, shown),
+              ),
             ),
           ),
         ],
@@ -453,7 +445,8 @@ class _CalculatorPageState extends State<CalculatorPage> {
     }
     if (shown == null) return const SizedBox.shrink();
 
-    final plain = SelectableText(
+    // 用 Text 而不是 SelectableText:点结果是"复制",选中文字反而挡住点击
+    final plain = Text(
       shown.text,
       style: const TextStyle(fontFamily: 'monospace', fontSize: 22),
     );
@@ -487,11 +480,12 @@ class _CalculatorPageState extends State<CalculatorPage> {
       builder: (context, box) {
         final rows = <List<_Key>>[
           [
-            _Key('∫', _Act.fnPrefixed, payload: 'Integrate', prefill: ['', 'x']),
-            _Key('Σ', _Act.fnPrefixed,
-                payload: 'Sum', prefill: ['', '{n, 1, Infinity}']),
+            // 不定积分:只给变量,不给上下限
+            _Key('∫', _Act.bigOp, payload: 'Integrate', prefill: ['x']),
+            _Key('Σ', _Act.bigOp,
+                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('lim', _Act.bigOp, payload: 'Limit', prefill: ['x', '0']),
             _Key('solve', _Act.fnPrefixed, payload: 'Solve', prefill: ['', 'x']),
           ],
           [
@@ -599,13 +593,11 @@ class _CalculatorPageState extends State<CalculatorPage> {
   }
 }
 
-/// 计算器右上角「⋮」菜单的项。
-enum _Menu { palette, copyExpr, copyResult, paste, clear }
-
 enum _Act {
   char,
   fn,
   fnPrefixed,
+  bigOp,
   sqrt,
   group,
   power,
@@ -725,6 +717,11 @@ class _FunctionPaletteState extends State<_FunctionPalette> {
                         fns[i],
                         style: const TextStyle(fontFamily: 'monospace'),
                       ),
+                      // 一句中文说明,挂名字后面
+                      subtitle: Text(
+                        kSymjaFunctionHelp[fns[i]] ?? '',
+                        style: const TextStyle(fontSize: 12),
+                      ),
                       onTap: () => Navigator.pop(
                           context, (value: fns[i], isFn: true)),
                     ),

+ 20 - 17
lib/console_page.dart

@@ -16,23 +16,26 @@ class ConsolePage extends StatefulWidget {
 }
 
 class _ConsolePageState extends State<ConsolePage> {
+  // 一行一条,不用分栏 —— 中英文混排在等宽字体里对不齐,分栏只会更乱
   static const _helpText = '''
-可输入任意 Symja 表达式,例如:
-  Integrate[x^2, x]        D[Sin[x], x]        Solve[x^2-1==0, x]
-  a = 42                   f[x_] := x^2 + 1     % + 1
-
-命令(小写,与 Symja 函数区分):
-  help / ?          显示这份帮助
-  clear / cls       清空历史
-  clear <变量名>     清除某个变量
-  reset             清空全部变量与历史
-  history           列出输入过的表达式
-  vars              列出当前变量
-  plot <表达式>      在 x ∈ [-6.3, 6.3] 上采样绘图
-  N <表达式> [位数]   数值化,默认 30 位
-  mcp               查看 MCP 服务器状态
-
-% 表示上一次求值的结果。变量与历史会自动保存,重启后恢复。''';
+输入任意 Symja 表达式,例如
+  Integrate[x^2, x]
+  Solve[x^2-1==0, x]
+  a = 42
+
+% 表示上一次求值的结果。
+变量、函数定义和历史都会自动保存,重启后恢复。
+
+命令(小写;大写的是 Symja 函数)
+  help 或 ?        显示这份帮助
+  clear 或 cls     清空历史
+  clear 变量名      删除某个变量
+  reset            清空全部变量和历史
+  history          列出输入过的表达式
+  vars             列出当前变量
+  plot 表达式       画图(x 从 -6.3 到 6.3)
+  N 表达式 [位数]   取近似值,默认 15 位
+  mcp              MCP 服务器开关''';
 
   final _ctrl = TextEditingController();
   final _focus = FocusNode();
@@ -259,7 +262,7 @@ class _ConsolePageState extends State<ConsolePage> {
           title: Text('$expr   (${s.ys.length} 点,${s.elapsedMs} ms)'),
           content: SizedBox(
             width: 360,
-            height: 280,
+            height: 420,
             child: CurvePlot(series: [plot]),
           ),
           actions: [

+ 164 - 91
lib/curve_plot.dart

@@ -19,76 +19,191 @@ class CurveSeries {
   });
 }
 
-/// 曲线绘制组件:自动定界、网格、刻度、多曲线图例、双指缩放/拖动、双击复位。
+/// 曲线绘制组件:自动定界、网格、刻度、多曲线图例。
 ///
-/// 放在 lib/ 顶层而不是 tools/ 下:控制台的 `plot` 命令和计算器都要用它,
-/// mathtool2 搬过来的「函数图像绘制 / 参数方程绘图」也换成它,
-/// 避免维护两套绘图。
+/// **不用手势操作** —— 缩放和平移改成下面两条滑动条。
+/// 图在手机屏幕上很小,双指捏合容易误触,而且缩过头就找不回来了;
+/// 滑条能看出当前窗口在哪、也能一键复位。
 class CurvePlot extends StatefulWidget {
   const CurvePlot({
     super.key,
     required this.series,
     this.yMin,
     this.yMax,
+    this.showControls = true,
   });
 
   final List<CurveSeries> series;
 
-  /// 显式指定 y 轴范围。不给就按数据自动定界。
-  ///
-  /// 为什么需要:自动定界对有极点的函数很难看(`Tan[x]` 会被几个巨大的有限值
-  /// 撑爆整幅图)。`函数图像绘制` 工具原本就能填 y 范围,这个参数把它接住。
+  /// 纵轴的**初始**窗口(不给就按数据自动定界)。之后由滑动条接管。
   final double? yMin;
   final double? yMax;
 
+  /// 是否显示下面的滑动条。嵌在很小的对话框里时可以关掉。
+  final bool showControls;
+
   @override
   State<CurvePlot> createState() => _CurvePlotState();
 }
 
+/// 数据在各轴上的范围。
+class _Bounds {
+  final double min;
+  final double max;
+  const _Bounds(this.min, this.max);
+  double get span => max - min;
+}
+
 class _CurvePlotState extends State<CurvePlot> {
-  double _zoom = 1;
-  Offset _pan = Offset.zero;
-  double _zoomStart = 1;
-  Offset _panStart = Offset.zero;
+  /// 当前视图窗口。null = 跟着数据自动定界。
+  double? _x0, _x1, _y0, _y1;
+
+  _Bounds? _dataX;
+  _Bounds? _dataY;
+
+  void _recomputeBounds() {
+    double? x0, x1, y0, y1;
+    for (final s in widget.series) {
+      for (final p in s.points) {
+        if (!p.dx.isFinite || !p.dy.isFinite) continue;
+        x0 = (x0 == null) ? p.dx : math.min(x0, p.dx);
+        x1 = (x1 == null) ? p.dx : math.max(x1, p.dx);
+        y0 = (y0 == null) ? p.dy : math.min(y0, p.dy);
+        y1 = (y1 == null) ? p.dy : math.max(y1, p.dy);
+      }
+    }
+    if (x0 == null || y0 == null || x1 == null || y1 == null) {
+      _dataX = null;
+      _dataY = null;
+      return;
+    }
+    if (x1 - x0 < 1e-12) {
+      x0 -= 1;
+      x1 += 1;
+    }
+    if (y1 - y0 < 1e-12) {
+      y0 -= 1;
+      y1 += 1;
+    }
+    // 上下留 5% 余量,别让曲线顶到边
+    final pad = (y1 - y0) * 0.05;
+    _dataX = _Bounds(x0, x1);
+    _dataY = _Bounds(y0 - pad, y1 + pad);
+  }
+
+  @override
+  void initState() {
+    super.initState();
+    _recomputeBounds();
+    _y0 = widget.yMin ?? _dataY?.min;
+    _y1 = widget.yMax ?? _dataY?.max;
+  }
+
+  @override
+  void didUpdateWidget(covariant CurvePlot old) {
+    super.didUpdateWidget(old);
+    if (!identical(old.series, widget.series)) {
+      _recomputeBounds();
+    }
+  }
 
   void _reset() => setState(() {
-        _zoom = 1;
-        _pan = Offset.zero;
+        _x0 = _x1 = null;
+        _y0 = widget.yMin ?? _dataY?.min;
+        _y1 = widget.yMax ?? _dataY?.max;
       });
 
   @override
   Widget build(BuildContext context) {
-    return GestureDetector(
-      onDoubleTap: _reset,
-      onScaleStart: (_) {
-        _zoomStart = _zoom;
-        _panStart = _pan;
-      },
-      onScaleUpdate: (d) => setState(() {
-        _zoom = (_zoomStart * d.scale).clamp(0.2, 50.0);
-        _pan = _panStart + d.focalPointDelta;
-      }),
-      child: ClipRect(
-        child: CustomPaint(
-          painter: _CurvePainter(
-            widget.series, _zoom, _pan,
-            yMin: widget.yMin, yMax: widget.yMax,
+    final dx = _dataX;
+    final dy = _dataY;
+    if (dx == null || dy == null) {
+      return const Center(child: Text('没有可绘制的有限点'));
+    }
+
+    final x0 = _x0 ?? dx.min, x1 = _x1 ?? dx.max;
+    final y0 = _y0 ?? dy.min, y1 = _y1 ?? dy.max;
+
+    return Column(
+      children: [
+        Expanded(
+          child: ClipRect(
+            child: CustomPaint(
+              painter: _CurvePainter(widget.series, x0, x1, y0, y1),
+              child: const SizedBox.expand(),
+            ),
           ),
-          child: const SizedBox.expand(),
         ),
-      ),
+        if (widget.showControls) _controls(dx, dy, x0, x1, y0, y1),
+      ],
+    );
+  }
+
+  Widget _controls(_Bounds dx, _Bounds dy,
+      double x0, double x1, double y0, double y1) {
+    // 滑条可调范围比数据范围宽一倍,这样既能放大也能缩小
+    final xLo = dx.min - dx.span, xHi = dx.max + dx.span;
+    final yLo = dy.min - dy.span, yHi = dy.max + dy.span;
+
+    return Column(
+      children: [
+        _slider('横向', xLo, xHi, x0, x1, (v) => setState(() {
+              _x0 = v.start;
+              _x1 = v.end;
+            })),
+        _slider('纵向', yLo, yHi, y0, y1, (v) => setState(() {
+              _y0 = v.start;
+              _y1 = v.end;
+            })),
+        Align(
+          alignment: Alignment.centerRight,
+          child: TextButton.icon(
+            onPressed: _reset,
+            icon: const Icon(Icons.restart_alt, size: 16),
+            label: const Text('复位'),
+          ),
+        ),
+      ],
+    );
+  }
+
+  Widget _slider(String label, double lo, double hi, double start, double end,
+      void Function(RangeValues) onChanged) {
+    return Row(
+      children: [
+        SizedBox(
+          width: 32,
+          child: Text(label, style: const TextStyle(fontSize: 11)),
+        ),
+        Expanded(
+          child: SliderTheme(
+            data: SliderTheme.of(context).copyWith(
+              trackHeight: 2,
+              thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 7),
+              overlayShape: const RoundSliderOverlayShape(overlayRadius: 14),
+            ),
+            child: RangeSlider(
+              min: lo,
+              max: hi,
+              // 滑条不允许左右端点交叉,否则画面会翻过来
+              values: RangeValues(
+                start.clamp(lo, hi - (hi - lo) * 1e-6),
+                end.clamp(lo + (hi - lo) * 1e-6, hi),
+              ),
+              onChanged: onChanged,
+            ),
+          ),
+        ),
+      ],
     );
   }
 }
 
 class _CurvePainter extends CustomPainter {
-  _CurvePainter(this.series, this.zoom, this.pan, {this.yMin, this.yMax});
+  _CurvePainter(this.series, this.x0, this.x1, this.y0, this.y1);
 
   final List<CurveSeries> series;
-  final double zoom;
-  final Offset pan;
-  final double? yMin;
-  final double? yMax;
+  final double x0, x1, y0, y1;
 
   static const _padLeft = 46.0;
   static const _padRight = 12.0;
@@ -102,37 +217,6 @@ class _CurvePainter extends CustomPainter {
       Paint()..color = const Color(0xFFFAFAFC),
     );
 
-    // 数据边界
-    double? x0, x1, y0, y1;
-    for (final s in series) {
-      for (final p in s.points) {
-        if (!p.dx.isFinite || !p.dy.isFinite) continue;
-        x0 = (x0 == null) ? p.dx : math.min(x0, p.dx);
-        x1 = (x1 == null) ? p.dx : math.max(x1, p.dx);
-        y0 = (y0 == null) ? p.dy : math.min(y0, p.dy);
-        y1 = (y1 == null) ? p.dy : math.max(y1, p.dy);
-      }
-    }
-    if (x0 == null || y0 == null || x1 == null || y1 == null) {
-      _text(canvas, '没有可绘制的有限点', Offset(size.width / 2 - 60, size.height / 2),
-          const Color(0xFF9E9E9E), 13);
-      return;
-    }
-    if (x1 - x0 < 1e-12) {
-      x0 -= 1;
-      x1 += 1;
-    }
-    // 显式给了 y 范围就用它 —— 自动定界碰上带极点的函数会被几个大值撑爆(Tan[x] 那种)
-    final fixedYMin = yMin;
-    final fixedYMax = yMax;
-    if (fixedYMin != null) y0 = fixedYMin;
-    if (fixedYMax != null) y1 = fixedYMax;
-
-    if (y1 - y0 < 1e-12) {
-      y0 -= 1;
-      y1 += 1;
-    }
-
     final plot = Rect.fromLTRB(
       _padLeft,
       _padTop,
@@ -140,26 +224,15 @@ class _CurvePainter extends CustomPainter {
       size.height - _padBottom,
     );
 
-    // 数据 -> 屏幕
-    double sx(double x) {
-      final t = (x - x0!) / (x1! - x0);
-      return plot.left + (plot.width / 2) + (t * plot.width - plot.width / 2) * zoom + pan.dx;
-    }
-
-    double sy(double y) {
-      final t = (y - y0!) / (y1! - y0);
-      return plot.bottom - (plot.height / 2) -
-          (t * plot.height - plot.height / 2) * zoom +
-          pan.dy;
-    }
+    double sx(double x) => plot.left + (x - x0) / (x1 - x0) * plot.width;
+    double sy(double y) => plot.bottom - (y - y0) / (y1 - y0) * plot.height;
 
-    // 内边距裁掉越界部分,避免曲线画到坐标轴外面
     canvas.save();
     canvas.clipRect(plot);
-    _grid(canvas, plot, x0, x1, y0, y1, sx, sy);
+    _grid(canvas, plot, sx, sy);
     canvas.restore();
 
-    // 坐标轴(可能落在可视区外,用画布坐标直接画)
+    // 坐标轴(可能在可视区外)
     final axes = Paint()
       ..color = const Color(0xFF9E9E9E)
       ..strokeWidth = 1.2;
@@ -203,12 +276,12 @@ class _CurvePainter extends CustomPainter {
     }
     canvas.restore();
 
-    _axisLabels(canvas, plot, x0, x1, y0, y1, sx, sy);
+    _axisLabels(canvas, plot, sx, sy);
     if (series.length > 1) _legend(canvas, plot);
   }
 
-  void _grid(Canvas canvas, Rect plot, double x0, double x1, double y0, double y1,
-      double Function(double) sx, double Function(double) sy) {
+  void _grid(Canvas canvas, Rect plot, double Function(double) sx,
+      double Function(double) sy) {
     final paint = Paint()
       ..color = const Color(0xFFE8E8EE)
       ..strokeWidth = 1;
@@ -229,8 +302,8 @@ class _CurvePainter extends CustomPainter {
     }
   }
 
-  void _axisLabels(Canvas canvas, Rect plot, double x0, double x1, double y0,
-      double y1, double Function(double) sx, double Function(double) sy) {
+  void _axisLabels(Canvas canvas, Rect plot, double Function(double) sx,
+      double Function(double) sy) {
     const c = Color(0xFF757575);
     final stepX = _niceStep(x1 - x0);
     for (var x = (x0 / stepX).ceil() * stepX; x <= x1; x += stepX) {
@@ -292,8 +365,8 @@ class _CurvePainter extends CustomPainter {
   @override
   bool shouldRepaint(covariant _CurvePainter old) =>
       old.series != series ||
-      old.zoom != zoom ||
-      old.pan != pan ||
-      old.yMin != yMin ||
-      old.yMax != yMax;
+      old.x0 != x0 ||
+      old.x1 != x1 ||
+      old.y0 != y0 ||
+      old.y1 != y1;
 }

+ 194 - 31
lib/expr_editor.dart

@@ -232,6 +232,7 @@ class _NodeView extends StatelessWidget {
       PowNode p => _power(context, p),
       GroupNode g => _group(context, g),
       BraceNode b => _brace(context, b),
+      BigOpNode b => _bigOp(context, b),
       PostfixNode p => _row([
           _slot(context, p.operand, parent.push(index, 0)),
           _text(p.op, size),
@@ -272,7 +273,70 @@ class _NodeView extends StatelessWidget {
     );
   }
 
+  /// 常见函数用**数学记号**显示,而不是代码式的 `Name[...]`。
+  ///
+  /// 关键点:这里只改**怎么画**,不改槽本身 —— 参数槽还是原来那些,
+  /// 所以照样能点进去编辑、光标照样能进。
   Widget _function(BuildContext context, FuncNode f) {
+    switch (f.name) {
+      case 'Sqrt':
+        if (f.args.length == 1) return _radical(context, f);
+      case 'Abs':
+        if (f.args.length == 1) return _bars(context, f);
+      case 'Integrate':
+        if (f.args.length == 2) return _integral(context, f);
+      case 'Sum':
+        if (f.args.length == 2) return _bigOperator(context, f, '∑');
+      case 'Product':
+        if (f.args.length == 2) return _bigOperator(context, f, '∏');
+    }
+    return _plainFunction(context, f);
+  }
+
+  TextStyle _mathStyle(BuildContext context, double scale) => TextStyle(
+        fontSize: size * scale,
+        color: Theme.of(context).colorScheme.onSurface,
+      );
+
+  /// √ 盖住被开方数。
+  ///
+  /// 用画笔画成**一条连起来的折线**(下折 → 上挑 → 横线),而不是
+  /// "√ 字符 + 一条上边框" —— 后者两边各按各的字形对齐,中间必然断开
+  /// (用户实测反馈"根号是断开的")。
+  Widget _radical(BuildContext context, FuncNode f) => _Radical(
+        size: size,
+        color: Theme.of(context).colorScheme.onSurface,
+        child: _slot(context, f.args.first, parent.push(index, 0)),
+      );
+
+  Widget _bars(BuildContext context, FuncNode f) => _row([
+        Text('|', style: _mathStyle(context, 1.2)),
+        _slot(context, f.args.first, parent.push(index, 0)),
+        Text('|', style: _mathStyle(context, 1.2)),
+      ]);
+
+  /// `Integrate[f, x]` 画成 `∫ f dx`。
+  ///
+  /// 参数顺序被重排了(数学写法就是 ∫ … d…),但**槽还是那两个槽**,
+  /// 所以光标和点击命中都不受影响。
+  Widget _integral(BuildContext context, FuncNode f) => _row([
+        Text('∫', style: _mathStyle(context, 1.6)),
+        _slot(context, f.args[0], parent.push(index, 0)),
+        Text('d', style: _mathStyle(context, 0.9)),
+        _slot(context, f.args[1], parent.push(index, 1)),
+      ]);
+
+  /// `Sum[f, {n,1,∞}]` 画成 `Σ f {n,1,∞}`(迭代器小一号)。
+  Widget _bigOperator(BuildContext context, FuncNode f, String glyph) => _row([
+        Text(glyph, style: _mathStyle(context, 1.6)),
+        _slot(context, f.args[0], parent.push(index, 0)),
+        Transform.translate(
+          offset: Offset(0, size * 0.25),
+          child: _slot(context, f.args[1], parent.push(index, 1), scale: 0.7),
+        ),
+      ]);
+
+  Widget _plainFunction(BuildContext context, FuncNode f) {
     final scheme = Theme.of(context).colorScheme;
     return _row([
       Text(
@@ -340,64 +404,163 @@ class _NodeView extends StatelessWidget {
     ]);
   }
 
+  /// 大运算符:上下限画在**习惯位置**。
+  ///
+  ///   ∫ 上限定在上方、下限定在下方,后面接被积函数和 dx
+  ///   ∑ / ∏ 同理,下限在下、上限在上
+  ///   lim 的条件写在 "lim" 正下方
+  ///
+  /// 上下限为空时不占位(不定积分就是"∫ f dx",没有上下限)。
+  Widget _bigOp(BuildContext context, BigOpNode b) {
+    final body = _slot(context, b.body, parent.push(index, 0));
+    final variable = _slot(context, b.variable, parent.push(index, 1));
+    final lower = _slot(context, b.lower, parent.push(index, 2), scale: 0.5);
+    final upper = _slot(context, b.upper, parent.push(index, 3), scale: 0.5);
+
+    if (b.kind == 'Limit') {
+      // lim 下面写"x→0"
+      final under = Row(
+        mainAxisSize: MainAxisSize.min,
+        children: [
+          _slot(context, b.variable, parent.push(index, 1), scale: 0.6),
+          Text('→', style: _mathStyle(context, 0.6)),
+          lower,
+        ],
+      );
+      return _row([
+        Column(
+          mainAxisSize: MainAxisSize.min,
+          children: [
+            Text('lim', style: _mathStyle(context, 0.95)),
+            under,
+          ],
+        ),
+        body,
+      ]);
+    }
+
+    final glyph = switch (b.kind) {
+      'Integrate' => '∫',
+      'Sum' => '∑',
+      'Product' => '∏',
+      _ => '∑',
+    };
+
+    final stack = Column(
+      mainAxisSize: MainAxisSize.min,
+      children: [
+        // 上下限**始终渲染** —— 空的时候画成小占位框。
+        // 不渲染的话不定积分的两个限槽就成了够不着的死结构,永远填不进去。
+        upper,
+        Text(glyph, style: _mathStyle(context, 1.7)),
+        lower,
+      ],
+    );
+
+    if (b.kind == 'Integrate') {
+      return _row([
+        stack,
+        body,
+        Text('d', style: _mathStyle(context, 0.9)),
+        variable,
+      ]);
+    }
+    return _row([stack, body]);
+  }
+
   Widget _row(List<Widget> children) => Wrap(
         crossAxisAlignment: WrapCrossAlignment.center,
         children: children,
       );
 
+  /// [scale] 是**真的把字号改小**,而不是 Transform 缩放。
+  ///
+  /// Transform 只改视觉、不改布局,占位仍是原尺寸 —— 结果就是"字看着小了
+  /// 但离运算符老远"(用户实测反馈)。直接把 size 传下去,布局才会跟着缩。
   Widget _slot(BuildContext context, SeqNode seq, SlotPath path,
           {double scale = 1}) =>
-      _ScaledSlot(
-        scale: scale,
-        child: _SlotView(
-          doc: doc,
-          seq: seq,
-          path: path,
-          size: size,
-          onTapNode: onTapNode,
-          onLongPressNode: onLongPressNode,
-          onTapSlot: onTapSlot,
-        ),
+      _SlotView(
+        doc: doc,
+        seq: seq,
+        path: path,
+        size: size * scale,
+        onTapNode: onTapNode,
+        onLongPressNode: onLongPressNode,
+        onTapSlot: onTapSlot,
       );
 
   Widget _text(String t, double s) => Text(t, style: TextStyle(fontSize: s));
 }
 
-/// 选中时给节点盖一层高亮背景。
-class _SelectedOrNot extends StatelessWidget {
-  const _SelectedOrNot({required this.highlighted, required this.child});
+/// 根号:画笔画出的折线 + 上划线,被开方数挂在横线下面。
+class _Radical extends StatelessWidget {
+  const _Radical({
+    required this.size,
+    required this.color,
+    required this.child,
+  });
 
-  final bool highlighted;
+  final double size;
+  final Color color;
   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),
+    final arm = size * 0.42; // 左侧那一段"折"的宽度
+    return CustomPaint(
+      painter: _RadicalPainter(color, arm),
+      child: Padding(
+        padding: EdgeInsets.only(left: arm, top: size * 0.30),
+        child: child,
       ),
-      child: child,
     );
   }
 }
 
-/// 给槽套一个缩放(指数要用小一号字)。
-class _ScaledSlot extends StatelessWidget {
-  const _ScaledSlot({required this.scale, required this.child});
+class _RadicalPainter extends CustomPainter {
+  _RadicalPainter(this.color, this.arm);
+
+  final Color color;
+  final double arm;
 
-  final double scale;
+  @override
+  void paint(Canvas canvas, Size s) {
+    final paint = Paint()
+      ..color = color
+      ..strokeWidth = 1.4
+      ..style = PaintingStyle.stroke
+      ..strokeJoin = StrokeJoin.miter
+      ..strokeCap = StrokeCap.butt;
+
+    // 从左下起笔 → 折到底 → 挑到顶 → 横着盖住被开方数
+    final path = Path()
+      ..moveTo(0, s.height * 0.55)
+      ..lineTo(arm * 0.4, s.height * 0.92)
+      ..lineTo(arm, 0)
+      ..lineTo(s.width, 0);
+    canvas.drawPath(path, paint);
+  }
+
+  @override
+  bool shouldRepaint(covariant _RadicalPainter old) =>
+      old.color != color || old.arm != arm;
+}
+
+/// 选中时给节点盖一层高亮背景。
+class _SelectedOrNot extends StatelessWidget {
+  const _SelectedOrNot({required this.highlighted, required this.child});
+
+  final bool highlighted;
   final Widget child;
 
   @override
   Widget build(BuildContext context) {
-    if (scale == 1) return child;
-    // 用 Transform 缩放而不是改字号:字号变了槽的布局参数也得跟着变,
-    // 缩放一行搞定且视觉上等价。
-    return Transform.scale(
-      scale: scale,
-      alignment: Alignment.bottomLeft,
+    if (!highlighted) return child;
+    return Container(
+      decoration: BoxDecoration(
+        color: Theme.of(context).colorScheme.primary.withValues(alpha: 0.22),
+        borderRadius: BorderRadius.circular(3),
+      ),
       child: child,
     );
   }

+ 78 - 2
lib/expr_node.dart

@@ -61,6 +61,45 @@ class BraceNode extends ExprNode {
   BraceNode(this.inner);
 }
 
+/// 大运算符:∫ / ∑ / ∏ / lim。
+///
+/// 为什么要单独一个节点,而不是复用 `FuncNode` + `{x,a,b}` 那种写法:
+/// 上下限得单独成槽才画得到**习惯位置**(积分号的上方/下方、Σ 的上下、
+/// lim 的下方)。塞在一个花括号字面量里就只能顺着排,画不出来。
+///
+/// 和 Symja 的对应关系(序列化时还原):
+///   Integrate[body, {variable, lower, upper}]   定积分;上下限都空时退化成不定积分
+///   Sum[body, {variable, lower, upper}]         求和
+///   Product[body, {variable, lower, upper}]     连乘
+///   Limit[body, variable -> lower]              极限(upper 不用)
+class BigOpNode extends ExprNode {
+  /// 'Integrate' | 'Sum' | 'Product' | 'Limit'
+  final String kind;
+
+  /// 被积函数 / 求和项 / 取极限的表达式。
+  SeqNode body;
+
+  /// 积分变量 / 求和指标 / 趋近的变量。
+  SeqNode variable;
+
+  /// 下限:积分下限、求和起点、Limit 的趋近值。
+  SeqNode lower;
+
+  /// 上限。Limit 不用,留空槽且不显示。
+  SeqNode upper;
+
+  BigOpNode({
+    required this.kind,
+    required this.body,
+    required this.variable,
+    required this.lower,
+    required this.upper,
+  });
+
+  bool get needsUpper => kind != 'Limit';
+  bool get isDefinite => !lower.isEmpty || (needsUpper && !upper.isEmpty);
+}
+
 /// 后缀运算符,目前只有阶乘 `!`。
 class PostfixNode extends ExprNode {
   SeqNode operand;
@@ -247,6 +286,7 @@ class ExprDoc {
         PowNode p => [p.base, p.exp], // 0=底 1=指数
         GroupNode g => [g.inner],
         BraceNode b => [b.inner],
+        BigOpNode b => [b.body, b.variable, b.lower, b.upper],
         PostfixNode p => [p.operand],
         LitNode _ => const <SeqNode>[],
       };
@@ -289,6 +329,22 @@ class ExprDoc {
 
   static bool _isNumberChar(String c) => RegExp(r'^[0-9.]$').hasMatch(c);
 
+  /// 造一个大运算符。光标落在 body 槽(要填的那个)。
+  void makeBigOp(String kind, {String variable = 'x', String lower = '',
+      String upper = ''}) {
+    SeqNode lit(String t) => t.isEmpty ? SeqNode() : SeqNode([LitNode(t)]);
+    insert(
+      BigOpNode(
+        kind: kind,
+        body: SeqNode(),
+        variable: lit(variable),
+        lower: lit(lower),
+        upper: lit(upper),
+      ),
+      enterSlot: 0,
+    );
+  }
+
   /// 插入一对括号(光标停在里面)。
   void makeGroup() => insert(GroupNode(SeqNode()), enterSlot: 0);
 
@@ -433,6 +489,7 @@ class ExprDoc {
   /// 整棵树 → 一条 Symja 表达式。
   String toSymja() => serializeSeq(root);
 
+
   /// 槽 → 表达式。空槽在 Symja 里写成 `Null`,求值时会明确报错而不是静默算错。
   String serializeSeq(SeqNode seq) {
     if (seq.isEmpty) return 'Null';
@@ -441,15 +498,34 @@ class ExprDoc {
 
   String serialize(ExprNode node) => switch (node) {
         LitNode l => l.text,
-        FuncNode f =>
-          '${f.name}[${f.args.map(serializeSeq).join(', ')}]',
+        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)})',
         BraceNode b => '{${serializeSeq(b.inner)}}',
+        BigOpNode b => _serializeBigOp(b),
         PostfixNode p => '${_grouped(serializeSeq(p.operand))}${p.op}',
       };
 
+  /// 大运算符还原成 Symja 写法。
+  static String _serializeBigOp(BigOpNode b) {
+    final body = ExprDoc().serializeSeq(b.body);
+    final v = ExprDoc().serializeSeq(b.variable);
+
+    if (b.kind == 'Limit') {
+      // Limit[f, x -> 0]
+      final to = ExprDoc().serializeSeq(b.lower);
+      return 'Limit[$body, $v -> $to]';
+    }
+    // 上下限都空 → 不定积分这种,只给变量
+    if (b.lower.isEmpty && b.upper.isEmpty) {
+      return '${b.kind}[$body, $v]';
+    }
+    final lo = ExprDoc().serializeSeq(b.lower);
+    final hi = ExprDoc().serializeSeq(b.upper);
+    return '${b.kind}[$body, {$v, $lo, $hi}]';
+  }
+
   /// 复合结构当操作数时要加括号,否则 `x+1` 当分母会算错优先级。
   ///
   /// 但**不多加**:整体已经被一对括号包住的(`(x+1)`)不再套一层成 `((x+1))`;

+ 78 - 1
lib/expr_parse.dart

@@ -199,7 +199,9 @@ class _Parser {
         i++;
         final args = _args();
         _expect(']');
-        return [FuncNode(name, args)];
+        // ∫ / ∑ / ∏ / lim 的习惯写法认成大运算符,这样复制→粘贴能闭环
+        final big = _tryBigOp(name, args);
+        return [big ?? FuncNode(name, args)];
       }
       return [LitNode(name)];
     }
@@ -207,6 +209,81 @@ class _Parser {
     throw ExprParseException('看不懂的字符「$c」', i);
   }
 
+  /// 认得出来就返回大运算符节点,认不出返回 null(交给普通的 FuncNode)。
+  static ExprNode? _tryBigOp(String name, List<SeqNode> args) {
+    if (args.length != 2) return null;
+
+    if (name == 'Limit') {
+      // Limit[f, x -> v]
+      final parts = _splitTopLevel(args[1], '->');
+      if (parts == null) return null;
+      return BigOpNode(
+        kind: 'Limit',
+        body: args[0],
+        variable: parts[0],
+        lower: parts[1],
+        upper: SeqNode(),
+      );
+    }
+
+    if (name != 'Integrate' && name != 'Sum' && name != 'Product') return null;
+
+    // Integrate[f, x] —— 不定积分,没有上下限
+    if (args[1].children.length != 1 || args[1].children.first is! BraceNode) {
+      return BigOpNode(
+        kind: name,
+        body: args[0],
+        variable: args[1],
+        lower: SeqNode(),
+        upper: SeqNode(),
+      );
+    }
+
+    // Integrate[f, {x, a, b}]
+    final inner = (args[1].children.first as BraceNode).inner;
+    final parts = _splitTopLevel(inner, ',');
+    if (parts == null || parts.length != 3) return null;
+    return BigOpNode(
+      kind: name,
+      body: args[0],
+      variable: parts[0],
+      lower: parts[1],
+      upper: parts[2],
+    );
+  }
+
+  /// 按顶层出现的 [sep] 字面量把槽切开。没出现就返回 null。
+  ///
+  /// 逗号在嵌套结构里(比如 `{Sin[x, y], 1}` 的内层)不算 —— 这里的
+  /// 子节点本来就是树,所以"顶层"天然成立。
+  static List<SeqNode>? _splitTopLevel(SeqNode seq, String sep) {
+    final hits = <int>[
+      for (var k = 0; k < seq.children.length; k++)
+        if (seq.children[k] is LitNode &&
+            ((seq.children[k] as LitNode).text == sep ||
+                (seq.children[k] as LitNode).text == '$sep '))
+          k,
+    ];
+    if (hits.isEmpty) return null;
+    if (hits.length == 1 && sep == '->') {
+      final k = hits.first;
+      return [
+        SeqNode(seq.children.sublist(0, k)),
+        SeqNode(seq.children.sublist(k + 1)),
+      ];
+    }
+    if (sep != ',') return null;
+
+    final out = <SeqNode>[];
+    var start = 0;
+    for (final k in hits) {
+      out.add(SeqNode(seq.children.sublist(start, k)));
+      start = k + 1;
+    }
+    out.add(SeqNode(seq.children.sublist(start)));
+    return out;
+  }
+
   List<SeqNode> _args() {
     final out = <SeqNode>[];
     _ws();

+ 0 - 205
lib/expr_syntax.dart

@@ -1,205 +0,0 @@
-/// mathtool2 风格表达式 → Symja 语法的兼容转换。
-///
-/// 为什么需要:工具页那两个绘图工具原来用 `math_expressions`,输入是
-/// `sin(x)`、`ln(x)`、`pi` 这种小写括号写法。统一到 Symja 之后,直接扔掉这套
-/// 语法对习惯了它的人是退步,所以这里保留兼容。
-///
-/// **界面上会显示转换后的表达式** —— 转换这种事最怕悄悄改错,让用户看得见。
-///
-/// 只做**保守**的转换,不确定的一律原样保留:
-///   `sin(x)`   → `Sin[x]`      函数名(大小写不敏感)+ 紧跟的括号
-///   `pi`       → `Pi`          常量
-///   `e`        → `E`           仅当它独立成词、且不与数字相邻(避免毁掉 `1e-3`)
-///   `2x`       → `2*x`         数字紧跟字母时补乘号
-///   `(x+1)*2`  → 原样           普通括号不是函数调用,不动
-library;
-
-/// 小写函数名 → Symja 名称。
-const _functions = <String, String>{
-  'sin': 'Sin',
-  'cos': 'Cos',
-  'tan': 'Tan',
-  'cot': 'Cot',
-  'sec': 'Sec',
-  'csc': 'Csc',
-  'asin': 'ArcSin',
-  'acos': 'ArcCos',
-  'atan': 'ArcTan',
-  'arcsin': 'ArcSin',
-  'arccos': 'ArcCos',
-  'arctan': 'ArcTan',
-  'sinh': 'Sinh',
-  'cosh': 'Cosh',
-  'tanh': 'Tanh',
-  'ln': 'Log',
-  'log': 'Log',
-  'lg': 'Log10',
-  'sqrt': 'Sqrt',
-  'abs': 'Abs',
-  'exp': 'Exp',
-  'floor': 'Floor',
-  'ceil': 'Ceiling',
-  'round': 'Round',
-  'sign': 'Sign',
-  'max': 'Max',
-  'min': 'Min',
-  'mod': 'Mod',
-};
-
-const _constants = <String, String>{
-  'pi': 'Pi',
-  'inf': 'Infinity',
-  'infinity': 'Infinity',
-};
-
-bool _isLetter(String c) {
-  final u = c.codeUnitAt(0);
-  return (u >= 0x41 && u <= 0x5A) || (u >= 0x61 && u <= 0x7A) || c == '_';
-}
-
-bool _isDigit(String c) {
-  if (c.isEmpty) return false; // 空串不是数字 —— 边界处会传进来,别让它崩
-  final u = c.codeUnitAt(0);
-  return u >= 0x30 && u <= 0x39;
-}
-
-bool _isIdentChar(String c) => _isLetter(c) || _isDigit(c);
-
-/// 从 [from] 起看是不是"指数部分":可选正负号后紧跟数字。
-bool _looksLikeExponent(String s, int from) {
-  var k = from;
-  if (k < s.length && (s[k] == '+' || s[k] == '-')) k++;
-  return k < s.length && _isDigit(s[k]);
-}
-
-/// 把 [input] 转换成 Symja 语法。无法识别的地方原样保留。
-String toSymjaSyntax(String input) {
-  final out = StringBuffer();
-  // 括号栈:记住每个左括号应该被闭合成什么
-  final closers = <String>[];
-  var i = 0;
-
-  while (i < input.length) {
-    final c = input[i];
-
-    if (_isLetter(c)) {
-      var j = i;
-      while (j < input.length && _isIdentChar(input[j])) {
-        j++;
-      }
-      final word = input.substring(i, j);
-
-      // 跳过空白看后面是不是紧跟 '('
-      var k = j;
-      while (k < input.length && input[k] == ' ') {
-        k++;
-      }
-      final isCall = k < input.length && input[k] == '(';
-      final fn = _functions[word.toLowerCase()];
-
-      if (isCall && fn != null) {
-        out.write(fn);
-        out.write('[');
-        closers.add(']');
-        i = k + 1; // 吃掉 '('
-        continue;
-      }
-
-      // 常量:'e' 要额外小心——别把 1e-3 这种科学计数法拆了
-      final lower = word.toLowerCase();
-      if (lower == 'e') {
-        final prev = out.isEmpty ? '' : out.toString()[out.length - 1];
-        final next = j < input.length ? input[j] : '';
-        if (!_isDigit(prev) && !_isDigit(next)) { // 空串会被 _isDigit 判成非数字,正是想要的
-          out.write('E');
-          i = j;
-          continue;
-        }
-      } else if (_constants[lower] != null) {
-        out.write(_constants[lower]);
-        i = j;
-        continue;
-      }
-
-      out.write(word);
-      i = j;
-      continue;
-    }
-
-    // `√3` -> `Sqrt[3]`,`√(x+1)` -> `Sqrt[(x+1)]`
-    //
-    // 根号是**前置一元**运算符,后面跟的操作数要么是一对配平的括号,要么是
-    // 一串数字/字母。不能让它走普通括号分支 —— 那样 `√(x+1)` 会变成
-    // `Sqrt[(x+1)`,右方括号永远配不上。
-    if (c == '√') {
-      var k = i + 1;
-      final start = k;
-      if (k < input.length && input[k] == '(') {
-        var depth = 0;
-        while (k < input.length) {
-          if (input[k] == '(') {
-            depth++;
-          } else if (input[k] == ')') {
-            depth--;
-            if (depth == 0) {
-              k++;
-              break;
-            }
-          }
-          k++;
-        }
-      } else {
-        while (k < input.length && (_isIdentChar(input[k]) || input[k] == '.')) {
-          k++;
-        }
-      }
-      final operand = input.substring(start, k);
-      out.write('Sqrt[');
-      out.write(operand.isEmpty ? 'Null' : toSymjaSyntax(operand));
-      out.write(']');
-      i = k;
-      continue;
-    }
-
-    if (c == '(') {
-      out.write('(');
-      closers.add(')');
-      i++;
-      continue;
-    }
-    if (c == ')') {
-      out.write(closers.isNotEmpty ? closers.removeLast() : ')');
-      i++;
-      continue;
-    }
-
-    // `2x` -> `2*x`:数字后面紧跟字母时补乘号
-    if (_isDigit(c)) {
-      var j = i;
-      while (j < input.length && (_isDigit(input[j]) || input[j] == '.')) {
-        j++;
-      }
-      out.write(input.substring(i, j));
-      // 根号也当操作数:`2√3` -> `2*Sqrt[3]`
-      if (j < input.length && (_isLetter(input[j]) || input[j] == '√')) {
-        // 例外:`1e-3`、`2.5E10` 是科学计数法,补了乘号就毁了
-        final isExponent = (input[j] == 'e' || input[j] == 'E') &&
-            _looksLikeExponent(input, j + 1);
-        if (!isExponent) out.write('*');
-      }
-      i = j;
-      continue;
-    }
-
-    out.write(c);
-    i++;
-  }
-
-  return out.toString();
-}
-
-/// 转换过(即与原串不同)才返回新串,否则返回 null —— 界面据此决定要不要提示。
-String? symjaRewriteOf(String input) {
-  final converted = toSymjaSyntax(input);
-  return converted == input ? null : converted;
-}

+ 113 - 0
lib/symja_functions.dart

@@ -48,3 +48,116 @@ const List<String> kSymjaFunctions = <String>[
 const List<String> kSymjaConstants = <String>[
   'Pi', 'E', 'I', 'Infinity', 'ComplexInfinity', 'True', 'False', 'Null',
 ];
+
+/// 每个函数/常量的一句中文说明。函数面板里挂在名字后面。
+const Map<String, String> kSymjaFunctionHelp = <String, String>{
+  // 基本数学
+  'Abs': '绝对值', 'Sign': '符号(正负零)', 'Max': '最大值', 'Min': '最小值',
+  'Sqrt': '平方根', 'Power': '幂', 'Exp': 'e 的幂', 'Log': '自然对数',
+  'Log10': '常用对数', 'Log2': '以 2 为底对数',
+  // 三角
+  'Sin': '正弦', 'Cos': '余弦', 'Tan': '正切', 'Cot': '余切', 'Sec': '正割', 'Csc': '余割',
+  'ArcSin': '反正弦', 'ArcCos': '反余弦', 'ArcTan': '反正切', 'ArcCot': '反余切',
+  'ArcSec': '反正割', 'ArcCsc': '反余割',
+  // 双曲
+  'Sinh': '双曲正弦', 'Cosh': '双曲余弦', 'Tanh': '双曲正切', 'Coth': '双曲余切',
+  'Sech': '双曲正割', 'Csch': '双曲余割',
+  'ArcSinh': '反双曲正弦', 'ArcCosh': '反双曲余弦', 'ArcTanh': '反双曲正切',
+  'ArcCoth': '反双曲余切', 'ArcSech': '反双曲正割', 'ArcCsch': '反双曲余割',
+  'TrigExpand': '三角展开', 'TrigReduce': '三角降幂', 'TrigToExp': '三角化为指数',
+  'ExpToTrig': '指数化为三角',
+  // 微积分
+  'D': '求导', 'Derivative': '导数算子', 'Integrate': '积分', 'Limit': '极限',
+  'Sum': '求和', 'Product': '连乘', 'Series': '级数展开', 'DSolve': '解微分方程',
+  // 代数
+  'Expand': '展开括号', 'Factor': '因式分解', 'Simplify': '化简',
+  'FullSimplify': '尽力化简', 'Together': '通分', 'Apart': '拆成部分分式',
+  'Cancel': '约分', 'ExpandAll': '全部展开', 'Collect': '按某变量合并',
+  'Coefficient': '取某项系数', 'Numerator': '分子', 'Denominator': '分母',
+  // 方程
+  'Solve': '解方程(符号解)', 'NSolve': '解方程(数值解)', 'Reduce': '化简方程组',
+  'FindRoot': '数值求根', 'Eliminate': '消元', 'Roots': '求全部根',
+  // 线性代数
+  'Det': '行列式', 'Inverse': '逆矩阵', 'Transpose': '转置',
+  'Eigenvalues': '特征值', 'Eigenvectors': '特征向量', 'MatrixPower': '矩阵的幂',
+  'MatrixRank': '矩阵的秩', 'Trace': '迹', 'Norm': '范数',
+  'IdentityMatrix': '单位矩阵', 'DiagonalMatrix': '对角矩阵',
+  'RowReduce': '化成行最简形', 'Dot': '点积', 'Cross': '叉积',
+  'LinearSolve': '解线性方程组', 'LeastSquares': '最小二乘解',
+  'QRDecomposition': 'QR 分解', 'LUDecomposition': 'LU 分解',
+  'CholeskyDecomposition': 'Cholesky 分解', 'SingularValueDecomposition': '奇异值分解',
+  // 多项式
+  'PolynomialQ': '是否为多项式', 'PolynomialQuotient': '多项式除法的商',
+  'PolynomialRemainder': '多项式除法的余式', 'PolynomialGCD': '多项式最大公因式',
+  'PolynomialLCM': '多项式最小公倍式', 'CoefficientList': '各项系数表',
+  'Exponent': '最高次数', 'Variables': '含哪些变量',
+  // 数论组合
+  'Prime': '第 n 个素数', 'PrimeQ': '是否为素数', 'NextPrime': '下一个素数',
+  'PrimePi': '不超过 n 的素数个数', 'FactorInteger': '素因数分解',
+  'GCD': '最大公因数', 'LCM': '最小公倍数', 'Mod': '取余', 'Divisors': '因数列表',
+  'EulerPhi': '欧拉函数 φ', 'MoebiusMu': '莫比乌斯函数 μ', 'DivisorSigma': '因数之和/个数',
+  'JacobiSymbol': '雅可比符号', 'Factorial': '阶乘', 'Binomial': '二项式系数',
+  'Multinomial': '多项式系数', 'Permutations': '全排列', 'Fibonacci': '斐波那契数',
+  'LucasL': '卢卡斯数', 'BernoulliB': '伯努利数', 'StirlingS1': '第一类斯特林数',
+  'StirlingS2': '第二类斯特林数', 'CatalanNumber': '卡特兰数', 'PartitionsP': '整数拆分数',
+  // 特殊函数
+  'Gamma': '伽玛函数 Γ', 'Beta': '贝塔函数 Β', 'Erf': '误差函数',
+  'Erfc': '互补误差函数', 'BesselJ': '第一类贝塞尔函数', 'BesselY': '第二类贝塞尔函数',
+  'BesselI': '第一类修正贝塞尔', 'BesselK': '第二类修正贝塞尔',
+  'HermiteH': '埃尔米特多项式', 'LaguerreL': '拉盖尔多项式',
+  'LegendreP': '勒让德多项式', 'ChebyshevT': '第一类切比雪夫多项式',
+  'ChebyshevU': '第二类切比雪夫多项式', 'Zeta': '黎曼 ζ 函数',
+  'PolyGamma': '多伽玛函数', 'HarmonicNumber': '调和数',
+  // 统计
+  'Mean': '均值', 'Median': '中位数', 'Variance': '方差',
+  'StandardDeviation': '标准差', 'Covariance': '协方差', 'Correlation': '相关系数',
+  'Quantile': '分位数', 'Quartiles': '四分位数',
+  // 列表
+  'Table': '生成表', 'Range': '等差数列', 'Length': '长度', 'First': '第一个',
+  'Last': '最后一个', 'Rest': '去掉第一个', 'Most': '去掉最后一个', 'Take': '取前 n 个',
+  'Drop': '去掉前 n 个', 'Join': '拼接', 'Append': '尾部追加', 'Prepend': '头部插入',
+  'Insert': '指定位置插入', 'Delete': '删除某位置', 'Select': '按条件筛选',
+  'Map': '对每个元素作用', 'Apply': '替换表达式头', 'Sort': '排序', 'Reverse': '反转',
+  'Flatten': '展平嵌套', 'Partition': '按长度分组', 'Riffle': '交错插入',
+  'Split': '按相邻相同分组', 'Union': '并集', 'Intersection': '交集',
+  'Complement': '差集', 'Subsets': '所有子集', 'MemberQ': '是否属于',
+  // 逻辑
+  'And': '与', 'Or': '或', 'Not': '非', 'Xor': '异或', 'Implies': '蕴含',
+  'Equivalent': '等价', 'Equal': '相等', 'Unequal': '不相等', 'Less': '小于',
+  'Greater': '大于', 'LessEqual': '小于等于', 'GreaterEqual': '大于等于',
+  // 数值与控制
+  'If': '条件分支', 'Which': '多路分支', 'Nest': '迭代 n 次', 'NestList': '迭代过程',
+  'N': '取近似值', 'Round': '四舍五入', 'Floor': '向下取整', 'Ceiling': '向上取整',
+  'Rationalize': '化为有理数', 'RealDigits': '各位数字', 'Precision': '精度',
+  'Accuracy': '准确度', 'Chop': '把极小量归零',
+  // 复数
+  'Re': '实部', 'Im': '虚部', 'Conjugate': '共轭', 'Arg': '辐角',
+  'ComplexExpand': '复数展开',
+  // 随机
+  'Random': '随机数', 'RandomInteger': '随机整数', 'RandomReal': '随机实数',
+  'RandomComplex': '随机复数', 'SeedRandom': '设定随机种子',
+  'RandomChoice': '随机取元素', 'RandomSample': '随机抽样',
+  // 变换与优化
+  'LaplaceTransform': '拉普拉斯变换', 'InverseLaplaceTransform': '逆拉普拉斯变换',
+  'ZTransform': 'Z 变换', 'InverseZTransform': '逆 Z 变换',
+  'Minimize': '符号求最小值', 'Maximize': '符号求最大值',
+  'FindMinimum': '数值最小化', 'FindMaximum': '数值最大化',
+  'NMinimize': '数值最小化', 'NMaximize': '数值最大化',
+  'Fit': '曲线拟合', 'LinearModelFit': '线性回归拟合', 'FindFit': '一般拟合',
+  // 日期
+  'Now': '当前时刻', 'Today': '今天', 'DateString': '日期字符串',
+  'AbsoluteTime': '绝对时间', 'DateList': '日期列表',
+  // 替换与函数
+  'ReplaceAll': '替换所有匹配', 'Replace': '替换一次', 'ReplaceRepeated': '反复替换',
+  'Function': '纯函数', 'Composition': '复合函数', 'Identity': '恒等函数',
+  'InverseFunction': '反函数',
+  // 杂项
+  'MemoryInUse': '已用内存', 'Total': '全部相加', 'Accumulate': '前缀和',
+  'Position': '匹配的位置', 'Count': '计数', 'Cases': '取出匹配的元素',
+  'DeleteCases': '删掉匹配的元素', 'ToString': '转成字符串',
+  'TeXForm': '转成 LaTeX', 'InputForm': '输入形式', 'FullForm': '完整形式',
+  'JavaForm': 'Java 形式',
+  // 常量
+  'Pi': '圆周率 π', 'E': '自然常数 e', 'I': '虚数单位 i', 'Infinity': '正无穷 ∞',
+  'ComplexInfinity': '复无穷', 'True': '真', 'False': '假', 'Null': '空',
+};

+ 26 - 37
lib/tools/bisection_method.dart

@@ -1,6 +1,8 @@
 // tools/bisection_method.dart
 import 'package:flutter/material.dart';
-import 'package:math_expressions/math_expressions.dart';
+import 'package:provider/provider.dart';
+
+import '../symja.dart';
 
 class BisectionMethodPage extends StatefulWidget {
   @override
@@ -18,35 +20,22 @@ class _BisectionMethodPageState extends State<BisectionMethodPage> {
   String _result = '';
   bool _isCalculating = false;
 
-  // 使用 math_expressions 库计算数学表达式
-  double _evaluateFunction(String function, double x) {
-    try {
-      // 预处理函数表达式
-      String exprString = function
-          .replaceAll(' ', '')
-          .replaceAll('×', '*')
-          .replaceAll('÷', '/');
-      
-      // 创建解析器
-      Parser p = Parser();
-      
-      // 解析表达式
-      Expression expression = p.parse(exprString);
-      
-      // 创建上下文并绑定变量 x
-      ContextModel contextModel = ContextModel()
-        ..bindVariable(Variable('x'), Number(x));
-      
-      // 计算表达式
-      double result = expression.evaluate(EvaluationType.REAL, contextModel);
-      
-      return result;
-    } catch (e) {
-      throw Exception('函数表达式解析错误: $e\n请检查表达式格式是否正确');
-    }
+  /// 用 Symja 求 f(x) 在 [x] 处的值。
+  ///
+  /// 表达式**直接交给 Symja 解析**(以前用 math_expressions:那是另一套语法、
+  /// 另一套优先级,同一个式子可能"在这里算得出来、在别处又不对")。
+  Future<double> _evaluateFunction(String function, double x) async {
+    final session = context.read<SymjaSession>();
+    final r = await session.compute('N[$function /. x -> $x]');
+    // Symja 的 N[...] 输出可能带精度标记(形如 3.14`2.5),去掉再解析
+    final cleaned = r.text.trim().replaceAll(RegExp(r'`[0-9.]*$'), '');
+    final v = double.tryParse(cleaned);
+    if (v == null) throw Exception('f($x) = ${r.text},不是数值');
+    return v;
   }
 
-  void _calculate() {
+  Future<void> _calculate() async {
+    if (_isCalculating) return;
     setState(() {
       _isCalculating = true;
       _iterations.clear();
@@ -64,8 +53,8 @@ class _BisectionMethodPageState extends State<BisectionMethodPage> {
         throw Exception('区间左端点必须小于右端点');
       }
 
-      double fa = _evaluateFunction(function, a);
-      double fb = _evaluateFunction(function, b);
+      double fa = await _evaluateFunction(function, a);
+      double fb = await _evaluateFunction(function, b);
 
       if (fa.isNaN || fb.isNaN) {
         throw Exception('函数在端点处计算失败,请检查表达式');
@@ -80,12 +69,13 @@ class _BisectionMethodPageState extends State<BisectionMethodPage> {
 
       while ((b - a).abs() > tolerance && iteration < maxIterations) {
         c = (a + b) / 2;
-        double fc = _evaluateFunction(function, c);
+        double fc = await _evaluateFunction(function, c);
 
         if (fc.isNaN) {
           throw Exception('迭代过程中函数计算失败');
         }
 
+        if (!mounted) return;
         _iterations.add('迭代 ${iteration + 1}: a=${a.toStringAsFixed(6)}, b=${b.toStringAsFixed(6)}, c=${c.toStringAsFixed(6)}, f(c)=${fc.toStringAsFixed(6)}');
 
         if (fc.abs() < tolerance) {
@@ -101,20 +91,19 @@ class _BisectionMethodPageState extends State<BisectionMethodPage> {
         iteration++;
       }
 
+      if (!mounted) return;
       setState(() {
         _result = '近似解: ${c.toStringAsFixed(8)}\n'
                  '迭代次数: $iteration\n'
                  '最终误差: ${(b - a).abs().toStringAsFixed(8)}';
       });
 
+    } on SymjaException catch (e) {
+      if (mounted) setState(() => _result = '错误: $e');
     } catch (e) {
-      setState(() {
-        _result = '错误: $e';
-      });
+      if (mounted) setState(() => _result = '错误: $e');
     } finally {
-      setState(() {
-        _isCalculating = false;
-      });
+      if (mounted) setState(() => _isCalculating = false);
     }
   }
 

+ 4 - 17
lib/tools/function_plotter.dart

@@ -3,7 +3,6 @@ import 'package:flutter/material.dart';
 import 'package:provider/provider.dart';
 
 import '../curve_plot.dart';
-import '../expr_syntax.dart';
 import '../symja.dart';
 
 /// 函数图像绘制:把一个或多个 y = f(x) 交给 Symja 采样,再用项目自己的
@@ -115,8 +114,9 @@ class _FunctionPlotterPageState extends State<FunctionPlotterPage> {
       final raw = _exprs[i].text.trim();
       if (raw.isEmpty) continue;
       try {
-        // 用户可能沿用旧的小写括号语法,先转成 Symja 语法再采样。
-        final expr = toSymjaSyntax(raw);
+        // 表达式**原样交给 Symja 解析** —— 不再自己转换,谁解析谁负责,
+        // 免得"工具里算得出来、控制台里又不对"。
+        final expr = raw;
         final s = await session.sample(
           expr,
           variable: 'x',
@@ -275,9 +275,6 @@ class _FunctionPlotterPageState extends State<FunctionPlotterPage> {
   }
 
   Widget _buildExprRow(BuildContext context, int index) {
-    final raw = _exprs[index].text.trim();
-    // 只有在语法真的被改写时才提示,避免对已经正确的输入刷屏。
-    final rewrite = raw.isEmpty ? null : symjaRewriteOf(raw);
     return Padding(
       padding: const EdgeInsets.symmetric(vertical: 5),
       child: Row(
@@ -299,22 +296,12 @@ class _FunctionPlotterPageState extends State<FunctionPlotterPage> {
               children: [
                 TextField(
                   controller: _exprs[index],
-                  // 输入时重建,让下面的语法转换提示实时更新。
-                  onChanged: (_) => setState(() {}),
                   decoration: InputDecoration(
-                    hintText: '例如: x^2, sin(x), Sin[x]',
+                    hintText: '例如: x^2, Sin[x], Sqrt[x], Exp[x]',
                     border: const OutlineInputBorder(),
                     errorText: index < _errors.length ? _errors[index] : null,
                   ),
                 ),
-                if (rewrite != null)
-                  Padding(
-                    padding: const EdgeInsets.only(top: 4, left: 4),
-                    child: Text(
-                      '已按 Symja 语法解释为: $rewrite',
-                      style: TextStyle(fontSize: 11, color: Colors.grey[600]),
-                    ),
-                  ),
               ],
             ),
           ),

+ 3 - 16
lib/tools/parametric_plotter.dart

@@ -3,7 +3,6 @@ import 'package:flutter/material.dart';
 import 'package:provider/provider.dart';
 
 import '../curve_plot.dart';
-import '../expr_syntax.dart';
 import '../symja.dart';
 
 /// 参数方程绘图:x = f(t), y = g(t),用 Symja 采样后交给 [CurvePlot] 画。
@@ -86,14 +85,14 @@ class _ParametricPlotterPageState extends State<ParametricPlotterPage> {
     try {
       // 同一个 t 区间、同一点数分别采样两条曲线——Symja 一次只能算一个表达式。
       final xs = await session.sample(
-        toSymjaSyntax(xRaw),
+        xRaw,
         variable: 't',
         from: tMin,
         to: tMax,
         count: n,
       );
       final ys = await session.sample(
-        toSymjaSyntax(yRaw),
+        yRaw,
         variable: 't',
         from: tMin,
         to: tMax,
@@ -219,35 +218,23 @@ class _ParametricPlotterPageState extends State<ParametricPlotterPage> {
     );
   }
 
-  /// 表达式输入框 + 语法转换提示(只有真的改写了才显示)。
+  /// 表达式输入框。内容原样交给 Symja 解析,这里不做任何转换。
   Widget _buildExprField(
     TextEditingController controller,
     String label,
     String hint,
   ) {
-    final raw = controller.text.trim();
-    final rewrite = raw.isEmpty ? null : symjaRewriteOf(raw);
     return Column(
       crossAxisAlignment: CrossAxisAlignment.start,
       children: [
         TextField(
           controller: controller,
-          // 输入时重建,让下面的语法转换提示实时更新。
-          onChanged: (_) => setState(() {}),
           decoration: InputDecoration(
             labelText: label,
             hintText: hint,
             border: const OutlineInputBorder(),
           ),
         ),
-        if (rewrite != null)
-          Padding(
-            padding: const EdgeInsets.only(top: 4, left: 4),
-            child: Text(
-              '已按 Symja 语法解释为: $rewrite',
-              style: TextStyle(fontSize: 11, color: Colors.grey[600]),
-            ),
-          ),
       ],
     );
   }

+ 2 - 2
lib/tools/vector_normal.dart

@@ -8,7 +8,6 @@ import 'package:flutter/material.dart';
 import 'package:flutter_math_fork/flutter_math.dart';
 import 'package:provider/provider.dart';
 
-import '../expr_syntax.dart';
 import '../symja.dart';
 
 class VectorNormalPage extends StatefulWidget {
@@ -44,7 +43,8 @@ class _VectorNormalPageState extends State<VectorNormalPage> {
     // 每个坐标先过一遍语法转换,这样用户沿用旧的 `2√3` 写法也能用
     String coord(int point, int axis) {
       final raw = _controllers[point * 3 + axis].text.trim();
-      return raw.isEmpty ? '0' : toSymjaSyntax(raw);
+      // 原样交给 Symja
+      return raw.isEmpty ? '0' : raw;
     }
 
     // 边向量:to - from

+ 19 - 2
test/expr_parse_test.dart

@@ -47,10 +47,27 @@ void main() {
 
     test('空槽写成 Null,解析回来还是空槽', () {
       final t = parseSymjaExpr('Integrate[Null, x]');
-      final fn = t.children.single as FuncNode;
-      expect(fn.args.first.isEmpty, isTrue);
+      final op = t.children.single as BigOpNode;
+      expect(op.body.isEmpty, isTrue);
       expect(symjaOf(t), 'Integrate[Null, x]');
     });
+
+    test('定积分/求和/极限认成大运算符并能往返', () {
+      final def = parseSymjaExpr('Integrate[x^2, {x, 0, 1}]');
+      expect(def.children.single, isA<BigOpNode>());
+      final op = def.children.single as BigOpNode;
+      expect(op.kind, 'Integrate');
+      expect(op.lower.isEmpty, isFalse);
+      expect(op.upper.isEmpty, isFalse);
+      expect(symjaOf(def), 'Integrate[x^2, {x, 0, 1}]');
+
+      expect(symjaOf(parseSymjaExpr('Sum[f, {n, 1, Infinity}]')),
+          'Sum[f, {n, 1, Infinity}]');
+      expect(symjaOf(parseSymjaExpr('Product[f, {n, 1, m}]')),
+          'Product[f, {n, 1, m}]');
+      expect(symjaOf(parseSymjaExpr('Limit[Sin[x]/x, x -> 0]')),
+          'Limit[Sin[x]/x, x -> 0]');
+    });
   });
 
   group('运算符与词法', () {

+ 0 - 128
test/expr_syntax_test.dart

@@ -1,128 +0,0 @@
-// 语法兼容层的测试。这个转换是"悄悄改错也看不出来"的那类东西,
-// 所以边界情况都钉在测试里。
-import 'package:flutter_test/flutter_test.dart';
-
-import 'package:cal/expr_syntax.dart';
-
-void main() {
-  group('函数调用', () {
-    test('小写括号写法转成 Symja 方括号', () {
-      expect(toSymjaSyntax('sin(x)'), 'Sin[x]');
-      expect(toSymjaSyntax('ln(x)'), 'Log[x]');
-      expect(toSymjaSyntax('sqrt(x)'), 'Sqrt[x]');
-      expect(toSymjaSyntax('abs(x)'), 'Abs[x]');
-    });
-
-    test('函数名大小写不敏感', () {
-      expect(toSymjaSyntax('SIN(x)'), 'Sin[x]');
-      expect(toSymjaSyntax('Sin(x)'), 'Sin[x]');
-    });
-
-    test('嵌套调用', () {
-      expect(toSymjaSyntax('sin(cos(x))'), 'Sin[Cos[x]]');
-      expect(toSymjaSyntax('sqrt(1+x^2)'), 'Sqrt[1+x^2]');
-    });
-
-    test('多参数', () {
-      expect(toSymjaSyntax('max(x, y)'), 'Max[x, y]');
-    });
-
-    test('已经是对的 Symja 写法原样保留', () {
-      expect(toSymjaSyntax('Sin[x]'), 'Sin[x]');
-      expect(toSymjaSyntax('x^2 + 1'), 'x^2 + 1');
-    });
-  });
-
-  group('常量', () {
-    test('pi', () {
-      expect(toSymjaSyntax('pi'), 'Pi');
-      expect(toSymjaSyntax('2*pi'), '2*Pi');
-    });
-
-    test('e 独立成词才转,且不与数字相邻', () {
-      expect(toSymjaSyntax('e^x'), 'E^x');
-      expect(toSymjaSyntax('e'), 'E');
-      // 科学计数法不能被拆掉
-      expect(toSymjaSyntax('1e-3'), '1e-3');
-      expect(toSymjaSyntax('2.5e10'), '2.5e10');
-    });
-  });
-
-  group('普通括号不能被当成函数调用', () {
-    test('分组括号保持原样', () {
-      expect(toSymjaSyntax('(x+1)*2'), '(x+1)*2');
-      expect(toSymjaSyntax('((x))'), '((x))');
-    });
-
-    test('未知名字后面跟括号也不动', () {
-      // 不是我们认识的函数名,就当它是用户自己的变量/函数,原样留着
-      expect(toSymjaSyntax('f(x)'), 'f(x)');
-      expect(toSymjaSyntax('myfunc(x+1)'), 'myfunc(x+1)');
-    });
-
-    test('函数与分组混在一起', () {
-      expect(toSymjaSyntax('sin(x)*(x+1)'), 'Sin[x]*(x+1)');
-      expect(toSymjaSyntax('sin(x)^2 + cos(x)^2'), 'Sin[x]^2 + Cos[x]^2');
-    });
-  });
-
-  group('隐式乘法', () {
-    test('数字紧跟字母补乘号', () {
-      expect(toSymjaSyntax('2x'), '2*x');
-      expect(toSymjaSyntax('3sin(x)'), '3*Sin[x]');
-    });
-
-    test('单独的系数不动', () {
-      expect(toSymjaSyntax('2*x'), '2*x');
-      expect(toSymjaSyntax('2'), '2');
-    });
-  });
-
-  group('symjaRewriteOf 只在真的改了才返回', () {
-    test('改了', () {
-      expect(symjaRewriteOf('sin(x)'), 'Sin[x]');
-    });
-    test('没改', () {
-      expect(symjaRewriteOf('Sin[x]'), isNull);
-      expect(symjaRewriteOf('x^2'), isNull);
-    });
-  });
-
-  group('根号(法向量工具用的老写法)', () {
-    test('√ 后跟数字', () {
-      expect(toSymjaSyntax('√3'), 'Sqrt[3]');
-      expect(toSymjaSyntax('√2'), 'Sqrt[2]');
-    });
-
-    test('系数与根号之间补乘号', () {
-      expect(toSymjaSyntax('2√3'), '2*Sqrt[3]');
-      expect(toSymjaSyntax('3√2/2'), '3*Sqrt[2]/2');
-    });
-
-    test('√ 后跟配平的括号', () {
-      expect(toSymjaSyntax('√(x+1)'), 'Sqrt[(x+1)]');
-      expect(toSymjaSyntax('√(2+3)*4'), 'Sqrt[(2+3)]*4');
-    });
-
-    test('√ 后跟字母变量', () {
-      expect(toSymjaSyntax('√x'), 'Sqrt[x]');
-    });
-
-    test('负数与分数系数', () {
-      expect(toSymjaSyntax('-√3'), '-Sqrt[3]');
-      expect(toSymjaSyntax('1/2√3'), '1/2*Sqrt[3]');
-    });
-
-    test('√ 后面什么都没有也不崩', () {
-      expect(toSymjaSyntax('√'), 'Sqrt[Null]');
-    });
-  });
-
-  group('不该崩', () {
-    test('空串与怪输入', () {
-      expect(toSymjaSyntax(''), '');
-      expect(toSymjaSyntax(')'), ')'); // 多余的右括号原样留着,让 Symja 去报错
-      expect(toSymjaSyntax('sin('), 'Sin[');
-    });
-  });
-}

+ 67 - 83
test/widget_test.dart

@@ -107,6 +107,17 @@ void main() {
     await tester.pumpAndSettle();
   }
 
+  /// 等防抖走完,取出真正送到通道的那条表达式。
+  ///
+  /// 显示区那行"原文"是按需出现的(没信息量就不显示),所以断言文档内容
+  /// 不能靠它 —— 看通道里实际发出去的东西才可靠。
+  Future<String> lastExpr(WidgetTester tester) async {
+    await tester.pump(const Duration(milliseconds: 700));
+    await tester.pumpAndSettle();
+    return calls.lastWhere((c) => c.method == 'evaluate').arguments['expr']
+        as String;
+  }
+
   group('外壳', () {
     testWidgets('三个界面:计算器 / 工具 / 控制台', (tester) async {
       await pumpApp(tester);
@@ -132,7 +143,7 @@ void main() {
       await press(tester, '∫');
       await press(tester, 'x');
 
-      expect(find.text('Integrate[x, x]'), findsOneWidget);
+      expect(await lastExpr(tester), 'Integrate[x, x]');
     });
 
     testWidgets('^ 把左边的节点收成底,光标落到指数', (tester) async {
@@ -141,7 +152,7 @@ void main() {
       await press(tester, '^');
       await press(tester, '2');
 
-      expect(find.text('x^2'), findsOneWidget);
+      expect(await lastExpr(tester), 'x^2');
     });
 
     testWidgets('数字键并入同一个字面量', (tester) async {
@@ -196,7 +207,7 @@ void main() {
       await press(tester, 'x');
       await press(tester, '+');
       await press(tester, '1');
-      expect(find.text('x+1'), findsOneWidget);
+      expect(await lastExpr(tester), 'x+1');
 
       // 点 x 的左半边 → 光标应当落到 x 前面
       final xRect = tester.getRect(
@@ -207,7 +218,7 @@ void main() {
 
       // 再打一个 2:插到最前面才是 2x+1;若光标落在 x 之后就成 x2+1
       await press(tester, '2');
-      expect(find.text('2x+1'), findsOneWidget);
+      expect(await lastExpr(tester), '2x+1');
     });
 
     testWidgets('点节点右半边把光标放到它后面', (tester) async {
@@ -222,7 +233,7 @@ void main() {
       await tester.pumpAndSettle();
 
       await press(tester, '2');
-      expect(find.text('x2+'), findsOneWidget);
+      expect(await lastExpr(tester), 'x2+');
     });
 
     testWidgets('默认精确:求值原样送出去', (tester) async {
@@ -278,8 +289,7 @@ void main() {
 
   group('复制粘贴', () {
     /// 把系统通道的剪贴板换成内存版,并记录写入。
-    ({List<String> copied, void Function(String?) feed}) mockClipboard(
-        WidgetTester tester) {
+    List<String> mockClipboard(WidgetTester tester) {
       final copied = <String>[];
       String? available;
       TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
@@ -291,7 +301,9 @@ void main() {
             available = t; // 有写有读,跟真剪贴板一样
             return null;
           case 'Clipboard.getData':
-            return available == null ? null : <String, dynamic>{'text': available};
+            return available == null
+                ? null
+                : <String, dynamic>{'text': available};
           default:
             return null;
         }
@@ -299,123 +311,95 @@ void main() {
       addTearDown(() => TestDefaultBinaryMessengerBinding
           .instance.defaultBinaryMessenger
           .setMockMethodCallHandler(SystemChannels.platform, null));
-      return (copied: copied, feed: (v) => available = v);
+      return copied;
     }
 
-    Future<void> pickMenu(WidgetTester tester, String item) async {
-      await tester.tap(find.byIcon(Icons.more_vert));
+    Finder inEditor(String text) => find.descendant(
+        of: find.byType(ExprEditor), matching: find.text(text));
+
+    /// 长按 [from] 再点 [to],选中两者之间(含)的一段。
+    Future<void> selectRange(
+        WidgetTester tester, String from, String to) async {
+      await tester.longPress(inEditor(from));
       await tester.pumpAndSettle();
-      await tester.tap(find.text(item));
+      await tester.tap(inEditor(to));
       await tester.pumpAndSettle();
     }
 
-    Finder inEditor(String text) => find.descendant(
-        of: find.byType(ExprEditor), matching: find.text(text));
+    /// 点"光标已经所在的地方",弹出粘贴入口。
+    ///
+    /// 直接点光标那根竖线最稳 —— 光标在哪,就点到哪。按节点坐标算的话,
+    /// 光标在某个参数槽里时点节点边界其实落不到同一个位置。
+    Future<void> tapCaret(WidgetTester tester) async {
+      await tester
+          .tapAt(tester.getCenter(find.byKey(const ValueKey('expr-caret'))));
+      await tester.pumpAndSettle();
+    }
 
-    testWidgets('长按选中一段 → 操作栏自动出现 → 剪切', (tester) async {
-      final clip = mockClipboard(tester);
+    testWidgets('选中一段 → 操作栏自动出现 → 复制', (tester) async {
+      final copied = 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();
+      await selectRange(tester, '2', '1');
       expect(find.textContaining('已选中  2x+1'), findsOneWidget);
 
-      await tester.tap(find.text('剪切'));
+      await tester.tap(find.text('复制'));
       await tester.pumpAndSettle();
-      expect(clip.copied, ['2x+1']);
-      expect(find.text('Null'), findsOneWidget); // 全剪掉了
+      expect(copied, ['2x+1']);
     });
 
     testWidgets('剪切后粘到 Log 的括号里', (tester) async {
-      final clip = mockClipboard(tester);
+      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 selectRange(tester, '2', '1');
       await tester.tap(find.text('剪切'));
       await tester.pumpAndSettle();
+      expect(find.text('Null'), findsOneWidget); // 全剪掉了
 
-      // 建一个空的 Log,光标在参数槽里
-      await press(tester, 'ln');
+      await press(tester, 'ln'); // Log[Null],光标在参数槽里
       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();
+      await tapCaret(tester);
       expect(find.text('粘贴到光标处'), findsOneWidget);
 
       await tester.tap(find.text('粘贴'));
       await tester.pumpAndSettle();
-      expect(find.textContaining('已粘贴到光标处'), findsOneWidget);
+      expect(await lastExpr(tester), 'Log[2x+1]');
     });
 
-    testWidgets('复制表达式进剪贴板', (tester) async {
-      final clip = mockClipboard(tester);
+    testWidgets('粘贴解析不了的东西会明确报错,不会乱猜', (tester) async {
+      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 Clipboard.setData(const ClipboardData(text: 'x@y'));
+      await tapCaret(tester);
+      await tester.tap(find.text('粘贴'));
+      await tester.pumpAndSettle();
 
-      await pickMenu(tester, '从剪贴板粘贴');
       expect(find.textContaining('没法转成按钮式输入'), findsOneWidget);
     });
 
-    testWidgets('复制粘贴走一圈,结果一致', (tester) async {
-      final clip = mockClipboard(tester);
+    testWidgets('点结果直接复制结果', (tester) async {
+      final copied = 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');
+      await press(tester, '2');
+      await press(tester, '+');
+      await press(tester, '3');
+      await press(tester, '=');
 
-      clip.feed(clip.copied.single);
-      await pickMenu(tester, '清空');
-      await pickMenu(tester, '从剪贴板粘贴');
-      expect(find.text('2x+1'), findsWidgets);
+      // 结果区渲染的是 LaTeX,纯文本那份不在,所以点整个结果条
+      await tester.tap(find.byKey(const ValueKey('calc-result')));
+      await tester.pumpAndSettle();
+      expect(copied, contains('x^3/3'));
+      expect(find.textContaining('已复制结果'), findsOneWidget);
     });
   });