diff --git a/packages/flutter/lib/src/services/text_input.dart b/packages/flutter/lib/src/services/text_input.dart index 8710c8beab8b..2d6320c630df 100644 --- a/packages/flutter/lib/src/services/text_input.dart +++ b/packages/flutter/lib/src/services/text_input.dart @@ -1165,6 +1165,11 @@ mixin TextInputClient { /// Requests that the client remove the text placeholder. void removeTextPlaceholder() {} + + /// Performs the specified MacOS-specific selector from the + /// `NSStandardKeyBindingResponding` protocol or user-specified selector + /// from `DefaultKeyBinding.Dict`. + void performSelector(String selectorName) {} } /// An interface to receive focus from the engine. @@ -1819,6 +1824,10 @@ class TextInput { case 'TextInputClient.performAction': _currentConnection!._client.performAction(_toTextInputAction(args[1] as String)); break; + case 'TextInputClient.performSelectors': + final List selectors = (args[1] as List).cast(); + selectors.forEach(_currentConnection!._client.performSelector); + break; case 'TextInputClient.performPrivateCommand': final Map firstArg = args[1] as Map; _currentConnection!._client.performPrivateCommand( diff --git a/packages/flutter/lib/src/widgets/default_text_editing_shortcuts.dart b/packages/flutter/lib/src/widgets/default_text_editing_shortcuts.dart index 2c426848307b..0ad4c0437168 100644 --- a/packages/flutter/lib/src/widgets/default_text_editing_shortcuts.dart +++ b/packages/flutter/lib/src/widgets/default_text_editing_shortcuts.dart @@ -6,6 +6,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; import 'actions.dart'; +import 'focus_traversal.dart'; import 'framework.dart'; import 'shortcuts.dart'; import 'text_editing_intents.dart'; @@ -258,6 +259,34 @@ class DefaultTextEditingShortcuts extends StatelessWidget { // The macOS shortcuts uses different word/line modifiers than most other // platforms. static final Map _macShortcuts = { + const SingleActivator(LogicalKeyboardKey.keyX, meta: true): const CopySelectionTextIntent.cut(SelectionChangedCause.keyboard), + const SingleActivator(LogicalKeyboardKey.keyC, meta: true): CopySelectionTextIntent.copy, + const SingleActivator(LogicalKeyboardKey.keyV, meta: true): const PasteTextIntent(SelectionChangedCause.keyboard), + const SingleActivator(LogicalKeyboardKey.keyA, meta: true): const SelectAllTextIntent(SelectionChangedCause.keyboard), + const SingleActivator(LogicalKeyboardKey.keyZ, meta: true): const UndoTextIntent(SelectionChangedCause.keyboard), + const SingleActivator(LogicalKeyboardKey.keyZ, shift: true, meta: true): const RedoTextIntent(SelectionChangedCause.keyboard), + + // On desktop these keys should go to the IME when a field is focused, not to other + // Shortcuts. + if (!kIsWeb) ...{ + const SingleActivator(LogicalKeyboardKey.arrowLeft): const DoNothingAndStopPropagationTextIntent(), + const SingleActivator(LogicalKeyboardKey.arrowRight): const DoNothingAndStopPropagationTextIntent(), + const SingleActivator(LogicalKeyboardKey.arrowUp): const DoNothingAndStopPropagationTextIntent(), + const SingleActivator(LogicalKeyboardKey.arrowDown): const DoNothingAndStopPropagationTextIntent(), + const SingleActivator(LogicalKeyboardKey.arrowLeft, meta: true): const DoNothingAndStopPropagationTextIntent(), + const SingleActivator(LogicalKeyboardKey.arrowRight, meta: true): const DoNothingAndStopPropagationTextIntent(), + const SingleActivator(LogicalKeyboardKey.arrowUp, meta: true): const DoNothingAndStopPropagationTextIntent(), + const SingleActivator(LogicalKeyboardKey.arrowDown, meta: true): const DoNothingAndStopPropagationTextIntent(), + const SingleActivator(LogicalKeyboardKey.escape): const DoNothingAndStopPropagationTextIntent(), + const SingleActivator(LogicalKeyboardKey.space): const DoNothingAndStopPropagationTextIntent(), + const SingleActivator(LogicalKeyboardKey.enter): const DoNothingAndStopPropagationTextIntent(), + const SingleActivator(LogicalKeyboardKey.tab): const DoNothingAndStopPropagationTextIntent(), + const SingleActivator(LogicalKeyboardKey.tab, shift: true): const DoNothingAndStopPropagationTextIntent(), + }, + }; + + // There is no complete documentation of iOS shortcuts. + static final Map _iOSShortcuts = { for (final bool pressShift in const [true, false]) ...{ SingleActivator(LogicalKeyboardKey.backspace, shift: pressShift): const DeleteCharacterIntent(forward: false), @@ -296,8 +325,8 @@ class DefaultTextEditingShortcuts extends StatelessWidget { const SingleActivator(LogicalKeyboardKey.arrowLeft, shift: true, meta: true): const ExpandSelectionToLineBreakIntent(forward: false), const SingleActivator(LogicalKeyboardKey.arrowRight, shift: true, meta: true): const ExpandSelectionToLineBreakIntent(forward: true), - const SingleActivator(LogicalKeyboardKey.arrowUp, shift: true, meta: true): const ExtendSelectionToDocumentBoundaryIntent(forward: false, collapseSelection: false), - const SingleActivator(LogicalKeyboardKey.arrowDown, shift: true, meta: true): const ExtendSelectionToDocumentBoundaryIntent(forward: true, collapseSelection: false), + const SingleActivator(LogicalKeyboardKey.arrowUp, shift: true, meta: true): const ExpandSelectionToDocumentBoundaryIntent(forward: false), + const SingleActivator(LogicalKeyboardKey.arrowDown, shift: true, meta: true): const ExpandSelectionToDocumentBoundaryIntent(forward: true), const SingleActivator(LogicalKeyboardKey.keyT, control: true): const TransposeCharactersIntent(), @@ -331,9 +360,6 @@ class DefaultTextEditingShortcuts extends StatelessWidget { // * Control + shift? + Z }; - // There is no complete documentation of iOS shortcuts. Use mac shortcuts for - // now. - static final Map _iOSShortcuts = _macShortcuts; // The following key combinations have no effect on text editing on this // platform: @@ -461,3 +487,67 @@ class DefaultTextEditingShortcuts extends StatelessWidget { ); } } + +/// Maps the selector from NSStandardKeyBindingResponding to the Intent if the +/// selector is recognized. +Intent? intentForMacOSSelector(String selectorName) { + const Map selectorToIntent = { + 'deleteBackward:': DeleteCharacterIntent(forward: false), + 'deleteWordBackward:': DeleteToNextWordBoundaryIntent(forward: false), + 'deleteToBeginningOfLine:': DeleteToLineBreakIntent(forward: false), + 'deleteForward:': DeleteCharacterIntent(forward: true), + 'deleteWordForward:': DeleteToNextWordBoundaryIntent(forward: true), + 'deleteToEndOfLine:': DeleteToLineBreakIntent(forward: true), + + 'moveLeft:': ExtendSelectionByCharacterIntent(forward: false, collapseSelection: true), + 'moveRight:': ExtendSelectionByCharacterIntent(forward: true, collapseSelection: true), + 'moveForward:': ExtendSelectionByCharacterIntent(forward: true, collapseSelection: true), + 'moveBackward:': ExtendSelectionByCharacterIntent(forward: false, collapseSelection: true), + + 'moveUp:': ExtendSelectionVerticallyToAdjacentLineIntent(forward: false, collapseSelection: true), + 'moveDown:': ExtendSelectionVerticallyToAdjacentLineIntent(forward: true, collapseSelection: true), + + 'moveLeftAndModifySelection:': ExtendSelectionByCharacterIntent(forward: false, collapseSelection: false), + 'moveRightAndModifySelection:': ExtendSelectionByCharacterIntent(forward: true, collapseSelection: false), + 'moveUpAndModifySelection:': ExtendSelectionVerticallyToAdjacentLineIntent(forward: false, collapseSelection: false), + 'moveDownAndModifySelection:': ExtendSelectionVerticallyToAdjacentLineIntent(forward: true, collapseSelection: false), + + 'moveWordLeft:': ExtendSelectionToNextWordBoundaryIntent(forward: false, collapseSelection: true), + 'moveWordRight:': ExtendSelectionToNextWordBoundaryIntent(forward: true, collapseSelection: true), + 'moveToBeginningOfParagraph:': ExtendSelectionToLineBreakIntent(forward: false, collapseSelection: true), + 'moveToEndOfParagraph:': ExtendSelectionToLineBreakIntent(forward: true, collapseSelection: true), + + 'moveWordLeftAndModifySelection:': ExtendSelectionToNextWordBoundaryOrCaretLocationIntent(forward: false), + 'moveWordRightAndModifySelection:': ExtendSelectionToNextWordBoundaryOrCaretLocationIntent(forward: true), + 'moveParagraphBackwardAndModifySelection:': ExtendSelectionToLineBreakIntent(forward: false, collapseSelection: false, collapseAtReversal: true), + 'moveParagraphForwardAndModifySelection:': ExtendSelectionToLineBreakIntent(forward: true, collapseSelection: false, collapseAtReversal: true), + + 'moveToLeftEndOfLine:': ExtendSelectionToLineBreakIntent(forward: false, collapseSelection: true), + 'moveToRightEndOfLine:': ExtendSelectionToLineBreakIntent(forward: true, collapseSelection: true), + 'moveToBeginningOfDocument:': ExtendSelectionToDocumentBoundaryIntent(forward: false, collapseSelection: true), + 'moveToEndOfDocument:': ExtendSelectionToDocumentBoundaryIntent(forward: true, collapseSelection: true), + + 'moveToLeftEndOfLineAndModifySelection:': ExpandSelectionToLineBreakIntent(forward: false), + 'moveToRightEndOfLineAndModifySelection:': ExpandSelectionToLineBreakIntent(forward: true), + 'moveToBeginningOfDocumentAndModifySelection:': ExpandSelectionToDocumentBoundaryIntent(forward: false), + 'moveToEndOfDocumentAndModifySelection:': ExpandSelectionToDocumentBoundaryIntent(forward: true), + + 'transpose:': TransposeCharactersIntent(), + + 'scrollToBeginningOfDocument:': ScrollToDocumentBoundaryIntent(forward: false), + 'scrollToEndOfDocument:': ScrollToDocumentBoundaryIntent(forward: true), + + // TODO(knopp): Page Up/Down intents are missing (https://github.com/flutter/flutter/pull/105497) + 'scrollPageUp:': ScrollToDocumentBoundaryIntent(forward: false), + 'scrollPageDown:': ScrollToDocumentBoundaryIntent(forward: true), + 'pageUpAndModifySelection': ExpandSelectionToDocumentBoundaryIntent(forward: false), + 'pageDownAndModifySelection:': ExpandSelectionToDocumentBoundaryIntent(forward: true), + + // Escape key when there's no IME selection popup. + 'cancelOperation:': DismissIntent(), + // Tab when there's no IME selection. + 'insertTab:': NextFocusIntent(), + 'insertBacktab:': PreviousFocusIntent(), + }; + return selectorToIntent[selectorName]; +} diff --git a/packages/flutter/lib/src/widgets/editable_text.dart b/packages/flutter/lib/src/widgets/editable_text.dart index 3653cd5d3f5c..11d45138d1f3 100644 --- a/packages/flutter/lib/src/widgets/editable_text.dart +++ b/packages/flutter/lib/src/widgets/editable_text.dart @@ -21,6 +21,7 @@ import 'binding.dart'; import 'constants.dart'; import 'debug.dart'; import 'default_selection_style.dart'; +import 'default_text_editing_shortcuts.dart'; import 'focus_manager.dart'; import 'focus_scope.dart'; import 'focus_traversal.dart'; @@ -3227,6 +3228,18 @@ class EditableTextState extends State with AutomaticKeepAliveClien }); } + @override + void performSelector(String selectorName) { + final Intent? intent = intentForMacOSSelector(selectorName); + + if (intent != null) { + final BuildContext? primaryContext = primaryFocus?.context; + if (primaryContext != null) { + Actions.invoke(primaryContext, intent); + } + } + } + @override String get autofillId => 'EditableText-$hashCode'; @@ -4421,7 +4434,16 @@ class _UpdateTextSelectionAction exten } final _TextBoundary textBoundary = getTextBoundariesForIntent(intent); - final TextSelection textBoundarySelection = textBoundary.textEditingValue.selection; + + // "textBoundary's selection is only updated after rebuild; if the text + // is the same, use the selection from state, which is more recent. + // This is necessary on macOS where alt+up sends the moveBackward: + // and moveToBeginningOfParagraph: selectors at the same time. + final TextSelection textBoundarySelection = + textBoundary.textEditingValue.text == state._value.text + ? state._value.selection + : textBoundary.textEditingValue.selection; + if (!textBoundarySelection.isValid) { return null; } diff --git a/packages/flutter/test/services/autofill_test.dart b/packages/flutter/test/services/autofill_test.dart index 366f37040aa8..4653fa8cdec0 100644 --- a/packages/flutter/test/services/autofill_test.dart +++ b/packages/flutter/test/services/autofill_test.dart @@ -156,6 +156,11 @@ class FakeAutofillClient implements TextInputClient, AutofillClient { void removeTextPlaceholder() { latestMethodCall = 'removeTextPlaceholder'; } + + @override + void performSelector(String selectorName) { + latestMethodCall = 'performSelector'; + } } class FakeAutofillScope with AutofillScopeMixin implements AutofillScope { diff --git a/packages/flutter/test/services/delta_text_input_test.dart b/packages/flutter/test/services/delta_text_input_test.dart index bdce6138668e..0c755140f22c 100644 --- a/packages/flutter/test/services/delta_text_input_test.dart +++ b/packages/flutter/test/services/delta_text_input_test.dart @@ -286,5 +286,10 @@ class FakeDeltaTextInputClient implements DeltaTextInputClient { latestMethodCall = 'showToolbar'; } + @override + void performSelector(String selectorName) { + latestMethodCall = 'performSelector'; + } + TextInputConfiguration get configuration => const TextInputConfiguration(enableDeltaModel: true); } diff --git a/packages/flutter/test/services/text_input_test.dart b/packages/flutter/test/services/text_input_test.dart index 289f38631863..e958aa32256a 100644 --- a/packages/flutter/test/services/text_input_test.dart +++ b/packages/flutter/test/services/text_input_test.dart @@ -379,6 +379,35 @@ void main() { expect(client.latestMethodCall, 'connectionClosed'); }); + test('TextInputClient performSelectors method is called', () async { + final FakeTextInputClient client = FakeTextInputClient(TextEditingValue.empty); + const TextInputConfiguration configuration = TextInputConfiguration(); + TextInput.attach(client, configuration); + + expect(client.performedSelectors, isEmpty); + expect(client.latestMethodCall, isEmpty); + + // Send performSelectors message. + final ByteData? messageBytes = const JSONMessageCodec().encodeMessage({ + 'args': [ + 1, + [ + 'selector1', + 'selector2', + ] + ], + 'method': 'TextInputClient.performSelectors', + }); + await ServicesBinding.instance.defaultBinaryMessenger.handlePlatformMessage( + 'flutter/textinput', + messageBytes, + (ByteData? _) {}, + ); + + expect(client.latestMethodCall, 'performSelector'); + expect(client.performedSelectors, ['selector1', 'selector2']); + }); + test('TextInputClient performPrivateCommand method is called', () async { // Assemble a TextInputConnection so we can verify its change in state. final FakeTextInputClient client = FakeTextInputClient(TextEditingValue.empty); @@ -704,6 +733,7 @@ class FakeTextInputClient with TextInputClient { FakeTextInputClient(this.currentTextEditingValue); String latestMethodCall = ''; + final List performedSelectors = []; @override TextEditingValue currentTextEditingValue; @@ -757,4 +787,10 @@ class FakeTextInputClient with TextInputClient { void removeTextPlaceholder() { latestMethodCall = 'removeTextPlaceholder'; } + + @override + void performSelector(String selectorName) { + latestMethodCall = 'performSelector'; + performedSelectors.add(selectorName); + } } diff --git a/packages/flutter/test/widgets/editable_text_test.dart b/packages/flutter/test/widgets/editable_text_test.dart index 51ed207f3b13..c63512756971 100644 --- a/packages/flutter/test/widgets/editable_text_test.dart +++ b/packages/flutter/test/widgets/editable_text_test.dart @@ -5870,17 +5870,39 @@ void main() { targetPlatform: defaultTargetPlatform, ); - expect( - selection, - equals( - const TextSelection( - baseOffset: 3, - extentOffset: 0, - affinity: TextAffinity.upstream, - ), - ), - reason: 'on $platform', - ); + switch (defaultTargetPlatform) { + // Extend selection. + case TargetPlatform.android: + case TargetPlatform.fuchsia: + case TargetPlatform.linux: + case TargetPlatform.windows: + expect( + selection, + equals( + const TextSelection( + baseOffset: 3, + extentOffset: 0, + affinity: TextAffinity.upstream, + ), + ), + reason: 'on $platform', + ); + break; + // On macOS/iOS expand selection. + case TargetPlatform.iOS: + case TargetPlatform.macOS: + expect( + selection, + equals( + const TextSelection( + baseOffset: 72, + extentOffset: 0, + ), + ), + reason: 'on $platform', + ); + break; + } // Move to start again. await sendKeys( @@ -12562,6 +12584,63 @@ void main() { variant: const TargetPlatformVariant({ TargetPlatform.iOS, TargetPlatform.macOS }), ); }); + + testWidgets('macOS selectors work', (WidgetTester tester) async { + controller.text = 'test\nline2'; + controller.selection = TextSelection.collapsed(offset: controller.text.length); + + final GlobalKey key = GlobalKey(); + + await tester.pumpWidget(MaterialApp( + home: Align( + alignment: Alignment.topLeft, + child: SizedBox( + width: 400, + child: EditableText( + key: key, + maxLines: 10, + controller: controller, + showSelectionHandles: true, + autofocus: true, + focusNode: FocusNode(), + style: Typography.material2018().black.subtitle1!, + cursorColor: Colors.blue, + backgroundCursorColor: Colors.grey, + selectionControls: materialTextSelectionControls, + keyboardType: TextInputType.text, + textAlign: TextAlign.right, + ), + ), + ), + )); + + key.currentState!.performSelector('moveLeft:'); + await tester.pump(); + + expect( + controller.selection, + const TextSelection.collapsed(offset: 9), + ); + + key.currentState!.performSelector('moveToBeginningOfParagraph:'); + await tester.pump(); + + expect( + controller.selection, + const TextSelection.collapsed(offset: 5), + ); + + // These both need to be handled, first moves cursor to the end of previous + // paragraph, second moves to the beginning of paragraph. + key.currentState!.performSelector('moveBackward:'); + key.currentState!.performSelector('moveToBeginningOfParagraph:'); + await tester.pump(); + + expect( + controller.selection, + const TextSelection.collapsed(offset: 0), + ); + }); }); group('magnifier', () { diff --git a/packages/flutter_test/lib/src/event_simulation.dart b/packages/flutter_test/lib/src/event_simulation.dart index 147b274056a1..79853f263c3c 100644 --- a/packages/flutter_test/lib/src/event_simulation.dart +++ b/packages/flutter_test/lib/src/event_simulation.dart @@ -902,8 +902,13 @@ Future simulateKeyDownEvent( String? platform, PhysicalKeyboardKey? physicalKey, String? character, -}) { - return KeyEventSimulator.simulateKeyDownEvent(key, platform: platform, physicalKey: physicalKey, character: character); +}) async { + final bool handled = await KeyEventSimulator.simulateKeyDownEvent(key, platform: platform, physicalKey: physicalKey, character: character); + final ServicesBinding binding = ServicesBinding.instance; + if (!handled && binding is TestWidgetsFlutterBinding) { + await binding.testTextInput.handleKeyDownEvent(key); + } + return handled; } /// Simulates sending a hardware key up event through the system channel. @@ -929,8 +934,13 @@ Future simulateKeyUpEvent( LogicalKeyboardKey key, { String? platform, PhysicalKeyboardKey? physicalKey, -}) { - return KeyEventSimulator.simulateKeyUpEvent(key, platform: platform, physicalKey: physicalKey); +}) async { + final bool handled = await KeyEventSimulator.simulateKeyUpEvent(key, platform: platform, physicalKey: physicalKey); + final ServicesBinding binding = ServicesBinding.instance; + if (!handled && binding is TestWidgetsFlutterBinding) { + await binding.testTextInput.handleKeyUpEvent(key); + } + return handled; } /// Simulates sending a hardware key repeat event through the system channel. diff --git a/packages/flutter_test/lib/src/test_text_input.dart b/packages/flutter_test/lib/src/test_text_input.dart index 1b27c413f256..55138cc94d4d 100644 --- a/packages/flutter_test/lib/src/test_text_input.dart +++ b/packages/flutter_test/lib/src/test_text_input.dart @@ -4,11 +4,13 @@ import 'dart:async'; +import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; import 'binding.dart'; import 'deprecated.dart'; import 'test_async_utils.dart'; +import 'test_text_input_key_handler.dart'; export 'package:flutter/services.dart' show TextEditingValue, TextInputAction; @@ -105,6 +107,9 @@ class TestTextInput { } bool _isVisible = false; + // Platform specific key handler that can process unhandled keyboard events. + TestTextInputKeyHandler? _keyHandler; + /// Resets any internal state of this object. /// /// This method is invoked by the testing framework between tests. It should @@ -131,6 +136,7 @@ class TestTextInput { case 'TextInput.clearClient': _client = null; _isVisible = false; + _keyHandler = null; onCleared?.call(); break; case 'TextInput.setEditingState': @@ -138,9 +144,13 @@ class TestTextInput { break; case 'TextInput.show': _isVisible = true; + if (!kIsWeb && defaultTargetPlatform == TargetPlatform.macOS) { + _keyHandler ??= MacOSTestTextInputKeyHandler(_client ?? -1); + } break; case 'TextInput.hide': _isVisible = false; + _keyHandler = null; break; } } @@ -350,4 +360,14 @@ class TestTextInput { (ByteData? data) { /* response from framework is discarded */ }, ); } + + /// Gives text input chance to respond to unhandled key down event. + Future handleKeyDownEvent(LogicalKeyboardKey key) async { + await _keyHandler?.handleKeyDownEvent(key); + } + + /// Gives text input chance to respond to unhandled key up event. + Future handleKeyUpEvent(LogicalKeyboardKey key) async { + await _keyHandler?.handleKeyUpEvent(key); + } } diff --git a/packages/flutter_test/lib/src/test_text_input_key_handler.dart b/packages/flutter_test/lib/src/test_text_input_key_handler.dart new file mode 100644 index 000000000000..86e92127f13a --- /dev/null +++ b/packages/flutter_test/lib/src/test_text_input_key_handler.dart @@ -0,0 +1,279 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import 'binding.dart'; + +/// Processes text input events that were not handled by the framework. +abstract class TestTextInputKeyHandler { + /// Process key down event that was not handled by the framework. + Future handleKeyDownEvent(LogicalKeyboardKey key); + + /// Process key up event that was not handled by the framework. + Future handleKeyUpEvent(LogicalKeyboardKey key); +} + +/// MacOS specific key input handler. This class translates standard macOS text editing shortcuts +/// into appropriate selectors similarly to what NSTextInputContext does in Flutter Engine. +class MacOSTestTextInputKeyHandler extends TestTextInputKeyHandler { + /// Create a new macOS specific text input handler. + MacOSTestTextInputKeyHandler(this.client); + + /// ClientId of TextInput + final int client; + + Future _sendSelectors(List selectors) async { + await TestDefaultBinaryMessengerBinding.instance!.defaultBinaryMessenger + .handlePlatformMessage( + SystemChannels.textInput.name, + SystemChannels.textInput.codec.encodeMethodCall( + MethodCall( + 'TextInputClient.performSelectors', [client, selectors]), + ), + (ByteData? data) {/* response from framework is discarded */}, + ); + } + + // These combinations must match NSStandardKeyBindingResponding. + static final Map> _macOSActivatorToSelectors = + >{ + for (final bool pressShift in const [ + true, + false + ]) ...>{ + SingleActivator(LogicalKeyboardKey.backspace, shift: pressShift): + ['deleteBackward:'], + SingleActivator(LogicalKeyboardKey.backspace, + alt: true, shift: pressShift): ['deleteWordBackward:'], + SingleActivator(LogicalKeyboardKey.backspace, + meta: true, shift: pressShift): ['deleteToBeginningOfLine:'], + SingleActivator(LogicalKeyboardKey.delete, shift: pressShift): [ + 'deleteForward:' + ], + SingleActivator(LogicalKeyboardKey.delete, alt: true, shift: pressShift): + ['deleteWordForward:'], + SingleActivator(LogicalKeyboardKey.delete, meta: true, shift: pressShift): + ['deleteToEndOfLine:'], + }, + const SingleActivator(LogicalKeyboardKey.arrowLeft): ['moveLeft:'], + const SingleActivator(LogicalKeyboardKey.arrowRight): [ + 'moveRight:' + ], + const SingleActivator(LogicalKeyboardKey.arrowUp): ['moveUp:'], + const SingleActivator(LogicalKeyboardKey.arrowDown): ['moveDown:'], + const SingleActivator(LogicalKeyboardKey.arrowLeft, shift: true): [ + 'moveLeftAndModifySelection:' + ], + const SingleActivator(LogicalKeyboardKey.arrowRight, shift: true): [ + 'moveRightAndModifySelection:' + ], + const SingleActivator(LogicalKeyboardKey.arrowUp, shift: true): [ + 'moveUpAndModifySelection:' + ], + const SingleActivator(LogicalKeyboardKey.arrowDown, shift: true): [ + 'moveDownAndModifySelection:' + ], + const SingleActivator(LogicalKeyboardKey.arrowLeft, alt: true): [ + 'moveWordLeft:' + ], + const SingleActivator(LogicalKeyboardKey.arrowRight, alt: true): [ + 'moveWordRight:' + ], + const SingleActivator(LogicalKeyboardKey.arrowUp, alt: true): [ + 'moveBackward:', + 'moveToBeginningOfParagraph:' + ], + const SingleActivator(LogicalKeyboardKey.arrowDown, alt: true): [ + 'moveForward:', + 'moveToEndOfParagraph:' + ], + const SingleActivator(LogicalKeyboardKey.arrowLeft, alt: true, shift: true): + ['moveWordLeftAndModifySelection:'], + const SingleActivator(LogicalKeyboardKey.arrowRight, + alt: true, shift: true): ['moveWordRightAndModifySelection:'], + const SingleActivator(LogicalKeyboardKey.arrowUp, alt: true, shift: true): + ['moveParagraphBackwardAndModifySelection:'], + const SingleActivator(LogicalKeyboardKey.arrowDown, alt: true, shift: true): + ['moveParagraphForwardAndModifySelection:'], + const SingleActivator(LogicalKeyboardKey.arrowLeft, meta: true): [ + 'moveToLeftEndOfLine:' + ], + const SingleActivator(LogicalKeyboardKey.arrowRight, meta: true): [ + 'moveToRightEndOfLine:' + ], + const SingleActivator(LogicalKeyboardKey.arrowUp, meta: true): [ + 'moveToBeginningOfDocument:' + ], + const SingleActivator(LogicalKeyboardKey.arrowDown, meta: true): [ + 'moveToEndOfDocument:' + ], + const SingleActivator(LogicalKeyboardKey.arrowLeft, + meta: true, + shift: true): ['moveToLeftEndOfLineAndModifySelection:'], + const SingleActivator(LogicalKeyboardKey.arrowRight, + meta: true, + shift: true): ['moveToRightEndOfLineAndModifySelection:'], + const SingleActivator(LogicalKeyboardKey.arrowUp, meta: true, shift: true): + ['moveToBeginningOfDocumentAndModifySelection:'], + const SingleActivator(LogicalKeyboardKey.arrowDown, + meta: true, + shift: true): ['moveToEndOfDocumentAndModifySelection:'], + const SingleActivator(LogicalKeyboardKey.keyA, control: true, shift: true): + ['moveToBeginningOfParagraphAndModifySelection:'], + const SingleActivator(LogicalKeyboardKey.keyA, control: true): [ + 'moveToBeginningOfParagraph:' + ], + const SingleActivator(LogicalKeyboardKey.keyB, control: true, shift: true): + ['moveBackwardAndModifySelection:'], + const SingleActivator(LogicalKeyboardKey.keyB, control: true): [ + 'moveBackward:' + ], + const SingleActivator(LogicalKeyboardKey.keyE, control: true, shift: true): + ['moveToEndOfParagraphAndModifySelection:'], + const SingleActivator(LogicalKeyboardKey.keyE, control: true): [ + 'moveToEndOfParagraph:' + ], + const SingleActivator(LogicalKeyboardKey.keyF, control: true, shift: true): + ['moveForwardAndModifySelection:'], + const SingleActivator(LogicalKeyboardKey.keyF, control: true): [ + 'moveForward:' + ], + const SingleActivator(LogicalKeyboardKey.keyK, control: true): [ + 'deleteToEndOfParagraph' + ], + const SingleActivator(LogicalKeyboardKey.keyL, control: true): [ + 'centerSelectionInVisibleArea' + ], + const SingleActivator(LogicalKeyboardKey.keyN, control: true): [ + 'moveDown:' + ], + const SingleActivator(LogicalKeyboardKey.keyN, control: true, shift: true): + ['moveDownAndModifySelection:'], + const SingleActivator(LogicalKeyboardKey.keyO, control: true): [ + 'insertNewlineIgnoringFieldEditor:' + ], + const SingleActivator(LogicalKeyboardKey.keyP, control: true): [ + 'moveUp:' + ], + const SingleActivator(LogicalKeyboardKey.keyP, control: true, shift: true): + ['moveUpAndModifySelection:'], + const SingleActivator(LogicalKeyboardKey.keyT, control: true): [ + 'transpose:' + ], + const SingleActivator(LogicalKeyboardKey.keyV, control: true): [ + 'pageDown:' + ], + const SingleActivator(LogicalKeyboardKey.keyV, control: true, shift: true): + ['pageDownAndModifySelection:'], + const SingleActivator(LogicalKeyboardKey.keyY, control: true): [ + 'yank:' + ], + const SingleActivator(LogicalKeyboardKey.quoteSingle, control: true): + ['insertSingleQuoteIgnoringSubstitution:'], + const SingleActivator(LogicalKeyboardKey.quote, control: true): [ + 'insertDoubleQuoteIgnoringSubstitution:' + ], + const SingleActivator(LogicalKeyboardKey.home): [ + 'scrollToBeginningOfDocument:' + ], + const SingleActivator(LogicalKeyboardKey.end): [ + 'scrollToEndOfDocument:' + ], + const SingleActivator(LogicalKeyboardKey.home, shift: true): [ + 'moveToBeginningOfDocumentAndModifySelection:' + ], + const SingleActivator(LogicalKeyboardKey.end, shift: true): [ + 'moveToEndOfDocumentAndModifySelection:' + ], + const SingleActivator(LogicalKeyboardKey.pageUp): ['scrollPageUp:'], + const SingleActivator(LogicalKeyboardKey.pageDown): [ + 'scrollPageDown:' + ], + const SingleActivator(LogicalKeyboardKey.pageUp, shift: true): [ + 'pageUpAndModifySelection:' + ], + const SingleActivator(LogicalKeyboardKey.pageDown, shift: true): [ + 'pageDownAndModifySelection:' + ], + const SingleActivator(LogicalKeyboardKey.escape): [ + 'cancelOperation:' + ], + const SingleActivator(LogicalKeyboardKey.enter): ['insertNewline:'], + const SingleActivator(LogicalKeyboardKey.enter, alt: true): [ + 'insertNewlineIgnoringFieldEditor:' + ], + const SingleActivator(LogicalKeyboardKey.enter, control: true): [ + 'insertLineBreak:' + ], + const SingleActivator(LogicalKeyboardKey.tab): ['insertTab:'], + const SingleActivator(LogicalKeyboardKey.tab, alt: true): [ + 'insertTabIgnoringFieldEditor:' + ], + const SingleActivator(LogicalKeyboardKey.tab, shift: true): [ + 'insertBacktab:' + ], + }; + + @override + Future handleKeyDownEvent(LogicalKeyboardKey key) async { + if (key == LogicalKeyboardKey.shift || + key == LogicalKeyboardKey.shiftLeft || + key == LogicalKeyboardKey.shiftRight) { + _shift = true; + } else if (key == LogicalKeyboardKey.alt || + key == LogicalKeyboardKey.altLeft || + key == LogicalKeyboardKey.altRight) { + _alt = true; + } else if (key == LogicalKeyboardKey.meta || + key == LogicalKeyboardKey.metaLeft || + key == LogicalKeyboardKey.metaRight) { + _meta = true; + } else if (key == LogicalKeyboardKey.control || + key == LogicalKeyboardKey.controlLeft || + key == LogicalKeyboardKey.controlRight) { + _control = true; + } else { + for (final MapEntry> entry + in _macOSActivatorToSelectors.entries) { + final SingleActivator activator = entry.key; + if (activator.triggers.first == key && + activator.shift == _shift && + activator.alt == _alt && + activator.meta == _meta && + activator.control == _control) { + await _sendSelectors(entry.value); + return; + } + } + } + } + + @override + Future handleKeyUpEvent(LogicalKeyboardKey key) async { + if (key == LogicalKeyboardKey.shift || + key == LogicalKeyboardKey.shiftLeft || + key == LogicalKeyboardKey.shiftRight) { + _shift = false; + } else if (key == LogicalKeyboardKey.alt || + key == LogicalKeyboardKey.altLeft || + key == LogicalKeyboardKey.altRight) { + _alt = false; + } else if (key == LogicalKeyboardKey.meta || + key == LogicalKeyboardKey.metaLeft || + key == LogicalKeyboardKey.metaRight) { + _meta = false; + } else if (key == LogicalKeyboardKey.control || + key == LogicalKeyboardKey.controlLeft || + key == LogicalKeyboardKey.controlRight) { + _control = false; + } + } + + bool _shift = false; + bool _alt = false; + bool _meta = false; + bool _control = false; +} diff --git a/packages/flutter_test/test/test_text_input_test.dart b/packages/flutter_test/test/test_text_input_test.dart index 20e2d6832221..b607d7e7c983 100644 --- a/packages/flutter_test/test/test_text_input_test.dart +++ b/packages/flutter_test/test/test_text_input_test.dart @@ -8,6 +8,7 @@ // Fails with "flutter test --test-randomize-ordering-seed=20210721" @Tags(['no-shuffle']) +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -54,4 +55,24 @@ void main() { throwsA(isA()), ); }); + + testWidgets('selectors are called on macOS', (WidgetTester tester) async { + List? selectorNames; + await SystemChannels.textInput.invokeMethod('TextInput.setClient', [1, {}]); + await SystemChannels.textInput.invokeMethod('TextInput.show'); + SystemChannels.textInput.setMethodCallHandler((MethodCall call) async { + if (call.method == 'TextInputClient.performSelectors') { + selectorNames = (call.arguments as List)[1] as List; + } + }); + await tester.sendKeyDownEvent(LogicalKeyboardKey.altLeft); + await tester.sendKeyDownEvent(LogicalKeyboardKey.arrowUp); + await SystemChannels.textInput.invokeMethod('TextInput.clearClient'); + + if (defaultTargetPlatform == TargetPlatform.macOS) { + expect(selectorNames, ['moveBackward:', 'moveToBeginningOfParagraph:']); + } else { + expect(selectorNames, isNull); + } + }, variant: TargetPlatformVariant.all()); }