Thanks to visit codestin.com
Credit goes to github.com

Skip to content

[macOS] Use editing intents from engine #105407

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 38 commits into from
Aug 3, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
07aaf53
Use editing intents from engine
knopp Jun 5, 2022
8ff3410
More shortcuts
knopp Jun 5, 2022
b30bc5f
Use DismissIntent for cancelOperation
knopp Jun 5, 2022
673be76
Expose macOS specific selectors in TextInputClient
knopp Jun 7, 2022
6d7b9d4
Obsolete comment
knopp Jun 7, 2022
5f293c4
Update comment
knopp Jun 7, 2022
454fcb5
Update shortcuts
knopp Jun 8, 2022
bc81a05
FlutterTester: add support for TextInputClient.performSelector on macOS
knopp Jun 8, 2022
d7e14fe
Remove trailing line
knopp Jun 9, 2022
ee30a6a
Use expand instad of extend on macOS/iOS
knopp Jun 9, 2022
ea83a91
Add license header
knopp Jun 9, 2022
2c741d2
Disable key input for web
knopp Jun 9, 2022
0e986ec
Add tests
knopp Jun 12, 2022
5ef4213
Add test for TestTextInput
knopp Jun 12, 2022
1feee7e
Rename
knopp Jun 12, 2022
828b125
Update shortcuts, fix TAB handling in IME
knopp Jun 12, 2022
b4185f3
Plural
knopp Jun 12, 2022
1f081e8
IME keys should only be disabled on desktop
knopp Jun 12, 2022
60cb56e
Trailing comment period.
knopp Jun 12, 2022
e8e5522
Properly handle multiple selectors for one key press
knopp Jun 13, 2022
003dc1a
Use Actions.invoke
knopp Jun 13, 2022
c3cd8ea
Fix
knopp Jun 13, 2022
2b66d7a
Make selector->intent table const and put it inside function
knopp Jun 13, 2022
027ec33
Nit
knopp Jun 13, 2022
9969ab4
Update comment
knopp Jun 13, 2022
fcbbfe9
Run test on all platforms
knopp Jun 15, 2022
8660095
Comment
knopp Jun 15, 2022
056d90f
Update comment
knopp Jun 15, 2022
6ee64a6
Plural
knopp Jun 15, 2022
ca8b0dd
Update comment
knopp Jul 5, 2022
3b938fa
Period
knopp Jul 5, 2022
e66caf3
Import foundation for kIsWeb
knopp Jul 5, 2022
54a613f
Update comment
knopp Jul 5, 2022
06077e5
Update comment
knopp Jul 5, 2022
ab642d5
Update comment
knopp Jul 5, 2022
85073b6
Fix comment
knopp Jul 14, 2022
3310569
Disable meta-arrow shortcuts
knopp Jul 14, 2022
79895d0
Simulate control
knopp Jul 28, 2022
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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) {}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One more thing I just thought of: I want to make sure that this is something that we want to add to TextInputClient and not split out. We've been having a bunch of discussion about this topic, see this design doc: https://docs.google.com/document/d/1OxDsf_eot7TlsRn57z-1_dGbvuqnMxO7QEGIAmOCZtA/edit

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could this be more generic than text input? Maybe it should have its own class instead of being a part of TextInputClient?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The selector is sent from TextInputContext, which is only used through the TextInputClient.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed after looking at the engine PR again.

If anyone is using TextInputClient with the implements keyword`, then they will be broken by this change. We should keep an eye out for that.

FYI here is a design doc that I wrote recently about adding platform methods. I think in this case we're all onboard with that pattern.

}

/// 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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On all desktop platforms or only on Mac?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Only on Mac (the comment is inside _macShortcuts), but not on web on mac (hence "desktop").

// 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 @@ -3165,6 +3166,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 @@ -4318,7 +4331,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 @@ -12543,6 +12565,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),
);
});
});
}

Expand Down
Loading