feat: 移动端自动重连、颜色渲染与 ENTER 键
- 移动端: 断线自动重连(指数退避, 重连后服务端下发初始快照自动恢复屏幕) - 服务端: 语义快照新增 segments 着色分段(命名色/256色/RGB), 移动端按段渲染颜色 - 移动端: 终端按 ANSI 16 色调色板 + RGB 渲染前景/背景/粗体/斜体/下划线 - 移动端: Keybar 新增 ENTER 按钮 - 验证: 手机实测彩色输出上屏; claude -p 经同步终端运行并返回 PONG
This commit is contained in:
@@ -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});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user