console_page.dart 15 KB

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