expr_node.dart 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498
  1. /// 结构化表达式模型 —— 计算器的输入不再是字符串,而是一棵树。
  2. ///
  3. /// 为什么值得单独做一套:纯文本光标表达不了多参数结构。用户按了 `∫` 之后
  4. /// 光标该放哪?按 `^` 之后指数该往哪写?用字符串只能靠"插入 `Integrate[, x]`
  5. /// 再把光标往回退 4 位"这种把戏,模板一复杂就散架,分数、上下标更是没法表达。
  6. /// 树 + 槽就没有这个问题。
  7. ///
  8. /// 分工:**计算器用这套结构化输入,控制台继续用纯文本**(长表达式、粘贴、
  9. /// 脚本式输入还是文本方便)。两者序列化后交给同一个求值内核。
  10. library;
  11. import 'package:meta/meta.dart';
  12. /// 表达式节点。
  13. sealed class ExprNode {
  14. const ExprNode();
  15. }
  16. /// 字面量:数字、变量名、常量。用字符串存,因为 `3.14`、`x`、`Pi` 都是文本。
  17. class LitNode extends ExprNode {
  18. final String text;
  19. const LitNode(this.text);
  20. }
  21. /// 函数调用:`name[arg0, arg1, …]`。每个参数是一个槽。
  22. class FuncNode extends ExprNode {
  23. final String name;
  24. final List<SeqNode> args;
  25. FuncNode(this.name, this.args);
  26. }
  27. /// 分式:分子 / 分母。
  28. class FracNode extends ExprNode {
  29. SeqNode num;
  30. SeqNode den;
  31. FracNode({required this.num, required this.den});
  32. }
  33. /// 幂:底 ^ 指数。
  34. class PowNode extends ExprNode {
  35. SeqNode base;
  36. SeqNode exp;
  37. PowNode({required this.base, required this.exp});
  38. }
  39. /// 括号分组 `( … )`。
  40. ///
  41. /// 用独立节点而不是把括号当字面量:括号必须成对且包住一整段,
  42. /// 当字面量的话用户能删掉半个括号,表达式就废了。
  43. class GroupNode extends ExprNode {
  44. SeqNode inner;
  45. GroupNode(this.inner);
  46. }
  47. /// 花括号列表 `{ … }`。
  48. ///
  49. /// 计算器自己不用它(Sum 的迭代范围是一个字面量),但**粘贴**进来的表达式
  50. /// 可能带花括号(`Sum[f, {n, 1, Infinity}]`),没有这个节点就解析不了。
  51. class BraceNode extends ExprNode {
  52. SeqNode inner;
  53. BraceNode(this.inner);
  54. }
  55. /// 后缀运算符,目前只有阶乘 `!`。
  56. class PostfixNode extends ExprNode {
  57. SeqNode operand;
  58. final String op;
  59. PostfixNode({required this.operand, this.op = '!'});
  60. }
  61. /// 一个槽 = 一串节点。分式的分子分母、函数的参数、幂的指数都是槽。
  62. class SeqNode {
  63. final List<ExprNode> children;
  64. SeqNode([List<ExprNode>? children]) : children = children ?? <ExprNode>[];
  65. bool get isEmpty => children.isEmpty;
  66. }
  67. /// 光标路径里的一跳:进入第 [child] 个孩子的第 [slot] 个槽。
  68. class SlotStep {
  69. final int child;
  70. final int slot;
  71. const SlotStep(this.child, this.slot);
  72. }
  73. /// 光标位置。空路径 = 根槽;[offset] 是在最终那个槽里的插入位置。
  74. class SlotPath {
  75. final List<SlotStep> steps;
  76. final int offset;
  77. const SlotPath(this.steps, this.offset);
  78. static const root = SlotPath(<SlotStep>[], 0);
  79. SlotPath push(int child, int slot) =>
  80. SlotPath([...steps, SlotStep(child, slot)], 0);
  81. /// 是否落在**同一个槽**里(只比路径,不比槽内位置)。
  82. ///
  83. /// 这个方法和 [operator ==] 的区别很关键:`offset` 是**槽内的插入位置**,
  84. /// 不属于"槽的身份"。渲染时判断"这个槽是不是光标所在的槽"必须用它 ——
  85. /// 早先用 `==` 比整个 SlotPath,而路径自带的 offset 恒为 0,
  86. /// 结果只有光标停在槽首时才认为聚焦,**打第一个字符光标就消失了**。
  87. bool sameSlotAs(SlotPath other) {
  88. if (other.steps.length != steps.length) return false;
  89. for (var i = 0; i < steps.length; i++) {
  90. if (steps[i].child != other.steps[i].child) return false;
  91. if (steps[i].slot != other.steps[i].slot) return false;
  92. }
  93. return true;
  94. }
  95. /// 回到父槽,并把光标停在刚才那个孩子之后。
  96. SlotPath? get parent {
  97. if (steps.isEmpty) return null;
  98. final last = steps.last;
  99. return SlotPath(steps.sublist(0, steps.length - 1), last.child + 1);
  100. }
  101. @override
  102. bool operator ==(Object other) {
  103. if (other is! SlotPath || other.offset != offset) return false;
  104. if (other.steps.length != steps.length) return false;
  105. for (var i = 0; i < steps.length; i++) {
  106. if (steps[i].child != other.steps[i].child) return false;
  107. if (steps[i].slot != other.steps[i].slot) return false;
  108. }
  109. return true;
  110. }
  111. @override
  112. int get hashCode =>
  113. Object.hash(offset, Object.hashAll(steps.map((s) => Object.hash(s.child, s.slot))));
  114. }
  115. /// 把一个槽序列化成 Symja 表达式。
  116. ///
  117. /// 序列化不依赖任何文档状态,所以给出这个顶层入口,
  118. /// 免得为了转个字符串还要 new 一个 ExprDoc。
  119. String symjaOf(SeqNode seq) => ExprDoc().serializeSeq(seq);
  120. /// 选区:**同一个槽里**一段连续的子节点。
  121. ///
  122. /// 不做跨槽的任意范围 —— 那要处理"从分子选到分母"这种语义模糊的情况,
  123. /// 而实际用起来,想剪切的东西几乎总在同一层(`2x+1` 就是根槽里连续的四个节点)。
  124. @immutable
  125. class SlotRange {
  126. final SlotPath path;
  127. final int start; // 含
  128. final int end; // 不含
  129. const SlotRange(this.path, this.start, this.end);
  130. bool get isEmpty => end <= start;
  131. @override
  132. bool operator ==(Object other) =>
  133. other is SlotRange && other.path == path && other.start == start && other.end == end;
  134. @override
  135. int get hashCode => Object.hash(path, start, end);
  136. }
  137. /// 编辑器文档:持有一棵树和一个光标,并实现所有编辑操作。
  138. ///
  139. /// 可变对象,调用方自己 setState。刻意不做成 ChangeNotifier —— 操作很密集
  140. /// (每敲一个键一次),让页面统一刷新比分发通知更简单也更快。
  141. class ExprDoc {
  142. final SeqNode root = SeqNode();
  143. SlotPath cursor = SlotPath.root;
  144. /// 当前选区。null = 没选中(此时操作都作用在光标处)。
  145. SlotRange? selection;
  146. // ------------------------------------------------------------------ 选区
  147. void selectNodeAt(SlotPath path, int index) {
  148. selection = SlotRange(path, index, index + 1);
  149. }
  150. /// 把选区扩展到包含第 [index] 个节点(按住不放再点另一个节点的语义)。
  151. /// 只支持同一个槽内,跨槽忽略。
  152. void extendSelectionTo(SlotPath path, int index) {
  153. final s = selection;
  154. if (s == null || !s.path.sameSlotAs(path)) return;
  155. selection = SlotRange(
  156. s.path,
  157. index < s.start ? index : s.start,
  158. index + 1 > s.end ? index + 1 : s.end,
  159. );
  160. }
  161. void clearSelection() => selection = null;
  162. /// 选中的节点(按原位序)。
  163. List<ExprNode> get selectedNodes {
  164. final s = selection;
  165. if (s == null || s.isEmpty) return const <ExprNode>[];
  166. final children = slotAt(s.path).children;
  167. final from = s.start.clamp(0, children.length);
  168. final to = s.end.clamp(0, children.length);
  169. return children.sublist(from, to);
  170. }
  171. /// 选区的 Symja 文本(复制到剪贴板的内容)。
  172. String get selectionText => symjaOf(SeqNode(selectedNodes));
  173. /// 删掉选区,光标落到选区起点。
  174. void deleteSelection() {
  175. final s = selection;
  176. if (s == null || s.isEmpty) return;
  177. final children = slotAt(s.path).children;
  178. final from = s.start.clamp(0, children.length);
  179. final to = s.end.clamp(0, children.length);
  180. children.removeRange(from, to);
  181. cursor = SlotPath(s.path.steps, from);
  182. selection = null;
  183. }
  184. /// 在光标处插入一串节点(粘贴用),插完光标落在它们之后。
  185. void insertNodesAtCursor(List<ExprNode> nodes) {
  186. if (nodes.isEmpty) return;
  187. final seq = current;
  188. final at = cursor.offset.clamp(0, seq.children.length);
  189. seq.children.insertAll(at, nodes);
  190. cursor = SlotPath(cursor.steps, at + nodes.length);
  191. selection = null;
  192. }
  193. // ------------------------------------------------------------------ 取槽
  194. /// 按路径找到对应的槽。路径失效时返回根槽。
  195. SeqNode slotAt(SlotPath path) {
  196. var seq = root;
  197. for (final step in path.steps) {
  198. if (step.child < 0 || step.child >= seq.children.length) return root;
  199. final node = seq.children[step.child];
  200. final slots = _slotsOf(node);
  201. if (step.slot < 0 || step.slot >= slots.length) return seq;
  202. seq = slots[step.slot];
  203. }
  204. return seq;
  205. }
  206. static List<SeqNode> _slotsOf(ExprNode node) => switch (node) {
  207. FuncNode f => f.args,
  208. FracNode f => [f.num, f.den], // 0=分子 1=分母
  209. PowNode p => [p.base, p.exp], // 0=底 1=指数
  210. GroupNode g => [g.inner],
  211. BraceNode b => [b.inner],
  212. PostfixNode p => [p.operand],
  213. LitNode _ => const <SeqNode>[],
  214. };
  215. SeqNode get current => slotAt(cursor);
  216. // ------------------------------------------------------------------ 插入
  217. /// 在当前光标处插入一个节点;[enterSlot] 不为空则顺便把光标移进它的某个槽。
  218. void insert(ExprNode node, {int? enterSlot}) {
  219. final seq = current;
  220. final at = cursor.offset.clamp(0, seq.children.length);
  221. seq.children.insert(at, node);
  222. cursor = enterSlot == null
  223. ? SlotPath(cursor.steps, at + 1)
  224. : _descend(cursor, at, enterSlot);
  225. }
  226. SlotPath _descend(SlotPath from, int child, int slot) =>
  227. from.push(child, slot);
  228. /// 插入一个字符。
  229. ///
  230. /// 数字和小数点会**并进左边那个数字字面量**,而不是各建一个节点 ——
  231. /// 否则 `42` 是两个节点,退格只删最后一位、显示上还分不出是一个数还是两个。
  232. void insertChar(String ch) {
  233. final seq = current;
  234. if (cursor.offset > 0 && seq.children.isNotEmpty) {
  235. final left = seq.children[cursor.offset - 1];
  236. if (left is LitNode && _isNumberText(left.text) && _isNumberChar(ch)) {
  237. seq.children[cursor.offset - 1] = LitNode(left.text + ch);
  238. return;
  239. }
  240. }
  241. insert(LitNode(ch));
  242. }
  243. static bool _isNumberText(String s) =>
  244. s.isNotEmpty && RegExp(r'^[0-9.]+$').hasMatch(s);
  245. static bool _isNumberChar(String c) => RegExp(r'^[0-9.]$').hasMatch(c);
  246. /// 插入一对括号(光标停在里面)。
  247. void makeGroup() => insert(GroupNode(SeqNode()), enterSlot: 0);
  248. /// 把当前槽里光标左边那个节点取出来(没有则返回 null),并把它从槽里摘掉。
  249. ///
  250. /// `^`、`/`、`√` 这些"把前面那个东西变成结构的一部分"的操作靠它实现 ——
  251. /// 这是结构化编辑器和纯文本手感最接近的地方:先打 `x` 再按 `^`,就成了 x 的幂。
  252. ExprNode? takeLeft() {
  253. final seq = current;
  254. if (cursor.offset <= 0 || cursor.offset > seq.children.length) return null;
  255. cursor = SlotPath(cursor.steps, cursor.offset - 1);
  256. return seq.children.removeAt(cursor.offset);
  257. }
  258. /// 制造一个幂:底 = 光标左边那个节点(没有就用空底),光标落到指数上。
  259. void makePower() {
  260. final base = takeLeft();
  261. final node = PowNode(base: SeqNode(base == null ? null : [base]), exp: SeqNode());
  262. insert(node, enterSlot: 1);
  263. }
  264. /// 制造一个分式:分子 = 光标左边那个节点(没有就空着),光标落到分母上。
  265. void makeFraction() {
  266. final num = takeLeft();
  267. final node = FracNode(num: SeqNode(num == null ? null : [num]), den: SeqNode());
  268. insert(node, enterSlot: 1);
  269. }
  270. /// 制造一个函数调用,光标落在第一个参数槽。
  271. ///
  272. /// [prefill] 每一项对应一个参数:[String] 为空表示**留一个空槽给用户填**,
  273. /// 非空则预置成字面量(例如 `Sum` 的 `{n, 1, Infinity}`)。
  274. void makeFunction(String name, {List<String> prefill = const []}) {
  275. insert(
  276. FuncNode(name, [
  277. for (final p in prefill)
  278. p.isEmpty ? SeqNode() : SeqNode([LitNode(p)]),
  279. if (prefill.isEmpty) SeqNode(),
  280. ]),
  281. enterSlot: 0,
  282. );
  283. }
  284. /// 切到当前函数调用的下一个参数;已经是最后一个就新建一个参数槽。
  285. ///
  286. /// 只能用在函数参数里(光标路径的最后一跳指向某个 FuncNode 的参数槽时)。
  287. void nextArgument() {
  288. if (cursor.steps.isEmpty) return;
  289. final here = cursor.steps.last;
  290. final parent = SlotPath(cursor.steps.sublist(0, cursor.steps.length - 1), 0);
  291. final siblings = slotAt(parent);
  292. if (here.child >= siblings.children.length) return;
  293. final node = siblings.children[here.child];
  294. if (node is! FuncNode) return;
  295. var target = here.slot + 1;
  296. if (target >= node.args.length) {
  297. node.args.add(SeqNode());
  298. target = node.args.length - 1;
  299. }
  300. cursor = parent.push(here.child, target);
  301. }
  302. // ------------------------------------------------------------------ 删除
  303. /// 槽里还有没填的空位("还没填完的模板")。
  304. static bool _incomplete(ExprNode n) => _slotsOf(n).any((s) => s.isEmpty);
  305. /// 退格。
  306. ///
  307. /// 三条规则,都是为了"手感":
  308. /// 1. 数字字面量**逐位删** —— 计算器按退格是删一位,不是把整个数清掉。
  309. /// 2. 左边是**还没填完的模板**(还有空槽)就钻进去删,而不是整个删掉 ——
  310. /// 用户按了 `∫` 后悔,一按退格整块模板没了会很难受。
  311. /// 3. 左边是**已经填完的模板**就整个删掉 —— 否则光标会永远在模板内部打转,
  312. /// 根本删不掉它。
  313. void backspace() {
  314. final seq = current;
  315. if (cursor.offset > 0 && seq.children.isNotEmpty) {
  316. final node = seq.children[cursor.offset - 1];
  317. // 规则 1
  318. if (node is LitNode && _isNumberText(node.text) && node.text.length > 1) {
  319. seq.children[cursor.offset - 1] =
  320. LitNode(node.text.substring(0, node.text.length - 1));
  321. return;
  322. }
  323. final slots = _slotsOf(node);
  324. if (slots.isEmpty) {
  325. seq.children.removeAt(cursor.offset - 1);
  326. cursor = SlotPath(cursor.steps, cursor.offset - 1);
  327. return;
  328. }
  329. if (_incomplete(node)) {
  330. // 规则 2:钻进最后一个空槽
  331. final i = slots.lastIndexWhere((s) => s.isEmpty);
  332. cursor = cursor.push(cursor.offset - 1, i);
  333. return;
  334. }
  335. // 规则 3
  336. seq.children.removeAt(cursor.offset - 1);
  337. cursor = SlotPath(cursor.steps, cursor.offset - 1);
  338. return;
  339. }
  340. // 已经在槽的最左边:退回父槽
  341. final up = cursor.parent;
  342. if (up == null) return; // 根槽最左边,没得删
  343. final parentSeq = slotAt(SlotPath(up.steps, up.offset));
  344. final idx = up.offset - 1;
  345. if (idx >= 0 && idx < parentSeq.children.length) {
  346. final child = parentSeq.children[idx];
  347. // 从"还没填完的模板"里退出来时,顺手把它也删掉
  348. if (_incomplete(child)) {
  349. parentSeq.children.removeAt(idx);
  350. cursor = SlotPath(up.steps, idx);
  351. return;
  352. }
  353. }
  354. cursor = up;
  355. }
  356. /// 用另一个槽的内容整体替换(粘贴用)。光标落到末尾 —— 粘完接着打最自然。
  357. void replaceWith(SeqNode other) {
  358. root.children
  359. ..clear()
  360. ..addAll(other.children);
  361. cursor = SlotPath(const <SlotStep>[], root.children.length);
  362. }
  363. void clear() {
  364. root.children.clear();
  365. cursor = SlotPath.root;
  366. }
  367. // ------------------------------------------------------------------ 序列化成 Symja
  368. /// 整棵树 → 一条 Symja 表达式。
  369. String toSymja() => serializeSeq(root);
  370. /// 槽 → 表达式。空槽在 Symja 里写成 `Null`,求值时会明确报错而不是静默算错。
  371. String serializeSeq(SeqNode seq) {
  372. if (seq.isEmpty) return 'Null';
  373. return seq.children.map(serialize).join();
  374. }
  375. String serialize(ExprNode node) => switch (node) {
  376. LitNode l => l.text,
  377. FuncNode f =>
  378. '${f.name}[${f.args.map(serializeSeq).join(', ')}]',
  379. FracNode f => '${_grouped(serializeSeq(f.num))}/${_grouped(serializeSeq(f.den))}',
  380. PowNode p => '${_grouped(serializeSeq(p.base))}^${_grouped(serializeSeq(p.exp))}',
  381. GroupNode g => '(${serializeSeq(g.inner)})',
  382. BraceNode b => '{${serializeSeq(b.inner)}}',
  383. PostfixNode p => '${_grouped(serializeSeq(p.operand))}${p.op}',
  384. };
  385. /// 复合结构当操作数时要加括号,否则 `x+1` 当分母会算错优先级。
  386. ///
  387. /// 但**不多加**:整体已经被一对括号包住的(`(x+1)`)不再套一层成 `((x+1))`;
  388. /// 单个项(字面量、函数调用)本来就不需要括号。
  389. String _grouped(String s) {
  390. if (s.isEmpty) return s;
  391. if (_isFullyParenthesized(s)) return s;
  392. if (_isSimpleTerm(s)) return s;
  393. return '($s)';
  394. }
  395. /// 整个串恰好被一对括号包住(括号配平、且中间没有提前闭合)。
  396. static bool _isFullyParenthesized(String s) {
  397. if (!s.startsWith('(') || !s.endsWith(')')) return false;
  398. var depth = 0;
  399. for (var i = 0; i < s.length; i++) {
  400. if (s[i] == '(') {
  401. depth++;
  402. } else if (s[i] == ')') {
  403. depth--;
  404. if (depth == 0) return i == s.length - 1;
  405. }
  406. }
  407. return false;
  408. }
  409. /// 顶层没有运算符 —— 单个字面量、函数调用或括号组,当操作数不需要括号。
  410. ///
  411. /// `[` `]` `{` `}` 也算一层,所以 `Sin[x+1]` 里的 `+` 不算顶层运算符 ——
  412. /// 它本来就不用括号。开头的正负号是正负号不是运算符,跳过。
  413. static bool _isSimpleTerm(String s) {
  414. var depth = 0;
  415. for (var i = 0; i < s.length; i++) {
  416. final c = s[i];
  417. if (c == '(' || c == '[' || c == '{') {
  418. depth++;
  419. } else if (c == ')' || c == ']' || c == '}') {
  420. depth--;
  421. } else if (depth == 0 && '+-*/^'.contains(c)) {
  422. if (i == 0 && (c == '-' || c == '+')) continue;
  423. return false;
  424. }
  425. }
  426. return true;
  427. }
  428. }