mirror of
https://github.com/rustdesk/rustdesk.git
synced 2026-03-07 12:20:03 +03:00
- Fix new tab not auto-focusing: add FocusNode to TerminalView and request focus when tab is selected via tab state listener - Fix NaN error when data arrives before terminal view layout: buffer output data until terminal view has valid dimensions, flush on first valid resize callback Signed-off-by: fufesou <linlong1266@gmail.com>
206 lines
7.3 KiB
Dart
206 lines
7.3 KiB
Dart
import 'dart:async';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:flutter_hbb/common.dart';
|
|
import 'package:flutter_hbb/desktop/widgets/tabbar_widget.dart';
|
|
import 'package:flutter_hbb/models/model.dart';
|
|
import 'package:flutter_hbb/models/terminal_model.dart';
|
|
import 'package:xterm/xterm.dart';
|
|
import 'terminal_connection_manager.dart';
|
|
|
|
class TerminalPage extends StatefulWidget {
|
|
TerminalPage({
|
|
Key? key,
|
|
required this.id,
|
|
required this.password,
|
|
required this.tabController,
|
|
required this.isSharedPassword,
|
|
required this.terminalId,
|
|
required this.tabKey,
|
|
this.forceRelay,
|
|
this.connToken,
|
|
}) : super(key: key);
|
|
final String id;
|
|
final String? password;
|
|
final DesktopTabController tabController;
|
|
final bool? forceRelay;
|
|
final bool? isSharedPassword;
|
|
final String? connToken;
|
|
final int terminalId;
|
|
/// Tab key for focus management, passed from parent to avoid duplicate construction
|
|
final String tabKey;
|
|
final SimpleWrapper<State<TerminalPage>?> _lastState = SimpleWrapper(null);
|
|
|
|
FFI get ffi => (_lastState.value! as _TerminalPageState)._ffi;
|
|
|
|
@override
|
|
State<TerminalPage> createState() {
|
|
final state = _TerminalPageState();
|
|
_lastState.value = state;
|
|
return state;
|
|
}
|
|
}
|
|
|
|
class _TerminalPageState extends State<TerminalPage>
|
|
with AutomaticKeepAliveClientMixin {
|
|
late FFI _ffi;
|
|
late TerminalModel _terminalModel;
|
|
double? _cellHeight;
|
|
final FocusNode _terminalFocusNode = FocusNode(canRequestFocus: false);
|
|
StreamSubscription<DesktopTabState>? _tabStateSubscription;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
|
|
// Listen for tab selection changes to request focus
|
|
_tabStateSubscription = widget.tabController.state.listen(_onTabStateChanged);
|
|
|
|
// Use shared FFI instance from connection manager
|
|
_ffi = TerminalConnectionManager.getConnection(
|
|
peerId: widget.id,
|
|
password: widget.password,
|
|
isSharedPassword: widget.isSharedPassword,
|
|
forceRelay: widget.forceRelay,
|
|
connToken: widget.connToken,
|
|
);
|
|
|
|
// Create terminal model with specific terminal ID
|
|
_terminalModel = TerminalModel(_ffi, widget.terminalId);
|
|
debugPrint(
|
|
'[TerminalPage] Terminal model created for terminal ${widget.terminalId}');
|
|
|
|
_terminalModel.onResizeExternal = (w, h, pw, ph) {
|
|
_cellHeight = ph * 1.0;
|
|
|
|
// Enable focus once terminal has valid dimensions (first valid resize)
|
|
if (!_terminalFocusNode.canRequestFocus && w > 0 && h > 0) {
|
|
_terminalFocusNode.canRequestFocus = true;
|
|
// Auto-focus if this tab is currently selected
|
|
_requestFocusIfSelected();
|
|
}
|
|
|
|
// Schedule the setState for the next frame
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (mounted) {
|
|
setState(() {});
|
|
}
|
|
});
|
|
};
|
|
|
|
// Register this terminal model with FFI for event routing
|
|
_ffi.registerTerminalModel(widget.terminalId, _terminalModel);
|
|
|
|
// Initialize terminal connection
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
widget.tabController.onSelected?.call(widget.id);
|
|
|
|
// Check if this is a new connection or additional terminal
|
|
// Note: When a connection exists, the ref count will be > 1 after this terminal is added
|
|
final isExistingConnection =
|
|
TerminalConnectionManager.hasConnection(widget.id) &&
|
|
TerminalConnectionManager.getTerminalCount(widget.id) > 1;
|
|
|
|
if (!isExistingConnection) {
|
|
// First terminal - show loading dialog, wait for onReady
|
|
_ffi.dialogManager
|
|
.showLoading(translate('Connecting...'), onCancel: closeConnection);
|
|
} else {
|
|
// Additional terminal - connection already established
|
|
// Open the terminal directly
|
|
_terminalModel.openTerminal();
|
|
}
|
|
});
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
// Cancel tab state subscription to prevent memory leak
|
|
_tabStateSubscription?.cancel();
|
|
// Unregister terminal model from FFI
|
|
_ffi.unregisterTerminalModel(widget.terminalId);
|
|
_terminalModel.dispose();
|
|
_terminalFocusNode.dispose();
|
|
// Release connection reference instead of closing directly
|
|
TerminalConnectionManager.releaseConnection(widget.id);
|
|
super.dispose();
|
|
}
|
|
|
|
void _onTabStateChanged(DesktopTabState state) {
|
|
// Check if this tab is now selected and request focus
|
|
if (state.selected >= 0 && state.selected < state.tabs.length) {
|
|
final selectedTab = state.tabs[state.selected];
|
|
if (selectedTab.key == widget.tabKey && mounted) {
|
|
_requestFocusIfSelected();
|
|
}
|
|
}
|
|
}
|
|
|
|
void _requestFocusIfSelected() {
|
|
if (!mounted || !_terminalFocusNode.canRequestFocus) return;
|
|
// Use post-frame callback to ensure widget is fully laid out in focus tree
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
// Re-check conditions after frame: mounted, focusable, still selected, not already focused
|
|
if (!mounted || !_terminalFocusNode.canRequestFocus || _terminalFocusNode.hasFocus) return;
|
|
final state = widget.tabController.state.value;
|
|
if (state.selected >= 0 && state.selected < state.tabs.length) {
|
|
if (state.tabs[state.selected].key == widget.tabKey) {
|
|
_terminalFocusNode.requestFocus();
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
// This method ensures that the number of visible rows is an integer by computing the
|
|
// extra space left after dividing the available height by the height of a single
|
|
// terminal row (`_cellHeight`) and distributing it evenly as top and bottom padding.
|
|
EdgeInsets _calculatePadding(double heightPx) {
|
|
if (_cellHeight == null) {
|
|
return const EdgeInsets.symmetric(horizontal: 5.0, vertical: 2.0);
|
|
}
|
|
final rows = (heightPx / _cellHeight!).floor();
|
|
final extraSpace = heightPx - rows * _cellHeight!;
|
|
final topBottom = extraSpace / 2.0;
|
|
return EdgeInsets.symmetric(horizontal: 5.0, vertical: topBottom);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
super.build(context);
|
|
return Scaffold(
|
|
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
|
|
body: LayoutBuilder(
|
|
builder: (context, constraints) {
|
|
final heightPx = constraints.maxHeight;
|
|
return TerminalView(
|
|
_terminalModel.terminal,
|
|
controller: _terminalModel.terminalController,
|
|
focusNode: _terminalFocusNode,
|
|
// Note: autofocus is not used here because focus is managed manually
|
|
// via _onTabStateChanged() to handle tab switching properly.
|
|
backgroundOpacity: 0.7,
|
|
padding: _calculatePadding(heightPx),
|
|
onSecondaryTapDown: (details, offset) async {
|
|
final selection = _terminalModel.terminalController.selection;
|
|
if (selection != null) {
|
|
final text = _terminalModel.terminal.buffer.getText(selection);
|
|
_terminalModel.terminalController.clearSelection();
|
|
await Clipboard.setData(ClipboardData(text: text));
|
|
} else {
|
|
final data = await Clipboard.getData('text/plain');
|
|
final text = data?.text;
|
|
if (text != null) {
|
|
_terminalModel.terminal.paste(text);
|
|
}
|
|
}
|
|
},
|
|
);
|
|
},
|
|
),
|
|
);
|
|
}
|
|
|
|
@override
|
|
bool get wantKeepAlive => true;
|
|
}
|