Skip to content

Commit

Permalink
[macOS] Use editing intents from engine (#105407)
Browse files Browse the repository at this point in the history
  • Loading branch information
knopp authored Aug 3, 2022
1 parent f7c41d0 commit 7e8f0e5
Show file tree
Hide file tree
Showing 11 changed files with 597 additions and 21 deletions.
9 changes: 9 additions & 0 deletions packages/flutter/lib/src/services/text_input.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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(
Expand Down
100 changes: 95 additions & 5 deletions packages/flutter/lib/src/widgets/default_text_editing_shortcuts.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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(),

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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];
}
24 changes: 23 additions & 1 deletion packages/flutter/lib/src/widgets/editable_text.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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';

Expand Down Expand Up @@ -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;
}
Expand Down
5 changes: 5 additions & 0 deletions packages/flutter/test/services/autofill_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
5 changes: 5 additions & 0 deletions packages/flutter/test/services/delta_text_input_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -286,5 +286,10 @@ class FakeDeltaTextInputClient implements DeltaTextInputClient {
latestMethodCall = 'showToolbar';
}

@override
void performSelector(String selectorName) {
latestMethodCall = 'performSelector';
}

TextInputConfiguration get configuration => const TextInputConfiguration(enableDeltaModel: true);
}
36 changes: 36 additions & 0 deletions packages/flutter/test/services/text_input_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -704,6 +733,7 @@ class FakeTextInputClient with TextInputClient {
FakeTextInputClient(this.currentTextEditingValue);

String latestMethodCall = '';
final List<String> performedSelectors = <String>[];

@override
TextEditingValue currentTextEditingValue;
Expand Down Expand Up @@ -757,4 +787,10 @@ class FakeTextInputClient with TextInputClient {
void removeTextPlaceholder() {
latestMethodCall = 'removeTextPlaceholder';
}

@override
void performSelector(String selectorName) {
latestMethodCall = 'performSelector';
performedSelectors.add(selectorName);
}
}
101 changes: 90 additions & 11 deletions packages/flutter/test/widgets/editable_text_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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', () {
Expand Down
Loading

0 comments on commit 7e8f0e5

Please sign in to comment.