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>
This commit is contained in:
dongrencd
2026-07-25 22:33:16 +08:00
committed by GitHub
parent cefff781d4
commit 57456f0b52
8 changed files with 929 additions and 48 deletions

View File

@@ -178,6 +178,7 @@ const String kOptionAllowAskForNoteAtEndOfConnection = "allow-ask-for-note";
const String kOptionAllowMonitorSwitchMainToolbar = "allow-monitor-switch-main-toolbar";
const String kOptionAllowMonitorSwitchMinToolbar = "allow-monitor-switch-min-toolbar";
const String kOptionEnableShowTerminalExtraKeys = "enable-show-terminal-extra-keys";
const String kOptionShowTerminalCtrlKeys = "show-terminal-extra-ctrl-keys";
// network options
const String kOptionAllowWebSocket = "allow-websocket";

View File

@@ -5,8 +5,11 @@ 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';
@@ -42,6 +45,11 @@ class _TerminalPageState extends State<TerminalPage>
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;
@@ -94,6 +102,18 @@ class _TerminalPageState extends State<TerminalPage>
// 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
@@ -148,6 +168,39 @@ class _TerminalPageState extends State<TerminalPage>
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);
@@ -185,6 +238,7 @@ class _TerminalPageState extends State<TerminalPage>
//
// Android works fine without this workaround.
deleteDetection: isIOS,
onKeyEvent: _handleTerminalKeyEvent,
padding: _calculatePadding(heightPx),
onSecondaryTapDown: (details, offset) async {
final selection = _terminalModel.terminalController.selection;
@@ -193,11 +247,7 @@ class _TerminalPageState extends State<TerminalPage>
_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);
}
await _pasteClipboardText();
}
},
);
@@ -324,66 +374,171 @@ class _TerminalPageState extends State<TerminalPage>
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: [
_buildKeyButton('Esc'),
const SizedBox(width: 2),
_buildKeyButton('/'),
const SizedBox(width: 2),
_buildKeyButton('|'),
const SizedBox(width: 2),
_buildKeyButton('Home'),
const SizedBox(width: 2),
_buildKeyButton(''),
const SizedBox(width: 2),
_buildKeyButton('End'),
const SizedBox(width: 2),
_buildKeyButton('PgUp'),
],
),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_buildKeyButton('Tab'),
const SizedBox(width: 2),
_buildKeyButton('Ctrl+C'),
const SizedBox(width: 2),
_buildKeyButton('~'),
const SizedBox(width: 2),
_buildKeyButton(''),
const SizedBox(width: 2),
_buildKeyButton(''),
const SizedBox(width: 2),
_buildKeyButton(''),
const SizedBox(width: 2),
_buildKeyButton('PgDn'),
..._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(48, 32),
minimumSize: const Size(terminalKeyboardKeyWidth, 32),
padding: EdgeInsets.zero,
textStyle: const TextStyle(fontSize: 12),
backgroundColor: Theme.of(context).colorScheme.surfaceVariant,
backgroundColor:
Theme.of(context).colorScheme.surfaceContainerHighest,
foregroundColor: Theme.of(context).colorScheme.onSurfaceVariant,
),
);
}
void _sendKeyToTerminal(String key) {
String? send;
String send;
switch (key) {
case 'Esc':
@@ -427,9 +582,7 @@ class _TerminalPageState extends State<TerminalPage>
break;
}
if (send != null) {
_terminalModel.sendVirtualKey(send);
}
_terminalModel.sendVirtualKey(send);
}
// https://github.com/TerminalStudio/xterm.dart/issues/42#issuecomment-877495472

View File

