console_page.dart 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507
  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. title: const Text('某人的计算器2'),
  264. actions: [
  265. IconButton(
  266. tooltip: 'MCP 服务器',
  267. icon: const Icon(Icons.cloud_outlined),
  268. onPressed: () => showMcpPanel(context),
  269. ),
  270. IconButton(
  271. tooltip: '变量',
  272. icon: Badge(
  273. isLabelVisible: session.variables.isNotEmpty,
  274. label: Text('${session.variables.length}'),
  275. child: const Icon(Icons.functions),
  276. ),
  277. onPressed: () => _submit('vars'),
  278. ),
  279. IconButton(
  280. tooltip: '帮助',
  281. icon: const Icon(Icons.help_outline),
  282. onPressed: () => _submit('help'),
  283. ),
  284. IconButton(
  285. tooltip: '清空历史',
  286. icon: const Icon(Icons.cleaning_services_outlined),
  287. onPressed: () => _submit('cls'),
  288. ),
  289. ],
  290. ),
  291. body: Column(
  292. children: [
  293. Expanded(
  294. child: entries.isEmpty
  295. ? const Center(child: Text('输入表达式开始,或输入 help'))
  296. : ListView.builder(
  297. controller: _scroll,
  298. padding: const EdgeInsets.fromLTRB(12, 12, 12, 4),
  299. itemCount: entries.length,
  300. itemBuilder: (_, i) =>
  301. _EntryTile(index: i + 1, entry: entries[i]),
  302. ),
  303. ),
  304. SafeArea(top: false, child: _inputBar(session)),
  305. ],
  306. ),
  307. );
  308. }
  309. Widget _inputBar(SymjaSession session) {
  310. return Container(
  311. decoration: BoxDecoration(
  312. border: Border(
  313. top: BorderSide(color: Theme.of(context).dividerColor),
  314. ),
  315. ),
  316. padding: const EdgeInsets.fromLTRB(8, 6, 8, 6),
  317. child: Row(
  318. crossAxisAlignment: CrossAxisAlignment.center,
  319. children: [
  320. Column(
  321. mainAxisSize: MainAxisSize.min,
  322. children: [
  323. _tinyBtn(Icons.keyboard_arrow_up, '上一条', _recallPrev),
  324. _tinyBtn(Icons.keyboard_arrow_down, '下一条', _recallNext),
  325. ],
  326. ),
  327. const SizedBox(width: 4),
  328. Text(
  329. '[${session.history.length + 1}]:',
  330. style: const TextStyle(fontFamily: 'monospace', fontSize: 13),
  331. ),
  332. const SizedBox(width: 6),
  333. Expanded(
  334. // 用 Focus 捕获方向键做历史回翻。输入框是单行的,
  335. // 上下键不会被光标移动占用,所以能冒泡到这里。
  336. child: Focus(
  337. onKeyEvent: (_, e) {
  338. if (e is! KeyDownEvent) return KeyEventResult.ignored;
  339. if (e.logicalKey == LogicalKeyboardKey.arrowUp) {
  340. _recallPrev();
  341. return KeyEventResult.handled;
  342. }
  343. if (e.logicalKey == LogicalKeyboardKey.arrowDown) {
  344. _recallNext();
  345. return KeyEventResult.handled;
  346. }
  347. return KeyEventResult.ignored;
  348. },
  349. child: TextField(
  350. controller: _ctrl,
  351. focusNode: _focus,
  352. style: const TextStyle(fontFamily: 'monospace', fontSize: 15),
  353. decoration: const InputDecoration(
  354. isDense: true,
  355. border: InputBorder.none,
  356. hintText: '输入表达式或 help',
  357. ),
  358. onSubmitted: _submit,
  359. ),
  360. ),
  361. ),
  362. if (session.busy)
  363. IconButton(
  364. tooltip: '取消',
  365. icon: const Icon(Icons.stop_circle_outlined),
  366. onPressed: session.cancel,
  367. )
  368. else
  369. IconButton(
  370. tooltip: '求值',
  371. icon: const Icon(Icons.send),
  372. onPressed: () => _submit(_ctrl.text),
  373. ),
  374. ],
  375. ),
  376. );
  377. }
  378. Widget _tinyBtn(IconData icon, String tip, VoidCallback onTap) => InkWell(
  379. onTap: onTap,
  380. child: Tooltip(
  381. message: tip,
  382. child: Icon(icon, size: 18),
  383. ),
  384. );
  385. }
  386. /// 历史里的一条:`[n]: 输入` + 结果(LaTeX 渲染 + 纯文本)或错误。
  387. class _EntryTile extends StatelessWidget {
  388. const _EntryTile({required this.index, required this.entry});
  389. final int index;
  390. final SymjaEntry entry;
  391. @override
  392. Widget build(BuildContext context) {
  393. final scheme = Theme.of(context).colorScheme;
  394. return Padding(
  395. padding: const EdgeInsets.only(bottom: 14),
  396. child: Column(
  397. crossAxisAlignment: CrossAxisAlignment.start,
  398. children: [
  399. SelectableText(
  400. '[$index]: ${entry.input}',
  401. style: TextStyle(
  402. fontFamily: 'monospace',
  403. fontSize: 14,
  404. color: scheme.primary,
  405. ),
  406. ),
  407. const SizedBox(height: 6),
  408. if (entry.ok) _ok(context, scheme) else _err(scheme),
  409. ],
  410. ),
  411. );
  412. }
  413. Widget _ok(BuildContext context, ColorScheme scheme) {
  414. final r = entry.result!;
  415. final plain = SelectableText(
  416. r.text,
  417. style: const TextStyle(fontFamily: 'monospace', fontSize: 16),
  418. );
  419. if (r.tex == null) return plain;
  420. return Column(
  421. crossAxisAlignment: CrossAxisAlignment.start,
  422. children: [
  423. // 同样缩到放得下:控制台的结果可能很长(大矩阵、长小数)
  424. FittedBox(
  425. fit: BoxFit.scaleDown,
  426. alignment: Alignment.centerLeft,
  427. child: Math.tex(
  428. r.tex!,
  429. mathStyle: MathStyle.display,
  430. textStyle: TextStyle(fontSize: 22, color: scheme.onSurface),
  431. // ComplexInfinity 之类的输出不是合法 LaTeX,直接退回纯文本
  432. onErrorFallback: (_) => plain,
  433. ),
  434. ),
  435. const SizedBox(height: 4),
  436. SelectableText(
  437. r.text,
  438. style: TextStyle(
  439. fontFamily: 'monospace',
  440. fontSize: 12,
  441. color: scheme.onSurfaceVariant,
  442. ),
  443. ),
  444. ],
  445. );
  446. }
  447. Widget _err(ColorScheme scheme) => Row(
  448. crossAxisAlignment: CrossAxisAlignment.start,
  449. children: [
  450. Icon(Icons.error_outline, size: 18, color: scheme.error),
  451. const SizedBox(width: 6),
  452. Expanded(
  453. child: SelectableText(
  454. entry.error ?? '未知错误',
  455. style: TextStyle(
  456. fontFamily: 'monospace',
  457. fontSize: 13,
  458. color: scheme.error,
  459. ),
  460. ),
  461. ),
  462. ],
  463. );
  464. }