| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507 |
- 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 'symja.dart';
- class ConsolePage extends StatefulWidget {
- const ConsolePage({super.key});
- @override
- State<ConsolePage> createState() => _ConsolePageState();
- }
- class _ConsolePageState extends State<ConsolePage> {
- // 一行一条,不用分栏 —— 中英文混排在等宽字体里对不齐,分栏只会更乱
- 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 服务器开关''';
- final _ctrl = TextEditingController();
- final _focus = FocusNode();
- final _scroll = ScrollController();
- final _store = SessionStore();
- /// 提交过的原始输入(含命令),供上下翻。
- final List<String> _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<void> _bootstrap() async {
- if (!mounted) return;
- final session = context.read<SymjaSession>();
- 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<void> _persist(SymjaSession session) async {
- final defs = await session.exportSession();
- await _store.save(<String, dynamic>{
- 'defs': defs,
- 'history': session.history
- .where((e) => e.result != null)
- .map((e) => <String, dynamic>{
- '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<void> _submit(String raw) async {
- final input = raw.trim();
- if (input.isEmpty) return;
- final session = context.read<SymjaSession>();
- _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<bool> _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 '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<bool> _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<void>(
- 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<SymjaSession>();
- final entries = session.history;
- return Scaffold(
- appBar: AppBar(
- title: const Text('某人的计算器2'),
- 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'),
- ),
- ],
- ),
- 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,
- ),
- ),
- ),
- ],
- );
- }
|