expr_editor.dart 9.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322
  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 StatefulWidget {
  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. /// 光标是否闪烁。
  20. ///
  21. /// **widget 测试里必须设成 false**:闪烁是无限循环动画,而计算器页面常驻在
  22. /// IndexedStack 里,于是 `pumpAndSettle` 永远等不到"没有待处理帧"而超时。
  23. static bool blinkEnabled = true;
  24. @override
  25. State<ExprEditor> createState() => _ExprEditorState();
  26. }
  27. class _ExprEditorState extends State<ExprEditor>
  28. with SingleTickerProviderStateMixin {
  29. late final AnimationController _blink = AnimationController(
  30. vsync: this,
  31. duration: const Duration(milliseconds: 1100),
  32. value: 1,
  33. );
  34. @override
  35. void initState() {
  36. super.initState();
  37. if (ExprEditor.blinkEnabled) _blink.repeat(reverse: true);
  38. }
  39. @override
  40. void dispose() {
  41. _blink.dispose();
  42. super.dispose();
  43. }
  44. void _setCursor(SlotPath p) {
  45. if (widget.doc.cursor == p) return;
  46. widget.doc.cursor = p;
  47. widget.onChanged();
  48. }
  49. @override
  50. Widget build(BuildContext context) {
  51. return AnimatedBuilder(
  52. animation: _blink,
  53. builder: (context, _) => SingleChildScrollView(
  54. scrollDirection: Axis.horizontal,
  55. reverse: true, // 长表达式总是把光标一侧露出来
  56. child: ConstrainedBox(
  57. constraints: BoxConstraints(
  58. minWidth: MediaQuery.sizeOf(context).width - 32,
  59. ),
  60. child: Align(
  61. alignment: Alignment.centerRight,
  62. child: Padding(
  63. padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
  64. child: _SlotView(
  65. doc: widget.doc,
  66. seq: widget.doc.root,
  67. path: SlotPath.root,
  68. size: widget.fontSize,
  69. blink: _blink.value,
  70. onCursor: _setCursor,
  71. ),
  72. ),
  73. ),
  74. ),
  75. ),
  76. );
  77. }
  78. }
  79. /// 一个槽的视图:把孩子按顺序排开,在光标位置插一根竖线。
  80. class _SlotView extends StatelessWidget {
  81. const _SlotView({
  82. required this.doc,
  83. required this.seq,
  84. required this.path,
  85. required this.size,
  86. required this.blink,
  87. required this.onCursor,
  88. });
  89. final ExprDoc doc;
  90. final SeqNode seq;
  91. final SlotPath path;
  92. final double size;
  93. final double blink;
  94. final void Function(SlotPath) onCursor;
  95. @override
  96. Widget build(BuildContext context) {
  97. final scheme = Theme.of(context).colorScheme;
  98. final focused = doc.cursor == path;
  99. return GestureDetector(
  100. // 点槽的空白处 → 光标落到槽尾
  101. onTap: () => onCursor(SlotPath(path.steps, seq.children.length)),
  102. behavior: HitTestBehavior.opaque,
  103. child: Container(
  104. padding: const EdgeInsets.symmetric(horizontal: 2, vertical: 3),
  105. decoration: BoxDecoration(
  106. borderRadius: BorderRadius.circular(4),
  107. color: focused ? scheme.primary.withValues(alpha: 0.06) : null,
  108. ),
  109. child: Wrap(
  110. crossAxisAlignment: WrapCrossAlignment.center,
  111. children: [
  112. for (var i = 0; i < seq.children.length; i++) ...[
  113. if (focused && doc.cursor.offset == i) _caret(scheme),
  114. _NodeView(
  115. doc: doc,
  116. node: seq.children[i],
  117. parent: path,
  118. index: i,
  119. size: size,
  120. blink: blink,
  121. onCursor: onCursor,
  122. ),
  123. ],
  124. if (focused && doc.cursor.offset >= seq.children.length)
  125. _caret(scheme),
  126. // 空槽给个占位,否则用户不知道能往里点
  127. if (seq.isEmpty && !focused) _placeholder(scheme),
  128. ],
  129. ),
  130. ),
  131. );
  132. }
  133. Widget _caret(ColorScheme scheme) => Container(
  134. width: 2.5,
  135. height: size * 1.25,
  136. margin: const EdgeInsets.symmetric(horizontal: 1),
  137. // 直接用动画值当透明度:收到的是 0..1 的呼吸值,比硬闪好看
  138. color: scheme.primary.withValues(alpha: 0.15 + blink * 0.85),
  139. );
  140. Widget _placeholder(ColorScheme scheme) => Container(
  141. width: 12,
  142. height: size * 1.05,
  143. margin: const EdgeInsets.symmetric(horizontal: 1),
  144. decoration: BoxDecoration(
  145. border: Border.all(color: scheme.outlineVariant, width: 1),
  146. borderRadius: BorderRadius.circular(2),
  147. ),
  148. );
  149. }
  150. /// 单个节点的视图。
  151. class _NodeView extends StatelessWidget {
  152. const _NodeView({
  153. required this.doc,
  154. required this.node,
  155. required this.parent,
  156. required this.index,
  157. required this.size,
  158. required this.blink,
  159. required this.onCursor,
  160. });
  161. final ExprDoc doc;
  162. final ExprNode node;
  163. /// 该节点所在槽的路径,以及它在槽里的下标。
  164. final SlotPath parent;
  165. final int index;
  166. final double size;
  167. final double blink;
  168. final void Function(SlotPath) onCursor;
  169. @override
  170. Widget build(BuildContext context) {
  171. final scheme = Theme.of(context).colorScheme;
  172. // 点中某个节点 → 光标落到它后面(符合"接着往下打"的直觉)
  173. return GestureDetector(
  174. onTap: () => onCursor(SlotPath(parent.steps, index + 1)),
  175. behavior: HitTestBehavior.opaque,
  176. child: switch (node) {
  177. LitNode l => _literal(l, scheme),
  178. FuncNode f => _function(context, f),
  179. FracNode f => _fraction(context, f),
  180. PowNode p => _power(context, p),
  181. GroupNode g => _group(context, g),
  182. PostfixNode p => _row([
  183. _slot(context, p.operand, parent.push(index, 0)),
  184. _text(p.op, size),
  185. ]),
  186. },
  187. );
  188. }
  189. Widget _literal(LitNode l, ColorScheme scheme) {
  190. final isLetter = RegExp(r'^[A-Za-z]+$').hasMatch(l.text);
  191. return Text(
  192. l.text,
  193. style: TextStyle(
  194. fontSize: size,
  195. fontFamily: 'serif',
  196. fontStyle: isLetter ? FontStyle.italic : FontStyle.normal,
  197. color: scheme.onSurface,
  198. ),
  199. );
  200. }
  201. Widget _function(BuildContext context, FuncNode f) {
  202. final scheme = Theme.of(context).colorScheme;
  203. return _row([
  204. Text(
  205. f.name,
  206. style: TextStyle(fontSize: size * 0.85, color: scheme.primary),
  207. ),
  208. Text('[', style: TextStyle(fontSize: size, color: scheme.onSurface)),
  209. for (var i = 0; i < f.args.length; i++) ...[
  210. if (i > 0)
  211. Text(', ', style: TextStyle(fontSize: size, color: scheme.onSurface)),
  212. _slot(context, f.args[i], parent.push(index, i)),
  213. ],
  214. Text(']', style: TextStyle(fontSize: size, color: scheme.onSurface)),
  215. ]);
  216. }
  217. Widget _fraction(BuildContext context, FracNode f) {
  218. final scheme = Theme.of(context).colorScheme;
  219. return Padding(
  220. padding: const EdgeInsets.symmetric(horizontal: 2),
  221. // IntrinsicWidth 让横线刚好横跨分子分母里较宽的那个。
  222. // 不能直接 width: double.infinity —— 这里是 Wrap 里的无界宽度,会报错。
  223. child: IntrinsicWidth(
  224. child: Column(
  225. mainAxisSize: MainAxisSize.min,
  226. children: [
  227. _slot(context, f.num, parent.push(index, 0)),
  228. Container(height: 1.4, color: scheme.onSurface),
  229. _slot(context, f.den, parent.push(index, 1)),
  230. ],
  231. ),
  232. ),
  233. );
  234. }
  235. Widget _power(BuildContext context, PowNode p) {
  236. return _row([
  237. _slot(context, p.base, parent.push(index, 0)),
  238. Transform.translate(
  239. offset: Offset(0, -size * 0.35),
  240. child: _slot(context, p.exp, parent.push(index, 1), scale: 0.7),
  241. ),
  242. ]);
  243. }
  244. Widget _group(BuildContext context, GroupNode g) {
  245. final scheme = Theme.of(context).colorScheme;
  246. final style = TextStyle(fontSize: size, color: scheme.onSurface);
  247. return _row([
  248. Text('(', style: style),
  249. _slot(context, g.inner, parent.push(index, 0)),
  250. Text(')', style: style),
  251. ]);
  252. }
  253. Widget _row(List<Widget> children) => Wrap(
  254. crossAxisAlignment: WrapCrossAlignment.center,
  255. children: children,
  256. );
  257. Widget _slot(BuildContext context, SeqNode seq, SlotPath path,
  258. {double scale = 1}) =>
  259. _ScaledSlot(
  260. scale: scale,
  261. child: _SlotView(
  262. doc: doc,
  263. seq: seq,
  264. path: path,
  265. size: size,
  266. blink: blink,
  267. onCursor: onCursor,
  268. ),
  269. );
  270. Widget _text(String t, double s) => Text(t, style: TextStyle(fontSize: s));
  271. }
  272. /// 给槽套一个缩放(指数要用小一号字)。
  273. class _ScaledSlot extends StatelessWidget {
  274. const _ScaledSlot({required this.scale, required this.child});
  275. final double scale;
  276. final Widget child;
  277. @override
  278. Widget build(BuildContext context) {
  279. if (scale == 1) return child;
  280. // 用 Transform 缩放而不是改字号:字号变了槽的布局参数也得跟着变,
  281. // 缩放一行搞定且视觉上等价。
  282. return Transform.scale(
  283. scale: scale,
  284. alignment: Alignment.bottomLeft,
  285. child: child,
  286. );
  287. }
  288. }