- 移动端: 断线自动重连(指数退避, 重连后服务端下发初始快照自动恢复屏幕) - 服务端: 语义快照新增 segments 着色分段(命名色/256色/RGB), 移动端按段渲染颜色 - 移动端: 终端按 ANSI 16 色调色板 + RGB 渲染前景/背景/粗体/斜体/下划线 - 移动端: Keybar 新增 ENTER 按钮 - 验证: 手机实测彩色输出上屏; claude -p 经同步终端运行并返回 PONG
539 lines
16 KiB
Dart
539 lines
16 KiB
Dart
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<TerminalPage> createState() => _TerminalPageState();
|
||
}
|
||
|
||
class _TerminalPageState extends State<TerminalPage> {
|
||
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<String> _lines = const [];
|
||
List<List<Segment>> _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<void> _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<void> _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<TextSpan> _lineSpans(int index) {
|
||
final segs = (index < _segments.length && _segments[index].isNotEmpty)
|
||
? _segments[index]
|
||
: <Segment>[Segment(text: index < _lines.length ? _lines[index] : '')];
|
||
|
||
final spans = <TextSpan>[];
|
||
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<Color> _ansiPalette = <Color>[
|
||
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),
|
||
),
|
||
);
|
||
}
|
||
}
|