calculator_page.dart 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736
  1. import 'dart:async';
  2. import 'package:flutter/material.dart';
  3. import 'package:flutter/services.dart';
  4. import 'package:flutter_math_fork/flutter_math.dart';
  5. import 'package:provider/provider.dart';
  6. import 'expr_editor.dart';
  7. import 'expr_node.dart';
  8. import 'expr_parse.dart';
  9. import 'symja.dart';
  10. import 'symja_functions.dart';
  11. /// 科学计算器(默认主界面)。
  12. ///
  13. /// 输入用**结构化节点树**(见 lib/expr_node.dart),不是字符串:
  14. /// 按键把节点插进当前光标槽,分数竖排、指数上标、多参数函数各有自己的槽。
  15. /// 纯文本输入留给控制台。
  16. class CalculatorPage extends StatefulWidget {
  17. const CalculatorPage({super.key});
  18. @override
  19. State<CalculatorPage> createState() => _CalculatorPageState();
  20. }
  21. class _CalculatorPageState extends State<CalculatorPage> {
  22. final _doc = ExprDoc();
  23. SymjaResult? _preview;
  24. SymjaResult? _committed;
  25. String? _error;
  26. Timer? _debounce;
  27. int _seq = 0;
  28. /// 用户点了"光标已经在的地方"—— 显示粘贴入口。
  29. bool _pastePrompt = false;
  30. @override
  31. void dispose() {
  32. _debounce?.cancel();
  33. super.dispose();
  34. }
  35. // ------------------------------------------------------------------ 求值
  36. /// 编辑器每次变化都走这里:清掉上次的提交态,防抖后重算预览。
  37. void _onDocChanged() {
  38. setState(() {
  39. _committed = null;
  40. _error = null;
  41. _pastePrompt = false; // 动过内容/光标就收起操作栏
  42. });
  43. _debounce?.cancel();
  44. _debounce = Timer(const Duration(milliseconds: 500), _runPreview);
  45. }
  46. String get _source => _doc.toSymja();
  47. void _snack(String msg) {
  48. if (!mounted) return;
  49. ScaffoldMessenger.of(context)
  50. ..hideCurrentSnackBar()
  51. ..showSnackBar(SnackBar(content: Text(msg)));
  52. }
  53. Future<void> _copy(String text, String what) async {
  54. await Clipboard.setData(ClipboardData(text: text));
  55. _snack('已复制$what:$text');
  56. }
  57. /// 从剪贴板读一段 Symja 表达式,解析成节点树后整体替换。
  58. ///
  59. /// 解析器只认一个明确的子集,**认不出来就报错**,不会猜着解析 ——
  60. /// 猜错了你会看到算错的结果,那比报错糟糕得多。
  61. Future<void> _paste() async {
  62. final data = await Clipboard.getData(Clipboard.kTextPlain);
  63. final text = (data?.text ?? '').trim();
  64. if (text.isEmpty) {
  65. _snack('剪贴板里没有文本');
  66. return;
  67. }
  68. try {
  69. final parsed = parseSymjaExpr(text);
  70. setState(() {
  71. _pastePrompt = false;
  72. if (_doc.selection != null) _doc.deleteSelection(); // 有选区就替换掉
  73. _doc.insertNodesAtCursor(parsed.children); // 否则插到光标处
  74. _preview = null;
  75. _committed = null;
  76. _error = null;
  77. });
  78. _onDocChanged();
  79. _snack('已粘贴到光标处:${_doc.toSymja()}');
  80. } on ExprParseException catch (e) {
  81. _snack('这段表达式没法转成按钮式输入 —— $e');
  82. }
  83. }
  84. // ------------------------------------------------------------------ 选区操作
  85. /// 操作栏上的图标按钮。压扁一点,别把这条栏撑高。
  86. Widget _barButton(IconData icon, String tip, VoidCallback onTap) => IconButton(
  87. tooltip: tip,
  88. icon: Icon(icon, size: 20),
  89. visualDensity: VisualDensity.compact,
  90. padding: const EdgeInsets.symmetric(horizontal: 6),
  91. constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
  92. onPressed: onTap,
  93. );
  94. Future<void> _copySelection() async {
  95. final t = _doc.selectionText;
  96. if (t.isEmpty) return;
  97. await _copy(t, '选中内容');
  98. setState(() => _doc.clearSelection());
  99. }
  100. Future<void> _cutSelection() async {
  101. final t = _doc.selectionText;
  102. if (t.isEmpty) return;
  103. await Clipboard.setData(ClipboardData(text: t));
  104. setState(() {
  105. _doc.deleteSelection();
  106. _pastePrompt = false;
  107. });
  108. _onDocChanged();
  109. _snack('已剪切:$t');
  110. }
  111. void _dismissBar() {
  112. setState(() {
  113. _pastePrompt = false;
  114. _doc.clearSelection();
  115. });
  116. }
  117. /// 选中部分内容后自动出现的操作栏;或点了光标处时的粘贴入口。
  118. Widget? _contextBar() {
  119. final sel = _doc.selection;
  120. final hasSel = sel != null && !sel.isEmpty;
  121. if (!hasSel && !_pastePrompt) return null;
  122. final scheme = Theme.of(context).colorScheme;
  123. return Material(
  124. color: scheme.surfaceContainerHighest,
  125. child: Padding(
  126. padding: const EdgeInsets.only(left: 12, right: 4),
  127. child: Row(
  128. children: [
  129. Expanded(
  130. child: Text(
  131. hasSel ? '已选中 ${_doc.selectionText}' : '粘贴到光标处',
  132. maxLines: 1,
  133. overflow: TextOverflow.ellipsis,
  134. style: const TextStyle(fontFamily: 'monospace', fontSize: 12),
  135. ),
  136. ),
  137. // 用图标而不是文字按钮 —— 文字太占地方,一条栏塞不下几个
  138. if (hasSel) ...[
  139. _barButton(Icons.copy, '复制', _copySelection),
  140. _barButton(Icons.cut, '剪切', _cutSelection),
  141. ] else
  142. _barButton(Icons.paste, '粘贴', _paste),
  143. _barButton(Icons.close, '取消选择', _dismissBar),
  144. ],
  145. ),
  146. ),
  147. );
  148. }
  149. Future<void> _runPreview() async {
  150. final expr = _source;
  151. if (expr.isEmpty || expr == 'Null') {
  152. if (mounted) setState(() => _preview = null);
  153. return;
  154. }
  155. final session = context.read<SymjaSession>();
  156. final id = ++_seq;
  157. final r = await session.preview(expr);
  158. if (!mounted || id != _seq) return; // 已有更新的预览在路上
  159. setState(() => _preview = r);
  160. }
  161. Future<void> _commit() async {
  162. final expr = _source;
  163. if (expr.isEmpty || expr == 'Null') return;
  164. _debounce?.cancel();
  165. final session = context.read<SymjaSession>();
  166. final id = ++_seq; // 让在途的预览作废
  167. try {
  168. final r = await session.evaluate(expr);
  169. if (!mounted || id != _seq) return;
  170. setState(() {
  171. _committed = r;
  172. _preview = null;
  173. _error = null;
  174. });
  175. } on SymjaException catch (e) {
  176. if (!mounted || id != _seq) return;
  177. setState(() {
  178. _error = '$e';
  179. _committed = null;
  180. _preview = null;
  181. });
  182. }
  183. await session.refreshVariables();
  184. }
  185. // ------------------------------------------------------------------ 按键
  186. void _press(_Key k) {
  187. switch (k.act) {
  188. case _Act.char:
  189. if (k.mergeDigit) {
  190. // 数字键并入左边那个数字字面量,`42` 才是一个数而不是两个节点
  191. _doc.insertChar(k.payload);
  192. } else {
  193. _doc.insert(LitNode(k.payload, display: k.display));
  194. }
  195. case _Act.fn:
  196. _doc.makeFunction(k.payload);
  197. case _Act.fnPrefixed:
  198. // 像 D 这种,第一个参数留空给用户填,其余预置好
  199. _doc.makeFunction(k.payload, prefill: k.prefill);
  200. case _Act.bigOp:
  201. // ∫ / Σ / lim:上下限单独成槽,才画得到习惯位置
  202. final p = k.prefill;
  203. _doc.makeBigOp(
  204. k.payload,
  205. variable: p.isNotEmpty ? p[0] : 'x',
  206. lower: p.length > 1 ? p[1] : '',
  207. upper: p.length > 2 ? p[2] : '',
  208. );
  209. case _Act.sqrt:
  210. _doc.makeFunction('Sqrt');
  211. case _Act.group:
  212. _doc.makeGroup();
  213. case _Act.power:
  214. _doc.makePower();
  215. case _Act.subst:
  216. // 代入骨架:`/. x -> `,光标落在最后,接着打要代进去的值就行。
  217. // 变量是 x 的占多数;要代 t/y 就点一下那个 x 改掉。
  218. _doc.insert(LitNode('/. '));
  219. _doc.insert(LitNode('x'));
  220. _doc.insert(LitNode(' -> '));
  221. case _Act.brace:
  222. _doc.makeBrace();
  223. case _Act.frac:
  224. _doc.makeFraction();
  225. case _Act.postfix:
  226. // 阶乘是后缀:把光标左边那个节点收进来当操作数
  227. final operand = _doc.takeLeft();
  228. _doc.insert(PostfixNode(
  229. operand: SeqNode(operand == null ? null : [operand]),
  230. ));
  231. case _Act.comma:
  232. // 在函数参数里 → 切到下一个参数;在花括号里(矩阵行)→ 插一个逗号
  233. if (!_doc.nextArgument()) _doc.insert(LitNode(','));
  234. case _Act.back:
  235. _doc.backspace();
  236. case _Act.clear:
  237. _doc.clear();
  238. case _Act.commit:
  239. _commit();
  240. return; // 提交不改文档,不用刷新
  241. }
  242. _onDocChanged();
  243. }
  244. Future<void> _showPalette() async {
  245. final picked = await showModalBottomSheet<({String value, bool isFn})>(
  246. context: context,
  247. isScrollControlled: true,
  248. showDragHandle: true,
  249. builder: (_) => const _FunctionPalette(),
  250. );
  251. if (picked == null) return;
  252. if (picked.isFn) {
  253. _doc.makeFunction(picked.value);
  254. } else {
  255. _doc.insert(LitNode(picked.value));
  256. }
  257. _onDocChanged();
  258. }
  259. // ------------------------------------------------------------------ UI
  260. @override
  261. Widget build(BuildContext context) {
  262. final session = context.watch<SymjaSession>();
  263. final bar = _contextBar();
  264. return Scaffold(
  265. appBar: AppBar(
  266. // 不显示标题、顶栏压扁 —— 省下来的竖向空间给键盘
  267. toolbarHeight: 44,
  268. actions: [
  269. // 精确 / 近似 切换。默认精确:`1/3` 就是 `1/3`,这是符号计算的意义所在。
  270. TextButton(
  271. onPressed: () {
  272. session.approximate = !session.approximate;
  273. _onDocChanged(); // 立刻按新模式重算预览
  274. },
  275. child: Text(
  276. session.approximate ? '≈ 近似' : '精确',
  277. style: TextStyle(
  278. fontFamily: 'monospace',
  279. fontSize: 13,
  280. color: Theme.of(context).colorScheme.primary,
  281. ),
  282. ),
  283. ),
  284. IconButton(
  285. tooltip: '函数面板',
  286. icon: const Icon(Icons.functions),
  287. onPressed: _showPalette,
  288. ),
  289. ],
  290. ),
  291. body: SafeArea(
  292. child: Column(
  293. children: [
  294. _display(context),
  295. ?bar,
  296. const Divider(height: 1),
  297. Expanded(child: _keypad(context)),
  298. ],
  299. ),
  300. ),
  301. );
  302. }
  303. Widget _display(BuildContext context) {
  304. final scheme = Theme.of(context).colorScheme;
  305. final shown = _committed ?? _preview;
  306. return Container(
  307. width: double.infinity,
  308. constraints: const BoxConstraints(minHeight: 132),
  309. padding: const EdgeInsets.fromLTRB(12, 10, 12, 10),
  310. color: scheme.surfaceContainerLow,
  311. child: Column(
  312. crossAxisAlignment: CrossAxisAlignment.end,
  313. children: [
  314. // 固定高度 + 超高时纵向滚动。
  315. // 不固定的话,表达式一变高(分式、Σ 带上下限)编辑区就撑大,
  316. // 下面的键盘整体往下跳,手指刚瞄好的键就换位置了。
  317. SizedBox(
  318. height: 104,
  319. child: SingleChildScrollView(
  320. scrollDirection: Axis.vertical,
  321. reverse: true, // 内容超高时把底部(光标那侧)留在视野里
  322. child: ExprEditor(
  323. doc: _doc,
  324. onChanged: _onDocChanged,
  325. onCaretTapped: () => setState(() => _pastePrompt = true),
  326. ),
  327. ),
  328. ),
  329. const SizedBox(height: 6),
  330. // 常显。它就是真正送进引擎的那串文本(也是"复制表达式"复制走的内容),
  331. // 空槽会写成 Null、隐含括号也会露出来。
  332. Text(
  333. _source,
  334. textAlign: TextAlign.right,
  335. maxLines: 2,
  336. overflow: TextOverflow.ellipsis,
  337. style: TextStyle(
  338. fontFamily: 'monospace',
  339. fontSize: 11,
  340. color: scheme.outline,
  341. ),
  342. ),
  343. const SizedBox(height: 6),
  344. SizedBox(
  345. height: 52,
  346. width: double.infinity,
  347. child: GestureDetector(
  348. key: const ValueKey('calc-result'),
  349. // 点结果 = 复制结果
  350. onTap: shown == null ? null : () => _copy(shown.text, '结果'),
  351. behavior: HitTestBehavior.opaque,
  352. child: FittedBox(
  353. fit: BoxFit.scaleDown,
  354. alignment: Alignment.centerRight,
  355. child: _resultView(context, shown),
  356. ),
  357. ),
  358. ),
  359. ],
  360. ),
  361. );
  362. }
  363. Widget _resultView(BuildContext context, SymjaResult? shown) {
  364. final scheme = Theme.of(context).colorScheme;
  365. if (_error != null) {
  366. return SelectableText(
  367. _error!,
  368. style: TextStyle(
  369. fontFamily: 'monospace',
  370. fontSize: 13,
  371. color: scheme.error,
  372. ),
  373. );
  374. }
  375. if (shown == null) return const SizedBox.shrink();
  376. // 用 Text 而不是 SelectableText:点结果是"复制",选中文字反而挡住点击
  377. final plain = Text(
  378. shown.text,
  379. style: const TextStyle(fontFamily: 'monospace', fontSize: 22),
  380. );
  381. final tex = shown.tex;
  382. final body = tex == null
  383. ? plain
  384. : Math.tex(
  385. tex,
  386. mathStyle: MathStyle.display,
  387. textStyle: TextStyle(fontSize: 26, color: scheme.onSurface),
  388. onErrorFallback: (_) => plain,
  389. );
  390. return Row(
  391. mainAxisSize: MainAxisSize.min,
  392. children: [
  393. if (_committed != null) ...[
  394. Icon(Icons.check_circle, size: 16, color: scheme.primary),
  395. const SizedBox(width: 6),
  396. Text('${_committed!.elapsedMs} ms',
  397. style: TextStyle(fontSize: 11, color: scheme.outline)),
  398. const SizedBox(width: 10),
  399. ],
  400. body,
  401. ],
  402. );
  403. }
  404. Widget _keypad(BuildContext context) {
  405. return LayoutBuilder(
  406. builder: (context, box) {
  407. final rows = <List<_Key>>[
  408. // 1 大运算符
  409. [
  410. _Key('∫', _Act.bigOp, payload: 'Integrate', prefill: ['x']),
  411. _Key('Σ', _Act.bigOp,
  412. payload: 'Sum', prefill: ['n', '1', 'Infinity']),
  413. _Key('d/dx', _Act.fnPrefixed, payload: 'D', prefill: ['', 'x']),
  414. _Key('lim', _Act.bigOp, payload: 'Limit', prefill: ['x', '0']),
  415. _Key('solve', _Act.fnPrefixed, payload: 'Solve', prefill: ['', 'x']),
  416. ],
  417. // 2 常用函数
  418. [
  419. _Key('sin', _Act.fn, payload: 'Sin'),
  420. _Key('cos', _Act.fn, payload: 'Cos'),
  421. _Key('tan', _Act.fn, payload: 'Tan'),
  422. _Key('ln', _Act.fn, payload: 'Log'),
  423. _Key('√', _Act.sqrt),
  424. ],
  425. // 3 变量
  426. [
  427. _Key('x', _Act.char, payload: 'x', mathVar: true),
  428. _Key('y', _Act.char, payload: 'y', mathVar: true),
  429. _Key('a', _Act.char, payload: 'a', mathVar: true),
  430. _Key('t', _Act.char, payload: 't', mathVar: true),
  431. _Key('n', _Act.char, payload: 'n', mathVar: true),
  432. ],
  433. // 4 运算符:解方程、赋值、代入、定义函数全靠它们。
  434. // 都是"运算符"不是"函数",所以放键盘上而不是函数面板里。
  435. [
  436. _Key('=', _Act.char, payload: '=', tint: true),
  437. _Key('==', _Act.char, payload: '==', tint: true),
  438. // /. 和 -> 永远成对出现(代入就是 `式子 /. x -> 值`),
  439. // 合成一个键直接给出骨架,省一个位置给逗号(矩阵行分隔)
  440. _Key('/.→', _Act.subst, tint: true),
  441. _Key(',', _Act.comma),
  442. _Key('{', _Act.brace, tint: true),
  443. ],
  444. // 5 数字与括号
  445. [
  446. _Key('7', _Act.char, payload: '7', mergeDigit: true),
  447. _Key('8', _Act.char, payload: '8', mergeDigit: true),
  448. _Key('9', _Act.char, payload: '9', mergeDigit: true),
  449. _Key('(', _Act.group),
  450. _Key('!', _Act.postfix),
  451. ],
  452. [
  453. _Key('4', _Act.char, payload: '4', mergeDigit: true),
  454. _Key('5', _Act.char, payload: '5', mergeDigit: true),
  455. _Key('6', _Act.char, payload: '6', mergeDigit: true),
  456. _Key('×', _Act.char, payload: '*'),
  457. _Key('/', _Act.frac),
  458. ],
  459. [
  460. _Key('1', _Act.char, payload: '1', mergeDigit: true),
  461. _Key('2', _Act.char, payload: '2', mergeDigit: true),
  462. _Key('3', _Act.char, payload: '3', mergeDigit: true),
  463. _Key('+', _Act.char, payload: '+'),
  464. _Key('−', _Act.char, payload: '-'),
  465. ],
  466. // 7 花括号:矩阵用
  467. [
  468. _Key('0', _Act.char, payload: '0', mergeDigit: true),
  469. _Key('.', _Act.char, payload: '.', mergeDigit: true),
  470. _Key('^', _Act.power),
  471. // 常量。编辑框显示 π / e,送进引擎的是 Pi / E(和 ANS 同一套机制)
  472. _Key('π', _Act.char, payload: 'Pi', tint: true, display: 'π'),
  473. _Key('e', _Act.char, payload: 'E', tint: true, display: 'e'),
  474. ],
  475. [
  476. _Key('⌫', _Act.back, tint: true),
  477. _Key('CLR', _Act.clear, tint: true),
  478. // 虚数单位。送进 Symja 的是 I,编辑框里显示小写 i
  479. _Key('i', _Act.char, payload: 'I', tint: true, display: 'i'),
  480. _Key('ANS', _Act.char, payload: '%', tint: true, display: 'ANS'),
  481. // 键盘负责"敲字符",✓ 负责"执行"。两者都叫 = 会撞车。
  482. _Key('✓', _Act.commit, primary: true),
  483. ],
  484. ];
  485. // 7 行 + 6 个间隙,再扣掉 Padding 上下各 6 ——
  486. // box.maxHeight 是 padding **之前**的高度,漏掉会溢出。
  487. final h = (box.maxHeight - 12 - 8 * 6) / 9;
  488. return Padding(
  489. padding: const EdgeInsets.all(6),
  490. child: Column(
  491. children: [
  492. for (var r = 0; r < rows.length; r++) ...[
  493. if (r > 0) const SizedBox(height: 6),
  494. SizedBox(
  495. height: h,
  496. child: Row(
  497. children: [
  498. for (var c = 0; c < rows[r].length; c++) ...[
  499. if (c > 0) const SizedBox(width: 6),
  500. Expanded(child: _keyButton(rows[r][c])),
  501. ],
  502. ],
  503. ),
  504. ),
  505. ],
  506. ],
  507. ),
  508. );
  509. },
  510. );
  511. }
  512. Widget _keyButton(_Key k) {
  513. final scheme = Theme.of(context).colorScheme;
  514. final bg = k.primary
  515. ? scheme.primary
  516. : k.tint
  517. ? scheme.surfaceContainerHighest
  518. : scheme.surfaceContainerLow;
  519. final fg = k.primary ? scheme.onPrimary : scheme.onSurface;
  520. return Material(
  521. // 加 key 是为了让测试能精确定位按键 —— 编辑器里渲染出的字面量(比如 'x')
  522. // 和键盘上同名的按键都符合 find.text,会撞车。
  523. key: ValueKey('key-${k.label}'),
  524. color: bg,
  525. borderRadius: BorderRadius.circular(10),
  526. child: InkWell(
  527. borderRadius: BorderRadius.circular(10),
  528. onTap: () => _press(k),
  529. child: Center(
  530. child: Text(
  531. k.label,
  532. style: TextStyle(
  533. color: fg,
  534. fontSize: k.label.length > 2 ? 13 : (k.mathVar ? 20 : 17),
  535. fontFamily: k.mathVar
  536. ? 'serif'
  537. : (k.label.length > 2 ? null : 'monospace'),
  538. fontStyle: k.mathVar ? FontStyle.italic : FontStyle.normal,
  539. ),
  540. ),
  541. ),
  542. ),
  543. );
  544. }
  545. }
  546. enum _Act {
  547. char,
  548. fn,
  549. fnPrefixed,
  550. bigOp,
  551. subst,
  552. brace,
  553. sqrt,
  554. group,
  555. power,
  556. frac,
  557. postfix,
  558. comma,
  559. back,
  560. clear,
  561. commit,
  562. }
  563. @immutable
  564. class _Key {
  565. final String label;
  566. final _Act act;
  567. final String payload;
  568. /// fnPrefixed 用:第一个参数留空给用户填,其余的预置。
  569. final List<String> prefill;
  570. /// 数字键:并入左边那个数字字面量,而不是各建一个节点。
  571. final bool mergeDigit;
  572. /// 编辑框里显示的名字(和真正送出去的 payload 不同时用)。
  573. final String? display;
  574. /// 是不是"数学变量"。变量键的标签用**斜体衬线**,和运算符区分开 ——
  575. /// 等宽字体里 `x`(变量)和 `×`(乘号)几乎一模一样,用户实测反馈过。
  576. final bool mathVar;
  577. final bool tint;
  578. final bool primary;
  579. const _Key(
  580. this.label,
  581. this.act, {
  582. this.payload = '',
  583. this.prefill = const [],
  584. this.mergeDigit = false,
  585. this.display,
  586. this.mathVar = false,
  587. this.tint = false,
  588. this.primary = false,
  589. });
  590. }
  591. /// 函数面板:检索 Symja 的两百多个函数名,选中后插入一个函数节点。
  592. class _FunctionPalette extends StatefulWidget {
  593. const _FunctionPalette();
  594. @override
  595. State<_FunctionPalette> createState() => _FunctionPaletteState();
  596. }
  597. class _FunctionPaletteState extends State<_FunctionPalette> {
  598. final _search = TextEditingController();
  599. String _q = '';
  600. @override
  601. void dispose() {
  602. _search.dispose();
  603. super.dispose();
  604. }
  605. @override
  606. Widget build(BuildContext context) {
  607. final q = _q.trim().toLowerCase();
  608. final fns = kSymjaFunctions
  609. .where((f) => q.isEmpty || f.toLowerCase().contains(q))
  610. .toList(growable: false);
  611. return DraggableScrollableSheet(
  612. expand: false,
  613. initialChildSize: 0.75,
  614. maxChildSize: 0.95,
  615. builder: (context, scroll) => Column(
  616. children: [
  617. Padding(
  618. padding: const EdgeInsets.fromLTRB(16, 0, 16, 8),
  619. child: TextField(
  620. controller: _search,
  621. autofocus: true,
  622. decoration: InputDecoration(
  623. isDense: true,
  624. hintText: '函数名(共 ${kSymjaFunctions.length} 个)',
  625. prefixIcon: const Icon(Icons.search),
  626. border: const OutlineInputBorder(),
  627. ),
  628. onChanged: (v) => setState(() => _q = v),
  629. ),
  630. ),
  631. // 常量做成可点的 —— 它们从键盘上挪走了,得有个地方能按。
  632. Padding(
  633. padding: const EdgeInsets.symmetric(horizontal: 16),
  634. child: Wrap(
  635. spacing: 8,
  636. runSpacing: 4,
  637. crossAxisAlignment: WrapCrossAlignment.center,
  638. children: [
  639. Text('常量', style: Theme.of(context).textTheme.bodySmall),
  640. for (final c in kSymjaConstants)
  641. ActionChip(
  642. label: Text(
  643. c,
  644. style: const TextStyle(
  645. fontFamily: 'monospace', fontSize: 12),
  646. ),
  647. visualDensity: VisualDensity.compact,
  648. materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
  649. onPressed: () =>
  650. Navigator.pop(context, (value: c, isFn: false)),
  651. ),
  652. ],
  653. ),
  654. ),
  655. const Divider(height: 16),
  656. Expanded(
  657. child: fns.isEmpty
  658. ? const Center(child: Text('没有匹配的函数'))
  659. : ListView.builder(
  660. controller: scroll,
  661. itemCount: fns.length,
  662. itemBuilder: (_, i) => ListTile(
  663. dense: true,
  664. title: Text(
  665. fns[i],
  666. style: const TextStyle(fontFamily: 'monospace'),
  667. ),
  668. // 一句中文说明,挂名字后面
  669. subtitle: Text(
  670. kSymjaFunctionHelp[fns[i]] ?? '',
  671. style: const TextStyle(fontSize: 12),
  672. ),
  673. onTap: () => Navigator.pop(
  674. context, (value: fns[i], isFn: true)),
  675. ),
  676. ),
  677. ),
  678. ],
  679. ),
  680. );
  681. }
  682. }