import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_math_fork/flutter_math.dart'; import 'package:provider/provider.dart'; import 'curve_plot.dart'; import 'mcp.dart'; import 'session_store.dart'; import 'settings.dart'; import 'symja.dart'; class ConsolePage extends StatefulWidget { const ConsolePage({super.key}); @override State createState() => _ConsolePageState(); } class _ConsolePageState extends State { // 一行一条,不用分栏 —— 中英文混排在等宽字体里对不齐,分栏只会更乱 static const _helpText = ''' 输入任意 Symja 表达式,例如 Integrate[x^2, x] Solve[x^2-1==0, x] a = 42 % 表示上一次求值的结果。 变量、函数定义和历史都会自动保存,重启后恢复。 命令(小写;大写的是 Symja 函数) help 或 ? 显示这份帮助 clear 或 cls 清空历史 clear 变量名 删除某个变量 reset 清空全部变量和历史 history 列出输入过的表达式 vars 列出当前变量 plot 表达式 画图(x 从 -6.3 到 6.3) N 表达式 [位数] 取近似值,默认 15 位 mcp MCP 服务器开关 settings 设置(MCP 端口、E-Ink 模式)'''; final _ctrl = TextEditingController(); final _focus = FocusNode(); final _scroll = ScrollController(); final _store = SessionStore(); /// 提交过的原始输入(含命令),供上下翻。 final List _typed = []; int _recall = -1; @override void initState() { super.initState(); WidgetsBinding.instance.addPostFrameCallback((_) => _bootstrap()); } @override void dispose() { _ctrl.dispose(); _focus.dispose(); _scroll.dispose(); super.dispose(); } // ------------------------------------------------------------------ 启动恢复 Future _bootstrap() async { if (!mounted) return; final session = context.read(); final data = await _store.load(); if (data != null) { final defs = ((data['defs'] as List?) ?? const []).map((e) => '$e').toList(); final r = await session.importSession(defs); final hist = (data['history'] as List?) ?? const []; session.restoreHistory(hist.map((h) { final m = h as Map; return ( input: '${m['input']}', text: '${m['text']}', ms: (m['ms'] as num?)?.toInt() ?? 0, ); })); if (defs.isNotEmpty && mounted) { session.addLocal( '(会话恢复)', text: '恢复了 ${r.applied} 个定义' '${r.failed.isEmpty ? '' : ',${r.failed.length} 个失败'}', ); } } await session.refreshVariables(); if (mounted) _scrollToEnd(); } // ------------------------------------------------------------------ 持久化 /// 注意:session 由调用方传进来,不在这里 context.read —— /// 本方法会在 await 之后被调用,那时页面可能已经销毁。 Future _persist(SymjaSession session) async { final defs = await session.exportSession(); await _store.save({ 'defs': defs, 'history': session.history .where((e) => e.result != null) .map((e) => { 'input': e.input, 'text': e.result!.text, 'ms': e.result!.elapsedMs, }) .toList(), }); } // ------------------------------------------------------------------ 输入 void _recallPrev() { if (_typed.isEmpty) return; setState(() { _recall = _recall < 0 ? _typed.length - 1 : (_recall - 1).clamp(0, _typed.length - 1); _ctrl.text = _typed[_recall]; _ctrl.selection = TextSelection.collapsed(offset: _ctrl.text.length); }); } void _recallNext() { if (_recall < 0) return; setState(() { _recall++; if (_recall >= _typed.length) { _recall = -1; _ctrl.text = ''; } else { _ctrl.text = _typed[_recall]; _ctrl.selection = TextSelection.collapsed(offset: _ctrl.text.length); } }); } Future _submit(String raw) async { final input = raw.trim(); if (input.isEmpty) return; final session = context.read(); _typed.add(input); _recall = -1; _ctrl.clear(); final handled = await _runCommand(input, session); if (!handled) { try { await session.evaluate(input); } on SymjaException { // 失败已记进 session.history,不需要额外处理 } await session.refreshVariables(); } await _persist(session); _scrollToEnd(); } /// 返回 true 表示是控制台命令、已经处理掉了。 Future _runCommand(String input, SymjaSession s) async { final parts = input.split(RegExp(r'\s+')); switch (parts.first) { case 'help': case '?': s.addLocal(input, text: _helpText); return true; case 'cls': s.clearHistory(); return true; case 'clear': if (parts.length == 1) { s.clearHistory(); } else { await s.clearVariable(parts[1]); s.addLocal(input, text: '已清除 ${parts[1]}'); } return true; case 'reset': await s.clearAll(); _typed.clear(); await _persist(s); s.addLocal(input, text: '已清空全部变量与历史'); return true; case 'history': s.addLocal( input, text: _typed.isEmpty ? '(无)' : [ for (var i = 0; i < _typed.length; i++) '[${i + 1}] ${_typed[i]}', ].join('\n'), ); return true; case 'vars': await s.refreshVariables(); s.addLocal( input, text: s.variables.isEmpty ? '(无变量)' : s.variables.map((v) => '${v.name} = ${v.value}').join('\n'), ); return true; case 'mcp': if (mounted) await showMcpPanel(context); return true; case 'settings': if (mounted) await showSettingsPanel(context); return true; case 'plot': final expr = input.substring('plot'.length).trim(); if (expr.isEmpty) { s.addLocal(input, error: '用法:plot <表达式>'); return true; } final ok = await _plot(s, expr); if (ok) { s.addLocal(input, text: '$expr (x ∈ [-6.3, 6.3],240 个采样点)'); } return true; case 'N': if (parts.length < 2) { s.addLocal(input, error: '用法:N <表达式> [位数]'); return true; } final digits = parts.length >= 3 ? parts.last : '30'; final expr = parts.length >= 3 ? parts.sublist(1, parts.length - 1).join(' ') : parts.sublist(1).join(' '); try { await s.evaluate('N[$expr, $digits]'); } on SymjaException { // 同上 } await s.refreshVariables(); return true; } return false; } Future _plot(SymjaSession session, String expr) async { try { final s = await session.sample(expr, from: -6.3, to: 6.3, count: 240); if (!mounted) return false; final plot = CurveSeries( label: expr, color: const Color(0xFF3F51B5), points: [ for (var i = 0; i < s.ys.length; i++) Offset(s.xAt(i), s.ys[i]), ], ); await showDialog( context: context, builder: (_) => AlertDialog( title: Text('$expr (${s.ys.length} 点,${s.elapsedMs} ms)'), content: SizedBox( width: 360, height: 420, child: CurvePlot(series: [plot]), ), actions: [ TextButton( onPressed: () => Navigator.pop(context), child: const Text('关闭'), ), ], ), ); return true; } on SymjaException catch (e) { session.addLocal('plot $expr', error: '$e'); return false; } } void _scrollToEnd() { WidgetsBinding.instance.addPostFrameCallback((_) { if (!_scroll.hasClients) return; _scroll.jumpTo(_scroll.position.maxScrollExtent); }); } // ------------------------------------------------------------------ UI @override Widget build(BuildContext context) { final session = context.watch(); final entries = session.history; return Scaffold( appBar: AppBar( // 不显示标题 —— 三个 tab 里只有这页还挂着应用名,切页面时顶栏 // 忽长忽短。应用名在启动器和最近任务里都看得到,这里没必要占一行。 actions: [ IconButton( tooltip: 'MCP 服务器', icon: const Icon(Icons.cloud_outlined), onPressed: () => showMcpPanel(context), ), IconButton( tooltip: '变量', icon: Badge( isLabelVisible: session.variables.isNotEmpty, label: Text('${session.variables.length}'), child: const Icon(Icons.functions), ), onPressed: () => _submit('vars'), ), IconButton( tooltip: '帮助', icon: const Icon(Icons.help_outline), onPressed: () => _submit('help'), ), IconButton( tooltip: '清空历史', icon: const Icon(Icons.cleaning_services_outlined), onPressed: () => _submit('cls'), ), IconButton( tooltip: '设置', icon: const Icon(Icons.settings_outlined), onPressed: () => showSettingsPanel(context), ), ], ), body: Column( children: [ Expanded( child: entries.isEmpty ? const Center(child: Text('输入表达式开始,或输入 help')) : ListView.builder( controller: _scroll, padding: const EdgeInsets.fromLTRB(12, 12, 12, 4), itemCount: entries.length, itemBuilder: (_, i) => _EntryTile(index: i + 1, entry: entries[i]), ), ), SafeArea(top: false, child: _inputBar(session)), ], ), ); } Widget _inputBar(SymjaSession session) { return Container( decoration: BoxDecoration( border: Border( top: BorderSide(color: Theme.of(context).dividerColor), ), ), padding: const EdgeInsets.fromLTRB(8, 6, 8, 6), child: Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ Column( mainAxisSize: MainAxisSize.min, children: [ _tinyBtn(Icons.keyboard_arrow_up, '上一条', _recallPrev), _tinyBtn(Icons.keyboard_arrow_down, '下一条', _recallNext), ], ), const SizedBox(width: 4), Text( '[${session.history.length + 1}]:', style: const TextStyle(fontFamily: 'monospace', fontSize: 13), ), const SizedBox(width: 6), Expanded( // 用 Focus 捕获方向键做历史回翻。输入框是单行的, // 上下键不会被光标移动占用,所以能冒泡到这里。 child: Focus( onKeyEvent: (_, e) { if (e is! KeyDownEvent) return KeyEventResult.ignored; if (e.logicalKey == LogicalKeyboardKey.arrowUp) { _recallPrev(); return KeyEventResult.handled; } if (e.logicalKey == LogicalKeyboardKey.arrowDown) { _recallNext(); return KeyEventResult.handled; } return KeyEventResult.ignored; }, child: TextField( controller: _ctrl, focusNode: _focus, style: const TextStyle(fontFamily: 'monospace', fontSize: 15), decoration: const InputDecoration( isDense: true, border: InputBorder.none, hintText: '输入表达式或 help', ), onSubmitted: _submit, ), ), ), if (session.busy) IconButton( tooltip: '取消', icon: const Icon(Icons.stop_circle_outlined), onPressed: session.cancel, ) else IconButton( tooltip: '求值', icon: const Icon(Icons.send), onPressed: () => _submit(_ctrl.text), ), ], ), ); } Widget _tinyBtn(IconData icon, String tip, VoidCallback onTap) => InkWell( onTap: onTap, child: Tooltip( message: tip, child: Icon(icon, size: 18), ), ); } /// 历史里的一条:`[n]: 输入` + 结果(LaTeX 渲染 + 纯文本)或错误。 class _EntryTile extends StatelessWidget { const _EntryTile({required this.index, required this.entry}); final int index; final SymjaEntry entry; @override Widget build(BuildContext context) { final scheme = Theme.of(context).colorScheme; return Padding( padding: const EdgeInsets.only(bottom: 14), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ SelectableText( '[$index]: ${entry.input}', style: TextStyle( fontFamily: 'monospace', fontSize: 14, color: scheme.primary, ), ), const SizedBox(height: 6), if (entry.ok) _ok(context, scheme) else _err(scheme), ], ), ); } Widget _ok(BuildContext context, ColorScheme scheme) { final r = entry.result!; final plain = SelectableText( r.text, style: const TextStyle(fontFamily: 'monospace', fontSize: 16), ); if (r.tex == null) return plain; return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // 同样缩到放得下:控制台的结果可能很长(大矩阵、长小数) FittedBox( fit: BoxFit.scaleDown, alignment: Alignment.centerLeft, child: Math.tex( r.tex!, mathStyle: MathStyle.display, textStyle: TextStyle(fontSize: 22, color: scheme.onSurface), // ComplexInfinity 之类的输出不是合法 LaTeX,直接退回纯文本 onErrorFallback: (_) => plain, ), ), const SizedBox(height: 4), SelectableText( r.text, style: TextStyle( fontFamily: 'monospace', fontSize: 12, color: scheme.onSurfaceVariant, ), ), ], ); } Widget _err(ColorScheme scheme) => Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Icon(Icons.error_outline, size: 18, color: scheme.error), const SizedBox(width: 6), Expanded( child: SelectableText( entry.error ?? '未知错误', style: TextStyle( fontFamily: 'monospace', fontSize: 13, color: scheme.error, ), ), ), ], ); }