feat: replace xterm with flutter_alacritty for true industrial-grade rendering

This commit is contained in:
2026-08-01 19:53:30 +08:00
parent 7e4236e2f3
commit 562662c1bd
12 changed files with 744 additions and 375 deletions

View File

@@ -1,6 +1,6 @@
import 'package:flutter/material.dart';
import 'rtty_client.dart';
import 'rtty_pty_backend.dart';
import 'theme.dart';
/// 顶部连接控制条。

View File

@@ -1,167 +0,0 @@
import 'dart:async';
import 'dart:convert';
import 'package:web_socket_channel/io.dart';
import 'package:web_socket_channel/web_socket_channel.dart';
import 'package:xterm/xterm.dart';
/// 连接状态。
enum ConnState { disconnected, connecting, connected, error }
/// WebSocket 终端客户端。
///
/// 负责与 Rtty 服务端建立连接:
/// - 消费服务端的 JSON 控制帧ready / snapshot / control / error / session_closed
/// - 把 PTY 输出的原始 ANSI 二进制帧交给 [Terminal] 渲染;
/// - 把终端产生的输出(用户键入 + 控制序列响应)以 `input` 消息发回服务端。
class RttyClient {
RttyClient(this.terminal) {
// xterm 需要把终端输出(键入、控制序列响应)回传给后端。
terminal.onOutput = _handleTerminalOutput;
}
final Terminal terminal;
WebSocketChannel? _channel;
StreamSubscription? _sub;
ConnState _state = ConnState.disconnected;
ConnState get state => _state;
String? _sessionId;
String? get sessionId => _sessionId;
String? _error;
String? get error => _error;
bool get isConnected => _state == ConnState.connected;
/// 状态变化回调。
void Function(ConnState state)? onStateChanged;
/// 就绪回调(收到 ready 帧,携带会话 ID 与初始尺寸)。
void Function(String id, int cols, int rows)? onReady;
/// 会话结束回调。
void Function()? onSessionClosed;
/// 原始 ANSI 数据回调(用于调试 / 集成测试)。
void Function(String data)? onRawData;
/// 连接到服务端。
Future<void> connect({
required String host,
required int port,
String? session,
}) async {
await disconnect();
_setState(ConnState.connecting);
_error = null;
final query = session != null && session.isNotEmpty
? '?session=${Uri.encodeQueryComponent(session)}'
: '';
final uri = Uri.parse('ws://$host:$port/ws$query');
try {
_channel = IOWebSocketChannel.connect(uri);
_sub = _channel!.stream.listen(
_handleIncoming,
onError: (Object e) {
_error = e.toString();
_setState(ConnState.error);
},
onDone: () {
if (_state == ConnState.connected) {
_setState(ConnState.disconnected);
}
},
);
_setState(ConnState.connected);
} catch (e) {
_error = e.toString();
_setState(ConnState.error);
}
}
/// 断开连接。
Future<void> disconnect() async {
await _sub?.cancel();
_sub = null;
await _channel?.sink.close();
_channel = null;
_sessionId = null;
if (_state != ConnState.disconnected) {
_setState(ConnState.disconnected);
}
}
void _handleIncoming(dynamic message) {
if (message is List<int>) {
// PC 端原始 ANSI 字节流。
final text = utf8.decode(message, allowMalformed: true);
onRawData?.call(text);
terminal.write(text);
} else if (message is String) {
_handleJsonFrame(message);
}
}
void _handleJsonFrame(String text) {
final Object? decoded;
try {
decoded = jsonDecode(text);
} catch (_) {
return;
}
if (decoded is! Map<String, dynamic>) return;
switch (decoded['type']) {
case 'ready':
final id = decoded['id'] as String? ?? '';
final cols = (decoded['cols'] as num?)?.toInt() ?? terminal.viewWidth;
final rows = (decoded['rows'] as num?)?.toInt() ?? terminal.viewHeight;
_sessionId = id;
_setState(ConnState.connected);
onReady?.call(id, cols, rows);
case 'mobile_snapshot':
// 桌面端使用原始 ANSI 渲染,忽略语义化快照。
break;
case 'control_response':
case 'error':
case 'session_closed':
onSessionClosed?.call();
_setState(ConnState.disconnected);
default:
break;
}
}
/// xterm 需要把终端输出回传给后端。
void _handleTerminalOutput(String data) {
if (!isConnected || _channel == null) return;
final frame = jsonEncode({'type': 'input', 'data': data});
_channel!.sink.add(frame);
}
/// 发送 resize 指令。
void sendResize(int cols, int rows) {
if (!isConnected || _channel == null) return;
final frame = jsonEncode({'type': 'resize', 'cols': cols, 'rows': rows});
_channel!.sink.add(frame);
}
/// 发送原始输入字节到服务端(写入 PTY
void sendInput(String data) {
if (!isConnected || _channel == null) return;
final frame = jsonEncode({'type': 'input', 'data': data});
_channel!.sink.add(frame);
}
void _setState(ConnState state) {
if (_state == state) return;
_state = state;
onStateChanged?.call(state);
}
}

View File

@@ -0,0 +1,169 @@
import 'dart:async';
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:flutter_alacritty/flutter_alacritty.dart';
import 'package:web_socket_channel/io.dart';
import 'package:web_socket_channel/web_socket_channel.dart';
/// 连接状态。
enum ConnState { disconnected, connecting, connected, error }
/// 将 Rtty Rust 服务端桥接为 flutter_alacritty 的 [PtyBackend]。
///
/// 这正是该库为远程WebSocket/SSH数据源预留的扩展点
/// - [output]:把 WebSocket 二进制帧(服务端原始 ANSI作为 PTY 输出流;
/// - [write]:把引擎产生的输入字节发回服务端写入远端 PTY
/// - [resize]:同步终端尺寸到服务端;
/// - [exitCode]:服务端会话结束时完成。
class RttyPtyBackend implements PtyBackend {
RttyPtyBackend();
final StreamController<Uint8List> _outputCtl =
StreamController<Uint8List>.broadcast();
final Completer<int> _exitCodeCtl = Completer<int>();
WebSocketChannel? _channel;
StreamSubscription? _sub;
ConnState _state = ConnState.disconnected;
ConnState get state => _state;
String? _sessionId;
String? get sessionId => _sessionId;
String? _error;
String? get error => _error;
/// 就绪回调(收到 ready 帧,携带会话 ID 与初始尺寸)。
void Function(String id, int cols, int rows)? onReady;
/// 状态变化回调。
void Function(ConnState state)? onStateChanged;
@override
Stream<Uint8List> get output => _outputCtl.stream;
@override
Future<int> get exitCode => _exitCodeCtl.future;
@override
ValueListenable<bool>? get isForegroundProcessRunning => null;
/// 连接到 Rtty 服务端。
Future<void> connect({
required String host,
required int port,
String? session,
}) async {
await close();
_setState(ConnState.connecting);
_error = null;
final query = (session != null && session.isNotEmpty)
? '?session=${Uri.encodeQueryComponent(session)}&client=desktop'
: '?client=desktop';
final uri = Uri.parse('ws://$host:$port/ws$query');
try {
_channel = IOWebSocketChannel.connect(uri);
_sub = _channel!.stream.listen(
_handleIncoming,
onError: (Object e) {
_error = e.toString();
_setState(ConnState.error);
},
onDone: () {
if (!_exitCodeCtl.isCompleted) _exitCodeCtl.complete(0);
if (_state == ConnState.connected) _setState(ConnState.disconnected);
},
);
_setState(ConnState.connected);
} catch (e) {
_error = e.toString();
if (!_exitCodeCtl.isCompleted) _exitCodeCtl.complete(1);
_setState(ConnState.error);
}
}
void _handleIncoming(dynamic message) {
if (message is List<int>) {
// PC 端原始 ANSI 字节流 → 喂给引擎渲染。
_outputCtl.add(Uint8List.fromList(message));
} else if (message is String) {
_handleJsonFrame(message);
}
}
void _handleJsonFrame(String text) {
final Object? decoded;
try {
decoded = jsonDecode(text);
} catch (_) {
return;
}
if (decoded is! Map<String, dynamic>) return;
switch (decoded['type']) {
case 'ready':
_sessionId = decoded['id'] as String? ?? '';
final cols = (decoded['cols'] as num?)?.toInt() ?? 80;
final rows = (decoded['rows'] as num?)?.toInt() ?? 24;
_setState(ConnState.connected);
onReady?.call(_sessionId ?? '', cols, rows);
case 'mobile_snapshot':
// 桌面端使用原始 ANSI 渲染,忽略语义化快照。
break;
case 'error':
case 'session_closed':
if (!_exitCodeCtl.isCompleted) _exitCodeCtl.complete(0);
_setState(ConnState.disconnected);
default:
break;
}
}
@override
void write(Uint8List data) {
if (_state != ConnState.connected || _channel == null) return;
_channel!.sink.add(jsonEncode({
'type': 'input',
'data': utf8.decode(data, allowMalformed: true),
}));
}
@override
void resize(int rows, int columns) {
if (_state != ConnState.connected || _channel == null) return;
_channel!.sink
.add(jsonEncode({'type': 'resize', 'cols': columns, 'rows': rows}));
}
@override
void kill() {
close();
}
/// 关闭连接并释放资源。
Future<void> close() async {
await _sub?.cancel();
_sub = null;
await _channel?.sink.close();
_channel = null;
_sessionId = null;
if (!_exitCodeCtl.isCompleted) _exitCodeCtl.complete(0);
if (_state != ConnState.disconnected) _setState(ConnState.disconnected);
}
void _setState(ConnState state) {
if (_state == state) return;
_state = state;
onStateChanged?.call(state);
}
void dispose() {
close();
_outputCtl.close();
}
}

View File

@@ -0,0 +1,50 @@
import 'package:flutter_alacritty/flutter_alacritty.dart';
/// 将 [RttyTheme] 的工业终端配色映射为 flutter_alacritty 的 [TerminalConfig]。
class RttyTerminalConfig {
RttyTerminalConfig._();
static TerminalConfig build() {
final defaults = TerminalConfig.defaults();
return defaults.copyWith(
colors: const TerminalColors(
background: 0xFF0B0E11,
foreground: 0xFFCBD5DF,
selection: 0x553A6EA5,
ansi: [
0xFF0B0E11, // black
0xFFE5534B, // red
0xFF3DDC97, // green
0xFFFFB454, // yellow
0xFF5AB0FF, // blue
0xFFD77BFF, // magenta
0xFF4ED6E0, // cyan
0xFFDDE6EE, // white
0xFF4A5866, // bright black
0xFFFF6B6B, // bright red
0xFF6BFFC2, // bright green
0xFFFFCE7A, // bright yellow
0xFF82C4FF, // bright blue
0xFFE8A6FF, // bright magenta
0xFF82EDF4, // bright cyan
0xFFFFFDF8, // bright white
],
searchMatchBg: 0xFFB45454,
searchMatchFg: 0xFF14100A,
searchFocusedBg: 0xFFFFB454,
searchFocusedFg: 0xFF14100A,
hintStartFg: 0xFF14100A,
hintStartBg: 0xFFFFB454,
cursorText: 0xFF06130D,
cursorBody: 0xFF3DDC97,
),
font: const FontConfig(
family: 'Consolas',
fallback: ['Cascadia Mono', 'JetBrains Mono', 'Menlo', 'monospace'],
size: 14.0,
lineHeight: 1.15,
),
scrolling: defaults.scrolling.copyWith(history: 10000),
);
}
}

View File

