[macOS] Use editing intents from engine (#105407)
This commit is contained in:
parent
f7c41d0988
commit
7e8f0e57bf
@ -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<String> selectors = (args[1] as List<dynamic>).cast<String>();
|
||||
selectors.forEach(_currentConnection!._client.performSelector);
|
||||
break;
|
||||
case 'TextInputClient.performPrivateCommand':
|
||||
final Map<String, dynamic> firstArg = args[1] as Map<String, dynamic>;
|
||||
_currentConnection!._client.performPrivateCommand(
|
||||
|
@ -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<ShortcutActivator, Intent> _macShortcuts = <ShortcutActivator, Intent>{
|
||||
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) ...<ShortcutActivator, Intent>{
|
||||
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<ShortcutActivator, Intent> _iOSShortcuts = <ShortcutActivator, Intent>{
|
||||
for (final bool pressShift in const <bool>[true, false])
|
||||
...<SingleActivator, Intent>{
|
||||
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<ShortcutActivator, Intent> _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<String, Intent> selectorToIntent = <String, Intent>{
|
||||
'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];
|
||||
}
|
||||
|
@ -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<EditableText> 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<T extends DirectionalCaretMovementIntent> 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;
|
||||
}
|
||||
|
@ -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 {
|
||||
|
@ -286,5 +286,10 @@ class FakeDeltaTextInputClient implements DeltaTextInputClient {
|
||||
latestMethodCall = 'showToolbar';
|
||||
}
|
||||
|
||||
@override
|
||||
void performSelector(String selectorName) {
|
||||
latestMethodCall = 'performSelector';
|
||||
}
|
||||
|
||||
TextInputConfiguration get configuration => const TextInputConfiguration(enableDeltaModel: true);
|
||||
}
|
||||
|
@ -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(<String, dynamic>{
|
||||
'args': <dynamic>[
|
||||
1,
|
||||
<dynamic>[
|
||||
'selector1',
|
||||
'selector2',
|
||||
]
|
||||
],
|
||||
'method': 'TextInputClient.performSelectors',
|
||||
});
|
||||
await ServicesBinding.instance.defaultBinaryMessenger.handlePlatformMessage(
|
||||
'flutter/textinput',
|
||||
messageBytes,
|
||||
(ByteData? _) {},
|
||||
);
|
||||
|
||||
expect(client.latestMethodCall, 'performSelector');
|
||||
expect(client.performedSelectors, <String>['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<String> performedSelectors = <String>[];
|
||||
|
||||
@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);
|
||||
}
|
||||
}
|
||||
|
@ -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>{ 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<EditableTextState> key = GlobalKey<EditableTextState>();
|
||||
|
||||
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', () {
|
||||
|
@ -902,8 +902,13 @@ Future<bool> 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<bool> 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.
|
||||
|
@ -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<void> handleKeyDownEvent(LogicalKeyboardKey key) async {
|
||||
await _keyHandler?.handleKeyDownEvent(key);
|
||||
}
|
||||
|
||||
/// Gives text input chance to respond to unhandled key up event.
|
||||
Future<void> handleKeyUpEvent(LogicalKeyboardKey key) async {
|
||||
await _keyHandler?.handleKeyUpEvent(key);
|
||||
}
|
||||
}
|
||||
|
279
packages/flutter_test/lib/src/test_text_input_key_handler.dart
Normal file
279
packages/flutter_test/lib/src/test_text_input_key_handler.dart
Normal file
@ -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<void> handleKeyDownEvent(LogicalKeyboardKey key);
|
||||
|
||||
/// Process key up event that was not handled by the framework.
|
||||
Future<void> 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<void> _sendSelectors(List<String> selectors) async {
|
||||
await TestDefaultBinaryMessengerBinding.instance!.defaultBinaryMessenger
|
||||
.handlePlatformMessage(
|
||||
SystemChannels.textInput.name,
|
||||
SystemChannels.textInput.codec.encodeMethodCall(
|
||||
MethodCall(
|
||||
'TextInputClient.performSelectors', <dynamic>[client, selectors]),
|
||||
),
|
||||
(ByteData? data) {/* response from framework is discarded */},
|
||||
);
|
||||
}
|
||||
|
||||
// These combinations must match NSStandardKeyBindingResponding.
|
||||
static final Map<SingleActivator, List<String>> _macOSActivatorToSelectors =
|
||||
<SingleActivator, List<String>>{
|
||||
for (final bool pressShift in const <bool>[
|
||||
true,
|
||||
false
|
||||
]) ...<SingleActivator, List<String>>{
|
||||
SingleActivator(LogicalKeyboardKey.backspace, shift: pressShift):
|
||||
<String>['deleteBackward:'],
|
||||
SingleActivator(LogicalKeyboardKey.backspace,
|
||||
alt: true, shift: pressShift): <String>['deleteWordBackward:'],
|
||||
SingleActivator(LogicalKeyboardKey.backspace,
|
||||
meta: true, shift: pressShift): <String>['deleteToBeginningOfLine:'],
|
||||
SingleActivator(LogicalKeyboardKey.delete, shift: pressShift): <String>[
|
||||
'deleteForward:'
|
||||
],
|
||||
SingleActivator(LogicalKeyboardKey.delete, alt: true, shift: pressShift):
|
||||
<String>['deleteWordForward:'],
|
||||
SingleActivator(LogicalKeyboardKey.delete, meta: true, shift: pressShift):
|
||||
<String>['deleteToEndOfLine:'],
|
||||
},
|
||||
const SingleActivator(LogicalKeyboardKey.arrowLeft): <String>['moveLeft:'],
|
||||
const SingleActivator(LogicalKeyboardKey.arrowRight): <String>[
|
||||
'moveRight:'
|
||||
],
|
||||
const SingleActivator(LogicalKeyboardKey.arrowUp): <String>['moveUp:'],
|
||||
const SingleActivator(LogicalKeyboardKey.arrowDown): <String>['moveDown:'],
|
||||
const SingleActivator(LogicalKeyboardKey.arrowLeft, shift: true): <String>[
|
||||
'moveLeftAndModifySelection:'
|
||||
],
|
||||
const SingleActivator(LogicalKeyboardKey.arrowRight, shift: true): <String>[
|
||||
'moveRightAndModifySelection:'
|
||||
],
|
||||
const SingleActivator(LogicalKeyboardKey.arrowUp, shift: true): <String>[
|
||||
'moveUpAndModifySelection:'
|
||||
],
|
||||
const SingleActivator(LogicalKeyboardKey.arrowDown, shift: true): <String>[
|
||||
'moveDownAndModifySelection:'
|
||||
],
|
||||
const SingleActivator(LogicalKeyboardKey.arrowLeft, alt: true): <String>[
|
||||
'moveWordLeft:'
|
||||
],
|
||||
const SingleActivator(LogicalKeyboardKey.arrowRight, alt: true): <String>[
|
||||
'moveWordRight:'
|
||||
],
|
||||
const SingleActivator(LogicalKeyboardKey.arrowUp, alt: true): <String>[
|
||||
'moveBackward:',
|
||||
'moveToBeginningOfParagraph:'
|
||||
],
|
||||
const SingleActivator(LogicalKeyboardKey.arrowDown, alt: true): <String>[
|
||||
'moveForward:',
|
||||
'moveToEndOfParagraph:'
|
||||
],
|
||||
const SingleActivator(LogicalKeyboardKey.arrowLeft, alt: true, shift: true):
|
||||
<String>['moveWordLeftAndModifySelection:'],
|
||||
const SingleActivator(LogicalKeyboardKey.arrowRight,
|
||||
alt: true, shift: true): <String>['moveWordRightAndModifySelection:'],
|
||||
const SingleActivator(LogicalKeyboardKey.arrowUp, alt: true, shift: true):
|
||||
<String>['moveParagraphBackwardAndModifySelection:'],
|
||||
const SingleActivator(LogicalKeyboardKey.arrowDown, alt: true, shift: true):
|
||||
<String>['moveParagraphForwardAndModifySelection:'],
|
||||
const SingleActivator(LogicalKeyboardKey.arrowLeft, meta: true): <String>[
|
||||
'moveToLeftEndOfLine:'
|
||||
],
|
||||
const SingleActivator(LogicalKeyboardKey.arrowRight, meta: true): <String>[
|
||||
'moveToRightEndOfLine:'
|
||||
],
|
||||
const SingleActivator(LogicalKeyboardKey.arrowUp, meta: true): <String>[
|
||||
'moveToBeginningOfDocument:'
|
||||
],
|
||||
const SingleActivator(LogicalKeyboardKey.arrowDown, meta: true): <String>[
|
||||
'moveToEndOfDocument:'
|
||||
],
|
||||
const SingleActivator(LogicalKeyboardKey.arrowLeft,
|
||||
meta: true,
|
||||
shift: true): <String>['moveToLeftEndOfLineAndModifySelection:'],
|
||||
const SingleActivator(LogicalKeyboardKey.arrowRight,
|
||||
meta: true,
|
||||
shift: true): <String>['moveToRightEndOfLineAndModifySelection:'],
|
||||
const SingleActivator(LogicalKeyboardKey.arrowUp, meta: true, shift: true):
|
||||
<String>['moveToBeginningOfDocumentAndModifySelection:'],
|
||||
const SingleActivator(LogicalKeyboardKey.arrowDown,
|
||||
meta: true,
|
||||
shift: true): <String>['moveToEndOfDocumentAndModifySelection:'],
|
||||
const SingleActivator(LogicalKeyboardKey.keyA, control: true, shift: true):
|
||||
<String>['moveToBeginningOfParagraphAndModifySelection:'],
|
||||
const SingleActivator(LogicalKeyboardKey.keyA, control: true): <String>[
|
||||
'moveToBeginningOfParagraph:'
|
||||
],
|
||||
const SingleActivator(LogicalKeyboardKey.keyB, control: true, shift: true):
|
||||
<String>['moveBackwardAndModifySelection:'],
|
||||
const SingleActivator(LogicalKeyboardKey.keyB, control: true): <String>[
|
||||
'moveBackward:'
|
||||
],
|
||||
const SingleActivator(LogicalKeyboardKey.keyE, control: true, shift: true):
|
||||
<String>['moveToEndOfParagraphAndModifySelection:'],
|
||||
const SingleActivator(LogicalKeyboardKey.keyE, control: true): <String>[
|
||||
'moveToEndOfParagraph:'
|
||||
],
|
||||
const SingleActivator(LogicalKeyboardKey.keyF, control: true, shift: true):
|
||||
<String>['moveForwardAndModifySelection:'],
|
||||
const SingleActivator(LogicalKeyboardKey.keyF, control: true): <String>[
|
||||
'moveForward:'
|
||||
],
|
||||
const SingleActivator(LogicalKeyboardKey.keyK, control: true): <String>[
|
||||
'deleteToEndOfParagraph'
|
||||
],
|
||||
const SingleActivator(LogicalKeyboardKey.keyL, control: true): <String>[
|
||||
'centerSelectionInVisibleArea'
|
||||
],
|
||||
const SingleActivator(LogicalKeyboardKey.keyN, control: true): <String>[
|
||||
'moveDown:'
|
||||
],
|
||||
const SingleActivator(LogicalKeyboardKey.keyN, control: true, shift: true):
|
||||
<String>['moveDownAndModifySelection:'],
|
||||
const SingleActivator(LogicalKeyboardKey.keyO, control: true): <String>[
|
||||
'insertNewlineIgnoringFieldEditor:'
|
||||
],
|
||||
const SingleActivator(LogicalKeyboardKey.keyP, control: true): <String>[
|
||||
'moveUp:'
|
||||
],
|
||||
const SingleActivator(LogicalKeyboardKey.keyP, control: true, shift: true):
|
||||
<String>['moveUpAndModifySelection:'],
|
||||
const SingleActivator(LogicalKeyboardKey.keyT, control: true): <String>[
|
||||
'transpose:'
|
||||
],
|
||||
const SingleActivator(LogicalKeyboardKey.keyV, control: true): <String>[
|
||||
'pageDown:'
|
||||
],
|
||||
const SingleActivator(LogicalKeyboardKey.keyV, control: true, shift: true):
|
||||
<String>['pageDownAndModifySelection:'],
|
||||
const SingleActivator(LogicalKeyboardKey.keyY, control: true): <String>[
|
||||
'yank:'
|
||||
],
|
||||
const SingleActivator(LogicalKeyboardKey.quoteSingle, control: true):
|
||||
<String>['insertSingleQuoteIgnoringSubstitution:'],
|
||||
const SingleActivator(LogicalKeyboardKey.quote, control: true): <String>[
|
||||
'insertDoubleQuoteIgnoringSubstitution:'
|
||||
],
|
||||
const SingleActivator(LogicalKeyboardKey.home): <String>[
|
||||
'scrollToBeginningOfDocument:'
|
||||
],
|
||||
const SingleActivator(LogicalKeyboardKey.end): <String>[
|
||||
'scrollToEndOfDocument:'
|
||||
],
|
||||
const SingleActivator(LogicalKeyboardKey.home, shift: true): <String>[
|
||||
'moveToBeginningOfDocumentAndModifySelection:'
|
||||
],
|
||||
const SingleActivator(LogicalKeyboardKey.end, shift: true): <String>[
|
||||
'moveToEndOfDocumentAndModifySelection:'
|
||||
],
|
||||
const SingleActivator(LogicalKeyboardKey.pageUp): <String>['scrollPageUp:'],
|
||||
const SingleActivator(LogicalKeyboardKey.pageDown): <String>[
|
||||
'scrollPageDown:'
|
||||
],
|
||||
const SingleActivator(LogicalKeyboardKey.pageUp, shift: true): <String>[
|
||||
'pageUpAndModifySelection:'
|
||||
],
|
||||
const SingleActivator(LogicalKeyboardKey.pageDown, shift: true): <String>[
|
||||
'pageDownAndModifySelection:'
|
||||
],
|
||||
const SingleActivator(LogicalKeyboardKey.escape): <String>[
|
||||
'cancelOperation:'
|
||||
],
|
||||
const SingleActivator(LogicalKeyboardKey.enter): <String>['insertNewline:'],
|
||||
const SingleActivator(LogicalKeyboardKey.enter, alt: true): <String>[
|
||||
'insertNewlineIgnoringFieldEditor:'
|
||||
],
|
||||
const SingleActivator(LogicalKeyboardKey.enter, control: true): <String>[
|
||||
'insertLineBreak:'
|
||||
],
|
||||
const SingleActivator(LogicalKeyboardKey.tab): <String>['insertTab:'],
|
||||
const SingleActivator(LogicalKeyboardKey.tab, alt: true): <String>[
|
||||
'insertTabIgnoringFieldEditor:'
|
||||
],
|
||||
const SingleActivator(LogicalKeyboardKey.tab, shift: true): <String>[
|
||||
'insertBacktab:'
|
||||
],
|
||||
};
|
||||
|
||||
@override
|
||||
Future<void> 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<SingleActivator, List<String>> 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<void> 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;
|
||||
}
|
@ -8,6 +8,7 @@
|
||||
// Fails with "flutter test --test-randomize-ordering-seed=20210721"
|
||||
@Tags(<String>['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<PlatformException>()),
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('selectors are called on macOS', (WidgetTester tester) async {
|
||||
List<dynamic>? selectorNames;
|
||||
await SystemChannels.textInput.invokeMethod('TextInput.setClient', <dynamic>[1, <String, dynamic>{}]);
|
||||
await SystemChannels.textInput.invokeMethod('TextInput.show');
|
||||
SystemChannels.textInput.setMethodCallHandler((MethodCall call) async {
|
||||
if (call.method == 'TextInputClient.performSelectors') {
|
||||
selectorNames = (call.arguments as List<dynamic>)[1] as List<dynamic>;
|
||||
}
|
||||
});
|
||||
await tester.sendKeyDownEvent(LogicalKeyboardKey.altLeft);
|
||||
await tester.sendKeyDownEvent(LogicalKeyboardKey.arrowUp);
|
||||
await SystemChannels.textInput.invokeMethod('TextInput.clearClient');
|
||||
|
||||
if (defaultTargetPlatform == TargetPlatform.macOS) {
|
||||
expect(selectorNames, <dynamic>['moveBackward:', 'moveToBeginningOfParagraph:']);
|
||||
} else {
|
||||
expect(selectorNames, isNull);
|
||||
}
|
||||
}, variant: TargetPlatformVariant.all());
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user