Files
Rtty/desktop/lib/src/terminal_screen.dart

350 lines
8.9 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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'),
);
}
}