feat: add Flutter desktop client for Rtty
This commit is contained in:
366
desktop/lib/src/terminal_screen.dart
Normal file
366
desktop/lib/src/terminal_screen.dart
Normal 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'),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user