feat: add Flutter desktop client for Rtty

This commit is contained in:
2026-08-01 17:40:52 +08:00
parent f25506ec5b
commit 93ee801f27
31 changed files with 2679 additions and 0 deletions

View File

@@ -0,0 +1,366 @@
import 'package:flutter/material.dart';
import 'package:xterm/xterm.dart';
import 'connection_bar.dart';
import 'rtty_client.dart';
import 'theme.dart';
/// 主终端页面。
///
/// 组织连接控制条 + xterm 终端渲染区 + 底部状态栏。
/// 断开时展示终端提示页,连接后进入实时渲染。
class TerminalScreen extends StatefulWidget {
const TerminalScreen({super.key});
@override
State<TerminalScreen> createState() => _TerminalScreenState();
}
class _TerminalScreenState extends State<TerminalScreen> {
late final Terminal _terminal;
late final RttyClient _client;
final _host = TextEditingController(text: '127.0.0.1');
final _port = TextEditingController(text: '8080');
final _session = TextEditingController();
ConnState _state = ConnState.disconnected;
String? _sessionId;
int _cols = 0;
int _rows = 0;
@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;
});
}
};
}
@override
void dispose() {
_client.disconnect();
_host.dispose();
_port.dispose();
_session.dispose();
super.dispose();
}
Future<void> _connect() async {
final host = _host.text.trim();
final port = int.tryParse(_port.text.trim()) ?? 8080;
final session = _session.text.trim();
if (host.isEmpty) return;
_terminal.write('\x1b[2J\x1b[H');
await _client.connect(host: host, port: port, session: session);
}
Future<void> _disconnect() => _client.disconnect();
@override
Widget build(BuildContext context) {
final sizeText = _state == ConnState.connected
? '$_cols × $_rows'
: '';
return Scaffold(
backgroundColor: RttyTheme.background,
body: Column(
children: [
ConnectionBar(
state: _state,
hostController: _host,
portController: _port,
sessionController: _session,
sessionId: _sessionId,
terminalSize: sizeText,
onConnect: _connect,
onDisconnect: _disconnect,
),
Expanded(child: _buildBody()),
const _StatusBar(),
],
),
);
}
Widget _buildBody() {
if (_state == ConnState.connected) {
return _TerminalViewport(
terminal: _terminal,
cols: _cols,
rows: _rows,
onResize: _handleResize,
);
}
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,
),
),
),
);
},
);
}
}
/// 未连接时的提示面板。
class _IdlePane extends StatelessWidget {
const _IdlePane({required this.state});
final ConnState state;
@override
Widget build(BuildContext context) {
final (title, subtitle) = switch (state) {
ConnState.disconnected => (
'AWAITING CONNECTION',
'输入服务端地址后点击 CONNECT 建立终端会话',
),
ConnState.connecting => (
'CONNECTING',
'正在协商 WebSocket 终端会话…',
),
ConnState.error => (
'CONNECTION LOST',
'无法建立连接,请检查服务端是否已启动',
),
ConnState.connected => ('', ''),
};
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
_ScanlineGlyph(state: state),
const SizedBox(height: 28),
Text(
title,
style: const TextStyle(
color: RttyTheme.textDim,
fontSize: 18,
fontWeight: FontWeight.w700,
letterSpacing: 3,
),
),
const SizedBox(height: 10),
Text(
subtitle,
style: const TextStyle(color: RttyTheme.textFaint, fontSize: 12),
),
],
),
);
}
}
/// 终端提示区的装饰性扫描线字形。
class _ScanlineGlyph extends StatelessWidget {
const _ScanlineGlyph({required this.state});
final ConnState state;
@override
Widget build(BuildContext context) {
final color = switch (state) {
ConnState.connected => RttyTheme.primary,
ConnState.connecting => RttyTheme.accent,
ConnState.error => RttyTheme.danger,
_ => RttyTheme.textFaint,
};
return Container(
width: 96,
height: 96,
decoration: BoxDecoration(
border: Border.all(color: color.withValues(alpha: 0.5), width: 1.5),
borderRadius: BorderRadius.circular(12),
),
child: CustomPaint(painter: _ScanlinePainter(color)),
);
}
}
class _ScanlinePainter extends CustomPainter {
_ScanlinePainter(this.color);
final Color color;
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()
..color = color.withValues(alpha: 0.18)
..strokeWidth = 1;
const spacing = 6.0;
for (double y = 0; y < size.height; y += spacing) {
canvas.drawLine(Offset(0, y), Offset(size.width, y), paint);
}
final cursorPaint = Paint()
..color = color.withValues(alpha: 0.8)
..strokeWidth = 2;
canvas.drawRect(
Rect.fromLTWH(size.width * 0.3, size.height * 0.45, 24, 14),
cursorPaint..style = PaintingStyle.stroke,
);
}
@override
bool shouldRepaint(covariant _ScanlinePainter old) => old.color != color;
}
/// 底部状态栏:会话 ID、连接状态、按键提示。
class _StatusBar extends StatelessWidget {
const _StatusBar();
@override
Widget build(BuildContext context) {
return Container(
height: 26,
decoration: BoxDecoration(
color: RttyTheme.surface,
border: Border(top: BorderSide(color: RttyTheme.border)),
),
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
children: const [
_StatusDot(),
SizedBox(width: 8),
Text(
'RTTY DESKTOP',
style: TextStyle(
color: RttyTheme.textFaint,
fontSize: 10,
letterSpacing: 1.6,
fontWeight: FontWeight.w600,
),
),
SizedBox(width: 20),
_Hint('鼠标拖选复制', RttyTheme.textFaint),
SizedBox(width: 14),
_Hint('右键粘贴', RttyTheme.textFaint),
Spacer(),
Text(
'FLUTTER × XTERM × ALCATTERM ENGINE',
style: TextStyle(color: RttyTheme.textFaint, fontSize: 10),
),
],
),
);
}
}
class _StatusDot extends StatelessWidget {
const _StatusDot();
@override
Widget build(BuildContext context) {
return Container(
width: 6,
height: 6,
decoration: const BoxDecoration(
color: RttyTheme.primary,
shape: BoxShape.circle,
),
);
}
}
class _Hint extends StatelessWidget {
const _Hint(this.text, this.color);
final String text;
final Color color;
@override
Widget build(BuildContext context) {
return Text(
text,
style: TextStyle(color: color, fontSize: 10, fontFamily: 'Consolas'),
);
}
}