Files
Rtty/mobile/lib/main.dart
CNWei 7323364b9c feat: 移动端自动重连、颜色渲染与 ENTER 键
- 移动端: 断线自动重连(指数退避, 重连后服务端下发初始快照自动恢复屏幕)
- 服务端: 语义快照新增 segments 着色分段(命名色/256色/RGB), 移动端按段渲染颜色
- 移动端: 终端按 ANSI 16 色调色板 + RGB 渲染前景/背景/粗体/斜体/下划线
- 移动端: Keybar 新增 ENTER 按钮
- 验证: 手机实测彩色输出上屏; claude -p 经同步终端运行并返回 PONG
2026-08-02 19:21:51 +08:00

539 lines
16 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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),
),
);
}
}