| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322 |
- import 'package:flutter/material.dart';
- import 'expr_node.dart';
- /// 结构化表达式的 2D 编辑器视图。
- ///
- /// 渲染方式:直接把节点树翻译成 widget 树 —— 分式用 Column(分子/横线/分母)、
- /// 幂用 Row + 抬高的上标、函数用 `名字[ 参数 ]`。没有自己写排版引擎,
- /// 因为 Flutter 的布局本来就够用,而且这样点击命中、无障碍都能白拿。
- class ExprEditor extends StatefulWidget {
- const ExprEditor({
- super.key,
- required this.doc,
- required this.onChanged,
- this.fontSize = 22,
- });
- final ExprDoc doc;
- /// 光标或内容变化后调用,让页面刷新。
- final VoidCallback onChanged;
- final double fontSize;
- /// 光标是否闪烁。
- ///
- /// **widget 测试里必须设成 false**:闪烁是无限循环动画,而计算器页面常驻在
- /// IndexedStack 里,于是 `pumpAndSettle` 永远等不到"没有待处理帧"而超时。
- static bool blinkEnabled = true;
- @override
- State<ExprEditor> createState() => _ExprEditorState();
- }
- class _ExprEditorState extends State<ExprEditor>
- with SingleTickerProviderStateMixin {
- late final AnimationController _blink = AnimationController(
- vsync: this,
- duration: const Duration(milliseconds: 1100),
- value: 1,
- );
- @override
- void initState() {
- super.initState();
- if (ExprEditor.blinkEnabled) _blink.repeat(reverse: true);
- }
- @override
- void dispose() {
- _blink.dispose();
- super.dispose();
- }
- void _setCursor(SlotPath p) {
- if (widget.doc.cursor == p) return;
- widget.doc.cursor = p;
- widget.onChanged();
- }
- @override
- Widget build(BuildContext context) {
- return AnimatedBuilder(
- animation: _blink,
- builder: (context, _) => SingleChildScrollView(
- scrollDirection: Axis.horizontal,
- reverse: true, // 长表达式总是把光标一侧露出来
- child: ConstrainedBox(
- constraints: BoxConstraints(
- minWidth: MediaQuery.sizeOf(context).width - 32,
- ),
- child: Align(
- alignment: Alignment.centerRight,
- child: Padding(
- padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
- child: _SlotView(
- doc: widget.doc,
- seq: widget.doc.root,
- path: SlotPath.root,
- size: widget.fontSize,
- blink: _blink.value,
- onCursor: _setCursor,
- ),
- ),
- ),
- ),
- ),
- );
- }
- }
- /// 一个槽的视图:把孩子按顺序排开,在光标位置插一根竖线。
- class _SlotView extends StatelessWidget {
- const _SlotView({
- required this.doc,
- required this.seq,
- required this.path,
- required this.size,
- required this.blink,
- required this.onCursor,
- });
- final ExprDoc doc;
- final SeqNode seq;
- final SlotPath path;
- final double size;
- final double blink;
- final void Function(SlotPath) onCursor;
- @override
- Widget build(BuildContext context) {
- final scheme = Theme.of(context).colorScheme;
- final focused = doc.cursor == path;
- return GestureDetector(
- // 点槽的空白处 → 光标落到槽尾
- onTap: () => onCursor(SlotPath(path.steps, seq.children.length)),
- behavior: HitTestBehavior.opaque,
- child: Container(
- padding: const EdgeInsets.symmetric(horizontal: 2, vertical: 3),
- decoration: BoxDecoration(
- borderRadius: BorderRadius.circular(4),
- color: focused ? scheme.primary.withValues(alpha: 0.06) : null,
- ),
- child: Wrap(
- crossAxisAlignment: WrapCrossAlignment.center,
- children: [
- for (var i = 0; i < seq.children.length; i++) ...[
- if (focused && doc.cursor.offset == i) _caret(scheme),
- _NodeView(
- doc: doc,
- node: seq.children[i],
- parent: path,
- index: i,
- size: size,
- blink: blink,
- onCursor: onCursor,
- ),
- ],
- if (focused && doc.cursor.offset >= seq.children.length)
- _caret(scheme),
- // 空槽给个占位,否则用户不知道能往里点
- if (seq.isEmpty && !focused) _placeholder(scheme),
- ],
- ),
- ),
- );
- }
- Widget _caret(ColorScheme scheme) => Container(
- width: 2.5,
- height: size * 1.25,
- margin: const EdgeInsets.symmetric(horizontal: 1),
- // 直接用动画值当透明度:收到的是 0..1 的呼吸值,比硬闪好看
- color: scheme.primary.withValues(alpha: 0.15 + blink * 0.85),
- );
- Widget _placeholder(ColorScheme scheme) => Container(
- width: 12,
- height: size * 1.05,
- margin: const EdgeInsets.symmetric(horizontal: 1),
- decoration: BoxDecoration(
- border: Border.all(color: scheme.outlineVariant, width: 1),
- borderRadius: BorderRadius.circular(2),
- ),
- );
- }
- /// 单个节点的视图。
- class _NodeView extends StatelessWidget {
- const _NodeView({
- required this.doc,
- required this.node,
- required this.parent,
- required this.index,
- required this.size,
- required this.blink,
- required this.onCursor,
- });
- final ExprDoc doc;
- final ExprNode node;
- /// 该节点所在槽的路径,以及它在槽里的下标。
- final SlotPath parent;
- final int index;
- final double size;
- final double blink;
- final void Function(SlotPath) onCursor;
- @override
- Widget build(BuildContext context) {
- final scheme = Theme.of(context).colorScheme;
- // 点中某个节点 → 光标落到它后面(符合"接着往下打"的直觉)
- return GestureDetector(
- onTap: () => onCursor(SlotPath(parent.steps, index + 1)),
- behavior: HitTestBehavior.opaque,
- child: switch (node) {
- LitNode l => _literal(l, scheme),
- FuncNode f => _function(context, f),
- FracNode f => _fraction(context, f),
- PowNode p => _power(context, p),
- GroupNode g => _group(context, g),
- PostfixNode p => _row([
- _slot(context, p.operand, parent.push(index, 0)),
- _text(p.op, size),
- ]),
- },
- );
- }
- Widget _literal(LitNode l, ColorScheme scheme) {
- final isLetter = RegExp(r'^[A-Za-z]+$').hasMatch(l.text);
- return Text(
- l.text,
- style: TextStyle(
- fontSize: size,
- fontFamily: 'serif',
- fontStyle: isLetter ? FontStyle.italic : FontStyle.normal,
- color: scheme.onSurface,
- ),
- );
- }
- Widget _function(BuildContext context, FuncNode f) {
- final scheme = Theme.of(context).colorScheme;
- return _row([
- Text(
- f.name,
- style: TextStyle(fontSize: size * 0.85, color: scheme.primary),
- ),
- Text('[', style: TextStyle(fontSize: size, color: scheme.onSurface)),
- for (var i = 0; i < f.args.length; i++) ...[
- if (i > 0)
- Text(', ', style: TextStyle(fontSize: size, color: scheme.onSurface)),
- _slot(context, f.args[i], parent.push(index, i)),
- ],
- Text(']', style: TextStyle(fontSize: size, color: scheme.onSurface)),
- ]);
- }
- Widget _fraction(BuildContext context, FracNode f) {
- final scheme = Theme.of(context).colorScheme;
- return Padding(
- padding: const EdgeInsets.symmetric(horizontal: 2),
- // IntrinsicWidth 让横线刚好横跨分子分母里较宽的那个。
- // 不能直接 width: double.infinity —— 这里是 Wrap 里的无界宽度,会报错。
- child: IntrinsicWidth(
- child: Column(
- mainAxisSize: MainAxisSize.min,
- children: [
- _slot(context, f.num, parent.push(index, 0)),
- Container(height: 1.4, color: scheme.onSurface),
- _slot(context, f.den, parent.push(index, 1)),
- ],
- ),
- ),
- );
- }
- Widget _power(BuildContext context, PowNode p) {
- return _row([
- _slot(context, p.base, parent.push(index, 0)),
- Transform.translate(
- offset: Offset(0, -size * 0.35),
- child: _slot(context, p.exp, parent.push(index, 1), scale: 0.7),
- ),
- ]);
- }
- Widget _group(BuildContext context, GroupNode g) {
- final scheme = Theme.of(context).colorScheme;
- final style = TextStyle(fontSize: size, color: scheme.onSurface);
- return _row([
- Text('(', style: style),
- _slot(context, g.inner, parent.push(index, 0)),
- Text(')', style: style),
- ]);
- }
- Widget _row(List<Widget> children) => Wrap(
- crossAxisAlignment: WrapCrossAlignment.center,
- children: children,
- );
- Widget _slot(BuildContext context, SeqNode seq, SlotPath path,
- {double scale = 1}) =>
- _ScaledSlot(
- scale: scale,
- child: _SlotView(
- doc: doc,
- seq: seq,
- path: path,
- size: size,
- blink: blink,
- onCursor: onCursor,
- ),
- );
- Widget _text(String t, double s) => Text(t, style: TextStyle(fontSize: s));
- }
- /// 给槽套一个缩放(指数要用小一号字)。
- class _ScaledSlot extends StatelessWidget {
- const _ScaledSlot({required this.scale, required this.child});
- final double scale;
- final Widget child;
- @override
- Widget build(BuildContext context) {
- if (scale == 1) return child;
- // 用 Transform 缩放而不是改字号:字号变了槽的布局参数也得跟着变,
- // 缩放一行搞定且视觉上等价。
- return Transform.scale(
- scale: scale,
- alignment: Alignment.bottomLeft,
- child: child,
- );
- }
- }
|