Ver código fonte

函数键能把整槽内容包进去(打完 2x+1 按 ln 得 Log[2x+1])

原来 ln / sin 这些键只会插一个空模板,不会包已有的表达式;只有 ^ / √
会包,而且只包**最后一个节点** —— `2x+1` 是四个节点,用 √ 也只会得到
`2x+√1`。用户问"打完 2x+1 怎么在外面套个 ln",确实没路可走。

新增 applyFunction,按"用户此刻想干什么"分两种情况:

 1. 光标在槽尾、槽里有内容、且最后不是运算符 → **整槽**包进函数。
    打完 2x+1 按 ln 就是 Log[2x+1]。
 2. 其余情况 → 插空模板,光标进括号里(空槽时按 ln、光标在中间时都是)。

一个刻意的例外:槽尾是运算符时**不包**。用户刚打完 `1+`,想要的是
`1+Log[...]`;包起来会变成 `Log[1+]`,那是明显的错。判据就是最后一个节点
是不是 + - * / ^ ,。

同时 `√` 和函数面板也走同一条路(`√` 原本只包最后一个节点,现在一致),
删掉了变成死代码的 makeSqrt。

80 个测试通过(新增 7 个模型测试 + 1 个端到端)。
真机验证:按键打 2x+1 再按 ln 得到 Log[2x+1],预览渲染 log(1 + 2·x)。

Co-Authored-By: Claude Code <noreply@anthropic.com>
nonvioce 4 semanas atrás
pai
commit
7133a002f7
4 arquivos alterados com 128 adições e 13 exclusões
  1. 5 6
      lib/calculator_page.dart
  2. 49 6
      lib/expr_node.dart
  3. 63 1
      test/expr_node_test.dart
  4. 11 0
      test/widget_test.dart

+ 5 - 6
lib/calculator_page.dart

@@ -141,13 +141,12 @@ class _CalculatorPageState extends State<CalculatorPage> {
           _doc.insert(LitNode(k.payload));
         }
       case _Act.fn:
-        _doc.makeFunction(k.payload);
+        _doc.applyFunction(k.payload);
       case _Act.fnPrefixed:
-        // 像 Integrate 这种,第一个参数是要填的,后面预置好
-        _doc.makeFunction(k.payload, prefill: k.prefill);
-        // prefill 之后光标停在第一个槽(空的),正是要填的位置
+        // 像 Integrate 这种:包的时候整槽当第一个参数,其余按 prefill 预置
+        _doc.applyFunction(k.payload, prefill: k.prefill);
       case _Act.sqrt:
-        _doc.makeSqrt();
+        _doc.applyFunction('Sqrt');
       case _Act.group:
         _doc.makeGroup();
       case _Act.power:
@@ -182,7 +181,7 @@ class _CalculatorPageState extends State<CalculatorPage> {
     );
     if (picked == null) return;
     if (picked.isFn) {
-      _doc.makeFunction(picked.value);
+      _doc.applyFunction(picked.value);
     } else {
       _doc.insert(LitNode(picked.value));
     }

+ 49 - 6
lib/expr_node.dart

@@ -220,12 +220,6 @@ class ExprDoc {
     insert(node, enterSlot: 1);
   }
 
-  /// 制造一个根式:被开方数 = 光标左边那个节点(没有就空着)。
-  void makeSqrt() {
-    final inner = takeLeft();
-    insert(FuncNode('Sqrt', [SeqNode(inner == null ? null : [inner])]), enterSlot: 0);
-  }
-
   /// 制造一个函数调用,光标落在第一个参数槽。
   ///
   /// [prefill] 每一项对应一个参数:[String] 为空表示**留一个空槽给用户填**,
@@ -241,6 +235,55 @@ class ExprDoc {
     );
   }
 
+  /// 槽尾是不是运算符 —— 说明用户这句还没写完。
+  static const _operators = {'+', '-', '*', '/', '^', ','};
+
+  static bool _endsWithOperator(SeqNode seq) {
+    if (seq.children.isEmpty) return false;
+    final last = seq.children.last;
+    return last is LitNode && _operators.contains(last.text);
+  }
+
+  /// 单参数(或多参数带预置)函数键的行为。
+  ///
+  /// 两种情况,按"用户此刻想干什么"分:
+  ///
+  ///  1. **光标在槽尾、槽里有内容、且最后不是运算符** → 把**整槽内容**包进函数。
+  ///     用户打完 `2x+1` 按 `ln`,要的是 `Log[2x+1]`,不是 `Log[]` 再让他重打。
+  ///     (注意是**整槽**不是"左边那个节点" —— `2x+1` 是四个节点,
+  ///     只包最后一个会变成 `2x+Log[1]`。)
+  ///
+  ///  2. **其余情况** → 插一个空模板,光标进括号里。
+  ///     空槽时按 `ln` 就是这种情况;光标停在中间时也是。
+  ///
+  /// 一个刻意的例外:槽尾是运算符(用户刚打完 `1+`)**不包**。
+  /// 那时他想要的是 `1+Log[...]`,包起来会变成 `Log[1+]`。
+  ///
+  /// [prefill] 里的第一项留空表示"由上面两种情况决定",其余项原样预置。
+  void applyFunction(String name, {List<String> prefill = const []}) {
+    final seq = current;
+    final atEnd = cursor.offset >= seq.children.length;
+    final shouldWrap =
+        atEnd && seq.children.isNotEmpty && !_endsWithOperator(seq);
+
+    // prefill 的第一项是"要填的那个参数",包裹时被整槽内容顶替;
+    // 没有 prefill 的函数(Sin、Log…)就只有一个参数。
+    final rest = prefill.isEmpty ? const <String>[] : prefill.sublist(1);
+
+    if (!shouldWrap) {
+      makeFunction(name, prefill: prefill);
+      return;
+    }
+
+    final inner = SeqNode(List<ExprNode>.from(seq.children));
+    seq.children.clear();
+    seq.children.add(FuncNode(name, [
+      inner,
+      for (final p in rest) p.isEmpty ? SeqNode() : SeqNode([LitNode(p)]),
+    ]));
+    cursor = SlotPath(cursor.steps, 1);
+  }
+
   /// 切到当前函数调用的下一个参数;已经是最后一个就新建一个参数槽。
   ///
   /// 只能用在函数参数里(光标路径的最后一跳指向某个 FuncNode 的参数槽时)。

