Files
rustdesk/flutter/lib/mobile/pages/terminal_page.dart
dongrencd 57456f0b52 feat(terminal): add Ctrl and Alt toggles to mobile terminal keyboard (#15532)
* feat(terminal): add Ctrl toggle and Ctrl+X shortcut keys to mobile terminal floating keyboard

Signed-off-by: dongrencd <dongrencd@users.noreply.github.com>

* refactor(terminal): restructure keyboard layout with collapse button

- Move | from Row1 position 3 to Row1 end (aligned with collapse button)
- Remove ~ from Row2, add collapse button (∨/∧) after PgDn
- Row3: conditional render, add ~ and -, remove trailing placeholders
- Collapse state persisted via kOptionEnableShowTerminalCtrlKeys
- Row3 defaults to collapsed for compact layout

Signed-off-by: dongrencd <dongrencd@users.noreply.github.com>

* fix(terminal): restore trailing placeholders in Row3 for alignment

Row3 needs trailing placeholders to match Row1/Row2 width (348px)
so Ctrl aligns with Tab in Row2 and Esc in Row1.

Signed-off-by: dongrencd <dongrencd@users.noreply.github.com>

* fix(terminal): update mobile keyboard layout per review

Signed-off-by: dong.ren.cd <dong.ren.cd@tcl.com>

* fix(terminal): address mobile keyboard review regressions

Signed-off-by: dong.ren.cd <dong.ren.cd@tcl.com>

* fix(terminal): preserve ctrl-j newline mapping on mobile

Signed-off-by: dong.ren.cd <dong.ren.cd@tcl.com>

* fix(terminal): preserve pasted input with modifiers

Signed-off-by: dong.ren.cd <dong.ren.cd@tcl.com>

* fix(terminal): harden mobile modifier and paste input

Signed-off-by: dong.ren.cd <dong.ren.cd@tcl.com>

* fix(terminal): harden mobile paste shortcut handling

Signed-off-by: dong.ren.cd <dong.ren.cd@tcl.com>

* fix(terminal): preserve unicode graphemes under ctrl

* fix(terminal): avoid modifier scan for inactive locks

* fix(terminal): keep default hardware paste shortcuts

* fix(terminal): guard hardware paste with modifier locks

* fix(terminal): update mobile key button color role

---------

Signed-off-by: dongrencd <dongrencd@users.noreply.github.com>
Signed-off-by: dong.ren.cd <dong.ren.cd@tcl.com>
Co-authored-by: dongrencd <dongrencd@users.noreply.github.com>
Co-authored-by: dong.ren.cd <dong.ren.cd@tcl.com>
2026-07-25 22:33:16 +08:00

602 lines
20 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 'dart:async';
import 'dart:math';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_hbb/common.dart';
import 'package:flutter_hbb/common/widgets/dialog.dart';
import 'package:flutter_hbb/models/input_modifier_utils.dart';
import 'package:flutter_hbb/models/model.dart';
import 'package:flutter_hbb/models/platform_model.dart';
import 'package:flutter_hbb/models/terminal_model.dart';
import 'package:flutter_hbb/mobile/terminal_keyboard_utils.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:xterm/xterm.dart';
import '../../desktop/pages/terminal_connection_manager.dart';
import '../../consts.dart';
class TerminalPage extends StatefulWidget {
const TerminalPage({
Key? key,
required this.id,
required this.password,
required this.isSharedPassword,
this.forceRelay,
this.connToken,
}) : super(key: key);
final String id;
final String? password;
final bool? forceRelay;
final bool? isSharedPassword;
final String? connToken;
final terminalId = 0;
@override
State<TerminalPage> createState() => _TerminalPageState();
}
class _TerminalPageState extends State<TerminalPage>
with AutomaticKeepAliveClientMixin, WidgetsBindingObserver {
late FFI _ffi;
late TerminalModel _terminalModel;
double? _cellHeight;
double _sysKeyboardHeight = 0;
Timer? _keyboardDebounce;
final GlobalKey _keyboardKey = GlobalKey();
double _keyboardHeight = 0;
late bool _showTerminalExtraKeys;
// Ctrl lock state for virtual keyboard: active key presses are mapped to control codes
bool _ctrlLocked = false;
bool _altLocked = false;
// Row3 expand/collapse state for compact keyboard layout
bool _row3Expanded = false;
// For iOS edge swipe gesture
double _swipeStartX = 0;
double _swipeCurrentX = 0;
// For web only.
// 'monospace' does not work on web, use Google Fonts, `??` is only for null safety.
final String _robotoMonoFontFamily = isWeb
? (GoogleFonts.robotoMono().fontFamily ?? 'monospace')
: 'monospace';
SessionID get sessionId => _ffi.sessionId;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
debugPrint(
'[TerminalPage] Initializing terminal ${widget.terminalId} for peer ${widget.id}');
// 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;
};
// Register this terminal model with FFI for event routing
_ffi.registerTerminalModel(widget.terminalId, _terminalModel);
// Auto-close connection when shell exits
_terminalModel.onClosed = () {
if (mounted) {
closeConnection(id: widget.id);
}
};
// Web desktop users have full hardware keyboard access, so the on-screen
// terminal extra keys bar is unnecessary and disabled.
_showTerminalExtraKeys = !isWebDesktop &&
mainGetLocalBoolOptionSync(kOptionEnableShowTerminalExtraKeys);
_terminalModel.isCtrlLocked = () => _ctrlLocked;
_terminalModel.clearCtrlLock = () {
if (_ctrlLocked) setState(() => _ctrlLocked = false);
};
_terminalModel.isAltLocked = () => _altLocked;
_terminalModel.clearAltLock = () {
if (_altLocked) setState(() => _altLocked = false);
};
// Load Row3 expand/collapse state from persistent storage. The raw option
// read keeps Row3 collapsed when no value has been saved yet.
_row3Expanded =
bind.mainGetLocalOption(key: kOptionShowTerminalCtrlKeys) == 'Y';
// Initialize terminal connection
WidgetsBinding.instance.addPostFrameCallback((_) {
_ffi.dialogManager
.showLoading(translate('Connecting...'), onCancel: closeConnection);
if (_showTerminalExtraKeys) {
_updateKeyboardHeight();
}
});
_ffi.ffiModel.updateEventListener(_ffi.sessionId, widget.id);
}
@override
void dispose() {
// Unregister terminal model from FFI
_ffi.unregisterTerminalModel(widget.terminalId);
_terminalModel.dispose();
_keyboardDebounce?.cancel();
WidgetsBinding.instance.removeObserver(this);
super.dispose();
TerminalConnectionManager.releaseConnection(widget.id);
}
@override
void didChangeMetrics() {
super.didChangeMetrics();
_keyboardDebounce?.cancel();
_keyboardDebounce = Timer(const Duration(milliseconds: 20), () {
final bottomInset = MediaQuery.of(context).viewInsets.bottom;
setState(() {
_sysKeyboardHeight = bottomInset;
});
});
}
void _updateKeyboardHeight() {
if (_keyboardKey.currentContext != null) {
final renderBox = _keyboardKey.currentContext!.findRenderObject() as RenderBox;
_keyboardHeight = renderBox.size.height;
}
}
EdgeInsets _calculatePadding(double heightPx) {
if (_cellHeight == null) {
return const EdgeInsets.symmetric(horizontal: 5.0, vertical: 2.0);
}
final realHeight = heightPx - _sysKeyboardHeight - _keyboardHeight;
final rows = (realHeight / _cellHeight!).floor();
final extraSpace = realHeight - rows * _cellHeight!;
final topBottom = max(0.0, extraSpace / 2.0);
return EdgeInsets.only(left: 5.0, right: 5.0, top: topBottom, bottom: topBottom + _sysKeyboardHeight + _keyboardHeight);
}
/// Pastes clipboard text through TerminalModel so keyboard-only modifiers and
/// mobile Enter normalization never alter clipboard data.
Future<void> _pasteClipboardText() async {
final data = await Clipboard.getData(Clipboard.kTextPlain);
final text = data?.text;
if (text == null || !mounted) return;
await _terminalModel.pasteText(text);
if (mounted) {
_terminalModel.terminalController.clearSelection();
}
}
KeyEventResult _handleTerminalKeyEvent(FocusNode _, KeyEvent event) {
final hardwareKeyboard = HardwareKeyboard.instance;
final shouldPaste = shouldHandleTerminalPasteShortcut(
logicalKey: event.logicalKey,
isKeyDown: event is KeyDownEvent,
isKeyRepeat: event is KeyRepeatEvent,
controlPressed: hardwareKeyboard.isControlPressed,
metaPressed: hardwareKeyboard.isMetaPressed,
altPressed: hardwareKeyboard.isAltPressed,
shiftPressed: hardwareKeyboard.isShiftPressed,
modifierLockActive: _ctrlLocked || _altLocked,
);
if (!shouldPaste) return KeyEventResult.ignored;
// Only locked virtual modifiers need interception. Without a lock, keep
// xterm's default hardware paste behavior, including bracketed paste mode.
unawaited(_pasteClipboardText());
return KeyEventResult.handled;
}
@override
Widget build(BuildContext context) {
super.build(context);
return WillPopScope(
onWillPop: () async {
clientClose(sessionId, _ffi);
return false; // Prevent default back behavior
},
child: buildBody(),
);
}
Widget buildBody() {
final scaffold = Scaffold(
resizeToAvoidBottomInset: false, // Disable automatic layout adjustment; manually control UI updates to prevent flickering when the keyboard shows/hides
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
body: Stack(
children: [
Positioned.fill(
child: SafeArea(
top: true,
child: LayoutBuilder(
builder: (context, constraints) {
final heightPx = constraints.maxHeight;
return TerminalView(
_terminalModel.terminal,
controller: _terminalModel.terminalController,
autofocus: true,
textStyle: _getTerminalStyle(),
backgroundOpacity: 0.7,
// The following comment is from xterm.dart source code:
// Workaround to detect delete key for platforms and IMEs that do not
// emit a hardware delete event. Preferred on mobile platforms. [false] by
// default.
//
// Android works fine without this workaround.
deleteDetection: isIOS,
onKeyEvent: _handleTerminalKeyEvent,
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 {
await _pasteClipboardText();
}
},
);
},
),
),
),
if (_showTerminalExtraKeys) _buildFloatingKeyboard(),
// iOS-style circular close button in top-right corner
if (isIOS) _buildCloseButton(),
],
),
);
// Add iOS edge swipe gesture to exit (similar to Android back button)
if (isIOS) {
return LayoutBuilder(
builder: (context, constraints) {
final screenWidth = constraints.maxWidth;
// Base thresholds on screen width but clamp to reasonable logical pixel ranges
// Edge detection region: ~10% of width, clamped between 20 and 80 logical pixels
final edgeThreshold = (screenWidth * 0.1).clamp(20.0, 80.0);
// Required horizontal movement: ~25% of width, clamped between 80 and 300 logical pixels
final swipeThreshold = (screenWidth * 0.25).clamp(80.0, 300.0);
return RawGestureDetector(
behavior: HitTestBehavior.translucent,
gestures: <Type, GestureRecognizerFactory>{
HorizontalDragGestureRecognizer: GestureRecognizerFactoryWithHandlers<HorizontalDragGestureRecognizer>(
() => HorizontalDragGestureRecognizer(
debugOwner: this,
// Only respond to touch input, exclude mouse/trackpad
supportedDevices: kTouchBasedDeviceKinds,
),
(HorizontalDragGestureRecognizer instance) {
instance
// Capture initial touch-down position (before touch slop)
..onDown = (details) {
_swipeStartX = details.localPosition.dx;
_swipeCurrentX = details.localPosition.dx;
}
..onUpdate = (details) {
_swipeCurrentX = details.localPosition.dx;
}
..onEnd = (details) {
// Check if swipe started from left edge and moved right
if (_swipeStartX < edgeThreshold && (_swipeCurrentX - _swipeStartX) > swipeThreshold) {
clientClose(sessionId, _ffi);
}
_swipeStartX = 0;
_swipeCurrentX = 0;
}
..onCancel = () {
_swipeStartX = 0;
_swipeCurrentX = 0;
};
},
),
},
child: scaffold,
);
},
);
}
return scaffold;
}
Widget _buildCloseButton() {
return Positioned(
top: 0,
right: 0,
child: SafeArea(
minimum: const EdgeInsets.only(
top: 16, // iOS standard margin
right: 16, // iOS standard margin
),
child: Semantics(
button: true,
label: translate('Close'),
child: Container(
width: 44, // iOS standard tap target size
height: 44,
decoration: BoxDecoration(
color: Colors.black.withOpacity(0.5), // Half transparency
shape: BoxShape.circle,
),
child: Material(
color: Colors.transparent,
shape: const CircleBorder(),
clipBehavior: Clip.antiAlias,
child: InkWell(
customBorder: const CircleBorder(),
onTap: () {
clientClose(sessionId, _ffi);
},
child: Tooltip(
message: translate('Close'),
child: const Icon(
Icons.chevron_left, // iOS-style back arrow
color: Colors.white,
size: 28,
),
),
),
),
),
),
),
);
}
Widget _buildFloatingKeyboard() {
return AnimatedPositioned(
duration: const Duration(milliseconds: 200),
left: 0,
right: 0,
bottom: _sysKeyboardHeight,
child: Container(
key: _keyboardKey,
color: Theme.of(context).scaffoldBackgroundColor,
padding: EdgeInsets.zero,
child: Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [
// Row 1 follows the latest reviewed PR layout.
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: _buildKeyboardKeyButtons(terminalKeyboardRow1Keys),
),
// Row 2 ends with the full-width Row3 collapse/expand toggle.
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
..._buildKeyboardKeyButtons(terminalKeyboardRow2Keys),
const SizedBox(width: terminalKeyboardKeySpacing),
_buildCollapseButton(),
],
),
// Row 3 restores paging keys and trailing alignment placeholders.
if (_row3Expanded)
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
..._buildKeyboardKeyButtons(terminalKeyboardRow3Keys),
for (var i = 0;
i < terminalKeyboardRow3TrailingPlaceholderCount;
i++) ...[
const SizedBox(width: terminalKeyboardKeySpacing),
const SizedBox(width: terminalKeyboardKeyWidth),
],
],
),
],
),
),
);
}
// Ctrl toggle button with highlighted locked state
Widget _buildCtrlKeyButton() {
return _buildModifierToggleButton(
text: 'Ctrl',
semanticsLabel: 'Ctrl',
isLocked: _ctrlLocked,
onPressed: () => setState(() => _ctrlLocked = !_ctrlLocked),
);
}
// Alt toggle button with highlighted locked state
Widget _buildAltKeyButton() {
return _buildModifierToggleButton(
text: 'Alt',
semanticsLabel: 'Alt',
isLocked: _altLocked,
onPressed: () => setState(() => _altLocked = !_altLocked),
);
}
// Collapse/expand toggle button for Row3
void _toggleRow3Expanded() {
final willExpand = !_row3Expanded;
final shouldClearModifiers = shouldClearTerminalModifiersWhenRow3Collapses(
wasExpanded: _row3Expanded,
willExpand: willExpand,
ctrlLocked: _ctrlLocked,
altLocked: _altLocked,
);
setState(() {
_row3Expanded = willExpand;
if (shouldClearModifiers) {
_ctrlLocked = false;
_altLocked = false;
}
});
mainSetLocalBoolOption(kOptionShowTerminalCtrlKeys, willExpand);
// The floating keyboard height changes after Row3 is inserted/removed.
// Re-measure on the next frame so terminal padding uses the new height.
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || !_showTerminalExtraKeys) return;
setState(() {
_updateKeyboardHeight();
});
});
}
Widget _buildCollapseButton() {
return Semantics(
label: translate('Show terminal extra keys'),
toggled: _row3Expanded,
child: ElevatedButton(
onPressed: _toggleRow3Expanded,
child: Text(_row3Expanded ? '' : ''),
style: ElevatedButton.styleFrom(
minimumSize: const Size(terminalKeyboardKeyWidth, 32),
padding: EdgeInsets.zero,
textStyle: const TextStyle(fontSize: 12),
backgroundColor:
Theme.of(context).colorScheme.surfaceContainerHighest,
foregroundColor: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
);
}
/// Builds a fixed-width key sequence with the reviewed 2dp spacing.
List<Widget> _buildKeyboardKeyButtons(List<String> labels) {
return [
for (var i = 0; i < labels.length; i++) ...[
_buildKeyButton(labels[i]),
if (i < labels.length - 1)
const SizedBox(width: terminalKeyboardKeySpacing),
],
];
}
/// Build a modifier toggle button (Ctrl/Alt) with one-shot behavior.
/// When [isLocked] is true, the button highlights in blue and the next
/// single-character input is mapped to its modified equivalent.
Widget _buildModifierToggleButton({
required String text,
required String semanticsLabel,
required bool isLocked,
required VoidCallback onPressed,
}) {
return Semantics(
// Ctrl and Alt are technical key names and intentionally stay unchanged.
label: semanticsLabel,
toggled: isLocked,
child: ElevatedButton(
onPressed: onPressed,
child: Text(text),
style: ElevatedButton.styleFrom(
minimumSize: const Size(terminalKeyboardKeyWidth, 32),
padding: EdgeInsets.zero,
textStyle: const TextStyle(fontSize: 12),
backgroundColor: isLocked
? Colors.blue
: Theme.of(context).colorScheme.surfaceContainerHighest,
foregroundColor: isLocked
? Colors.white
: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
);
}
Widget _buildKeyButton(String label) {
if (label == 'Ctrl') return _buildCtrlKeyButton();
if (label == 'Alt') return _buildAltKeyButton();
return ElevatedButton(
onPressed: () {
_sendKeyToTerminal(label);
},
child: Text(label),
style: ElevatedButton.styleFrom(
minimumSize: const Size(terminalKeyboardKeyWidth, 32),
padding: EdgeInsets.zero,
textStyle: const TextStyle(fontSize: 12),
backgroundColor:
Theme.of(context).colorScheme.surfaceContainerHighest,
foregroundColor: Theme.of(context).colorScheme.onSurfaceVariant,
),
);
}
void _sendKeyToTerminal(String key) {
String send;
switch (key) {
case 'Esc':
send = '\x1B';
break;
case 'Tab':
send = '\t';
break;
case 'Ctrl+C':
send = '\x03';
break;
case '':
send = '\x1B[A';
break;
case '':
send = '\x1B[B';
break;
case '':
send = '\x1B[C';
break;
case '':
send = '\x1B[D';
break;
case 'Home':
send = '\x1B[H';
break;
case 'End':
send = '\x1B[F';
break;
case 'PgUp':
send = '\x1B[5~';
break;
case 'PgDn':
send = '\x1B[6~';
break;
default:
send = key;
break;
}
_terminalModel.sendVirtualKey(send);
}
// https://github.com/TerminalStudio/xterm.dart/issues/42#issuecomment-877495472
// https://github.com/TerminalStudio/xterm.dart/issues/198#issuecomment-2526548458
TerminalStyle _getTerminalStyle() {
return isWeb
? TerminalStyle(
fontFamily: _robotoMonoFontFamily,
fontSize: 14,
)
: const TerminalStyle();
}
@override
bool get wantKeepAlive => true;
}