Unverified Commit 7e8f0e57 authored by Matej Knopp's avatar Matej Knopp Committed by GitHub

[macOS] Use editing intents from engine (#105407)

parent f7c41d09
......@@ -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);
}
}
This diff is collapsed.
......@@ -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());
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment