diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart index c27b1bc..d72b41e 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -42,6 +42,7 @@ class _TerminalPageState extends State { String? _sessionId; bool _resumed = false; List _lines = const []; + List> _segments = const []; int _cursorX = 0; int _cursorY = 0; @@ -59,6 +60,7 @@ class _TerminalPageState extends State { ('↓', '\x1b[B'), ('←', '\x1b[D'), ('→', '\x1b[C'), + ('ENTER', '\r'), ]; @override @@ -106,6 +108,7 @@ class _TerminalPageState extends State { if (!mounted) return; setState(() { _lines = snap.lines; + _segments = snap.segments; _cursorX = snap.cursorX; _cursorY = snap.cursorY; }); @@ -343,39 +346,102 @@ class _TerminalPageState extends State { 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 _lineSpans(int index) { + final segs = (index < _segments.length && _segments[index].isNotEmpty) + ? _segments[index] + : [Segment(text: index < _lines.length ? _lines[index] : '')]; + + final spans = []; + 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 { } } +/// 标准 ANSI 16 色调色板(移动端渲染命名色用)。 +const List _ansiPalette = [ + 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}); diff --git a/mobile/lib/src/rtty_mobile_client.dart b/mobile/lib/src/rtty_mobile_client.dart index b1a0cee..2be03b6 100644 --- a/mobile/lib/src/rtty_mobile_client.dart +++ b/mobile/lib/src/rtty_mobile_client.dart @@ -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 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 lines; + final List> segments; factory Snapshot.fromJson(Map json) { + final lines = ((json['lines'] as List?) ?? const []) + .map((l) => l.toString()) + .toList(); + + // 优先解析着色分段;旧服务端无 segments 时退化为纯文本行。 + final rawSegs = json['segments'] as List?; + final segments = >[]; + if (rawSegs != null) { + for (final rawLine in rawSegs) { + final line = []; + if (rawLine is List) { + for (final s in rawLine) { + if (s is Map) { + 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 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; diff --git a/src/terminal/engine.rs b/src/terminal/engine.rs index e6caa2c..48602dd 100644 --- a/src/terminal/engine.rs +++ b/src/terminal/engine.rs @@ -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 = Vec::new(); + let mut cur_style: Option<(Option, Option, 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 { + 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 色索引到 RGB:16-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) + } +} diff --git a/src/ws/protocol.rs b/src/ws/protocol.rs index 028ddf1..8e130a4 100644 --- a/src/ws/protocol.rs +++ b/src/ws/protocol.rs @@ -40,6 +40,23 @@ pub struct MobileSnapshot { pub scrollback_lines: usize, /// 视口内的逐行文本(已按词、去尾空白)。 pub lines: Vec, + /// 视口内逐行的着色片段(与 `lines` 一一对应),供移动端渲染颜色。 + pub segments: Vec>, +} + +/// 一行中的一段同风格文本。 +#[derive(Debug, Clone, Serialize)] +pub struct SnapshotSegment { + /// 文本内容。 + pub text: String, + /// 前景色编码:`null` 表示默认色;`n:5` 表示命名色索引 0-15; + /// `r,g,b` 表示具体 RGB。 + pub fg: Option, + /// 背景色编码,格式同 `fg`。 + pub bg: Option, + pub bold: bool, + pub italic: bool, + pub underline: bool, } /// 服务端推送给客户端的消息(JSON 文本帧)。