diff --git a/flutter/lib/mobile/pages/terminal_page.dart b/flutter/lib/mobile/pages/terminal_page.dart index 800b0f8f4..7a8c03ebb 100644 --- a/flutter/lib/mobile/pages/terminal_page.dart +++ b/flutter/lib/mobile/pages/terminal_page.dart @@ -1,5 +1,6 @@ import 'dart:async'; import 'dart:math'; +import 'package:flutter/foundation.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -8,6 +9,7 @@ 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_copy_shortcut.dart'; import 'package:flutter_hbb/models/terminal_model.dart'; import 'package:flutter_hbb/mobile/terminal_keyboard_utils.dart'; import 'package:flutter_hbb/web/dummy.dart' @@ -190,6 +192,7 @@ class _TerminalPageState extends State KeyEventResult _handleTerminalKeyEvent(FocusNode _, KeyEvent event) { final hardwareKeyboard = HardwareKeyboard.instance; final shouldPaste = shouldHandleTerminalPasteShortcut( + platform: defaultTargetPlatform, logicalKey: event.logicalKey, isKeyDown: event is KeyDownEvent, isKeyRepeat: event is KeyRepeatEvent, @@ -244,7 +247,12 @@ class _TerminalPageState extends State // // Android works fine without this workaround. deleteDetection: isIOS, - onKeyEvent: _handleTerminalKeyEvent, + shortcuts: platformTerminalShortcuts(), + onKeyEvent: terminalCopyHandler( + _terminalModel.terminal, + _terminalModel.terminalController, + fallback: _handleTerminalKeyEvent, + ), padding: _calculatePadding(heightPx), onSecondaryTapDown: (details, offset) async { final selection = _terminalModel.terminalController.selection; diff --git a/flutter/lib/models/input_modifier_utils.dart b/flutter/lib/models/input_modifier_utils.dart index 9b8aae881..093e65776 100644 --- a/flutter/lib/models/input_modifier_utils.dart +++ b/flutter/lib/models/input_modifier_utils.dart @@ -117,10 +117,11 @@ String prepareTerminalInputPayload( /// 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. +/// xterm already handles each platform's paste shortcut 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 off. bool shouldHandleTerminalPasteShortcut({ + required TargetPlatform platform, required LogicalKeyboardKey logicalKey, required bool isKeyDown, required bool isKeyRepeat, @@ -133,8 +134,18 @@ bool shouldHandleTerminalPasteShortcut({ if (!modifierLockActive) return false; if (!isKeyDown && !isKeyRepeat) return false; if (logicalKey != LogicalKeyboardKey.keyV) return false; - if (altPressed || shiftPressed) return false; - return controlPressed != metaPressed; + if (altPressed) return false; + switch (platform) { + case TargetPlatform.linux: + return controlPressed && !metaPressed && shiftPressed; + case TargetPlatform.iOS: + case TargetPlatform.macOS: + return !controlPressed && metaPressed && !shiftPressed; + case TargetPlatform.android: + case TargetPlatform.fuchsia: + case TargetPlatform.windows: + return controlPressed && !metaPressed && !shiftPressed; + } } /// Returns true when collapsing Row3 should also clear hidden modifier state. diff --git a/flutter/lib/models/terminal_copy_shortcut.dart b/flutter/lib/models/terminal_copy_shortcut.dart index a526c16b4..242586e6b 100644 --- a/flutter/lib/models/terminal_copy_shortcut.dart +++ b/flutter/lib/models/terminal_copy_shortcut.dart @@ -20,43 +20,68 @@ Future writeTerminalClipboard(String text) async { } Map? platformTerminalShortcuts() { - if (defaultTargetPlatform != TargetPlatform.linux) return null; + final platform = defaultTargetPlatform; + if (platform == TargetPlatform.linux) { + return { + for (final entry in defaultTerminalShortcuts.entries) + if (!_isControlShortcut(entry.key, LogicalKeyboardKey.keyV)) + entry.key: entry.value, + _controlShiftVPasteShortcut: + const PasteTextIntent(SelectionChangedCause.keyboard), + }; + } + if (platform != TargetPlatform.windows && + platform != TargetPlatform.android) { + return null; + } return { for (final entry in defaultTerminalShortcuts.entries) - if (!_isControlVShortcut(entry.key)) entry.key: entry.value, - _controlShiftVPasteShortcut: - const PasteTextIntent(SelectionChangedCause.keyboard), + if (!_isControlShortcut( + entry.key, + LogicalKeyboardKey.keyC, + shift: true, + )) + entry.key: entry.value, }; } -bool _isControlVShortcut(ShortcutActivator shortcut) => +bool _isControlShortcut( + ShortcutActivator shortcut, + LogicalKeyboardKey key, { + bool shift = false, +}) => shortcut is SingleActivator && - shortcut.trigger == LogicalKeyboardKey.keyV && + shortcut.trigger == key && shortcut.control && - !shortcut.shift && + shortcut.shift == shift && !shortcut.alt && !shortcut.meta; FocusOnKeyEventCallback terminalCopyHandler( Terminal terminal, - TerminalController controller, -) => - (_, event) { - if (!_isWindowsCopyShortcut(event)) return KeyEventResult.ignored; - final selection = controller.selection; - if (selection == null || selection.isCollapsed) { - return KeyEventResult.ignored; + TerminalController controller, { + FocusOnKeyEventCallback? fallback, +}) => + (focusNode, event) { + if (_isSelectionCopyShortcut(event)) { + final selection = controller.selection; + if (selection != null && !selection.isCollapsed) { + if (event is KeyDownEvent) { + final text = terminal.buffer.getText(selection); + unawaited(writeTerminalClipboard(text)); + } + return KeyEventResult.handled; + } } - if (event is KeyDownEvent) { - final text = terminal.buffer.getText(selection); - unawaited(writeTerminalClipboard(text)); - } - return KeyEventResult.handled; + return fallback?.call(focusNode, event) ?? KeyEventResult.ignored; }; -bool _isWindowsCopyShortcut(KeyEvent event) { +bool _isSelectionCopyShortcut(KeyEvent event) { final keyboard = HardwareKeyboard.instance; - return defaultTargetPlatform == TargetPlatform.windows && + final platform = defaultTargetPlatform; + final usesControlCopy = + platform == TargetPlatform.windows || platform == TargetPlatform.android; + return usesControlCopy && (event is KeyDownEvent || event is KeyRepeatEvent) && event.logicalKey == LogicalKeyboardKey.keyC && keyboard.isControlPressed && diff --git a/flutter/test/input_modifier_utils_test.dart b/flutter/test/input_modifier_utils_test.dart index 5a1a76a77..bebf8d8af 100644 --- a/flutter/test/input_modifier_utils_test.dart +++ b/flutter/test/input_modifier_utils_test.dart @@ -342,11 +342,43 @@ void main() { }); group('shouldHandleTerminalPasteShortcut', () { + test('handles only Ctrl+Shift+V on Linux with a virtual lock', () { + expect( + shouldHandleTerminalPasteShortcut( + platform: TargetPlatform.linux, + logicalKey: LogicalKeyboardKey.keyV, + isKeyDown: true, + isKeyRepeat: false, + controlPressed: true, + metaPressed: false, + altPressed: false, + shiftPressed: true, + modifierLockActive: true, + ), + isTrue, + ); + expect( + shouldHandleTerminalPasteShortcut( + platform: TargetPlatform.linux, + logicalKey: LogicalKeyboardKey.keyV, + isKeyDown: true, + isKeyRepeat: false, + controlPressed: true, + metaPressed: false, + altPressed: false, + shiftPressed: false, + modifierLockActive: true, + ), + isFalse, + ); + }); + test( 'keeps default xterm paste behavior when virtual modifiers are inactive', () { expect( shouldHandleTerminalPasteShortcut( + platform: TargetPlatform.windows, logicalKey: LogicalKeyboardKey.keyV, isKeyDown: true, isKeyRepeat: false, @@ -364,6 +396,7 @@ void main() { () { expect( shouldHandleTerminalPasteShortcut( + platform: TargetPlatform.windows, logicalKey: LogicalKeyboardKey.keyV, isKeyDown: true, isKeyRepeat: false, @@ -377,6 +410,7 @@ void main() { ); expect( shouldHandleTerminalPasteShortcut( + platform: TargetPlatform.macOS, logicalKey: LogicalKeyboardKey.keyV, isKeyDown: true, isKeyRepeat: false, @@ -393,6 +427,7 @@ void main() { test('handles paste shortcut repeats while a virtual lock is active', () { expect( shouldHandleTerminalPasteShortcut( + platform: TargetPlatform.windows, logicalKey: LogicalKeyboardKey.keyV, isKeyDown: false, isKeyRepeat: true, @@ -409,6 +444,7 @@ void main() { test('ignores key-up and unmodified V events', () { expect( shouldHandleTerminalPasteShortcut( + platform: TargetPlatform.windows, logicalKey: LogicalKeyboardKey.keyV, isKeyDown: false, isKeyRepeat: false, @@ -422,6 +458,7 @@ void main() { ); expect( shouldHandleTerminalPasteShortcut( + platform: TargetPlatform.windows, logicalKey: LogicalKeyboardKey.keyV, isKeyDown: true, isKeyRepeat: false, @@ -444,6 +481,7 @@ void main() { ]) { expect( shouldHandleTerminalPasteShortcut( + platform: TargetPlatform.windows, logicalKey: LogicalKeyboardKey.keyV, isKeyDown: true, isKeyRepeat: false, @@ -461,6 +499,7 @@ void main() { test('ignores non-V key events', () { expect( shouldHandleTerminalPasteShortcut( + platform: TargetPlatform.windows, logicalKey: LogicalKeyboardKey.keyC, isKeyDown: true, isKeyRepeat: false,