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});
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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() {
|
||||
|
||||
// 相同 (前景, 背景, 样式) 的连续单元格归入同一片段;
|
||||
// 风格变化时开启新片段,供移动端逐段着色渲染。
|
||||
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 色索引到 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 文本帧)。
|
||||
|
||||
Reference in New Issue
Block a user