console_page.dart 15 KB

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