Przeglądaj źródła

修光标消失、只打 arm64、精确/近似切换、法向量工具改用 CAS

## 光标"有时看得到有时看不到"(真 bug)
渲染时用 `doc.cursor == path` 判断哪个槽聚焦,但 SlotPath 的 offset 是
**槽内的插入位置**,不属于槽的身份;而路径自带的 offset 恒为 0。
于是只有光标停在槽首时才认为聚焦 —— **打第一个字符光标就没了**。
改用 sameSlotAs(只比路径)。加了回归测试。

顺带把光标从"0.15~1.0 平滑呼吸"改成常亮实心:最暗那半程基本等于看不见,
而且为它引入的无限动画会让 pumpAndSettle 永远等不到静止。
现在用它连 blinkEnabled 那个测试后门和每帧重建都一起去掉了。

## 只打包 arm64-v8a
`flutter build apk` 会把三个 ABI 的 libflutter.so 都塞进去(36.8MB → 74.1MB)。
两条路试过都无效:
  · defaultConfig 里写 ndk.abiFilters —— 实测照样三个 ABI 全进包
  · gradle.properties 里写 target-platform —— flutter_tools 会显式传
    -Ptarget-platform=... (gradle.dart:581),命令行参数盖过它
改成 packaging.jniLibs.excludes 在 APK 组装阶段剔目录,Flutter 管不着。
现在不带任何参数构建就是 arm64-only。

## 计算结果默认为精确值,可切近似
符号计算本来就是精确的(1/3 就是 1/3),默认不开近似。
打开时给表达式套 N[expr, 15],**跳过带赋值的表达式**(`a = 1/3` 包成
N[a = 1/3] 会把赋值变成求值;正则 `=(?!=)` 恰好放过 `==` 方程)。

位数一开始给 30,用户实测"根本显示不全" —— 三十多个字符横向滚走了,
只能看到尾巴。降到 15 位,并把结果区从横向滚动改成 FittedBox 等比缩放,
保证整个结果都在屏幕里。控制台的历史条目同理。

## 法向量工具改用符号计算库
不再用自研的 rationalsqrt2.dart(只能表示"有理数 × 一层根号 + 几项之和"),
改成拼一个 Cross[AB, AC] 交给 Symja。任意坐标表达式都能精确算。
去掉那个 √ 按钮(直接按 Symja 语法输 Sqrt[3] 即可),连带删掉只为插根号
而存在的焦点跟踪代码。语法转换层新增 √ 支持,老写法 2√3 仍可用。

真机验证:光标打一个字后仍在、近似 0.333333333333333 一屏放得下、
{0, 0, √3} 精确根式结果、APK 36.8MB 且只剩 arm64。

Co-Authored-By: Claude Code <noreply@anthropic.com>
nonvioce 4 tygodni temu
rodzic
commit
1ccefe861b

+ 15 - 0
android/app/build.gradle.kts

@@ -95,6 +95,21 @@ android {
                 "**/*.xsd",
             )
         }
+
+        // 只留 arm64-v8a 的原生库。
+        //
+        // 另外两条路都试过、都不行:
+        //  · defaultConfig 里写 ndk.abiFilters —— 实测三个 ABI 的 libflutter.so 照样全进包
+        //  · android/gradle.properties 里写 target-platform —— flutter_tools 会显式传
+        //    -Ptarget-platform=... (gradle.dart:581),命令行参数盖过 gradle.properties
+        // 这里在 APK 组装阶段剔目录,Flutter 就管不着了。
+        jniLibs {
+            excludes += setOf(
+                "lib/armeabi-v7a/**",
+                "lib/x86/**",
+                "lib/x86_64/**",
+            )
+        }
     }
 }
 

+ 23 - 6
lib/calculator_page.dart