+ 63 - 1
test/expr_node_test.dart

@@ -152,7 +152,7 @@ void main() {
 
     test('根式把左边的节点收进被开方数', () {
       final d = docWith([LitNode('2')]);
-      d.makeSqrt();
+      d.applyFunction('Sqrt');
       expect(d.toSymja(), 'Sqrt[2]');
     });
   });
@@ -245,6 +245,68 @@ void main() {
     });
   });
 
+  group('函数键:把整个槽包进去', () {
+    ExprDoc twoXPlusOne() {
+      final d = ExprDoc();
+      d.insertChar('2');
+      d.insert(LitNode('x'));
+      d.insert(LitNode('+'));
+      d.insertChar('1');
+      return d;
+    }
+
+    test('打完 2x+1 按 ln → Log[2x+1](整槽,不是只包最后一个节点)', () {
+      final d = twoXPlusOne();
+      expect(d.toSymja(), '2x+1');
+      d.applyFunction('Log');
+      expect(d.toSymja(), 'Log[2x+1]');
+    });
+
+    test('单参数函数包完只带一个参数(不能多塞空槽)', () {
+      final d = ExprDoc();
+      d.insertChar('2');
+      d.applyFunction('Sqrt');
+      expect(d.toSymja(), 'Sqrt[2]');
+    });
+
+    test('带预置参数的:整槽当第一个参数,其余照旧', () {
+      final d = twoXPlusOne();
+      d.applyFunction('Integrate', prefill: ['', 'x']);
+      expect(d.toSymja(), 'Integrate[2x+1, x]');
+    });
+
+    test('空槽时按 ln → 空模板,不是 Log[Null] 包 Null', () {
+      final d = ExprDoc();
+      d.applyFunction('Log');
+      expect(d.toSymja(), 'Log[Null]');
+      d.insertChar('x');
+      expect(d.toSymja(), 'Log[x]');
+    });
+
+    test('槽尾是运算符时不包 —— 用户刚打完 1+ ,要的是 1+Log[...]', () {
+      final d = ExprDoc();
+      d.insertChar('1');
+      d.insert(LitNode('+'));
+      d.applyFunction('Log');
+      expect(d.toSymja(), '1+Log[Null]');
+    });
+
+    test('光标停在中间时也插模板,不把整槽包走', () {
+      final d = twoXPlusOne();
+      d.cursor = const SlotPath(<SlotStep>[], 2); // 停在 x 之后、+ 之前
+      d.applyFunction('Log');
+      expect(d.toSymja(), '2xLog[Null]+1');
+    });
+
+    test('包完之后光标在函数外面(能继续往后打)', () {
+      final d = twoXPlusOne();
+      d.applyFunction('Log');
+      d.insert(LitNode('+'));
+      d.insertChar('3');
+      expect(d.toSymja(), 'Log[2x+1]+3');
+    });
+  });
+
   group('光标槽判定', () {
     test('槽内位置不同,仍算同一个槽(渲染光标靠这个)', () {
       const a = SlotPath(<SlotStep>[], 0);

+ 11 - 0
test/widget_test.dart

@@ -191,6 +191,17 @@ void main() {
       expect(find.byKey(const ValueKey('expr-caret')), findsOneWidget);
     });
 
+    testWidgets('打完 2x+1 按 ln,整个式子被包成 Log[2x+1]', (tester) async {
+      await pumpApp(tester);
+      for (final k in ['2', 'x', '+', '1']) {
+        await press(tester, k);
+      }
+      expect(find.text('2x+1'), findsOneWidget);
+
+      await press(tester, 'ln');
+      expect(find.text('Log[2x+1]'), findsOneWidget);
+    });
+
     testWidgets('点节点左半边能把光标放回最前面', (tester) async {
       await pumpApp(tester);
       await press(tester, 'x');