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

View File

@@ -1,5 +1,6 @@
import 'dart:async';
import 'dart:convert';
import 'dart:math' as math;
import 'package:web_socket_channel/io.dart';
import 'package:web_socket_channel/web_socket_channel.dart';
@@ -7,6 +8,40 @@ import 'package:web_socket_channel/web_socket_channel.dart';
/// 连接状态。
enum ConnState { disconnected, connecting, connected, error }
/// 一行中的一段同风格文本(对应服务端 `SnapshotSegment`)。
class Segment {
const Segment({
required this.text,
this.fg,
this.bg,
this.bold = false,
this.italic = false,
this.underline = false,
});
final String text;
/// 前景色编码:`null` 默认色;`n:5` 命名色索引;`r,g,b` 具体 RGB。
final String? fg;
/// 背景色编码,格式同 [fg]。
final String? bg;
final bool bold;
final bool italic;
final bool underline;
factory Segment.fromJson(Map<String, dynamic> json) {
return Segment(
text: json['text'] as String? ?? '',
fg: json['fg'] as String?,
bg: json['bg'] as String?,
bold: json['bold'] == true,
italic: json['italic'] == true,
underline: json['underline'] == true,
);
}
}
/// 语义化屏显快照(对应服务端 `MobileSnapshot`)。
class Snapshot {
const Snapshot({
@@ -17,6 +52,7 @@ class Snapshot {
required this.displayOffset,
required this.scrollbackLines,
required this.lines,
required this.segments,
});
final int cursorX;
@@ -26,8 +62,32 @@ class Snapshot {
final int displayOffset;
final int scrollbackLines;
final List<String> lines;
final List<List<Segment>> segments;
factory Snapshot.fromJson(Map<String, dynamic> json) {
final lines = ((json['lines'] as List?) ?? const [])
.map((l) => l.toString())
.toList();
// 优先解析着色分段;旧服务端无 segments 时退化为纯文本行。
final rawSegs = json['segments'] as List?;
final segments = <List<Segment>>[];
if (rawSegs != null) {
for (final rawLine in rawSegs) {
final line = <Segment>[];
if (rawLine is List) {
for (final s in rawLine) {
if (s is Map<String, dynamic>) {
line.add(Segment.fromJson(s));
}
}
}
segments.add(line);
}
} else {
segments.addAll(lines.map((l) => [Segment(text: l)]));
}
return Snapshot(
cursorX: (json['cursor_x'] as num?)?.toInt() ?? 0,
cursorY: (json['cursor_y'] as num?)?.toInt() ?? 0,
@@ -35,17 +95,34 @@ class Snapshot {
rows: (json['rows'] as num?)?.toInt() ?? 0,
displayOffset: (json['display_offset'] as num?)?.toInt() ?? 0,
scrollbackLines: (json['scrollback_lines'] as num?)?.toInt() ?? 0,
lines: ((json['lines'] as List?) ?? const [])
.map((l) => l.toString())
.toList(),
lines: lines,
segments: segments,
);
}
}
/// 一次连接的参数(断线重连时复用)。
class _ConnectParams {
const _ConnectParams({
required this.host,
required this.port,
this.session,
this.token,
});
final String host;
final int port;
final String? session;
final String? token;
}
/// 移动端语义化客户端。
///
/// 连接 Rtty 服务端,消费 `mobile_snapshot` 语义化快照(已去 ANSI、按行切分
/// 并把用户输入 / resize / 控制权指令发回服务端。
/// 连接 Rtty 服务端,消费 `mobile_snapshot` 语义化快照(已去 ANSI、按行切分
/// 带着色片段),并把用户输入 / resize / 控制权指令发回服务端。
///
/// 额外能力:断线自动重连(指数退避),重连成功后服务端会下发初始快照,
/// 屏幕状态自动恢复。
class RttyMobileClient {
WebSocketChannel? _channel;
StreamSubscription? _sub;
@@ -67,6 +144,15 @@ class RttyMobileClient {
Timer? _pingTimer;
/// 用户主动断开:不再自动重连。
bool _manualClose = true;
/// 不可恢复错误(鉴权失败 / 会话结束):不再自动重连。
bool _fatal = false;
int _reconnectAttempts = 0;
Timer? _reconnectTimer;
_ConnectParams? _params;
/// 状态变化回调。
void Function(ConnState state)? onStateChanged;
@@ -86,43 +172,84 @@ class RttyMobileClient {
String? token,
}) async {
await disconnect();
_manualClose = false;
_fatal = false;
_reconnectAttempts = 0;
_params = _ConnectParams(host: host, port: port, session: session, token: token);
_setState(ConnState.connecting);
_error = null;
_openChannel();
}
void _openChannel() {
final p = _params;
if (p == null || _manualClose || _fatal) return;
// 关键:必须声明 client=mobile服务端才会推送语义化快照。
final query = StringBuffer('?client=mobile');
if (token != null && token.isNotEmpty) {
query.write('&token=${Uri.encodeQueryComponent(token)}');
if (p.token != null && p.token!.isNotEmpty) {
query.write('&token=${Uri.encodeQueryComponent(p.token!)}');
}
if (session != null && session.isNotEmpty) {
query.write('&session=${Uri.encodeQueryComponent(session)}');
if (p.session != null && p.session!.isNotEmpty) {
query.write('&session=${Uri.encodeQueryComponent(p.session!)}');
}
final uri = Uri.parse('ws://$host:$port/ws$query');
final uri = Uri.parse('ws://${p.host}:${p.port}/ws$query');
try {
_channel = IOWebSocketChannel.connect(uri);
_sub = _channel!.stream.listen(
final channel = IOWebSocketChannel.connect(uri);
_channel = channel;
_sub = channel.stream.listen(
_handleIncoming,
onError: (Object e) {
if (_manualClose || _fatal) return;
_error = e.toString();
_setState(ConnState.error);
_scheduleReconnect();
},
onDone: () {
if (_state == ConnState.connected) {
_setState(ConnState.disconnected);
}
},
onDone: _handleDone,
);
_setState(ConnState.connected);
_startPing();
} catch (e) {
if (_manualClose || _fatal) return;
_error = e.toString();
_setState(ConnState.error);
_scheduleReconnect();
}
}
void _handleDone() {
_stopPing();
if (_manualClose) {
if (_state == ConnState.connected) _setState(ConnState.disconnected);
return;
}
if (_fatal) {
if (_state != ConnState.error) _setState(ConnState.error);
return;
}
// 网络断开:进入指数退避重连。
if (_state != ConnState.disconnected) _setState(ConnState.disconnected);
_scheduleReconnect();
}
/// 指数退避重连500ms → 1s → 2s → …,上限 10s收到 ready 后重置。
void _scheduleReconnect() {
if (_manualClose || _fatal) return;
_reconnectTimer?.cancel();
final backoffMs =
math.min(500 * (1 << math.min(_reconnectAttempts, 5)), 10000);
_reconnectAttempts++;
_reconnectTimer = Timer(Duration(milliseconds: backoffMs), () {
if (_manualClose || _fatal) return;
_setState(ConnState.connecting);
_openChannel();
});
}
Future<void> disconnect() async {
_manualClose = true;
_reconnectTimer?.cancel();
_reconnectTimer = null;
_stopPing();
await _sub?.cancel();
_sub = null;
@@ -156,6 +283,7 @@ class RttyMobileClient {
_resumed = decoded['resumed'] == true;
final cols = (decoded['cols'] as num?)?.toInt() ?? 0;
final rows = (decoded['rows'] as num?)?.toInt() ?? 0;
_reconnectAttempts = 0; // 重连成功,重置退避。
_setState(ConnState.connected);
onReady?.call(_sessionId ?? '', cols, rows, _resumed);
break;
@@ -167,9 +295,14 @@ class RttyMobileClient {
break;
case 'control_response':
case 'pong':
break;
case 'error':
_error = decoded['message'] as String? ?? 'server error';
_fatal = true; // 鉴权失败等不可恢复错误,不再重连。
_setState(ConnState.error);
break;
case 'session_closed':
_fatal = true; // 会话已结束,不再重连。
onSessionClosed?.call();
_setState(ConnState.disconnected);
break;

View File

@@ -16,7 +16,7 @@ use alacritty_terminal::term::cell::Flags;
use alacritty_terminal::term::{point_to_viewport, viewport_to_point, Config, Term};
use alacritty_terminal::vte::ansi::{Color, Processor};
use crate::ws::protocol::MobileSnapshot;
use crate::ws::protocol::{MobileSnapshot, SnapshotSegment};
/// 终端尺寸,实现 alacritty 的 [`Dimensions`]。
#[derive(Debug, Clone, Copy)]
@@ -119,25 +119,68 @@ impl TerminalEngine {
let cursor = point_to_viewport(display_offset, cursor_point);
let mut lines = Vec::with_capacity(rows);
let mut segments_out = Vec::with_capacity(rows);
for line_idx in 0..rows {
// 将视口内逻辑行号转换为网格行号。
let grid_point = viewport_to_point(display_offset, Point::new(line_idx, Column(0)));
let row = &grid[grid_point.line];
let mut text = String::new();
let mut line_segments: Vec<SnapshotSegment> = Vec::new();
let mut cur_style: Option<(Option<String>, Option<String>, Flags)> = None;
for cell in row {
// 跳过全角字符的占位空格,避免语义化文本中出现多余空白。
if cell.flags.contains(Flags::WIDE_CHAR_SPACER) {
if cell.flags.intersects(
Flags::WIDE_CHAR_SPACER | Flags::LEADING_WIDE_CHAR_SPACER | Flags::HIDDEN,
) {
continue;
}
// 跳过空格的连续尾部会在上层处理;这里仍收集可见字符。
if cell.c != ' ' || !text.is_empty() {
text.push(cell.c);
// 相同 (前景, 背景, 样式) 的连续单元格归入同一片段;
// 风格变化时开启新片段,供移动端逐段着色渲染。
let style = cell.flags & STYLE_FLAGS;
let fg = color_code(cell.fg);
let bg = color_code(cell.bg);
let style_key = (fg.clone(), bg.clone(), style);
if cur_style.as_ref() != Some(&style_key) {
cur_style = Some(style_key);
line_segments.push(SnapshotSegment {
text: String::new(),
fg,
bg,
bold: style.contains(Flags::BOLD),
italic: style.contains(Flags::ITALIC),
underline: style.intersects(Flags::ALL_UNDERLINES),
});
}
if let Some(seg) = line_segments.last_mut() {
seg.text.push(cell.c);
}
text.push(cell.c);
// 零宽字符(组合音标等)跟随主字符。
if let Some(zw) = cell.zerowidth() {
for &c in zw {
if let Some(seg) = line_segments.last_mut() {
seg.text.push(c);
}
text.push(c);
}
}
}
lines.push(text.trim_end().to_string());
// 去掉片段尾部空白:删除末尾整段为空的片段,再修剪最后一段。
while line_segments.last().is_some_and(|s| s.text.trim_end().is_empty()) {
line_segments.pop();
}
if let Some(last) = line_segments.last_mut() {
last.text = last.text.trim_end().to_string();
}
segments_out.push(line_segments);
}
MobileSnapshot {
@@ -148,6 +191,7 @@ impl TerminalEngine {
display_offset,
scrollback_lines: grid.history_size(),
lines,
segments: segments_out,
}
}
@@ -276,3 +320,57 @@ fn push_flags_sgr(out: &mut String, flags: Flags) {
out.push_str(";9");
}
}
/// 将颜色编码为快照字段值:
/// - 命名色 0-15 → `n:k`(移动端用自己的 16 色调色板渲染);
/// - 索引色 → 标准 256 色表映射为 `r,g,b`
/// - 默认前景/背景等特殊色 → `None`(移动端用默认色渲染)。
fn color_code(color: Color) -> Option<String> {
match color {
Color::Named(n) if (n as usize) < 16 => Some(format!("n:{}", n as u8)),
Color::Named(_) => None,
Color::Indexed(i) => {
let (r, g, b) = indexed_to_rgb(i);
Some(format!("{r},{g},{b}"))
}
Color::Spec(rgb) => Some(format!("{},{},{}", rgb.r, rgb.g, rgb.b)),
}
}
/// 标准 256 色索引到 RGB16-231 为 6x6x6 色立方232-255 为灰度。
fn indexed_to_rgb(i: u8) -> (u8, u8, u8) {
const CUBE: [u8; 6] = [0, 95, 135, 175, 215, 255];
if i < 16 {
// 理论上前 16 色走 Named 分支,这里兜底。
const BASE: [[u8; 3]; 16] = [
[0, 0, 0],
[128, 0, 0],
[0, 128, 0],
[128, 128, 0],
[0, 0, 128],
[128, 0, 128],
[0, 128, 128],
[192, 192, 192],
[128, 128, 128],
[255, 0, 0],
[0, 255, 0],
[255, 255, 0],
[0, 0, 255],
[255, 0, 255],
[0, 255, 255],
[255, 255, 255],
];
let c = BASE[i as usize];
(c[0], c[1], c[2])
} else if i < 232 {
let n = i - 16;
(
CUBE[(n / 36) as usize],
CUBE[((n % 36) / 6) as usize],
CUBE[(n % 6) as usize],
)
} else {
let g = 8 + (i - 232) * 10;
(g, g, g)
}
}

View File

@@ -40,6 +40,23 @@ pub struct MobileSnapshot {
pub scrollback_lines: usize,
/// 视口内的逐行文本(已按词、去尾空白)。
pub lines: Vec<String>,
/// 视口内逐行的着色片段(与 `lines` 一一对应),供移动端渲染颜色。
pub segments: Vec<Vec<SnapshotSegment>>,
}
/// 一行中的一段同风格文本。
#[derive(Debug, Clone, Serialize)]
pub struct SnapshotSegment {
/// 文本内容。
pub text: String,
/// 前景色编码:`null` 表示默认色;`n:5` 表示命名色索引 0-15
/// `r,g,b` 表示具体 RGB。
pub fg: Option<String>,
/// 背景色编码,格式同 `fg`。
pub bg: Option<String>,
pub bold: bool,
pub italic: bool,
pub underline: bool,
}
/// 服务端推送给客户端的消息JSON 文本帧)。