@@ -193,10 +193,27 @@ class _CalculatorPageState extends State<CalculatorPage> {
 
   @override
   Widget build(BuildContext context) {
+    final session = context.watch<SymjaSession>();
+
     return Scaffold(
       appBar: AppBar(
         title: const Text('计算器'),
         actions: [
+          // 精确 / 近似 切换。默认精确:`1/3` 就是 `1/3`,这是符号计算的意义所在。
+          TextButton(
+            onPressed: () {
+              session.approximate = !session.approximate;
+              _onDocChanged(); // 立刻按新模式重算预览
+            },
+            child: Text(
+              session.approximate ? '≈ 近似' : '精确',
+              style: TextStyle(
+                fontFamily: 'monospace',
+                fontSize: 13,
+                color: Theme.of(context).colorScheme.primary,
+              ),
+            ),
+          ),
           IconButton(
             tooltip: '画图(x ∈ [-6.3, 6.3])',
             icon: const Icon(Icons.show_chart),
@@ -256,13 +273,13 @@ class _CalculatorPageState extends State<CalculatorPage> {
           const SizedBox(height: 6),
           SizedBox(
             height: 52,
-            child: Align(
+            width: double.infinity,
+            // 等比缩到放得下,**不横向滚动** ——
+            // 滚动的话长结果只能看到尾巴,"显示不全"就是这么来的。
+            child: FittedBox(
+              fit: BoxFit.scaleDown,
               alignment: Alignment.centerRight,
-              child: SingleChildScrollView(
-                scrollDirection: Axis.horizontal,
-                reverse: true,
-                child: _resultView(context, shown),
-              ),
+              child: _resultView(context, shown),
             ),
           ),
         ],

+ 11 - 6
lib/console_page.dart

@@ -459,12 +459,17 @@ class _EntryTile extends StatelessWidget {
     return Column(
       crossAxisAlignment: CrossAxisAlignment.start,
       children: [
-        Math.tex(
-          r.tex!,
-          mathStyle: MathStyle.display,
-          textStyle: TextStyle(fontSize: 22, color: scheme.onSurface),
-          // ComplexInfinity 之类的输出不是合法 LaTeX,直接退回纯文本
-          onErrorFallback: (_) => plain,
+        // 同样缩到放得下:控制台的结果可能很长(大矩阵、长小数)
+        FittedBox(
+          fit: BoxFit.scaleDown,
+          alignment: Alignment.centerLeft,
+          child: Math.tex(
+            r.tex!,
+            mathStyle: MathStyle.display,
+            textStyle: TextStyle(fontSize: 22, color: scheme.onSurface),
+            // ComplexInfinity 之类的输出不是合法 LaTeX,直接退回纯文本
+            onErrorFallback: (_) => plain,
+          ),
         ),
         const SizedBox(height: 4),
         SelectableText(

+ 34 - 65
lib/expr_editor.dart

@@ -7,7 +7,7 @@ import 'expr_node.dart';
 /// 渲染方式:直接把节点树翻译成 widget 树 —— 分式用 Column(分子/横线/分母)、
 /// 幂用 Row + 抬高的上标、函数用 `名字[ 参数 ]`。没有自己写排版引擎,
 /// 因为 Flutter 的布局本来就够用,而且这样点击命中、无障碍都能白拿。
-class ExprEditor extends StatefulWidget {
+class ExprEditor extends StatelessWidget {
   const ExprEditor({
     super.key,
     required this.doc,
@@ -22,65 +22,31 @@ class ExprEditor extends StatefulWidget {
 
   final double fontSize;
 
-  /// 光标是否闪烁。
-  ///
-  /// **widget 测试里必须设成 false**:闪烁是无限循环动画,而计算器页面常驻在
-  /// IndexedStack 里,于是 `pumpAndSettle` 永远等不到"没有待处理帧"而超时。
-  static bool blinkEnabled = true;
-
-  @override
-  State<ExprEditor> createState() => _ExprEditorState();
-}
-
-class _ExprEditorState extends State<ExprEditor>
-    with SingleTickerProviderStateMixin {
-  late final AnimationController _blink = AnimationController(
-    vsync: this,
-    duration: const Duration(milliseconds: 1100),
-    value: 1,
-  );
-
-  @override
-  void initState() {
-    super.initState();
-    if (ExprEditor.blinkEnabled) _blink.repeat(reverse: true);
-  }
-
-  @override
-  void dispose() {
-    _blink.dispose();
-    super.dispose();
-  }
-
-  void _setCursor(SlotPath p) {
-    if (widget.doc.cursor == p) return;
-    widget.doc.cursor = p;
-    widget.onChanged();
+  void _setCursor(BuildContext context, SlotPath p) {
+    if (doc.cursor == p) return;
+    doc.cursor = p;
+    onChanged();
   }
 
   @override
   Widget build(BuildContext context) {
-    return AnimatedBuilder(
-      animation: _blink,
-      builder: (context, _) => SingleChildScrollView(
-        scrollDirection: Axis.horizontal,
-        reverse: true, // 长表达式总是把光标一侧露出来
-        child: ConstrainedBox(
-          constraints: BoxConstraints(
-            minWidth: MediaQuery.sizeOf(context).width - 32,
-          ),
-          child: Align(
-            alignment: Alignment.centerRight,
-            child: Padding(
-              padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
-              child: _SlotView(
-                doc: widget.doc,
-                seq: widget.doc.root,
-                path: SlotPath.root,
-                size: widget.fontSize,
-                blink: _blink.value,
-                onCursor: _setCursor,
-              ),
+    return SingleChildScrollView(
+      scrollDirection: Axis.horizontal,
+      reverse: true, // 长表达式总是把光标一侧露出来
+      child: ConstrainedBox(
+        constraints: BoxConstraints(
+          minWidth: MediaQuery.sizeOf(context).width - 32,
+        ),
+        child: Align(
+          alignment: Alignment.centerRight,
+          child: Padding(
+            padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
+            child: _SlotView(
+              doc: doc,
+              seq: doc.root,
+              path: SlotPath.root,
+              size: fontSize,
+              onCursor: (p) => _setCursor(context, p),
             ),
           ),
         ),
@@ -96,7 +62,6 @@ class _SlotView extends StatelessWidget {
     required this.seq,
     required this.path,
     required this.size,
-    required this.blink,
     required this.onCursor,
   });
 
@@ -104,13 +69,13 @@ class _SlotView extends StatelessWidget {
   final SeqNode seq;
   final SlotPath path;
   final double size;
-  final double blink;
   final void Function(SlotPath) onCursor;
 
   @override
   Widget build(BuildContext context) {
     final scheme = Theme.of(context).colorScheme;
-    final focused = doc.cursor == path;
+    // 用 sameSlotAs 而不是 ==:offset 是槽内的插入位置,不属于槽的身份
+    final focused = doc.cursor.sameSlotAs(path);
 
     return GestureDetector(
       // 点槽的空白处 → 光标落到槽尾
@@ -133,7 +98,6 @@ class _SlotView extends StatelessWidget {
                 parent: path,
                 index: i,
                 size: size,
-                blink: blink,
                 onCursor: onCursor,
               ),
             ],
@@ -147,12 +111,20 @@ class _SlotView extends StatelessWidget {
     );
   }
 
+  /// 光标。**常亮、不闪烁**。
+  ///
+  /// 之前做成 0.15~1.0 的平滑呼吸,最暗那半程看着就跟没有一样(用户反馈
+  /// "有时能看到有时看不到")。计算器不是文本编辑器,聚焦的槽本来就有底色
+  /// 高亮,这里再常亮一根实心竖线,位置一眼就能看到。
   Widget _caret(ColorScheme scheme) => Container(
+        // 整个编辑区同时最多只有一根光标,所以固定 key 不会冲突。
+        // 加它是为了能写"打完字光标还在不在"的回归测试 —— 这个 bug
+        // 之前就是靠人眼看截图才发现的。
+        key: const ValueKey('expr-caret'),
         width: 2.5,
         height: size * 1.25,
         margin: const EdgeInsets.symmetric(horizontal: 1),
-        // 直接用动画值当透明度:收到的是 0..1 的呼吸值,比硬闪好看
-        color: scheme.primary.withValues(alpha: 0.15 + blink * 0.85),
+        color: scheme.primary,
       );
 
   Widget _placeholder(ColorScheme scheme) => Container(
@@ -174,7 +146,6 @@ class _NodeView extends StatelessWidget {
     required this.parent,
     required this.index,
     required this.size,
-    required this.blink,
     required this.onCursor,
   });
 
@@ -186,7 +157,6 @@ class _NodeView extends StatelessWidget {
   final int index;
 
   final double size;
-  final double blink;
   final void Function(SlotPath) onCursor;
 
   @override
@@ -293,7 +263,6 @@ class _NodeView extends StatelessWidget {
           seq: seq,
           path: path,
           size: size,
-          blink: blink,
           onCursor: onCursor,
         ),
       );

+ 15 - 0
lib/expr_node.dart

@@ -84,6 +84,21 @@ class SlotPath {
   SlotPath push(int child, int slot) =>
       SlotPath([...steps, SlotStep(child, slot)], 0);
 
+  /// 是否落在**同一个槽**里(只比路径,不比槽内位置)。
+  ///
+  /// 这个方法和 [operator ==] 的区别很关键:`offset` 是**槽内的插入位置**,
+  /// 不属于"槽的身份"。渲染时判断"这个槽是不是光标所在的槽"必须用它 ——
+  /// 早先用 `==` 比整个 SlotPath,而路径自带的 offset 恒为 0,
+  /// 结果只有光标停在槽首时才认为聚焦,**打第一个字符光标就消失了**。
+  bool sameSlotAs(SlotPath other) {
+    if (other.steps.length != steps.length) return false;
+    for (var i = 0; i < steps.length; i++) {
+      if (steps[i].child != other.steps[i].child) return false;
+      if (steps[i].slot != other.steps[i].slot) return false;
+    }
+    return true;
+  }
+
   /// 回到父槽,并把光标停在刚才那个孩子之后。
   SlotPath? get parent {
     if (steps.isEmpty) return null;

+ 37 - 1
lib/expr_syntax.dart

@@ -126,6 +126,41 @@ String toSymjaSyntax(String input) {
       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(')');
@@ -145,7 +180,8 @@ String toSymjaSyntax(String input) {
         j++;
       }
       out.write(input.substring(i, j));
-      if (j < input.length && _isLetter(input[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);

+ 62 - 2
lib/symja.dart

@@ -91,6 +91,32 @@ class SymjaSession extends ChangeNotifier {
   /// 保留多少条历史。
   int maxHistory = 200;
 
+  /// 结果用**近似值**还是**精确值**。默认精确。
+  ///
+  /// 精确是 Symja 的天然行为(`1/3` 就是 `1/3`,`Sqrt[2]` 就是 `Sqrt[2]`),
+  /// 也是符号计算的意义所在,所以默认不开。开起来时给表达式套一层 `N[expr, 30]`。
+  bool approximate = false;
+
+  /// 近似模式下的有效位数。
+  ///
+  /// 15 位是刻意选的:再高就放不下了。`N[1/3, 30]` 会给出三十多个字符的长串,
+  /// 手机上怎么看都显示不全(用户实测反馈)。15 位比一般科学计算器还多,
+  /// 而且刚好一屏能看完。
+  int approximateDigits = 15;
+
+  static final _assignment = RegExp(r'=(?!=)');
+
+  /// 按当前模式把表达式包成近似求值。
+  ///
+  /// **跳过带赋值的表达式**:`a = 1/3` 包成 `N[a = 1/3]` 就变成"先赋值再取近似",
+  /// 显示和存储都可能走样;`f[x_] := x^2` 更会被直接毁掉。
+  /// `=(?!=)` 恰好放过 `==`(方程),所以 `Solve[x^2-1==0, x]` 不受影响。
+  String _applyMode(String expr) {
+    if (!approximate) return expr;
+    if (_assignment.hasMatch(expr)) return expr;
+    return 'N[$expr, $approximateDigits]';
+  }
+
   int _seq = 0;
   int? _running;
 
@@ -121,7 +147,7 @@ class SymjaSession extends ChangeNotifier {
     try {
       final m = await symjaChannel.invokeMapMethod<String, dynamic>('evaluate', {
         'taskId': id,
-        'expr': input,
+        'expr': _applyMode(input),
         'timeoutMs': timeout.inMilliseconds,
         'wantTeX': wantTeX,
         // 显式声明这不是预览:预览不更新 `%`,正式求值要更新
@@ -163,7 +189,7 @@ class SymjaSession extends ChangeNotifier {
     try {
       final m = await symjaChannel.invokeMapMethod<String, dynamic>('evaluate', {
         'taskId': ++_seq,
-        'expr': input,
+        'expr': _applyMode(input),
         'timeoutMs': 4000,
         'wantTeX': true,
         'preview': true,
@@ -180,6 +206,40 @@ class SymjaSession extends ChangeNotifier {
     }
   }
 
+  /// 求值但**不记历史、不动 `%`**,失败抛 [SymjaException]。
+  ///
+  /// 工具页做内部计算用它:那些表达式不是用户在控制台里敲的,塞进历史只是噪音;
+  /// 而 [preview] 会把错误吞掉,工具却需要把错误显示出来。
+  Future<SymjaResult> compute(
+    String expr, {
+    Duration timeout = const Duration(seconds: 20),
+    bool wantTeX = true,
+  }) async {
+    final input = expr.trim();
+    if (input.isEmpty) throw const SymjaException('表达式为空');
+    try {
+      final m = await symjaChannel.invokeMapMethod<String, dynamic>('evaluate', {
+        'taskId': ++_seq,
+        'expr': _applyMode(input),
+        'timeoutMs': timeout.inMilliseconds,
+        'wantTeX': wantTeX,
+        // preview=true 同时意味着原生侧不更新 lastOutput,
+        // 所以工具算完不会顶掉用户在计算器上按 ANS 拿到的东西。
+        'preview': true,
+      });
+      if (m == null || m['ok'] != true) {
+        throw SymjaException('${m?['error'] ?? '未知错误'}');
+      }
+      return SymjaResult(
+        text: '${m['text']}',
+        tex: m['tex'] as String?,
+        elapsedMs: (m['ms'] as num?)?.toInt() ?? 0,
+      );
+    } on PlatformException catch (e) {
+      throw SymjaException('通道错误 ${e.code}: ${e.message}');
+    }
+  }
+
   /// 取消当前求值。原生侧只是取消 Future,Symja 自身不一定能立刻中断
   /// (复杂表达式可能要算完才返回)。
   Future<void> cancel() async {

+ 93 - 133
lib/tools/vector_normal.dart

@@ -1,6 +1,15 @@
 // tools/vector_normal.dart
+//
+// 计算部分走 Symja(符号计算库),不再用自研的 rationalsqrt2.dart。
+// 那个自研的 √n 代数只能表示"有理数 × 一层根号 + 若干项之和",遇到
+// 坐标里出现两层根号、分数套根号、或者三角函数就得放弃;交给 CAS 之后
+// 输入什么就能精确算什么。
 import 'package:flutter/material.dart';
-import '../utils/rationalsqrt2.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 {
   @override
@@ -9,106 +18,55 @@ class VectorNormalPage extends StatefulWidget {
 
 class _VectorNormalPageState extends State<VectorNormalPage> {
   final List<TextEditingController> _controllers = List.generate(9, (_) => TextEditingController());
-  final List<FocusNode> _focusNodes = List.generate(9, (_) => FocusNode());
-  TextEditingController? _currentFocusedController;
-  FocusNode? _currentFocusedNode;
   String _result = '';
+  String? _resultTex;
+  String? _error;
   bool _isCalculating = false;
 
-  @override
-  void initState() {
-    super.initState();
-    // 为每个焦点节点添加监听器
-    for (int i = 0; i < _focusNodes.length; i++) {
-      _focusNodes[i].addListener(() {
-        if (_focusNodes[i].hasFocus) {
-          _setCurrentFocusedController(_controllers[i], _focusNodes[i]);
-        }
-      });
-    }
-  }
-
   @override
   void dispose() {
     for (var controller in _controllers) {
       controller.dispose();
     }
-    for (var focusNode in _focusNodes) {
-      focusNode.dispose();
-    }
     super.dispose();
   }
 
-  // 设置当前聚焦的控制器和焦点节点
-  void _setCurrentFocusedController(TextEditingController controller, FocusNode focusNode) {
-    setState(() {
-      _currentFocusedController = controller;
-      _currentFocusedNode = focusNode;
-    });
-  }
-
-  // 插入根号到当前光标位置
-  void _insertSqrt() {
-    if (_currentFocusedController == null || _currentFocusedNode == null) return;
-    
-    final controller = _currentFocusedController!;
-    final focusNode = _currentFocusedNode!;
-    final text = controller.text;
-    final selection = controller.selection;
-    final newText = text.substring(0, selection.start) + '√' + text.substring(selection.end);
-    final newCursorPosition = selection.start + 1;
-    
-    controller.value = TextEditingValue(
-      text: newText,
-      selection: TextSelection.collapsed(offset: newCursorPosition),
-    );
-    
-    // 确保焦点保持在当前输入框
-    if (!focusNode.hasFocus) {
-      focusNode.requestFocus();
-    }
-    
-    // 延迟设置光标位置,避免全选
-    Future.delayed(Duration(milliseconds: 10), () {
-      controller.selection = TextSelection.collapsed(offset: newCursorPosition);
-    });
-  }
-
-  // 计算法向量
-  void _calculateNormal() {
+  // 计算法向量:AB × AC,全部交给 Symja 做符号运算
+  Future<void> _calculateNormal() async {
+    final session = context.read<SymjaSession>();
     setState(() {
       _isCalculating = true;
       _result = '';
+      _resultTex = null;
+      _error = null;
     });
 
-    try {
-      // 解析三个点的坐标
-      List<Vector3> points = [];
-      for (int i = 0; i < 3; i++) {
-        final x = RationalExtend.parse(_controllers[i * 3].text.isEmpty ? '0' : _controllers[i * 3].text);
-        final y = RationalExtend.parse(_controllers[i * 3 + 1].text.isEmpty ? '0' : _controllers[i * 3 + 1].text);
-        final z = RationalExtend.parse(_controllers[i * 3 + 2].text.isEmpty ? '0' : _controllers[i * 3 + 2].text);
-        points.add(Vector3(x, y, z));
-      }
+    // 每个坐标先过一遍语法转换,这样用户沿用旧的 `2√3` 写法也能用
+    String coord(int point, int axis) {
+      final raw = _controllers[point * 3 + axis].text.trim();
+      return raw.isEmpty ? '0' : toSymjaSyntax(raw);
+    }
 
-      // 计算两个向量
-      final vector1 = points[1] - points[0];
-      final vector2 = points[2] - points[0];
+    // 边向量:to - from
+    String vec(int from, int to) => '{'
+        '(${coord(to, 0)} - ${coord(from, 0)}), '
+        '(${coord(to, 1)} - ${coord(from, 1)}), '
+        '(${coord(to, 2)} - ${coord(from, 2)})}';
 
-      // 计算叉积(法向量)
-      final normal = vector1.cross(vector2);
+    final expr = 'Cross[${vec(0, 1)}, ${vec(0, 2)}]';
 
+    try {
+      final r = await session.compute(expr);
+      if (!mounted) return;
       setState(() {
-        _result = '法向量:(${normal.x}, ${normal.y}, ${normal.z})';
-      });
-    } catch (e) {
-      setState(() {
-        _result = '计算错误:$e';
+        _result = r.text;
+        _resultTex = r.tex;
       });
+    } on SymjaException catch (e) {
+      if (!mounted) return;
+      setState(() => _error = '$e');
     } finally {
-      setState(() {
-        _isCalculating = false;
-      });
+      if (mounted) setState(() => _isCalculating = false);
     }
   }
 
@@ -119,8 +77,8 @@ class _VectorNormalPageState extends State<VectorNormalPage> {
     }
     setState(() {
       _result = '';
-      _currentFocusedController = null;
-      _currentFocusedNode = null;
+      _resultTex = null;
+      _error = null;
     });
   }
 
@@ -169,11 +127,11 @@ class _VectorNormalPageState extends State<VectorNormalPage> {
             SizedBox(height: 12),
             Row(
               children: [
-                _buildCoordinateInput('x', _controllers[startIndex], _focusNodes[startIndex]),
+                _buildCoordinateInput('x', _controllers[startIndex]),
                 SizedBox(width: 12),
-                _buildCoordinateInput('y', _controllers[startIndex + 1], _focusNodes[startIndex + 1]),
+                _buildCoordinateInput('y', _controllers[startIndex + 1]),
                 SizedBox(width: 12),
-                _buildCoordinateInput('z', _controllers[startIndex + 2], _focusNodes[startIndex + 2]),
+                _buildCoordinateInput('z', _controllers[startIndex + 2]),
               ],
             ),
           ],
@@ -182,7 +140,7 @@ class _VectorNormalPageState extends State<VectorNormalPage> {
     );
   }
 
-  Widget _buildCoordinateInput(String label, TextEditingController controller, FocusNode focusNode) {
+  Widget _buildCoordinateInput(String label, TextEditingController controller) {
     return Expanded(
       child: Column(
         crossAxisAlignment: CrossAxisAlignment.start,
@@ -194,7 +152,6 @@ class _VectorNormalPageState extends State<VectorNormalPage> {
           SizedBox(height: 4),
           TextField(
             controller: controller,
-            focusNode: focusNode,
             decoration: InputDecoration(
               hintText: '0',
               border: OutlineInputBorder(),
@@ -244,29 +201,33 @@ class _VectorNormalPageState extends State<VectorNormalPage> {
             child: Text('清除', style: TextStyle(fontSize: 16)),
           ),
         ),
-        SizedBox(width: 12),
-        // 使用 GestureDetector 而不是 ElevatedButton 来避免焦点转移
-        GestureDetector(
-          onTap: _insertSqrt,
-          child: Container(
-            width: 60,
-            padding: EdgeInsets.symmetric(vertical: 12),
-            decoration: BoxDecoration(
-              color: Colors.blue,
-              borderRadius: BorderRadius.circular(4),
-            ),
-            child: Text(
-              '√',
-              style: TextStyle(fontSize: 18, color: Colors.white),
-              textAlign: TextAlign.center,
-            ),
-          ),
-        ),
       ],
     );
   }
 
   Widget _buildResultSection() {
+    if (_error != null) {
+      return Card(
+        color: Colors.red[50],
+        elevation: 2,
+        child: Padding(
+          padding: EdgeInsets.all(16),
+          child: Row(
+            crossAxisAlignment: CrossAxisAlignment.start,
+            children: [
+              Icon(Icons.error_outline, color: Colors.red[700], size: 20),
+              SizedBox(width: 8),
+              Expanded(
+                child: SelectableText(
+                  '计算错误:$_error',
+                  style: TextStyle(color: Colors.red[700], fontSize: 14),
+                ),
+              ),
+            ],
+          ),
+        ),
+      );
+    }
     if (_result.isEmpty) return SizedBox();
 
     return Card(
@@ -282,9 +243,28 @@ class _VectorNormalPageState extends State<VectorNormalPage> {
               style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
             ),
             SizedBox(height: 12),
-            Text(
+            // 法向量是精确的符号结果(可能带根号、分数),用 LaTeX 排出来
+            // 比纯文本好读得多。
+            if (_resultTex != null)
+              Math.tex(
+                _resultTex!,
+                mathStyle: MathStyle.display,
+                textStyle: TextStyle(fontSize: 20, color: Colors.blue[800]),
+                onErrorFallback: (_) => Text(
+                  _result,
+                  style: TextStyle(fontSize: 16, color: Colors.blue[700]),
+                ),
+              )
+            else
+              Text(
+                _result,
+                style: TextStyle(fontSize: 16, color: Colors.blue[700]),
+              ),
+            SizedBox(height: 8),
+            // 纯文本那份留着,方便复制到别处
+            SelectableText(
               _result,
-              style: TextStyle(fontSize: 16, color: Colors.blue[700]),
+              style: TextStyle(fontSize: 12, color: Colors.grey[700]),
             ),
             SizedBox(height: 8),
             Text(
@@ -312,8 +292,13 @@ class _VectorNormalPageState extends State<VectorNormalPage> {
             ),
             SizedBox(height: 12),
             Text(
-              '√2/2请输入为1/2√2\n'
-              '如果已知两向量坐标,将一个点设为原点即可',
+              '坐标按 Symja 语法输入,例如:\n'
+              '  2/3      分数\n'
+              '  Sqrt[2]  根号(也可写 √2)\n'
+              '  Sqrt[2]/2、1 + Sqrt[3]、Pi\n'
+              '\n'
+              '结果是精确值:带根号、分数会原样保留,不会转成小数。\n'
+              '如果已知两向量坐标,将一个点设为原点即可。',
               style: TextStyle(fontSize: 14, color: Colors.grey[700]),
             ),
           ],
@@ -322,28 +307,3 @@ class _VectorNormalPageState extends State<VectorNormalPage> {
     );
   }
 }
-
-// 三维向量类,使用RationalExtend进行计算
-class Vector3 {
-  final RationalExtend x, y, z;
-
-  Vector3(this.x, this.y, this.z);
-
-  // 向量减法
-  Vector3 operator -(Vector3 other) {
-    return Vector3(x - other.x, y - other.y, z - other.z);
-  }
-
-  // 向量叉积
-  Vector3 cross(Vector3 other) {
-    final newX = y * other.z - z * other.y;
-    final newY = z * other.x - x * other.z;
-    final newZ = x * other.y - y * other.x;
-    return Vector3(newX, newY, newZ);
-  }
-
-  @override
-  String toString() {
-    return '($x, $y, $z)';
-  }
-}

+ 34 - 0
test/expr_node_test.dart

@@ -245,6 +245,40 @@ void main() {
     });
   });
 
+  group('光标槽判定', () {
+    test('槽内位置不同,仍算同一个槽(渲染光标靠这个)', () {
+      const a = SlotPath(<SlotStep>[], 0);
+      const b = SlotPath(<SlotStep>[], 3);
+      expect(a == b, isFalse); // == 比的是完整位置
+      expect(a.sameSlotAs(b), isTrue); // sameSlotAs 只比槽
+    });
+
+    test('不同槽不算同一个', () {
+      const a = SlotPath(<SlotStep>[SlotStep(0, 0)], 1);
+      const b = SlotPath(<SlotStep>[SlotStep(0, 1)], 1);
+      expect(a.sameSlotAs(b), isFalse);
+    });
+
+    test('打一个字之后,光标仍被认为在根槽里', () {
+      final d = ExprDoc();
+      expect(d.cursor.sameSlotAs(SlotPath.root), isTrue);
+      d.insertChar('x');
+      expect(d.cursor.offset, 1);
+      expect(d.cursor.sameSlotAs(SlotPath.root), isTrue,
+          reason: 'offset 变了但槽没变,光标必须还画得出来');
+    });
+
+    test('进到函数参数里后,根槽不再算聚焦', () {
+      final d = ExprDoc();
+      d.makeFunction('Sin');
+      expect(d.cursor.sameSlotAs(SlotPath.root), isFalse);
+      expect(
+        d.cursor.sameSlotAs(const SlotPath(<SlotStep>[SlotStep(0, 0)], 0)),
+        isTrue,
+      );
+    });
+  });
+
   group('clear', () {
     test('清空后回到根槽', () {
       final d = ExprDoc();

+ 30 - 0
test/expr_syntax_test.dart

@@ -88,6 +88,36 @@ void main() {
     });
   });
 
+  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(''), '');

+ 53 - 3
test/widget_test.dart

@@ -6,7 +6,6 @@ import 'package:flutter_test/flutter_test.dart';
 
 import 'package:cal/calculator_page.dart';
 import 'package:cal/console_page.dart';
-import 'package:cal/expr_editor.dart';
 import 'package:cal/main.dart';
 import 'package:cal/tools_page.dart';
 
@@ -16,8 +15,6 @@ void main() {
   final calls = <MethodCall>[];
 
   setUp(() {
-    // 光标闪烁是无限循环动画,会让 pumpAndSettle 永远等不到静止
-    ExprEditor.blinkEnabled = false;
     calls.clear();
     TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
         .setMockMethodCallHandler(symja, (call) async {
@@ -181,6 +178,59 @@ void main() {
       expect(committed.last.arguments['expr'], 'Sin[x]');
     });
 
+    testWidgets('打完字光标仍在(回归:光标槽判定不能比槽内位置)', (tester) async {
+      await pumpApp(tester);
+      expect(find.byKey(const ValueKey('expr-caret')), findsOneWidget);
+
+      await press(tester, 'x');
+      // 以前这里会消失:offset 从 0 变成 1,而它错误地参与了对"槽"的相等判断
+      expect(find.byKey(const ValueKey('expr-caret')), findsOneWidget);
+
+      await press(tester, '2');
+      expect(find.byKey(const ValueKey('expr-caret')), findsOneWidget);
+    });
+
+    testWidgets('默认精确:求值原样送出去', (tester) async {
+      await pumpApp(tester);
+      await press(tester, 'sin');
+      await press(tester, 'x');
+      await press(tester, '=');
+
+      final committed = calls.where(
+          (c) => c.method == 'evaluate' && c.arguments['preview'] == false);
+      expect(committed.last.arguments['expr'], 'Sin[x]');
+    });
+
+    testWidgets('切到近似后,求值套上 N[...]', (tester) async {
+      await pumpApp(tester);
+      await press(tester, 'sin');
+      await press(tester, 'x');
+
+      await tester.tap(find.text('精确'));
+      await tester.pumpAndSettle();
+      expect(find.text('≈ 近似'), findsOneWidget);
+
+      await press(tester, '=');
+      final committed = calls.where(
+          (c) => c.method == 'evaluate' && c.arguments['preview'] == false);
+      expect(committed.last.arguments['expr'], 'N[Sin[x], 15]');
+    });
+
+    testWidgets('近似模式不碰赋值表达式(否则赋值会被变成求值)', (tester) async {
+      await pumpApp(tester);
+      // 先在计算器里打开近似
+      await tester.tap(find.text('精确'));
+      await tester.pumpAndSettle();
+
+      // 到控制台提交一个赋值
+      await goTo(tester, '控制台');
+      await submit(tester, 'a = 1/3');
+
+      final committed = calls.where(
+          (c) => c.method == 'evaluate' && c.arguments['preview'] == false);
+      expect(committed.last.arguments['expr'], 'a = 1/3');
+    });
+
     testWidgets('CLR 清空', (tester) async {
       await pumpApp(tester);
       await press(tester, 'sin');