calculator_page.dart 27 KB

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