diff --git a/flutter/lib/consts.dart b/flutter/lib/consts.dart index 722f7a23c..ce5441ddf 100644 --- a/flutter/lib/consts.dart +++ b/flutter/lib/consts.dart @@ -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"; diff --git a/flutter/lib/mobile/pages/terminal_page.dart b/flutter/lib/mobile/pages/terminal_page.dart index cbf47a7e9..a4a76f9af 100644 --- a/flutter/lib/mobile/pages/terminal_page.dart +++ b/flutter/lib/mobile/pages/terminal_page.dart @@ -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 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 // 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 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 _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 // // 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 _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 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 _buildKeyboardKeyButtons(List 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 break; } - if (send != null) { - _terminalModel.sendVirtualKey(send); - } + _terminalModel.sendVirtualKey(send); } // https://github.com/TerminalStudio/xterm.dart/issues/42#issuecomment-877495472 diff --git a/flutter/lib/mobile/terminal_keyboard_utils.dart b/flutter/lib/mobile/terminal_keyboard_utils.dart new file mode 100644 index 000000000..9248d1d4a --- /dev/null +++ b/flutter/lib/mobile/terminal_keyboard_utils.dart @@ -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; +} diff --git a/flutter/lib/models/input_modifier_utils.dart b/flutter/lib/models/input_modifier_utils.dart index e65c32790..9b8aae881 100644 --- a/flutter/lib/models/input_modifier_utils.dart +++ b/flutter/lib/models/input_modifier_utils.dart @@ -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; +} diff --git a/flutter/lib/models/terminal_model.dart b/flutter/lib/models/terminal_model.dart index 2b3fd4837..6f179afe2 100644 --- a/flutter/lib/models/terminal_model.dart +++ b/flutter/lib/models/terminal_model.dart @@ -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 = []; + + /// 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 = []; @@ -42,6 +61,10 @@ class TerminalModel with ChangeNotifier { VoidCallback? onClosed; Future _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 _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 pasteText(String data) async { + final payload = prepareTerminalInputPayload( + data, + source: TerminalInputSource.paste, + isMobileOrWebMobile: false, + bracketedPasteMode: terminal.bracketedPasteMode, + ctrlLocked: false, + altLocked: false, + ); + return _sendInputPayload(payload); + } + Future 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(); diff --git a/flutter/test/input_modifier_utils_test.dart b/flutter/test/input_modifier_utils_test.dart index 2e1971753..5a1a76a77 100644 --- a/flutter/test/input_modifier_utils_test.dart +++ b/flutter/test/input_modifier_utils_test.dart @@ -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, + ); + }); + }); } diff --git a/flutter/test/terminal_keyboard_utils_test.dart b/flutter/test/terminal_keyboard_utils_test.dart new file mode 100644 index 000000000..c93a42413 --- /dev/null +++ b/flutter/test/terminal_keyboard_utils_test.dart @@ -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)); + }); + }); +} diff --git a/flutter/test/terminal_model_lifecycle_test.dart b/flutter/test/terminal_model_lifecycle_test.dart new file mode 100644 index 000000000..d00646b2b --- /dev/null +++ b/flutter/test/terminal_model_lifecycle_test.dart @@ -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(); + + // 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); + }); +}