console_page.dart 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499
  1. import 'package:flutter/material.dart';
  2. import 'package:flutter/services.dart';
  3. import 'package:flutter_math_fork/flutter_math.dart';
  4. import 'package:provider/provider.dart';
  5. import 'curve_plot.dart';
  6. import 'mcp.dart';
  7. import 'session_store.dart';
  8. import 'symja.dart';
  9. class ConsolePage extends StatefulWidget {
  10. const ConsolePage({super.key});
  11. @override
  12. State<ConsolePage> createState() => _ConsolePageState();
  13. }
  14. class _ConsolePageState extends State<ConsolePage> {
  15. static const _helpText = '''
  16. 可输入任意 Symja 表达式,例如:
  17. Integrate[x^2, x] D[Sin[x], x] Solve[x^2-1==0, x]
  18. a = 42 f[x_] := x^2 + 1 % + 1
  19. 命令(小写,与 Symja 函数区分):
  20. help / ? 显示这份帮助
  21. clear / cls 清空历史
  22. clear <变量名> 清除某个变量
  23. reset 清空全部变量与历史
  24. history 列出输入过的表达式
  25. vars 列出当前变量
  26. plot <表达式> 在 x ∈ [-6.3, 6.3] 上采样绘图
  27. N <表达式> [位数] 数值化,默认 30 位
  28. mcp 查看 MCP 服务器状态
  29. % 表示上一次求值的结果。变量与历史会自动保存,重启后恢复。''';
  30. final _ctrl = TextEditingController();
  31. final _focus = FocusNode();
  32. final _scroll = ScrollController();
  33. final _store = SessionStore();
  34. /// 提交过的原始输入(含命令),供上下翻。
  35. final List<String> _typed = [];
  36. int _recall = -1;
  37. @override
  38. void initState() {
  39. super.initState();
  40. WidgetsBinding.instance.addPostFrameCallback((_) => _bootstrap());
  41. }
  42. @override
  43. void dispose() {
  44. _ctrl.dispose();
  45. _focus.dispose();
  46. _scroll.dispose();
  47. super.dispose();
  48. }
  49. // ------------------------------------------------------------------ 启动恢复
  50. Future<void> _bootstrap() async {
  51. if (!mounted) return;
  52. final session = context.read<SymjaSession>();
  53. final data = await _store.load();
  54. if (data != null) {
  55. final defs =
  56. ((data['defs'] as List?) ?? const []).map((e) => '$e').toList();
  57. final r = await session.importSession(defs);
  58. final hist = (data['history'] as List?) ?? const [];
  59. session.restoreHistory(hist.map((h) {
  60. final m = h as Map;
  61. return (
  62. input: '${m['input']}',
  63. text: '${m['text']}',
  64. ms: (m['ms'] as num?)?.toInt() ?? 0,
  65. );
  66. }));
  67. if (defs.isNotEmpty && mounted) {
  68. session.addLocal(
  69. '(会话恢复)',
  70. text: '恢复了 ${r.applied} 个定义'
  71. '${r.failed.isEmpty ? '' : ',${r.failed.length} 个失败'}',
  72. );
  73. }
  74. }
  75. await session.refreshVariables();
  76. if (mounted) _scrollToEnd();
  77. }
  78. // ------------------------------------------------------------------ 持久化
  79. /// 注意:session 由调用方传进来,不在这里 context.read ——
  80. /// 本方法会在 await 之后被调用,那时页面可能已经销毁。
  81. Future<void> _persist(SymjaSession session) async {
  82. final defs = await session.exportSession();
  83. await _store.save(<String, dynamic>{
  84. 'defs': defs,
  85. 'history': session.history
  86. .where((e) => e.result != null)
  87. .map((e) => <String, dynamic>{
  88. 'input': e.input,
  89. 'text': e.result!.text,
  90. 'ms': e.result!.elapsedMs,
  91. })
  92. .toList(),
  93. });
  94. }
  95. // ------------------------------------------------------------------ 输入
  96. void _recallPrev() {
  97. if (_typed.isEmpty) return;
  98. setState(() {
  99. _recall = _recall < 0 ? _typed.length - 1 : (_recall - 1).clamp(0, _typed.length - 1);
  100. _ctrl.text = _typed[_recall];
  101. _ctrl.selection = TextSelection.collapsed(offset: _ctrl.text.length);
  102. });
  103. }
  104. void _recallNext() {
  105. if (_recall < 0) return;
  106. setState(() {
  107. _recall++;
  108. if (_recall >= _typed.length) {
  109. _recall = -1;
  110. _ctrl.text = '';
  111. } else {
  112. _ctrl.text = _typed[_recall];
  113. _ctrl.selection = TextSelection.collapsed(offset: _ctrl.text.length);
  114. }
  115. });
  116. }
  117. Future<void> _submit(String raw) async {
  118. final input = raw.trim();
  119. if (input.isEmpty) return;
  120. final session = context.read<SymjaSession>();
  121. _typed.add(input);
  122. _recall = -1;
  123. _ctrl.clear();
  124. final handled = await _runCommand(input, session);
  125. if (!handled) {
  126. try {
  127. await session.evaluate(input);
  128. } on SymjaException {
  129. // 失败已记进 session.history,不需要额外处理
  130. }
  131. await session.refreshVariables();
  132. }
  133. await _persist(session);
  134. _scrollToEnd();
  135. }
  136. /// 返回 true 表示是控制台命令、已经处理掉了。
  137. Future<bool> _runCommand(String input, SymjaSession s) async {
  138. final parts = input.split(RegExp(r'\s+'));
  139. switch (parts.first) {
  140. case 'help':
  141. case '?':
  142. s.addLocal(input, text: _helpText);
  143. return true;
  144. case 'cls':
  145. s.clearHistory();
  146. return true;
  147. case 'clear':
  148. if (parts.length == 1) {
  149. s.clearHistory();
  150. } else {
  151. await s.clearVariable(parts[1]);
  152. s.addLocal(input, text: '已清除 ${parts[1]}');
  153. }
  154. return true;
  155. case 'reset':
  156. await s.clearAll();
  157. _typed.clear();
  158. await _persist(s);
  159. s.addLocal(input, text: '已清空全部变量与历史');
  160. return true;
  161. case 'history':
  162. s.addLocal(
  163. input,
  164. text: _typed.isEmpty
  165. ? '(无)'
  166. : [
  167. for (var i = 0; i < _typed.length; i++) '[${i + 1}] ${_typed[i]}',
  168. ].join('\n'),
  169. );
  170. return true;
  171. case 'vars':
  172. await s.refreshVariables();
  173. s.addLocal(
  174. input,
  175. text: s.variables.isEmpty
  176. ? '(无变量)'
  177. : s.variables.map((v) => '${v.name} = ${v.value}').join('\n'),
  178. );
  179. return true;
  180. case 'mcp':
  181. if (mounted) await showMcpPanel(context);
  182. return true;
  183. case 'plot':
  184. final expr = input.substring('plot'.length).trim();
  185. if (expr.isEmpty) {
  186. s.addLocal(input, error: '用法:plot <表达式>');
  187. return true;
  188. }
  189. final ok = await _plot(s, expr);
  190. if (ok) {
  191. s.addLocal(input, text: '$expr (x ∈ [-6.3, 6.3],240 个采样点)');
  192. }
  193. return true;
  194. case 'N':
  195. if (parts.length < 2) {
  196. s.addLocal(input, error: '用法:N <表达式> [位数]');
  197. return true;
  198. }
  199. final digits = parts.length >= 3 ? parts.last : '30';
  200. final expr = parts.length >= 3
  201. ? parts.sublist(1, parts.length - 1).join(' ')
  202. : parts.sublist(1).join(' ');
  203. try {
  204. await s.evaluate('N[$expr, $digits]');
  205. } on SymjaException {
  206. // 同上
  207. }
  208. await s.refreshVariables();
  209. return true;
  210. }
  211. return false;
  212. }
  213. Future<bool> _plot(SymjaSession session, String expr) async {
  214. try {
  215. final s = await session.sample(expr, from: -6.3, to: 6.3, count: 240);
  216. if (!mounted) return false;
  217. final plot = CurveSeries(
  218. label: expr,
  219. color: const Color(0xFF3F51B5),
  220. points: [
  221. for (var i = 0; i < s.ys.length; i++) Offset(s.xAt(i), s.ys[i]),
  222. ],
  223. );
  224. await showDialog<void>(
  225. context: context,
  226. builder: (_) => AlertDialog(
  227. title: Text('$expr (${s.ys.length} 点,${s.elapsedMs} ms)'),
  228. content: SizedBox(
  229. width: 360,
  230. height: 280,
  231. child: CurvePlot(series: [plot]),
  232. ),
  233. actions: [
  234. TextButton(
  235. onPressed: () => Navigator.pop(context),
  236. child: const Text('关闭'),
  237. ),
  238. ],
  239. ),
  240. );
  241. return true;
  242. } on SymjaException catch (e) {
  243. session.addLocal('plot $expr', error: '$e');
  244. return false;
  245. }
  246. }
  247. void _scrollToEnd() {
  248. WidgetsBinding.instance.addPostFrameCallback((_) {
  249. if (!_scroll.hasClients) return;
  250. _scroll.jumpTo(_scroll.position.maxScrollExtent);
  251. });
  252. }
  253. // ------------------------------------------------------------------ UI
  254. @override
  255. Widget build(BuildContext context) {
  256. final session = context.watch<SymjaSession>();
  257. final entries = session.history;
  258. return Scaffold(
  259. appBar: AppBar(
  260. title: const Text('某人的计算器2'),
  261. actions: [
  262. IconButton(
  263. tooltip: 'MCP 服务器',
  264. icon: const Icon(Icons.cloud_outlined),
  265. onPressed: () => showMcpPanel(context),
  266. ),
  267. IconButton(
  268. tooltip: '变量',
  269. icon: Badge(
  270. isLabelVisible: session.variables.isNotEmpty,
  271. label: Text('${session.variables.length}'),
  272. child: const Icon(Icons.functions),
  273. ),
  274. onPressed: () => _submit('vars'),
  275. ),
  276. IconButton(
  277. tooltip: '帮助',
  278. icon: const Icon(Icons.help_outline),
  279. onPressed: () => _submit('help'),
  280. ),
  281. IconButton(
  282. tooltip: '清空历史',
  283. icon: const Icon(Icons.cleaning_services_outlined),
  284. onPressed: () => _submit('cls'),
  285. ),
  286. ],
  287. ),
  288. body: Column(
  289. children: [
  290. Expanded(
  291. child: entries.isEmpty
  292. ? const Center(child: Text('输入表达式开始,或输入 help'))
  293. : ListView.builder(
  294. controller: _scroll,
  295. padding: const EdgeInsets.fromLTRB(12, 12, 12, 4),
  296. itemCount: entries.length,
  297. itemBuilder: (_, i) =>
  298. _EntryTile(index: i + 1, entry: entries[i]),
  299. ),
  300. ),
  301. SafeArea(top: false, child: _inputBar(session)),
  302. ],
  303. ),
  304. );
  305. }
  306. Widget _inputBar(SymjaSession session) {
  307. return Container(
  308. decoration: BoxDecoration(
  309. border: Border(
  310. top: BorderSide(color: Theme.of(context).dividerColor),
  311. ),
  312. ),
  313. padding: const EdgeInsets.fromLTRB(8, 6, 8, 6),
  314. child: Row(
  315. crossAxisAlignment: CrossAxisAlignment.center,
  316. children: [
  317. Column(
  318. mainAxisSize: MainAxisSize.min,
  319. children: [
  320. _tinyBtn(Icons.keyboard_arrow_up, '上一条', _recallPrev),
  321. _tinyBtn(Icons.keyboard_arrow_down, '下一条', _recallNext),
  322. ],
  323. ),
  324. const SizedBox(width: 4),
  325. Text(
  326. '[${session.history.length + 1}]:',
  327. style: const TextStyle(fontFamily: 'monospace', fontSize: 13),
  328. ),
  329. const SizedBox(width: 6),
  330. Expanded(
  331. // 用 Focus 捕获方向键做历史回翻。输入框是单行的,
  332. // 上下键不会被光标移动占用,所以能冒泡到这里。
  333. child: Focus(
  334. onKeyEvent: (_, e) {
  335. if (e is! KeyDownEvent) return KeyEventResult.ignored;
  336. if (e.logicalKey == LogicalKeyboardKey.arrowUp) {
  337. _recallPrev();
  338. return KeyEventResult.handled;
  339. }
  340. if (e.logicalKey == LogicalKeyboardKey.arrowDown) {
  341. _recallNext();
  342. return KeyEventResult.handled;
  343. }
  344. return KeyEventResult.ignored;
  345. },
  346. child: TextField(
  347. controller: _ctrl,
  348. focusNode: _focus,
  349. style: const TextStyle(fontFamily: 'monospace', fontSize: 15),
  350. decoration: const InputDecoration(
  351. isDense: true,
  352. border: InputBorder.none,
  353. hintText: '输入表达式或 help',
  354. ),
  355. onSubmitted: _submit,
  356. ),
  357. ),
  358. ),
  359. if (session.busy)
  360. IconButton(
  361. tooltip: '取消',
  362. icon: const Icon(Icons.stop_circle_outlined),
  363. onPressed: session.cancel,
  364. )
  365. else
  366. IconButton(
  367. tooltip: '求值',
  368. icon: const Icon(Icons.send),
  369. onPressed: () => _submit(_ctrl.text),
  370. ),
  371. ],
  372. ),
  373. );
  374. }
  375. Widget _tinyBtn(IconData icon, String tip, VoidCallback onTap) => InkWell(
  376. onTap: onTap,
  377. child: Tooltip(
  378. message: tip,
  379. child: Icon(icon, size: 18),
  380. ),
  381. );
  382. }
  383. /// 历史里的一条:`[n]: 输入` + 结果(LaTeX 渲染 + 纯文本)或错误。
  384. class _EntryTile extends StatelessWidget {
  385. const _EntryTile({required this.index, required this.entry});
  386. final int index;
  387. final SymjaEntry entry;
  388. @override
  389. Widget build(BuildContext context) {
  390. final scheme = Theme.of(context).colorScheme;
  391. return Padding(
  392. padding: const EdgeInsets.only(bottom: 14),
  393. child: Column(
  394. crossAxisAlignment: CrossAxisAlignment.start,
  395. children: [
  396. SelectableText(
  397. '[$index]: ${entry.input}',
  398. style: TextStyle(
  399. fontFamily: 'monospace',
  400. fontSize: 14,
  401. color: scheme.primary,
  402. ),
  403. ),
  404. const SizedBox(height: 6),
  405. if (entry.ok) _ok(context, scheme) else _err(scheme),
  406. ],
  407. ),
  408. );
  409. }
  410. Widget _ok(BuildContext context, ColorScheme scheme) {
  411. final r = entry.result!;
  412. final plain = SelectableText(
  413. r.text,
  414. style: const TextStyle(fontFamily: 'monospace', fontSize: 16),
  415. );
  416. if (r.tex == null) return plain;
  417. return Column(
  418. crossAxisAlignment: CrossAxisAlignment.start,
  419. children: [
  420. Math.tex(
  421. r.tex!,
  422. mathStyle: MathStyle.display,
  423. textStyle: TextStyle(fontSize: 22, color: scheme.onSurface),
  424. // ComplexInfinity 之类的输出不是合法 LaTeX,直接退回纯文本
  425. onErrorFallback: (_) => plain,
  426. ),
  427. const SizedBox(height: 4),
  428. SelectableText(
  429. r.text,
  430. style: TextStyle(
  431. fontFamily: 'monospace',
  432. fontSize: 12,
  433. color: scheme.onSurfaceVariant,
  434. ),
  435. ),
  436. ],
  437. );
  438. }
  439. Widget _err(ColorScheme scheme) => Row(
  440. crossAxisAlignment: CrossAxisAlignment.start,
  441. children: [
  442. Icon(Icons.error_outline, size: 18, color: scheme.error),
  443. const SizedBox(width: 6),
  444. Expanded(
  445. child: SelectableText(
  446. entry.error ?? '未知错误',
  447. style: TextStyle(
  448. fontFamily: 'monospace',
  449. fontSize: 13,
  450. color: scheme.error,
  451. ),
  452. ),
  453. ),
  454. ],
  455. );
  456. }