@@ -1,14 +1,16 @@
import 'package:flutter/material.dart';
import 'package:xterm/xterm.dart';
import 'package:flutter_alacritty/flutter_alacritty.dart';
import 'connection_bar.dart';
import 'rtty_client.dart';
import 'rtty_pty_backend.dart';
import 'terminal_config.dart';
import 'theme.dart';
/// 主终端页面。
///
/// 组织连接控制条 + xterm 终端渲染区 + 底部状态栏。
/// 断开时展示终端提示页,连接后进入实时渲染
/// 使用 `flutter_alacritty` 的 Rust 引擎TerminalEngine + TerminalView渲染
/// 通过 [RttyPtyBackend] 桥接远程 Rtty Rust 服务端的 WebSocket 数据流
/// 断开时展示终端提示页,连接后进入实时工业级 ANSI 渲染。
class TerminalScreen extends StatefulWidget {
const TerminalScreen({super.key});
@@ -17,8 +19,12 @@ class TerminalScreen extends StatefulWidget {
}
class _TerminalScreenState extends State<TerminalScreen> {
late final Terminal _terminal;
late final RttyClient _client;
late final TerminalConfig _config;
TerminalEngine? _engine;
TerminalController _controller = TerminalController();
final FocusNode _focus = FocusNode();
RttyPtyBackend? _backend;
final _host = TextEditingController(text: '127.0.0.1');
final _port = TextEditingController(text: '8080');
@@ -32,32 +38,17 @@ class _TerminalScreenState extends State<TerminalScreen> {
@override
void initState() {
super.initState();
_terminal = Terminal(onOutput: (_) {});
_client = RttyClient(_terminal);
_client.onStateChanged = (s) => setState(() => _state = s);
_client.onReady = (id, cols, rows) {
setState(() {
_sessionId = id;
_cols = cols;
_rows = rows;
});
};
_client.onSessionClosed = () {
if (mounted) {
setState(() {
_state = ConnState.disconnected;
_sessionId = null;
});
}
};
_config = RttyTerminalConfig.build();
}
@override
void dispose() {
_client.disconnect();
_tearDownSession();
_host.dispose();
_port.dispose();
_session.dispose();
_controller.dispose();
_focus.dispose();
super.dispose();
}
@@ -67,17 +58,65 @@ class _TerminalScreenState extends State<TerminalScreen> {
final session = _session.text.trim();
if (host.isEmpty) return;
_terminal.write('\x1b[2J\x1b[H');
await _client.connect(host: host, port: port, session: session);
_tearDownSession();
setState(() {
_engine = TerminalEngine(config: _config);
_controller = TerminalController()..attach(_engine!);
_backend = RttyPtyBackend();
});
_backend!.onStateChanged = (s) {
if (!mounted) return;
setState(() => _state = s);
if (s == ConnState.disconnected || s == ConnState.error) {
_sessionId = null;
}
};
_backend!.onReady = (id, cols, rows) {
if (!mounted) return;
setState(() {
_sessionId = id;
_cols = cols;
_rows = rows;
});
};
await _backend!.connect(host: host, port: port, session: session);
}
Future<void> _disconnect() => _client.disconnect();
void _disconnect() => _tearDownSession();
void _tearDownSession() {
final b = _backend;
if (b != null) {
b.onStateChanged = null;
b.onReady = null;
b.kill();
b.dispose();
}
_backend = null;
_engine?.dispose();
_engine = null;
_sessionId = null;
_state = ConnState.disconnected;
}
void _handlePtyResize(int cols, int rows) {
final b = _backend;
if (b == null) return;
b.resize(rows, cols);
if (mounted) {
setState(() {
_cols = cols;
_rows = rows;
});
}
}
@override
Widget build(BuildContext context) {
final sizeText = _state == ConnState.connected
? '$_cols × $_rows'
: '';
final sizeText = _state == ConnState.connected ? '$_cols × $_rows' : '';
return Scaffold(
backgroundColor: RttyTheme.background,
@@ -101,92 +140,36 @@ class _TerminalScreenState extends State<TerminalScreen> {
}
Widget _buildBody() {
if (_state == ConnState.connected) {
return _TerminalViewport(
terminal: _terminal,
cols: _cols,
rows: _rows,
onResize: _handleResize,
final engine = _engine;
if (_state == ConnState.connected && engine != null) {
return Container(
color: RttyTheme.background,
padding: const EdgeInsets.all(16),
child: ClipRRect(
borderRadius: BorderRadius.circular(4),
child: Container(
decoration: BoxDecoration(
color: RttyTheme.background,
border: Border.all(color: RttyTheme.border),
borderRadius: BorderRadius.circular(4),
),
padding: const EdgeInsets.fromLTRB(12, 10, 12, 10),
child: TerminalView(
engine,
controller: _controller,
focusNode: _focus,
autofocus: true,
onPtyResize: _handlePtyResize,
theme: _config.theme,
textStyle: _config.style,
mouseCursor: SystemMouseCursors.text,
),
),
),
);
}
return _IdlePane(state: _state);
}
void _handleResize(int cols, int rows) {
setState(() {
_cols = cols;
_rows = rows;
});
_client.sendResize(cols, rows);
}
}
/// 终端渲染视口。监听尺寸变化并同步给服务端。
class _TerminalViewport extends StatefulWidget {
const _TerminalViewport({
required this.terminal,
required this.cols,
required this.rows,
required this.onResize,
});
final Terminal terminal;
final int cols;
final int rows;
final void Function(int cols, int rows) onResize;
@override
State<_TerminalViewport> createState() => _TerminalViewportState();
}
class _TerminalViewportState extends State<_TerminalViewport> {
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
// 估算每格宽高,把像素尺寸转成网格尺寸并触发 resize。
final style = RttyTheme.terminalStyle();
final fontSize = style.fontSize;
final colWidth = fontSize * 0.62;
final rowHeight = fontSize * style.height;
final cols = (constraints.maxWidth / colWidth).floor().clamp(20, 500);
final rows =
(constraints.maxHeight / rowHeight).floor().clamp(5, 300);
// 网格变化时通知服务端。
if (cols != widget.cols || rows != widget.rows) {
WidgetsBinding.instance.addPostFrameCallback((_) {
widget.terminal.resize(cols, rows);
widget.onResize(cols, rows);
});
}
return Container(
color: RttyTheme.background,
padding: const EdgeInsets.all(16),
child: ClipRRect(
borderRadius: BorderRadius.circular(4),
child: Container(
decoration: BoxDecoration(
color: RttyTheme.background,
border: Border.all(color: RttyTheme.border),
borderRadius: BorderRadius.circular(4),
),
padding: const EdgeInsets.fromLTRB(12, 10, 12, 10),
child: TerminalView(
widget.terminal,
theme: RttyTheme.terminal(),
textStyle: style,
autofocus: true,
mouseCursor: SystemMouseCursors.text,
),
),
),
);
},
);
}
}
/// 未连接时的提示面板。
@@ -325,7 +308,7 @@ class _StatusBar extends StatelessWidget {
_Hint('Ctrl+Shift+C / V 复制粘贴', RttyTheme.textFaint),
Spacer(),
Text(
'FLUTTER × XTERM TERMINAL',
'FLUTTER × ALACRITTY ENGINE',
style: TextStyle(color: RttyTheme.textFaint, fontSize: 10),
),
],

View File

@@ -1,5 +1,4 @@
import 'package:flutter/material.dart';
import 'package:xterm/xterm.dart';
/// Rtty 桌面端主题。
///
@@ -44,39 +43,4 @@ class RttyTheme {
),
);
}
/// xterm 终端主题。
static TerminalTheme terminal() => TerminalTheme(
cursor: primary,
selection: const Color(0xFF2A5566).withValues(alpha: 0.45),
searchHitBackground: accent.withValues(alpha: 0.25),
searchHitBackgroundCurrent: accent,
searchHitForeground: const Color(0xFF14100A),
foreground: const Color(0xFFCBD5DF),
background: const Color(0xFF0B0E11),
black: const Color(0xFF0B0E11),
white: const Color(0xFFDDE6EE),
red: const Color(0xFFE5534B),
green: const Color(0xFF3DDC97),
yellow: const Color(0xFFFFB454),
blue: const Color(0xFF5AB0FF),
magenta: const Color(0xFFD77BFF),
cyan: const Color(0xFF4ED6E0),
brightBlack: const Color(0xFF4A5866),
brightRed: const Color(0xFFFF6B6B),
brightGreen: const Color(0xFF6BFFC2),
brightYellow: const Color(0xFFFFCE7A),
brightBlue: const Color(0xFF82C4FF),
brightMagenta: const Color(0xFFE8A6FF),
brightCyan: const Color(0xFF82EDF4),
brightWhite: const Color(0xFFFFFDF8),
);
/// xterm 终端字体。
static TerminalStyle terminalStyle() => const TerminalStyle(
fontFamily: 'Consolas',
fontFamilyFallback: ['Cascadia Mono', 'JetBrains Mono', 'Menlo'],
fontSize: 14,
height: 1.15,
);
}