expr_editor.dart 9.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305
  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. this.fontSize = 22,
  14. });
  15. final ExprDoc doc;
  16. /// 光标或内容变化后调用,让页面刷新。
  17. final VoidCallback onChanged;
  18. final double fontSize;
  19. void _setCursor(BuildContext context, SlotPath p) {
  20. if (doc.cursor == p) return;
  21. doc.cursor = p;
  22. onChanged();
  23. }
  24. @override
  25. Widget build(BuildContext context) {
  26. return SingleChildScrollView(
  27. scrollDirection: Axis.horizontal,
  28. reverse: true, // 长表达式总是把光标一侧露出来
  29. child: ConstrainedBox(
  30. constraints: BoxConstraints(
  31. minWidth: MediaQuery.sizeOf(context).width - 32,
  32. ),
  33. child: Align(
  34. alignment: Alignment.centerRight,
  35. child: Padding(
  36. padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
  37. child: _SlotView(
  38. doc: doc,
  39. seq: doc.root,
  40. path: SlotPath.root,
  41. size: fontSize,
  42. onCursor: (p) => _setCursor(context, p),
  43. ),
  44. ),
  45. ),
  46. ),
  47. );
  48. }
  49. }
  50. /// 一个槽的视图:把孩子按顺序排开,在光标位置插一根竖线。
  51. class _SlotView extends StatelessWidget {
  52. const _SlotView({
  53. required this.doc,
  54. required this.seq,
  55. required this.path,
  56. required this.size,
  57. required this.onCursor,
  58. });
  59. final ExprDoc doc;
  60. final SeqNode seq;
  61. final SlotPath path;
  62. final double size;
  63. final void Function(SlotPath) onCursor;
  64. @override
  65. Widget build(BuildContext context) {
  66. final scheme = Theme.of(context).colorScheme;
  67. // 用 sameSlotAs 而不是 ==:offset 是槽内的插入位置,不属于槽的身份
  68. final focused = doc.cursor.sameSlotAs(path);
  69. return GestureDetector(
  70. // 点槽的空白处 → 光标落到槽尾
  71. onTap: () => onCursor(SlotPath(path.steps, seq.children.length)),
  72. behavior: HitTestBehavior.opaque,
  73. child: Container(
  74. padding: const EdgeInsets.symmetric(horizontal: 2, vertical: 3),
  75. decoration: BoxDecoration(
  76. borderRadius: BorderRadius.circular(4),
  77. color: focused ? scheme.primary.withValues(alpha: 0.06) : null,
  78. ),
  79. child: Wrap(
  80. crossAxisAlignment: WrapCrossAlignment.center,
  81. children: [
  82. for (var i = 0; i < seq.children.length; i++) ...[
  83. if (focused && doc.cursor.offset == i) _caret(scheme),
  84. _NodeView(
  85. doc: doc,
  86. node: seq.children[i],
  87. parent: path,
  88. index: i,
  89. size: size,
  90. onCursor: onCursor,
  91. ),
  92. ],
  93. if (focused && doc.cursor.offset >= seq.children.length)
  94. _caret(scheme),
  95. // 空槽给个占位,否则用户不知道能往里点
  96. if (seq.isEmpty && !focused) _placeholder(scheme),
  97. ],
  98. ),
  99. ),
  100. );
  101. }
  102. /// 光标。**常亮、不闪烁**。
  103. ///
  104. /// 之前做成 0.15~1.0 的平滑呼吸,最暗那半程看着就跟没有一样(用户反馈
  105. /// "有时能看到有时看不到")。计算器不是文本编辑器,聚焦的槽本来就有底色
  106. /// 高亮,这里再常亮一根实心竖线,位置一眼就能看到。
  107. Widget _caret(ColorScheme scheme) => Container(
  108. // 整个编辑区同时最多只有一根光标,所以固定 key 不会冲突。
  109. // 加它是为了能写"打完字光标还在不在"的回归测试 —— 这个 bug
  110. // 之前就是靠人眼看截图才发现的。
  111. key: const ValueKey('expr-caret'),
  112. width: 2.5,
  113. height: size * 1.25,
  114. margin: const EdgeInsets.symmetric(horizontal: 1),
  115. color: scheme.primary,
  116. );
  117. Widget _placeholder(ColorScheme scheme) => Container(
  118. width: 12,
  119. height: size * 1.05,
  120. margin: const EdgeInsets.symmetric(horizontal: 1),
  121. decoration: BoxDecoration(
  122. border: Border.all(color: scheme.outlineVariant, width: 1),
  123. borderRadius: BorderRadius.circular(2),
  124. ),
  125. );
  126. }
  127. /// 单个节点的视图。
  128. class _NodeView extends StatelessWidget {
  129. const _NodeView({
  130. required this.doc,
  131. required this.node,
  132. required this.parent,
  133. required this.index,
  134. required this.size,
  135. required this.onCursor,
  136. });
  137. final ExprDoc doc;
  138. final ExprNode node;
  139. /// 该节点所在槽的路径,以及它在槽里的下标。
  140. final SlotPath parent;
  141. final int index;
  142. final double size;
  143. final void Function(SlotPath) onCursor;
  144. @override
  145. Widget build(BuildContext context) {
  146. final scheme = Theme.of(context).colorScheme;
  147. final body = switch (node) {
  148. LitNode l => _literal(l, scheme),
  149. FuncNode f => _function(context, f),
  150. FracNode f => _fraction(context, f),
  151. PowNode p => _power(context, p),
  152. GroupNode g => _group(context, g),
  153. PostfixNode p => _row([
  154. _slot(context, p.operand, parent.push(index, 0)),
  155. _text(p.op, size),
  156. ]),
  157. };
  158. // 点节点的**左半边**把光标放到它前面,右半边放到它后面。
  159. //
  160. // 之前无论点哪都落到"后面",于是偏移 0(表达式最前面)那个位置永远够不着 ——
  161. // 用户只能把光标停在第一个字符之后,回不到行首。
  162. //
  163. // 宽度用 Builder 拿:Builder 自身不是 RenderObject,findRenderObject()
  164. // 会往下找到 GestureDetector 那个 box,尺寸正是节点的尺寸。
  165. return Builder(
  166. builder: (inner) => GestureDetector(
  167. behavior: HitTestBehavior.opaque,
  168. onTapDown: (d) {
  169. final box = inner.findRenderObject() as RenderBox?;
  170. final before = d.localPosition.dx < (box?.size.width ?? 0) / 2;
  171. onCursor(SlotPath(parent.steps, before ? index : index + 1));
  172. },
  173. child: body,
  174. ),
  175. );
  176. }
  177. Widget _literal(LitNode l, ColorScheme scheme) {
  178. final isLetter = RegExp(r'^[A-Za-z]+$').hasMatch(l.text);
  179. return Text(
  180. l.text,
  181. style: TextStyle(
  182. fontSize: size,
  183. fontFamily: 'serif',
  184. fontStyle: isLetter ? FontStyle.italic : FontStyle.normal,
  185. color: scheme.onSurface,
  186. ),
  187. );
  188. }
  189. Widget _function(BuildContext context, FuncNode f) {
  190. final scheme = Theme.of(context).colorScheme;
  191. return _row([
  192. Text(
  193. f.name,
  194. style: TextStyle(fontSize: size * 0.85, color: scheme.primary),
  195. ),
  196. Text('[', style: TextStyle(fontSize: size, color: scheme.onSurface)),
  197. for (var i = 0; i < f.args.length; i++) ...[
  198. if (i > 0)
  199. Text(', ', style: TextStyle(fontSize: size, color: scheme.onSurface)),
  200. _slot(context, f.args[i], parent.push(index, i)),
  201. ],
  202. Text(']', style: TextStyle(fontSize: size, color: scheme.onSurface)),
  203. ]);
  204. }
  205. Widget _fraction(BuildContext context, FracNode f) {
  206. final scheme = Theme.of(context).colorScheme;
  207. return Padding(
  208. padding: const EdgeInsets.symmetric(horizontal: 2),
  209. // IntrinsicWidth 让横线刚好横跨分子分母里较宽的那个。
  210. // 不能直接 width: double.infinity —— 这里是 Wrap 里的无界宽度,会报错。
  211. child: IntrinsicWidth(
  212. child: Column(
  213. mainAxisSize: MainAxisSize.min,
  214. children: [
  215. _slot(context, f.num, parent.push(index, 0)),
  216. Container(height: 1.4, color: scheme.onSurface),
  217. _slot(context, f.den, parent.push(index, 1)),
  218. ],
  219. ),
  220. ),
  221. );
  222. }
  223. Widget _power(BuildContext context, PowNode p) {
  224. return _row([
  225. _slot(context, p.base, parent.push(index, 0)),
  226. Transform.translate(
  227. offset: Offset(0, -size * 0.35),
  228. child: _slot(context, p.exp, parent.push(index, 1), scale: 0.7),
  229. ),
  230. ]);
  231. }
  232. Widget _group(BuildContext context, GroupNode g) {
  233. final scheme = Theme.of(context).colorScheme;
  234. final style = TextStyle(fontSize: size, color: scheme.onSurface);
  235. return _row([
  236. Text('(', style: style),
  237. _slot(context, g.inner, parent.push(index, 0)),
  238. Text(')', style: style),
  239. ]);
  240. }
  241. Widget _row(List<Widget> children) => Wrap(
  242. crossAxisAlignment: WrapCrossAlignment.center,
  243. children: children,
  244. );
  245. Widget _slot(BuildContext context, SeqNode seq, SlotPath path,
  246. {double scale = 1}) =>
  247. _ScaledSlot(
  248. scale: scale,
  249. child: _SlotView(
  250. doc: doc,
  251. seq: seq,
  252. path: path,
  253. size: size,
  254. onCursor: onCursor,
  255. ),
  256. );
  257. Widget _text(String t, double s) => Text(t, style: TextStyle(fontSize: s));
  258. }
  259. /// 给槽套一个缩放(指数要用小一号字)。
  260. class _ScaledSlot extends StatelessWidget {
  261. const _ScaledSlot({required this.scale, required this.child});
  262. final double scale;
  263. final Widget child;
  264. @override
  265. Widget build(BuildContext context) {
  266. if (scale == 1) return child;
  267. // 用 Transform 缩放而不是改字号:字号变了槽的布局参数也得跟着变,
  268. // 缩放一行搞定且视觉上等价。
  269. return Transform.scale(
  270. scale: scale,
  271. alignment: Alignment.bottomLeft,
  272. child: child,
  273. );
  274. }
  275. }