expr_editor.dart 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404
  1. import 'package:flutter/material.dart';
  2. import 'expr_node.dart';
  3. /// 结构化表达式的 2D 编辑器视图。
  4. ///
  5. /// 渲染方式:直接把节点树翻译成 widget 树 —— 分式用 Column(分子/横线/分母)、
  6. /// 幂用 Row + 抬高的上标、函数用 `名字[ 参数 ]`。没有自己写排版引擎,
  7. /// 因为 Flutter 的布局本来就够用,而且这样点击命中、无障碍都能白拿。
  8. class ExprEditor extends StatelessWidget {
  9. const ExprEditor({
  10. super.key,
  11. required this.doc,
  12. required this.onChanged,
  13. required this.onCaretTapped,
  14. this.fontSize = 22,
  15. });
  16. final ExprDoc doc;
  17. /// 光标或内容变化后调用,让页面刷新。
  18. final VoidCallback onChanged;
  19. /// 用户点了**光标已经所在的那个位置**(且没有选区)—— 页面据此弹出"粘贴"。
  20. final VoidCallback onCaretTapped;
  21. final double fontSize;
  22. /// 点一个节点。
  23. ///
  24. /// 三种语义:
  25. /// · 有选区时 → 扩展选区("按住选中一个,再点另一个")
  26. /// · 没选区、但点的是光标已经待着的地方 → 交给页面弹粘贴
  27. /// · 其余 → 移动光标
  28. void _tapNode(SlotPath parent, int index, bool before) {
  29. if (doc.selection != null) {
  30. doc.extendSelectionTo(parent, index);
  31. onChanged();
  32. return;
  33. }
  34. final target = SlotPath(parent.steps, before ? index : index + 1);
  35. if (doc.cursor == target) {
  36. onCaretTapped();
  37. return;
  38. }
  39. doc.cursor = target;
  40. onChanged();
  41. }
  42. /// 长按一个节点 → 选中它,工具条随之出现。
  43. void _longPressNode(SlotPath parent, int index) {
  44. doc.selectNodeAt(parent, index);
  45. onChanged();
  46. }
  47. /// 点槽的空白处:有选区就取消选区,否则光标落到槽尾。
  48. void _tapSlot(SlotPath path, SeqNode seq) {
  49. if (doc.selection != null) {
  50. doc.clearSelection();
  51. onChanged();
  52. return;
  53. }
  54. final p = SlotPath(path.steps, seq.children.length);
  55. if (doc.cursor == p) {
  56. onCaretTapped();
  57. return;
  58. }
  59. doc.cursor = p;
  60. onChanged();
  61. }
  62. @override
  63. Widget build(BuildContext context) {
  64. return SingleChildScrollView(
  65. scrollDirection: Axis.horizontal,
  66. reverse: true, // 长表达式总是把光标一侧露出来
  67. child: ConstrainedBox(
  68. constraints: BoxConstraints(
  69. minWidth: MediaQuery.sizeOf(context).width - 32,
  70. ),
  71. child: Align(
  72. alignment: Alignment.centerRight,
  73. child: Padding(
  74. padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
  75. child: _SlotView(
  76. doc: doc,
  77. seq: doc.root,
  78. path: SlotPath.root,
  79. size: fontSize,
  80. onTapNode: _tapNode,
  81. onLongPressNode: _longPressNode,
  82. onTapSlot: _tapSlot,
  83. ),
  84. ),
  85. ),
  86. ),
  87. );
  88. }
  89. }
  90. /// 一个槽的视图:把孩子按顺序排开,在光标位置插一根竖线。
  91. class _SlotView extends StatelessWidget {
  92. const _SlotView({
  93. required this.doc,
  94. required this.seq,
  95. required this.path,
  96. required this.size,
  97. required this.onTapNode,
  98. required this.onLongPressNode,
  99. required this.onTapSlot,
  100. });
  101. final ExprDoc doc;
  102. final SeqNode seq;
  103. final SlotPath path;
  104. final double size;
  105. final void Function(SlotPath parent, int index, bool before) onTapNode;
  106. final void Function(SlotPath parent, int index) onLongPressNode;
  107. final void Function(SlotPath path, SeqNode seq) onTapSlot;
  108. @override
  109. Widget build(BuildContext context) {
  110. final scheme = Theme.of(context).colorScheme;
  111. // 用 sameSlotAs 而不是 ==:offset 是槽内的插入位置,不属于槽的身份
  112. final focused = doc.cursor.sameSlotAs(path);
  113. final sel = doc.selection;
  114. final selectedHere = sel != null && !sel.isEmpty && sel.path.sameSlotAs(path);
  115. return GestureDetector(
  116. onTap: () => onTapSlot(path, seq),
  117. behavior: HitTestBehavior.opaque,
  118. child: Container(
  119. padding: const EdgeInsets.symmetric(horizontal: 2, vertical: 3),
  120. // 空槽只有一个光标那么宽(2.5px),手指点不到 —— 而"点光标处弹粘贴"
  121. // 恰恰最常在空槽里用(先按 ln 再粘参数)。给个最小的可点面积。
  122. constraints: focused && seq.isEmpty
  123. ? const BoxConstraints(minWidth: 24, minHeight: 30)
  124. : null,
  125. decoration: BoxDecoration(
  126. borderRadius: BorderRadius.circular(4),
  127. color: focused ? scheme.primary.withValues(alpha: 0.06) : null,
  128. ),
  129. child: Wrap(
  130. crossAxisAlignment: WrapCrossAlignment.center,
  131. children: [
  132. for (var i = 0; i < seq.children.length; i++) ...[
  133. if (focused && doc.cursor.offset == i) _caret(scheme),
  134. _SelectedOrNot(
  135. highlighted: selectedHere && i >= sel.start && i < sel.end,
  136. child: _NodeView(
  137. doc: doc,
  138. node: seq.children[i],
  139. parent: path,
  140. index: i,
  141. size: size,
  142. onTapNode: onTapNode,
  143. onLongPressNode: onLongPressNode,
  144. onTapSlot: onTapSlot,
  145. ),
  146. ),
  147. ],
  148. if (focused && doc.cursor.offset >= seq.children.length)
  149. _caret(scheme),
  150. // 空槽给个占位,否则用户不知道能往里点
  151. if (seq.isEmpty && !focused) _placeholder(scheme),
  152. ],
  153. ),
  154. ),
  155. );
  156. }
  157. /// 光标。**常亮、不闪烁**。
  158. ///
  159. /// 之前做成 0.15~1.0 的平滑呼吸,最暗那半程看着就跟没有一样(用户反馈
  160. /// "有时能看到有时看不到")。计算器不是文本编辑器,聚焦的槽本来就有底色
  161. /// 高亮,这里再常亮一根实心竖线,位置一眼就能看到。
  162. Widget _caret(ColorScheme scheme) => Container(
  163. // 整个编辑区同时最多只有一根光标,所以固定 key 不会冲突。
  164. // 加它是为了能写"打完字光标还在不在"的回归测试 —— 这个 bug
  165. // 之前就是靠人眼看截图才发现的。
  166. key: const ValueKey('expr-caret'),
  167. width: 2.5,
  168. height: size * 1.25,
  169. margin: const EdgeInsets.symmetric(horizontal: 1),
  170. color: scheme.primary,
  171. );
  172. Widget _placeholder(ColorScheme scheme) => Container(
  173. width: 12,
  174. height: size * 1.05,
  175. margin: const EdgeInsets.symmetric(horizontal: 1),
  176. decoration: BoxDecoration(
  177. border: Border.all(color: scheme.outlineVariant, width: 1),
  178. borderRadius: BorderRadius.circular(2),
  179. ),
  180. );
  181. }
  182. /// 单个节点的视图。
  183. class _NodeView extends StatelessWidget {
  184. const _NodeView({
  185. required this.doc,
  186. required this.node,
  187. required this.parent,
  188. required this.index,
  189. required this.size,
  190. required this.onTapNode,
  191. required this.onLongPressNode,
  192. required this.onTapSlot,
  193. });
  194. final ExprDoc doc;
  195. final ExprNode node;
  196. /// 该节点所在槽的路径,以及它在槽里的下标。
  197. final SlotPath parent;
  198. final int index;
  199. final double size;
  200. final void Function(SlotPath parent, int index, bool before) onTapNode;
  201. final void Function(SlotPath parent, int index) onLongPressNode;
  202. final void Function(SlotPath path, SeqNode seq) onTapSlot;
  203. @override
  204. Widget build(BuildContext context) {
  205. final scheme = Theme.of(context).colorScheme;
  206. final body = switch (node) {
  207. LitNode l => _literal(l, scheme),
  208. FuncNode f => _function(context, f),
  209. FracNode f => _fraction(context, f),
  210. PowNode p => _power(context, p),
  211. GroupNode g => _group(context, g),
  212. BraceNode b => _brace(context, b),
  213. PostfixNode p => _row([
  214. _slot(context, p.operand, parent.push(index, 0)),
  215. _text(p.op, size),
  216. ]),
  217. };
  218. // 点节点的**左半边**把光标放到它前面,右半边放到它后面。
  219. //
  220. // 之前无论点哪都落到"后面",于是偏移 0(表达式最前面)那个位置永远够不着 ——
  221. // 用户只能把光标停在第一个字符之后,回不到行首。
  222. //
  223. // 宽度用 Builder 拿:Builder 自身不是 RenderObject,findRenderObject()
  224. // 会往下找到 GestureDetector 那个 box,尺寸正是节点的尺寸。
  225. return Builder(
  226. builder: (inner) => GestureDetector(
  227. behavior: HitTestBehavior.opaque,
  228. onLongPress: () => onLongPressNode(parent, index),
  229. onTapDown: (d) {
  230. final box = inner.findRenderObject() as RenderBox?;
  231. final before = d.localPosition.dx < (box?.size.width ?? 0) / 2;
  232. onTapNode(parent, index, before);
  233. },
  234. child: body,
  235. ),
  236. );
  237. }
  238. Widget _literal(LitNode l, ColorScheme scheme) {
  239. final isLetter = RegExp(r'^[A-Za-z]+$').hasMatch(l.text);
  240. return Text(
  241. l.text,
  242. style: TextStyle(
  243. fontSize: size,
  244. fontFamily: 'serif',
  245. fontStyle: isLetter ? FontStyle.italic : FontStyle.normal,
  246. color: scheme.onSurface,
  247. ),
  248. );
  249. }
  250. Widget _function(BuildContext context, FuncNode f) {
  251. final scheme = Theme.of(context).colorScheme;
  252. return _row([
  253. Text(
  254. f.name,
  255. style: TextStyle(fontSize: size * 0.85, color: scheme.primary),
  256. ),
  257. Text('[', style: TextStyle(fontSize: size, color: scheme.onSurface)),
  258. for (var i = 0; i < f.args.length; i++) ...[
  259. if (i > 0)
  260. Text(', ', style: TextStyle(fontSize: size, color: scheme.onSurface)),
  261. _slot(context, f.args[i], parent.push(index, i)),
  262. ],
  263. Text(']', style: TextStyle(fontSize: size, color: scheme.onSurface)),
  264. ]);
  265. }
  266. Widget _fraction(BuildContext context, FracNode f) {
  267. final scheme = Theme.of(context).colorScheme;
  268. return Padding(
  269. padding: const EdgeInsets.symmetric(horizontal: 2),
  270. // IntrinsicWidth 让横线刚好横跨分子分母里较宽的那个。
  271. // 不能直接 width: double.infinity —— 这里是 Wrap 里的无界宽度,会报错。
  272. child: IntrinsicWidth(
  273. child: Column(
  274. mainAxisSize: MainAxisSize.min,
  275. children: [
  276. _slot(context, f.num, parent.push(index, 0)),
  277. Container(height: 1.4, color: scheme.onSurface),
  278. _slot(context, f.den, parent.push(index, 1)),
  279. ],
  280. ),
  281. ),
  282. );
  283. }
  284. Widget _power(BuildContext context, PowNode p) {
  285. return _row([
  286. _slot(context, p.base, parent.push(index, 0)),
  287. Transform.translate(
  288. offset: Offset(0, -size * 0.35),
  289. child: _slot(context, p.exp, parent.push(index, 1), scale: 0.7),
  290. ),
  291. ]);
  292. }
  293. Widget _group(BuildContext context, GroupNode g) {
  294. final scheme = Theme.of(context).colorScheme;
  295. final style = TextStyle(fontSize: size, color: scheme.onSurface);
  296. return _row([
  297. Text('(', style: style),
  298. _slot(context, g.inner, parent.push(index, 0)),
  299. Text(')', style: style),
  300. ]);
  301. }
  302. Widget _brace(BuildContext context, BraceNode b) {
  303. final style = TextStyle(
  304. fontSize: size,
  305. color: Theme.of(context).colorScheme.onSurface,
  306. );
  307. return _row([
  308. Text('{', style: style),
  309. _slot(context, b.inner, parent.push(index, 0)),
  310. Text('}', style: style),
  311. ]);
  312. }
  313. Widget _row(List<Widget> children) => Wrap(
  314. crossAxisAlignment: WrapCrossAlignment.center,
  315. children: children,
  316. );
  317. Widget _slot(BuildContext context, SeqNode seq, SlotPath path,
  318. {double scale = 1}) =>
  319. _ScaledSlot(
  320. scale: scale,
  321. child: _SlotView(
  322. doc: doc,
  323. seq: seq,
  324. path: path,
  325. size: size,
  326. onTapNode: onTapNode,
  327. onLongPressNode: onLongPressNode,
  328. onTapSlot: onTapSlot,
  329. ),
  330. );
  331. Widget _text(String t, double s) => Text(t, style: TextStyle(fontSize: s));
  332. }
  333. /// 选中时给节点盖一层高亮背景。
  334. class _SelectedOrNot extends StatelessWidget {
  335. const _SelectedOrNot({required this.highlighted, required this.child});
  336. final bool highlighted;
  337. final Widget child;
  338. @override
  339. Widget build(BuildContext context) {
  340. if (!highlighted) return child;
  341. return Container(
  342. decoration: BoxDecoration(
  343. color: Theme.of(context).colorScheme.primary.withValues(alpha: 0.22),
  344. borderRadius: BorderRadius.circular(3),
  345. ),
  346. child: child,
  347. );
  348. }
  349. }
  350. /// 给槽套一个缩放(指数要用小一号字)。
  351. class _ScaledSlot extends StatelessWidget {
  352. const _ScaledSlot({required this.scale, required this.child});
  353. final double scale;
  354. final Widget child;
  355. @override
  356. Widget build(BuildContext context) {
  357. if (scale == 1) return child;
  358. // 用 Transform 缩放而不是改字号:字号变了槽的布局参数也得跟着变,
  359. // 缩放一行搞定且视觉上等价。
  360. return Transform.scale(
  361. scale: scale,
  362. alignment: Alignment.bottomLeft,
  363. child: child,
  364. );
  365. }
  366. }