feat: 移动端自动重连、颜色渲染与 ENTER 键

- 移动端: 断线自动重连(指数退避, 重连后服务端下发初始快照自动恢复屏幕)
- 服务端: 语义快照新增 segments 着色分段(命名色/256色/RGB), 移动端按段渲染颜色
- 移动端: 终端按 ANSI 16 色调色板 + RGB 渲染前景/背景/粗体/斜体/下划线
- 移动端: Keybar 新增 ENTER 按钮
- 验证: 手机实测彩色输出上屏; claude -p 经同步终端运行并返回 PONG
This commit is contained in:
2026-08-02 19:21:51 +08:00
parent 86d399c706
commit 7323364b9c
4 changed files with 381 additions and 47 deletions

View File

@@ -42,6 +42,7 @@ class _TerminalPageState extends State<TerminalPage> {
String? _sessionId;
bool _resumed = false;
List<String> _lines = const [];
List<List<Segment>> _segments = const [];
int _cursorX = 0;
int _cursorY = 0;
@@ -59,6 +60,7 @@ class _TerminalPageState extends State<TerminalPage> {
('', '\x1b[B'),
('', '\x1b[D'),
('', '\x1b[C'),
('ENTER', '\r'),
];
@override
@@ -106,6 +108,7 @@ class _TerminalPageState extends State<TerminalPage> {
if (!mounted) return;
setState(() {
_lines = snap.lines;
_segments = snap.segments;
_cursorX = snap.cursorX;
_cursorY = snap.cursorY;
});
@@ -343,39 +346,102 @@ class _TerminalPageState extends State<TerminalPage> {
height: 1.25,
color: RttyMobileTheme.text,
);
if (index != _cursorY) {
return SizedBox(height: _lineHeight, child: Text(line, style: mono));
}
// 光标行:用高亮色块标出光标位置。
final x = _cursorX.clamp(0, line.length);
return SizedBox(
height: _lineHeight,
child: Text.rich(
TextSpan(
style: mono,
children: [
TextSpan(text: line.substring(0, x)),
if (x < line.length)
TextSpan(
text: line[x],
style: const TextStyle(
color: RttyMobileTheme.background,
backgroundColor: RttyMobileTheme.primary,
),
)
else
const TextSpan(
text: ' ',
style: TextStyle(backgroundColor: RttyMobileTheme.primary),
),
if (x + 1 < line.length) TextSpan(text: line.substring(x + 1)),
],
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),
@@ -422,6 +488,26 @@ class _TerminalPageState extends State<TerminalPage> {
}
}
/// 标准 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});