import 'package:flutter/material.dart'; import 'src/rtty_mobile_client.dart'; import 'src/theme.dart'; void main() { runApp(const RttyMobileApp()); } class RttyMobileApp extends StatelessWidget { const RttyMobileApp({super.key}); @override Widget build(BuildContext context) { return MaterialApp( title: 'Rtty Mobile', debugShowCheckedModeBanner: false, theme: RttyMobileTheme.app(), home: const TerminalPage(), ); } } /// 最小验证页:连接 Rtty 服务端,消费语义化快照实时显示终端内容, /// 并支持输入命令 + 常用按键(ESC/TAB/Ctrl/方向键)做端到端同步验证。 class TerminalPage extends StatefulWidget { const TerminalPage({super.key}); @override State createState() => _TerminalPageState(); } class _TerminalPageState extends State { final RttyMobileClient _client = RttyMobileClient(); final TextEditingController _serverCtrl = TextEditingController(); final TextEditingController _tokenCtrl = TextEditingController(); final TextEditingController _sessionCtrl = TextEditingController(); final TextEditingController _inputCtrl = TextEditingController(); final ScrollController _scrollCtrl = ScrollController(); ConnState _state = ConnState.disconnected; String? _sessionId; bool _resumed = false; List _lines = const []; List> _segments = const []; int _cursorX = 0; int _cursorY = 0; /// 终端行高(逻辑像素),与 [_buildLine] 的字号/行高保持一致, /// 用于“让光标行保持可见”的滚动计算。 static const double _lineHeight = 17.0; /// 常用按键:标签 -> 发送到 PTY 的字节序列。 static const List<(String, String)> _quickKeys = [ ('ESC', '\x1b'), ('TAB', '\t'), ('CTRL+C', '\x03'), ('CTRL+L', '\x0c'), ('↑', '\x1b[A'), ('↓', '\x1b[B'), ('←', '\x1b[D'), ('→', '\x1b[C'), ('ENTER', '\r'), ]; @override void initState() { super.initState(); _client.onStateChanged = _onStateChanged; _client.onReady = (id, cols, rows, resumed) { if (!mounted) return; setState(() { _sessionId = id; _resumed = resumed; _state = ConnState.connected; }); }; _client.onSnapshot = _onSnapshot; _client.onSessionClosed = () { if (!mounted) return; setState(() => _state = ConnState.disconnected); }; } @override void dispose() { _client.disconnect(); _serverCtrl.dispose(); _tokenCtrl.dispose(); _sessionCtrl.dispose(); _inputCtrl.dispose(); _scrollCtrl.dispose(); super.dispose(); } void _onStateChanged(ConnState s) { if (!mounted) return; setState(() { _state = s; if (s == ConnState.disconnected || s == ConnState.error) { _sessionId = null; _resumed = false; } }); } void _onSnapshot(Snapshot snap) { if (!mounted) return; setState(() { _lines = snap.lines; _segments = snap.segments; _cursorX = snap.cursorX; _cursorY = snap.cursorY; }); // 终端语义:让光标行始终可见(尽量靠近视口底部),而不是盲目滚到 // 列表末尾——网格下半部分通常是空行,滚到底会把内容顶出屏幕。 WidgetsBinding.instance.addPostFrameCallback((_) { if (!_scrollCtrl.hasClients) return; final viewport = _scrollCtrl.position.viewportDimension; final max = _scrollCtrl.position.maxScrollExtent; final target = ((_cursorY + 1) * _lineHeight) - viewport; _scrollCtrl.jumpTo(target.clamp(0.0, max)); }); } (String, int) _parseServer() { final raw = _serverCtrl.text.trim(); if (raw.isEmpty) return ('', 8080); final idx = raw.lastIndexOf(':'); if (idx <= 0) return (raw, 8080); final port = int.tryParse(raw.substring(idx + 1)) ?? 8080; return (raw.substring(0, idx), port); } Future _connect() async { final (host, port) = _parseServer(); if (host.isEmpty) { _showSnack('请输入服务端地址,如 192.168.1.100:8080'); return; } setState(() => _state = ConnState.connecting); await _client.connect( host: host, port: port, session: _sessionCtrl.text.trim().isEmpty ? null : _sessionCtrl.text.trim(), token: _tokenCtrl.text.trim().isEmpty ? null : _tokenCtrl.text.trim(), ); } Future _disconnect() async { await _client.disconnect(); } void _send(String data) { if (!_client.isConnected) return; _client.sendInput(data); } void _sendLine() { if (_inputCtrl.text.isEmpty) return; _send('${_inputCtrl.text}\r'); _inputCtrl.clear(); } void _showSnack(String msg) { if (!mounted) return; ScaffoldMessenger.of(context) .showSnackBar(SnackBar(content: Text(msg), duration: const Duration(seconds: 2))); } @override Widget build(BuildContext context) { final connected = _client.isConnected; return Scaffold( body: SafeArea( child: Column( children: [ _buildConnectionPanel(connected), const Divider(height: 1, color: RttyMobileTheme.border), Expanded(child: _buildTerminal()), _buildKeybar(connected), _buildInputRow(connected), ], ), ), ); } Widget _buildConnectionPanel(bool connected) { return Padding( padding: const EdgeInsets.fromLTRB(12, 12, 12, 8), child: Column( children: [ Row( children: [ Expanded( child: TextField( controller: _serverCtrl, enabled: !connected, style: const TextStyle(fontFamily: 'monospace', fontSize: 14), decoration: const InputDecoration( labelText: 'SERVER', hintText: '192.168.1.100:8080', isDense: true, ), ), ), const SizedBox(width: 8), SizedBox( width: 110, child: TextField( controller: _tokenCtrl, enabled: !connected, obscureText: true, style: const TextStyle(fontFamily: 'monospace', fontSize: 14), decoration: const InputDecoration( labelText: 'TOKEN', hintText: '可选', isDense: true, ), ), ), const SizedBox(width: 8), FilledButton( onPressed: connected ? _disconnect : _connect, style: FilledButton.styleFrom( backgroundColor: connected ? RttyMobileTheme.danger : RttyMobileTheme.primary, foregroundColor: connected ? Colors.white : RttyMobileTheme.background, ), child: Text(connected ? '断开' : '连接'), ), ], ), const SizedBox(height: 8), Row( children: [ Expanded( child: TextField( controller: _sessionCtrl, enabled: !connected, style: const TextStyle(fontFamily: 'monospace', fontSize: 14), decoration: const InputDecoration( labelText: 'SESSION', hintText: '留空自动新建', isDense: true, ), ), ), const SizedBox(width: 8), Expanded(child: _buildStatus()), ], ), ], ), ); } Widget _buildStatus() { final stateText = switch (_state) { ConnState.disconnected => '未连接', ConnState.connecting => '连接中…', ConnState.connected => '已连接', ConnState.error => '连接失败', }; final color = switch (_state) { ConnState.connected => RttyMobileTheme.primary, ConnState.connecting => RttyMobileTheme.accent, ConnState.error => RttyMobileTheme.danger, ConnState.disconnected => RttyMobileTheme.textFaint, }; final session = (_sessionId == null || _sessionId!.isEmpty) ? '' : ' · ${_resumed ? '恢复' : '新建'}:$_sessionId'; final err = (_state == ConnState.error && _client.error != null) ? '\n${_client.error}' : ''; return Text( '$stateText$session$err', maxLines: 2, overflow: TextOverflow.ellipsis, style: TextStyle( color: color, fontSize: 12, fontFamily: 'monospace', ), ); } Widget _buildTerminal() { if (!_client.isConnected) { final (title, sub) = switch (_state) { ConnState.error => ( '连接失败', _client.error ?? '请检查地址、令牌与服务端是否启动', ), ConnState.connecting => ('连接中', '正在协商 WebSocket 会话…'), _ => ('未连接', '输入服务端地址后点击「连接」'), }; return Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ Icon( Icons.terminal, size: 48, color: _state == ConnState.error ? RttyMobileTheme.danger : RttyMobileTheme.textFaint, ), const SizedBox(height: 12), Text(title, style: TextStyle( color: RttyMobileTheme.textDim, fontSize: 16, fontWeight: FontWeight.w600)), const SizedBox(height: 6), Text(sub, textAlign: TextAlign.center, style: const TextStyle( color: RttyMobileTheme.textFaint, fontSize: 12)), ], ), ); } return Container( color: RttyMobileTheme.background, padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), child: ListView.builder( controller: _scrollCtrl, itemCount: _lines.length, itemBuilder: (context, i) => _buildLine(i), ), ); } Widget _buildLine(int index) { final line = index < _lines.length ? _lines[index] : ''; final mono = const TextStyle( fontFamily: 'monospace', fontSize: 13, height: 1.25, color: RttyMobileTheme.text, ); return SizedBox( height: _lineHeight, child: Text.rich( TextSpan( style: mono, children: _lineSpans(index), ), ), ); } /// 按着色片段生成文本 spans;光标行额外叠加高亮光标块。 List _lineSpans(int index) { final segs = (index < _segments.length && _segments[index].isNotEmpty) ? _segments[index] : [Segment(text: index < _lines.length ? _lines[index] : '')]; final spans = []; var offset = 0; var cursorPlaced = false; for (final seg in segs) { if (seg.text.isEmpty) continue; final textLen = seg.text.length; if (!cursorPlaced && index == _cursorY && _cursorX >= offset && _cursorX <= offset + textLen) { final cut = _cursorX - offset; if (cut > 0) { spans.add(TextSpan(text: seg.text.substring(0, cut), style: _segStyle(seg))); } // 光标块:覆盖该列字符(行尾则补一个空格块)。 final cursorChar = cut < textLen ? seg.text[cut] : ' '; spans.add(TextSpan( text: cursorChar, style: const TextStyle( color: RttyMobileTheme.background, backgroundColor: RttyMobileTheme.primary, fontWeight: FontWeight.bold, ), )); if (cut + 1 < textLen) { spans.add(TextSpan(text: seg.text.substring(cut + 1), style: _segStyle(seg))); } cursorPlaced = true; } else { spans.add(TextSpan(text: seg.text, style: _segStyle(seg))); } offset += textLen; } // 光标位于行文本之外(例如整行被去尾空白)时,行尾追加光标块。 if (!cursorPlaced && index == _cursorY && _cursorX >= (index < _lines.length ? _lines[index].length : 0)) { spans.add(const TextSpan( text: ' ', style: TextStyle(backgroundColor: RttyMobileTheme.primary), )); } return spans; } TextStyle _segStyle(Segment seg) { return TextStyle( fontFamily: 'monospace', fontSize: 13, height: 1.25, color: _colorFromCode(seg.fg) ?? RttyMobileTheme.text, backgroundColor: _colorFromCode(seg.bg), fontWeight: seg.bold ? FontWeight.bold : null, fontStyle: seg.italic ? FontStyle.italic : null, decoration: seg.underline ? TextDecoration.underline : null, ); } /// 把服务端的颜色编码(`null` / `n:k` / `r,g,b`)解析为 Flutter 颜色。 Color? _colorFromCode(String? code) { if (code == null) return null; if (code.startsWith('n:')) { final i = int.tryParse(code.substring(2)) ?? 0; return _ansiPalette[i.clamp(0, 15)]; } final parts = code.split(','); if (parts.length == 3) { final r = int.tryParse(parts[0]); final g = int.tryParse(parts[1]); final b = int.tryParse(parts[2]); if (r != null && g != null && b != null) { return Color.fromARGB(255, r, g, b); } } return null; } Widget _buildKeybar(bool connected) { return Padding( padding: const EdgeInsets.fromLTRB(12, 8, 12, 0), child: Wrap( spacing: 6, runSpacing: 6, children: [ for (final (label, seq) in _quickKeys) _KeyButton( label: label, onTap: connected ? () => _send(seq) : null, ), ], ), ); } Widget _buildInputRow(bool connected) { return Padding( padding: const EdgeInsets.fromLTRB(12, 8, 12, 12), child: Row( children: [ Expanded( child: TextField( controller: _inputCtrl, enabled: connected, style: const TextStyle(fontFamily: 'monospace', fontSize: 14), textInputAction: TextInputAction.send, onSubmitted: (_) => _sendLine(), decoration: const InputDecoration( hintText: '输入命令,回车发送', isDense: true, ), ), ), const SizedBox(width: 8), FilledButton( onPressed: connected ? _sendLine : null, child: const Text('发送'), ), ], ), ); } } /// 标准 ANSI 16 色调色板(移动端渲染命名色用)。 const List _ansiPalette = [ Color(0xFF1E1E1E), // black Color(0xFFCD3131), // red Color(0xFF0DBC79), // green Color(0xFFE5E510), // yellow Color(0xFF2472C8), // blue Color(0xFFBC3FBC), // magenta Color(0xFF11A8CD), // cyan Color(0xFFE5E5E5), // white Color(0xFF666666), // bright black Color(0xFFF14C4C), // bright red Color(0xFF23D18B), // bright green Color(0xFFF5F543), // bright yellow Color(0xFF3B8EEA), // bright blue Color(0xFFD670D6), // bright magenta Color(0xFF29B8DB), // bright cyan Color(0xFFFFFFFF), // bright white ]; class _KeyButton extends StatelessWidget { const _KeyButton({required this.label, this.onTap}); final String label; final VoidCallback? onTap; @override Widget build(BuildContext context) { return SizedBox( height: 30, child: OutlinedButton( onPressed: onTap, style: OutlinedButton.styleFrom( padding: const EdgeInsets.symmetric(horizontal: 10), side: BorderSide( color: onTap == null ? RttyMobileTheme.border : RttyMobileTheme.primaryDim, ), foregroundColor: onTap == null ? RttyMobileTheme.textFaint : RttyMobileTheme.primary, textStyle: const TextStyle(fontSize: 11, fontFamily: 'monospace'), ), child: Text(label), ), ); } }