@@ -0,0 +1,20 @@
/// Reviewed mobile terminal keyboard layout from PR #15532.
///
/// Keeping the key order outside the widget makes the intended layout explicit
/// and prevents behavior fixes from silently moving keys between rows.
const terminalKeyboardRow1Keys = ['Esc', '/', '|', 'Home', '', 'End', r'\'];
const terminalKeyboardRow2Keys = ['Tab', 'Ctrl+C', '~', '', '', ''];
const terminalKeyboardRow3Keys = ['Ctrl', 'Alt', '-', 'PgUp', 'PgDn'];
const terminalKeyboardKeyWidth = 48.0;
const terminalKeyboardKeySpacing = 2.0;
/// Empty 48dp slots keep expanded Row3 aligned with the two rows above it.
const terminalKeyboardRow3TrailingPlaceholderCount = 2;
/// Returns the fixed width occupied by a row of equally sized key slots.
double terminalKeyboardRowWidth(int slotCount) {
if (slotCount <= 0) return 0;
return slotCount * terminalKeyboardKeyWidth +
(slotCount - 1) * terminalKeyboardKeySpacing;
}

View File

@@ -1,4 +1,12 @@
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
/// Identifies where terminal input originated so paste data can bypass all
/// keyboard-only transformations.
enum TerminalInputSource {
keyboard,
paste,
}
/// Returns true when a stale mobile one-shot Shift state should be released
/// by replaying a tracked Shift key-down as a synthesized key-up.
@@ -36,3 +44,147 @@ bool shouldReleaseStaleMobileShift({
}
return true;
}
/// Applies the terminal Ctrl/Alt one-shot modifiers to a single input payload.
///
String applyTerminalInputModifiers(
String data, {
required bool ctrlLocked,
required bool altLocked,
}) {
var result = data;
if (ctrlLocked) {
result = _applyTerminalCtrlModifier(result);
}
if (altLocked) {
result = '\x1B$result';
}
return result;
}
/// Builds the exact payload xterm sends for paste, without applying modifiers.
String terminalPastePayload(String text, {required bool bracketedPasteMode}) {
if (!bracketedPasteMode) {
return text;
}
return '\x1B[200~$text\x1B[201~';
}
/// Returns whether one-shot Ctrl/Alt may transform and consume this input.
///
/// xterm emits terminal control keys as either one control byte or a longer
/// escape sequence. Neither form is ordinary text input, so a pending modifier
/// must survive until the user enters a printable character.
bool shouldApplyTerminalInputModifiers(String data) {
if (data.characters.length != 1) return false;
final codeUnit = data.codeUnitAt(0);
return codeUnit >= 0x20 && codeUnit != 0x7F;
}
/// Builds the payload sent to the remote terminal for keyboard and paste input.
///
/// Keyboard input keeps the mobile Enter workaround and one-shot Ctrl/Alt
/// mapping. Paste input deliberately bypasses both transformations so even a
/// one-character clipboard payload is preserved exactly.
String prepareTerminalInputPayload(
String data, {
required TerminalInputSource source,
required bool isMobileOrWebMobile,
required bool bracketedPasteMode,
required bool ctrlLocked,
required bool altLocked,
}) {
if (source == TerminalInputSource.paste) {
return terminalPastePayload(
data,
bracketedPasteMode: bracketedPasteMode,
);
}
var result = data;
if (isMobileOrWebMobile && result == '\n') {
result = '\r';
}
if ((ctrlLocked || altLocked) && shouldApplyTerminalInputModifiers(result)) {
result = applyTerminalInputModifiers(
result,
ctrlLocked: ctrlLocked,
altLocked: altLocked,
);
}
return result;
}
/// Returns true when a hardware paste shortcut must bypass keyboard modifiers.
///
/// xterm already handles hardware Ctrl/Cmd+V correctly in the common case. Only
/// intercept while a virtual Ctrl/Alt lock is active, because xterm can emit a
/// one-character paste as normal text when bracketed paste mode is disabled.
bool shouldHandleTerminalPasteShortcut({
required LogicalKeyboardKey logicalKey,
required bool isKeyDown,
required bool isKeyRepeat,
required bool controlPressed,
required bool metaPressed,
required bool altPressed,
required bool shiftPressed,
required bool modifierLockActive,
}) {
if (!modifierLockActive) return false;
if (!isKeyDown && !isKeyRepeat) return false;
if (logicalKey != LogicalKeyboardKey.keyV) return false;
if (altPressed || shiftPressed) return false;
return controlPressed != metaPressed;
}
/// Returns true when collapsing Row3 should also clear hidden modifier state.
bool shouldClearTerminalModifiersWhenRow3Collapses({
required bool wasExpanded,
required bool willExpand,
required bool ctrlLocked,
required bool altLocked,
}) {
return wasExpanded && !willExpand && (ctrlLocked || altLocked);
}
String _applyTerminalCtrlModifier(String data) {
// Ctrl mappings are defined only for ASCII scalars. A visible character can
// be multiple scalars (for example, a decomposed accent), so leave those
// graphemes untouched instead of rewriting only their ASCII base letter.
final graphemes = data.characters.toList(growable: false);
if (graphemes.length != 1) {
return data;
}
final runes = graphemes.single.runes.toList(growable: false);
if (runes.length != 1) {
return data;
}
final code = runes.single;
if (code >= 0x61 && code <= 0x7A) {
return String.fromCharCode(code - 0x60);
}
if (code >= 0x41 && code <= 0x5A) {
return String.fromCharCode(code - 0x40);
}
if (code == 0x20) {
return String.fromCharCode(0);
}
if (code == 0x5B) {
return String.fromCharCode(27);
}
if (code == 0x5C) {
return String.fromCharCode(28);
}
if (code == 0x5D) {
return String.fromCharCode(29);
}
if (code == 0x5E) {
return String.fromCharCode(30);
}
if (code == 0x5F || code == 0x2F) {
return String.fromCharCode(31);
}
return data;
}

View File

@@ -7,6 +7,7 @@ import 'package:flutter_hbb/consts.dart';
import 'package:flutter_hbb/main.dart';
import 'package:xterm/xterm.dart';
import 'input_modifier_utils.dart';
import 'model.dart';
import 'platform_model.dart';
@@ -22,7 +23,25 @@ class TerminalModel with ChangeNotifier {
bool _disposed = false;
/// Callback to check whether Ctrl modifier lock is currently active.
/// When active, keyboard input is mapped to control codes (e.g. 'b' → \x02).
bool Function()? isCtrlLocked;
/// Callback to clear Ctrl lock after a key is pressed (one-shot mode).
void Function()? clearCtrlLock;
/// Callback to check whether Alt modifier lock is currently active.
bool Function()? isAltLocked;
/// Callback to clear Alt lock after a key is pressed (one-shot mode).
void Function()? clearAltLock;
final _inputBuffer = <String>[];
/// Exposes buffered input only for lifecycle regression tests.
@visibleForTesting
int get debugBufferedInputCount => _inputBuffer.length;
// Buffer for output data received before terminal view has valid dimensions.
// This prevents NaN errors when writing to terminal before layout is complete.
final _pendingOutputChunks = <String>[];
@@ -42,6 +61,10 @@ class TerminalModel with ChangeNotifier {
VoidCallback? onClosed;
Future<void> _handleInput(String data) async {
// xterm can complete asynchronous input after the Flutter page has gone
// away. Stop before reading or clearing widget-owned modifier state.
if (_disposed) return;
// Soft keyboards (notably iOS) emit '\n' when Enter is pressed, while a
// real keyboard's Enter sends '\r'. Some Android keyboards also emit '\n'.
// - Peer Windows: '\r' works, '\n' is just a newline.
@@ -49,13 +72,44 @@ class TerminalModel with ChangeNotifier {
// (readline, prompt_toolkit, vim, TUI frameworks) expect '\r'.
// - Peer macOS: same as Linux, raw-mode apps expect '\r'
// (https://github.com/rustdesk/rustdesk/issues/14907).
// So on mobile / web-mobile, always normalize a lone '\n' to '\r'.
// We deliberately do not touch multi-character payloads (e.g. pasted text)
// so embedded newlines in pasted content are preserved.
final isMobileOrWebMobile = (isMobile || (isWeb && !isWebDesktop));
if (isMobileOrWebMobile && data == '\n') {
data = '\r';
// So on mobile / web-mobile, normalize the original lone '\n' to '\r'
// before modifier mappings. This keeps Ctrl+J mapped to LF instead of
// having the generated control code rewritten to CR afterward.
// Multi-character keyboard payloads, such as terminal escape sequences,
// remain unchanged. Paste input follows a separate preprocessing path.
final ctrlLocked = isCtrlLocked?.call() ?? false;
final altLocked = isAltLocked?.call() ?? false;
final modifiersActive = ctrlLocked || altLocked;
// Use the same predicate for transformation and consumption. Control keys
// and escape sequences must not silently consume a pending one-shot lock.
final shouldConsumeModifiers =
modifiersActive && shouldApplyTerminalInputModifiers(data);
data = prepareTerminalInputPayload(
data,
// IME soft-keyboard paste prompts currently arrive from xterm as normal
// text input with no paste-origin metadata. Keep them on the keyboard path;
// clipboard-content heuristics can misclassify ordinary typing.
source: TerminalInputSource.keyboard,
isMobileOrWebMobile: isMobile || (isWeb && !isWebDesktop),
bracketedPasteMode: terminal.bracketedPasteMode,
ctrlLocked: ctrlLocked,
altLocked: altLocked,
);
if (shouldConsumeModifiers) {
if (ctrlLocked) clearCtrlLock?.call();
if (altLocked) clearAltLock?.call();
}
return _sendInputPayload(data);
}
/// Sends an already prepared payload without applying keyboard semantics.
/// Both normal input and paste use this transport path after their source-
/// specific preprocessing has completed.
Future<void> _sendInputPayload(String data) async {
// Clipboard reads and native sends may complete after the terminal page has
// closed. Never send or re-buffer input once this model is disposed.
if (_disposed) return;
if (_terminalOpened) {
// Send user input to remote terminal
try {
@@ -176,6 +230,18 @@ class TerminalModel with ChangeNotifier {
return _handleInput(data);
}
Future<void> pasteText(String data) async {
final payload = prepareTerminalInputPayload(
data,
source: TerminalInputSource.paste,
isMobileOrWebMobile: false,
bracketedPasteMode: terminal.bracketedPasteMode,
ctrlLocked: false,
altLocked: false,
);
return _sendInputPayload(payload);
}
Future<void> closeTerminal() async {
if (_terminalOpened) {
try {
@@ -516,6 +582,14 @@ class TerminalModel with ChangeNotifier {
void dispose() {
if (_disposed) return;
_disposed = true;
terminal.onOutput = null;
terminal.onResize = null;
isCtrlLocked = null;
clearCtrlLock = null;
isAltLocked = null;
clearAltLock = null;
onResizeExternal = null;
onClosed = null;
// Clear buffers to free memory
_inputBuffer.clear();
_pendingOutputChunks.clear();

View File

@@ -122,4 +122,394 @@ void main() {
);
});
});
group('shouldApplyTerminalInputModifiers', () {
test('accepts ordinary single-character keyboard input', () {
expect(shouldApplyTerminalInputModifiers('a'), isTrue);
expect(shouldApplyTerminalInputModifiers(' '), isTrue);
expect(shouldApplyTerminalInputModifiers('/'), isTrue);
});
test('accepts supplementary-plane single-character keyboard input', () {
expect(shouldApplyTerminalInputModifiers('😀'), isTrue);
});
test('rejects terminal control bytes and multi-character sequences', () {
for (final input in ['\x00', '\x03', '\t', '\n', '\r', '\x1B', '\x7F']) {
expect(
shouldApplyTerminalInputModifiers(input),
isFalse,
reason: '${input.codeUnits} must not consume a one-shot modifier',
);
}
expect(shouldApplyTerminalInputModifiers('\x1B[A'), isFalse);
});
});
group('applyTerminalInputModifiers', () {
test('keeps decomposed graphemes intact under Ctrl', () {
const decomposedEAcute = 'e\u0301';
expect(
applyTerminalInputModifiers(
decomposedEAcute,
ctrlLocked: true,
altLocked: false,
),
decomposedEAcute,
);
});
test('keeps non-ASCII graphemes intact under Ctrl', () {
for (final input in ['é', '😀']) {
expect(
applyTerminalInputModifiers(
input,
ctrlLocked: true,
altLocked: false,
),
input,
);
}
});
test('maps Ctrl underscore to unit separator', () {
expect(
applyTerminalInputModifiers(
'_',
ctrlLocked: true,
altLocked: false,
),
'\x1F',
);
});
test('maps the complete Ctrl symbol range', () {
const mappings = {
'[': '\x1B',
r'\': '\x1C',
']': '\x1D',
'^': '\x1E',
'_': '\x1F',
'/': '\x1F',
};
for (final entry in mappings.entries) {
expect(
applyTerminalInputModifiers(
entry.key,
ctrlLocked: true,
altLocked: false,
),
entry.value,
reason: 'Ctrl+${entry.key} should map to ${entry.value.codeUnits}',
);
}
});
test('applies Ctrl before Alt for combined modifiers', () {
expect(
applyTerminalInputModifiers(
'b',
ctrlLocked: true,
altLocked: true,
),
'\x1B\x02',
);
});
});
group('terminalPastePayload', () {
test('wraps paste text when bracketed paste mode is active', () {
expect(
terminalPastePayload('d', bracketedPasteMode: true),
'\x1B[200~d\x1B[201~',
);
});
test('keeps a lone newline unchanged when bracketed paste is disabled', () {
expect(
terminalPastePayload('\n', bracketedPasteMode: false),
'\n',
);
});
});
group('prepareTerminalInputPayload', () {
test('normalizes a mobile keyboard Enter to carriage return', () {
expect(
prepareTerminalInputPayload(
'\n',
source: TerminalInputSource.keyboard,
isMobileOrWebMobile: true,
bracketedPasteMode: false,
ctrlLocked: false,
altLocked: false,
),
'\r',
);
});
test('keeps Ctrl+J as line feed on mobile', () {
expect(
prepareTerminalInputPayload(
'j',
source: TerminalInputSource.keyboard,
isMobileOrWebMobile: true,
bracketedPasteMode: false,
ctrlLocked: true,
altLocked: false,
),
'\n',
);
});
test('does not apply Alt to a terminal control byte', () {
expect(
prepareTerminalInputPayload(
'\x1B',
source: TerminalInputSource.keyboard,
isMobileOrWebMobile: true,
bracketedPasteMode: false,
ctrlLocked: false,
altLocked: true,
),
'\x1B',
);
});
test('keeps large keyboard payloads unchanged when modifiers are inactive',
() {
final payload = 'd' * (1024 * 1024);
expect(
prepareTerminalInputPayload(
payload,
source: TerminalInputSource.keyboard,
isMobileOrWebMobile: false,
bracketedPasteMode: false,
ctrlLocked: false,
altLocked: false,
),
payload,
);
});
test('keeps decomposed graphemes intact with locked keyboard modifiers',
() {
const decomposedEAcute = 'e\u0301';
expect(
prepareTerminalInputPayload(
decomposedEAcute,
source: TerminalInputSource.keyboard,
isMobileOrWebMobile: true,
bracketedPasteMode: false,
ctrlLocked: true,
altLocked: false,
),
decomposedEAcute,
);
});
test('preserves a lone pasted newline when modifiers are locked', () {
expect(
prepareTerminalInputPayload(
'\n',
source: TerminalInputSource.paste,
isMobileOrWebMobile: true,
bracketedPasteMode: false,
ctrlLocked: true,
altLocked: true,
),
'\n',
);
});
test('wraps paste without applying locked modifiers', () {
expect(
prepareTerminalInputPayload(
'd',
source: TerminalInputSource.paste,
isMobileOrWebMobile: true,
bracketedPasteMode: true,
ctrlLocked: true,
altLocked: true,
),
'\x1B[200~d\x1B[201~',
);
});
});
group('shouldHandleTerminalPasteShortcut', () {
test(
'keeps default xterm paste behavior when virtual modifiers are inactive',
() {
expect(
shouldHandleTerminalPasteShortcut(
logicalKey: LogicalKeyboardKey.keyV,
isKeyDown: true,
isKeyRepeat: false,
controlPressed: true,
metaPressed: false,
altPressed: false,
shiftPressed: false,
modifierLockActive: false,
),
isFalse,
);
});
test('handles Ctrl+V and Meta+V when a virtual modifier lock is active',
() {
expect(
shouldHandleTerminalPasteShortcut(
logicalKey: LogicalKeyboardKey.keyV,
isKeyDown: true,
isKeyRepeat: false,
controlPressed: true,
metaPressed: false,
altPressed: false,
shiftPressed: false,
modifierLockActive: true,
),
isTrue,
);
expect(
shouldHandleTerminalPasteShortcut(
logicalKey: LogicalKeyboardKey.keyV,
isKeyDown: true,
isKeyRepeat: false,
controlPressed: false,
metaPressed: true,
altPressed: false,
shiftPressed: false,
modifierLockActive: true,
),
isTrue,
);
});
test('handles paste shortcut repeats while a virtual lock is active', () {
expect(
shouldHandleTerminalPasteShortcut(
logicalKey: LogicalKeyboardKey.keyV,
isKeyDown: false,
isKeyRepeat: true,
controlPressed: true,
metaPressed: false,
altPressed: false,
shiftPressed: false,
modifierLockActive: true,
),
isTrue,
);
});
test('ignores key-up and unmodified V events', () {
expect(
shouldHandleTerminalPasteShortcut(
logicalKey: LogicalKeyboardKey.keyV,
isKeyDown: false,
isKeyRepeat: false,
controlPressed: true,
metaPressed: false,
altPressed: false,
shiftPressed: false,
modifierLockActive: true,
),
isFalse,
);
expect(
shouldHandleTerminalPasteShortcut(
logicalKey: LogicalKeyboardKey.keyV,
isKeyDown: true,
isKeyRepeat: false,
controlPressed: false,
metaPressed: false,
altPressed: false,
shiftPressed: false,
modifierLockActive: true,
),
isFalse,
);
});
test('ignores paste shortcuts with extra modifiers', () {
for (final state in [
(control: true, meta: false, alt: true, shift: false),
(control: true, meta: false, alt: false, shift: true),
(control: false, meta: true, alt: false, shift: true),
(control: true, meta: true, alt: false, shift: false),
]) {
expect(
shouldHandleTerminalPasteShortcut(
logicalKey: LogicalKeyboardKey.keyV,
isKeyDown: true,
isKeyRepeat: false,
controlPressed: state.control,
metaPressed: state.meta,
altPressed: state.alt,
shiftPressed: state.shift,
modifierLockActive: true,
),
isFalse,
);
}
});
test('ignores non-V key events', () {
expect(
shouldHandleTerminalPasteShortcut(
logicalKey: LogicalKeyboardKey.keyC,
isKeyDown: true,
isKeyRepeat: false,
controlPressed: true,
metaPressed: false,
altPressed: false,
shiftPressed: false,
modifierLockActive: true,
),
isFalse,
);
});
});
group('shouldClearTerminalModifiersWhenRow3Collapses', () {
test('clears visible modifier state when expanded row is collapsed', () {
expect(
shouldClearTerminalModifiersWhenRow3Collapses(
wasExpanded: true,
willExpand: false,
ctrlLocked: true,
altLocked: false,
),
isTrue,
);
});
test('does not clear modifiers when row expands', () {
expect(
shouldClearTerminalModifiersWhenRow3Collapses(
wasExpanded: false,
willExpand: true,
ctrlLocked: true,
altLocked: true,
),
isFalse,
);
});
test('clears Alt state when expanded row is collapsed', () {
expect(
shouldClearTerminalModifiersWhenRow3Collapses(
wasExpanded: true,
willExpand: false,
ctrlLocked: false,
altLocked: true,
),
isTrue,
);
});
});
}

View File

@@ -0,0 +1,40 @@
import 'package:flutter_hbb/mobile/terminal_keyboard_utils.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
group('mobile terminal keyboard layout', () {
test('keeps the latest key order from the reviewed PR layout', () {
expect(
terminalKeyboardRow1Keys,
['Esc', '/', '|', 'Home', '', 'End', r'\'],
);
expect(
terminalKeyboardRow2Keys,
['Tab', 'Ctrl+C', '~', '', '', ''],
);
expect(
terminalKeyboardRow3Keys,
['Ctrl', 'Alt', '-', 'PgUp', 'PgDn'],
);
});
test('keeps two trailing Row3 placeholders for row alignment', () {
expect(terminalKeyboardRow3TrailingPlaceholderCount, 2);
});
test('keeps every expanded row aligned at 348dp', () {
final rowWidths = [
terminalKeyboardRowWidth(terminalKeyboardRow1Keys.length),
terminalKeyboardRowWidth(terminalKeyboardRow2Keys.length + 1),
terminalKeyboardRowWidth(
terminalKeyboardRow3Keys.length +
terminalKeyboardRow3TrailingPlaceholderCount,
),
];
expect(terminalKeyboardKeyWidth, 48);
expect(terminalKeyboardKeySpacing, 2);
expect(rowWidths, everyElement(348));
});
});
}

View File

@@ -0,0 +1,51 @@
import 'dart:async';
import 'package:flutter_hbb/models/model.dart';
import 'package:flutter_hbb/models/terminal_model.dart';
import 'package:flutter_test/flutter_test.dart';
class _FakeFFI implements FFI {
@override
String id = 'test-peer';
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
void main() {
test('ignores paste that completes after the terminal model is disposed',
() async {
final model = TerminalModel(_FakeFFI());
final delayedClipboardText = Completer<String>();
// This mirrors Ctrl/Cmd+V: clipboard access starts first, then the page and
// model are disposed before the asynchronous read supplies its text.
final paste = delayedClipboardText.future.then(model.pasteText);
model.dispose();
delayedClipboardText.complete('late clipboard text');
await paste;
expect(model.debugBufferedInputCount, 0);
});
test('ignores terminal text input after the terminal model is disposed', () {
final model = TerminalModel(_FakeFFI());
var checkedCtrlLock = false;
var clearedCtrlLock = false;
model.isCtrlLocked = () {
checkedCtrlLock = true;
return true;
};
model.clearCtrlLock = () {
clearedCtrlLock = true;
};
model.dispose();
model.terminal.textInput('d');
expect(checkedCtrlLock, isFalse);
expect(clearedCtrlLock, isFalse);
expect(model.debugBufferedInputCount, 0);
});
}