350 lines
8.9 KiB
Dart
350 lines
8.9 KiB
Dart
import 'package:flutter/material.dart';
|
||
import 'package:flutter_alacritty/flutter_alacritty.dart';
|
||
|
||
import 'connection_bar.dart';
|
||
import 'rtty_pty_backend.dart';
|
||
import 'terminal_config.dart';
|
||
import 'theme.dart';
|
||
|
||
/// 主终端页面。
|
||
///
|
||
/// 使用 `flutter_alacritty` 的 Rust 引擎(TerminalEngine + TerminalView)渲染,
|
||
/// 通过 [RttyPtyBackend] 桥接远程 Rtty Rust 服务端的 WebSocket 数据流。
|
||
/// 断开时展示终端提示页,连接后进入实时工业级 ANSI 渲染。
|
||
class TerminalScreen extends StatefulWidget {
|
||
const TerminalScreen({super.key});
|
||
|
||
@override
|
||
State<TerminalScreen> createState() => _TerminalScreenState();
|
||
}
|
||
|
||
class _TerminalScreenState extends State<TerminalScreen> {
|
||
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');
|
||
final _session = TextEditingController();
|
||
|
||
ConnState _state = ConnState.disconnected;
|
||
String? _sessionId;
|
||
int _cols = 0;
|
||
int _rows = 0;
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
_config = RttyTerminalConfig.build();
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
_tearDownSession();
|
||
_host.dispose();
|
||
_port.dispose();
|
||
_session.dispose();
|
||
_controller.dispose();
|
||
_focus.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;
|
||
|
||
_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);
|
||
}
|
||
|
||
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' : '—';
|
||
|
||
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() {
|
||
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);
|
||
}
|
||
}
|
||
|
||
/// 未连接时的提示面板。
|
||
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('Ctrl+Shift+C / V 复制粘贴', RttyTheme.textFaint),
|
||
Spacer(),
|
||
Text(
|
||
'FLUTTER × ALACRITTY 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'),
|
||
);
|
||
}
|
||
}
|