fix(flutter): align terminal shortcuts with platform conventions (#15970)

* fix(flutter): align terminal shortcuts with platform conventions

Signed-off-by: fufesou <linlong1266@gmail.com>

* fix(flutter): handle Linux terminal paste with modifier locks

Detect platform-specific paste shortcuts so Ctrl+Shift+V bypasses
virtual Ctrl/Alt modifiers on Linux. Add regression coverage.

Signed-off-by: fufesou <linlong1266@gmail.com>

---------

Signed-off-by: fufesou <linlong1266@gmail.com>
This commit is contained in:
fufesou
2026-08-27 16:33:58 +08:00
committed by GitHub
parent 1fe451c2e8
commit 03a7fc5992
4 changed files with 110 additions and 27 deletions

View File

@@ -1,5 +1,6 @@
import 'dart:async'; import 'dart:async';
import 'dart:math'; import 'dart:math';
import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart'; import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.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/input_modifier_utils.dart';
import 'package:flutter_hbb/models/model.dart'; import 'package:flutter_hbb/models/model.dart';
import 'package:flutter_hbb/models/platform_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/models/terminal_model.dart';
import 'package:flutter_hbb/mobile/terminal_keyboard_utils.dart'; import 'package:flutter_hbb/mobile/terminal_keyboard_utils.dart';
import 'package:flutter_hbb/web/dummy.dart' import 'package:flutter_hbb/web/dummy.dart'
@@ -190,6 +192,7 @@ class _TerminalPageState extends State<TerminalPage>
KeyEventResult _handleTerminalKeyEvent(FocusNode _, KeyEvent event) { KeyEventResult _handleTerminalKeyEvent(FocusNode _, KeyEvent event) {
final hardwareKeyboard = HardwareKeyboard.instance; final hardwareKeyboard = HardwareKeyboard.instance;
final shouldPaste = shouldHandleTerminalPasteShortcut( final shouldPaste = shouldHandleTerminalPasteShortcut(
platform: defaultTargetPlatform,
logicalKey: event.logicalKey, logicalKey: event.logicalKey,
isKeyDown: event is KeyDownEvent, isKeyDown: event is KeyDownEvent,
isKeyRepeat: event is KeyRepeatEvent, isKeyRepeat: event is KeyRepeatEvent,
@@ -244,7 +247,12 @@ class _TerminalPageState extends State<TerminalPage>
// //
// Android works fine without this workaround. // Android works fine without this workaround.
deleteDetection: isIOS, deleteDetection: isIOS,
onKeyEvent: _handleTerminalKeyEvent, shortcuts: platformTerminalShortcuts(),
onKeyEvent: terminalCopyHandler(
_terminalModel.terminal,
_terminalModel.terminalController,
fallback: _handleTerminalKeyEvent,
),
padding: _calculatePadding(heightPx), padding: _calculatePadding(heightPx),
onSecondaryTapDown: (details, offset) async { onSecondaryTapDown: (details, offset) async {
final selection = _terminalModel.terminalController.selection; final selection = _terminalModel.terminalController.selection;

View File

@@ -117,10 +117,11 @@ String prepareTerminalInputPayload(
/// Returns true when a hardware paste shortcut must bypass keyboard modifiers. /// Returns true when a hardware paste shortcut must bypass keyboard modifiers.
/// ///
/// xterm already handles hardware Ctrl/Cmd+V correctly in the common case. Only /// xterm already handles each platform's paste shortcut in the common case.
/// intercept while a virtual Ctrl/Alt lock is active, because xterm can emit a /// Only intercept while a virtual Ctrl/Alt lock is active, because xterm can
/// one-character paste as normal text when bracketed paste mode is disabled. /// emit a one-character paste as normal text when bracketed paste mode is off.
bool shouldHandleTerminalPasteShortcut({ bool shouldHandleTerminalPasteShortcut({
required TargetPlatform platform,
required LogicalKeyboardKey logicalKey, required LogicalKeyboardKey logicalKey,
required bool isKeyDown, required bool isKeyDown,
required bool isKeyRepeat, required bool isKeyRepeat,
@@ -133,8 +134,18 @@ bool shouldHandleTerminalPasteShortcut({
if (!modifierLockActive) return false; if (!modifierLockActive) return false;
if (!isKeyDown && !isKeyRepeat) return false; if (!isKeyDown && !isKeyRepeat) return false;
if (logicalKey != LogicalKeyboardKey.keyV) return false; if (logicalKey != LogicalKeyboardKey.keyV) return false;
if (altPressed || shiftPressed) return false; if (altPressed) return false;
return controlPressed != metaPressed; 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. /// Returns true when collapsing Row3 should also clear hidden modifier state.

View File

@@ -20,43 +20,68 @@ Future<void> writeTerminalClipboard(String text) async {
} }
Map<ShortcutActivator, Intent>? platformTerminalShortcuts() { Map<ShortcutActivator, Intent>? 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 { return {
for (final entry in defaultTerminalShortcuts.entries) for (final entry in defaultTerminalShortcuts.entries)
if (!_isControlVShortcut(entry.key)) entry.key: entry.value, if (!_isControlShortcut(
_controlShiftVPasteShortcut: entry.key,
const PasteTextIntent(SelectionChangedCause.keyboard), 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 is SingleActivator &&
shortcut.trigger == LogicalKeyboardKey.keyV && shortcut.trigger == key &&
shortcut.control && shortcut.control &&
!shortcut.shift && shortcut.shift == shift &&
!shortcut.alt && !shortcut.alt &&
!shortcut.meta; !shortcut.meta;
FocusOnKeyEventCallback terminalCopyHandler( FocusOnKeyEventCallback terminalCopyHandler(
Terminal terminal, Terminal terminal,
TerminalController controller, TerminalController controller, {
) => FocusOnKeyEventCallback? fallback,
(_, event) { }) =>
if (!_isWindowsCopyShortcut(event)) return KeyEventResult.ignored; (focusNode, event) {
final selection = controller.selection; if (_isSelectionCopyShortcut(event)) {
if (selection == null || selection.isCollapsed) { final selection = controller.selection;
return KeyEventResult.ignored; 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) { return fallback?.call(focusNode, event) ?? KeyEventResult.ignored;
final text = terminal.buffer.getText(selection);
unawaited(writeTerminalClipboard(text));
}
return KeyEventResult.handled;
}; };
bool _isWindowsCopyShortcut(KeyEvent event) { bool _isSelectionCopyShortcut(KeyEvent event) {
final keyboard = HardwareKeyboard.instance; 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 is KeyDownEvent || event is KeyRepeatEvent) &&
event.logicalKey == LogicalKeyboardKey.keyC && event.logicalKey == LogicalKeyboardKey.keyC &&
keyboard.isControlPressed && keyboard.isControlPressed &&

View File

@@ -342,11 +342,43 @@ void main() {
}); });
group('shouldHandleTerminalPasteShortcut', () { 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( test(
'keeps default xterm paste behavior when virtual modifiers are inactive', 'keeps default xterm paste behavior when virtual modifiers are inactive',
() { () {
expect( expect(
shouldHandleTerminalPasteShortcut( shouldHandleTerminalPasteShortcut(
platform: TargetPlatform.windows,
logicalKey: LogicalKeyboardKey.keyV, logicalKey: LogicalKeyboardKey.keyV,
isKeyDown: true, isKeyDown: true,
isKeyRepeat: false, isKeyRepeat: false,
@@ -364,6 +396,7 @@ void main() {
() { () {
expect( expect(
shouldHandleTerminalPasteShortcut( shouldHandleTerminalPasteShortcut(
platform: TargetPlatform.windows,
logicalKey: LogicalKeyboardKey.keyV, logicalKey: LogicalKeyboardKey.keyV,
isKeyDown: true, isKeyDown: true,
isKeyRepeat: false, isKeyRepeat: false,
@@ -377,6 +410,7 @@ void main() {
); );
expect( expect(
shouldHandleTerminalPasteShortcut( shouldHandleTerminalPasteShortcut(
platform: TargetPlatform.macOS,
logicalKey: LogicalKeyboardKey.keyV, logicalKey: LogicalKeyboardKey.keyV,
isKeyDown: true, isKeyDown: true,
isKeyRepeat: false, isKeyRepeat: false,
@@ -393,6 +427,7 @@ void main() {
test('handles paste shortcut repeats while a virtual lock is active', () { test('handles paste shortcut repeats while a virtual lock is active', () {
expect( expect(
shouldHandleTerminalPasteShortcut( shouldHandleTerminalPasteShortcut(
platform: TargetPlatform.windows,
logicalKey: LogicalKeyboardKey.keyV, logicalKey: LogicalKeyboardKey.keyV,
isKeyDown: false, isKeyDown: false,
isKeyRepeat: true, isKeyRepeat: true,
@@ -409,6 +444,7 @@ void main() {
test('ignores key-up and unmodified V events', () { test('ignores key-up and unmodified V events', () {
expect( expect(
shouldHandleTerminalPasteShortcut( shouldHandleTerminalPasteShortcut(
platform: TargetPlatform.windows,
logicalKey: LogicalKeyboardKey.keyV, logicalKey: LogicalKeyboardKey.keyV,
isKeyDown: false, isKeyDown: false,
isKeyRepeat: false, isKeyRepeat: false,
@@ -422,6 +458,7 @@ void main() {
); );
expect( expect(
shouldHandleTerminalPasteShortcut( shouldHandleTerminalPasteShortcut(
platform: TargetPlatform.windows,
logicalKey: LogicalKeyboardKey.keyV, logicalKey: LogicalKeyboardKey.keyV,
isKeyDown: true, isKeyDown: true,
isKeyRepeat: false, isKeyRepeat: false,
@@ -444,6 +481,7 @@ void main() {
]) { ]) {
expect( expect(
shouldHandleTerminalPasteShortcut( shouldHandleTerminalPasteShortcut(
platform: TargetPlatform.windows,
logicalKey: LogicalKeyboardKey.keyV, logicalKey: LogicalKeyboardKey.keyV,
isKeyDown: true, isKeyDown: true,
isKeyRepeat: false, isKeyRepeat: false,
@@ -461,6 +499,7 @@ void main() {
test('ignores non-V key events', () { test('ignores non-V key events', () {
expect( expect(
shouldHandleTerminalPasteShortcut( shouldHandleTerminalPasteShortcut(
platform: TargetPlatform.windows,
logicalKey: LogicalKeyboardKey.keyC, logicalKey: LogicalKeyboardKey.keyC,
isKeyDown: true, isKeyDown: true,
isKeyRepeat: false, isKeyRepeat: false,