Mac cmd + shift + left/right (#95948)
diff --git a/packages/flutter/lib/src/services/text_editing.dart b/packages/flutter/lib/src/services/text_editing.dart
index 403fae5..8286540 100644
--- a/packages/flutter/lib/src/services/text_editing.dart
+++ b/packages/flutter/lib/src/services/text_editing.dart
@@ -238,8 +238,8 @@
/// [TextSelection.extentOffset] to the given [TextPosition].
///
/// In some cases, the [TextSelection.baseOffset] and
- /// [TextSelection.extentOffset] may flip during this operation, or the size
- /// of the selection may shrink.
+ /// [TextSelection.extentOffset] may flip during this operation, and/or the
+ /// size of the selection may shrink.
///
/// ## Difference with [expandTo]
/// In contrast with this method, [expandTo] is strictly growth; the
diff --git a/packages/flutter/lib/src/widgets/default_text_editing_shortcuts.dart b/packages/flutter/lib/src/widgets/default_text_editing_shortcuts.dart
index 8414590..370dd4b 100644
--- a/packages/flutter/lib/src/widgets/default_text_editing_shortcuts.dart
+++ b/packages/flutter/lib/src/widgets/default_text_editing_shortcuts.dart
@@ -307,8 +307,8 @@
const SingleActivator(LogicalKeyboardKey.arrowUp, meta: true): const ExtendSelectionToDocumentBoundaryIntent(forward: false, collapseSelection: true),
const SingleActivator(LogicalKeyboardKey.arrowDown, meta: true): const ExtendSelectionToDocumentBoundaryIntent(forward: true, collapseSelection: true),
- const SingleActivator(LogicalKeyboardKey.arrowLeft, shift: true, meta: true): const ExtendSelectionToLineBreakIntent(forward: false, collapseSelection: false),
- const SingleActivator(LogicalKeyboardKey.arrowRight, shift: true, meta: true): const ExtendSelectionToLineBreakIntent(forward: true, collapseSelection: false),
+ 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),
diff --git a/packages/flutter/lib/src/widgets/editable_text.dart b/packages/flutter/lib/src/widgets/editable_text.dart
index 67f5674..9a09c50 100644
--- a/packages/flutter/lib/src/widgets/editable_text.dart
+++ b/packages/flutter/lib/src/widgets/editable_text.dart
@@ -2931,6 +2931,30 @@
late final _UpdateTextSelectionToAdjacentLineAction<ExtendSelectionVerticallyToAdjacentLineIntent> _adjacentLineAction = _UpdateTextSelectionToAdjacentLineAction<ExtendSelectionVerticallyToAdjacentLineIntent>(this);
+ void _expandSelection(ExpandSelectionToLineBreakIntent intent) {
+ final _TextBoundary textBoundary = _linebreak(intent);
+ final TextSelection textBoundarySelection = textBoundary.textEditingValue.selection;
+ if (!textBoundarySelection.isValid) {
+ return;
+ }
+
+ final bool inOrder = textBoundarySelection.baseOffset <= textBoundarySelection.extentOffset;
+ final bool towardsExtent = intent.forward == inOrder;
+ final TextPosition position = towardsExtent
+ ? textBoundarySelection.extent
+ : textBoundarySelection.base;
+
+ final TextPosition newExtent = intent.forward
+ ? textBoundary.getTrailingTextBoundaryAt(position)
+ : textBoundary.getLeadingTextBoundaryAt(position);
+
+ final TextSelection newSelection = textBoundarySelection.expandTo(newExtent, textBoundarySelection.isCollapsed);
+ userUpdateTextEditingValue(
+ _value.copyWith(selection: newSelection),
+ SelectionChangedCause.keyboard,
+ );
+ }
+
late final Map<Type, Action<Intent>> _actions = <Type, Action<Intent>>{
DoNothingAndStopPropagationTextIntent: DoNothingAction(consumesKey: false),
ReplaceTextIntent: _replaceTextAction,
@@ -2946,6 +2970,7 @@
ExtendSelectionByCharacterIntent: _makeOverridable(_UpdateTextSelectionAction<ExtendSelectionByCharacterIntent>(this, false, _characterBoundary,)),
ExtendSelectionToNextWordBoundaryIntent: _makeOverridable(_UpdateTextSelectionAction<ExtendSelectionToNextWordBoundaryIntent>(this, true, _nextWordBoundary)),
ExtendSelectionToLineBreakIntent: _makeOverridable(_UpdateTextSelectionAction<ExtendSelectionToLineBreakIntent>(this, true, _linebreak)),
+ ExpandSelectionToLineBreakIntent: _makeOverridable(CallbackAction<ExpandSelectionToLineBreakIntent>(onInvoke: _expandSelection)),
ExtendSelectionVerticallyToAdjacentLineIntent: _makeOverridable(_adjacentLineAction),
ExtendSelectionToDocumentBoundaryIntent: _makeOverridable(_UpdateTextSelectionAction<ExtendSelectionToDocumentBoundaryIntent>(this, true, _documentBoundary)),
ExtendSelectionToNextWordBoundaryOrCaretLocationIntent: _makeOverridable(_ExtendSelectionOrCaretPositionAction(this, _nextWordBoundary)),
diff --git a/packages/flutter/lib/src/widgets/text_editing_intents.dart b/packages/flutter/lib/src/widgets/text_editing_intents.dart
index f75ad6e..b4554f5 100644
--- a/packages/flutter/lib/src/widgets/text_editing_intents.dart
+++ b/packages/flutter/lib/src/widgets/text_editing_intents.dart
@@ -92,7 +92,7 @@
final bool collapseAtReversal;
}
-/// Expands, or moves the current selection from the current
+/// Extends, or moves the current selection from the current
/// [TextSelection.extent] position to the previous or the next character
/// boundary.
class ExtendSelectionByCharacterIntent extends DirectionalCaretMovementIntent {
@@ -103,7 +103,7 @@
}) : super(forward, collapseSelection);
}
-/// Expands, or moves the current selection from the current
+/// Extends, or moves the current selection from the current
/// [TextSelection.extent] position to the previous or the next word
/// boundary.
class ExtendSelectionToNextWordBoundaryIntent extends DirectionalCaretMovementIntent {
@@ -114,7 +114,7 @@
}) : super(forward, collapseSelection);
}
-/// Expands, or moves the current selection from the current
+/// Extends, or moves the current selection from the current
/// [TextSelection.extent] position to the previous or the next word
/// boundary, or the [TextSelection.base] position if it's closer in the move
/// direction.
@@ -124,7 +124,7 @@
/// when the order of [TextSelection.base] and [TextSelection.extent] would
/// reverse.
///
-/// This is typically only used on macOS.
+/// This is typically only used on MacOS.
class ExtendSelectionToNextWordBoundaryOrCaretLocationIntent extends DirectionalTextEditingIntent {
/// Creates an [ExtendSelectionToNextWordBoundaryOrCaretLocationIntent].
const ExtendSelectionToNextWordBoundaryOrCaretLocationIntent({
@@ -132,9 +132,33 @@
}) : super(forward);
}
-/// Expands, or moves the current selection from the current
+/// Expands the current selection to the closest line break in the direction
+/// given by [forward].
+///
+/// Either the base or extent can move, whichever is closer to the line break.
+/// The selection will never shrink.
+///
+/// This behavior is common on MacOS.
+///
+/// See also:
+///
+/// [ExtendSelectionToLineBreakIntent], which is similar but always moves the
+/// extent.
+class ExpandSelectionToLineBreakIntent extends DirectionalTextEditingIntent {
+ /// Creates an [ExpandSelectionToLineBreakIntent].
+ const ExpandSelectionToLineBreakIntent({
+ required bool forward,
+ }) : super(forward);
+}
+
+/// Extends, or moves the current selection from the current
/// [TextSelection.extent] position to the closest line break in the direction
/// given by [forward].
+///
+/// See also:
+///
+/// [ExpandSelectionToLineBreakIntent], which is similar but always increases
+/// the size of the selection.
class ExtendSelectionToLineBreakIntent extends DirectionalCaretMovementIntent {
/// Creates an [ExtendSelectionToLineBreakIntent].
const ExtendSelectionToLineBreakIntent({
@@ -145,7 +169,7 @@
super(forward, collapseSelection, collapseAtReversal);
}
-/// Expands, or moves the current selection from the current
+/// Extends, or moves the current selection from the current
/// [TextSelection.extent] position to the closest position on the adjacent
/// line.
class ExtendSelectionVerticallyToAdjacentLineIntent extends DirectionalCaretMovementIntent {
@@ -156,7 +180,7 @@
}) : super(forward, collapseSelection);
}
-/// Expands, or moves the current selection from the current
+/// Extends, or moves the current selection from the current
/// [TextSelection.extent] position to the start or the end of the document.
class ExtendSelectionToDocumentBoundaryIntent extends DirectionalCaretMovementIntent {
/// Creates an [ExtendSelectionToDocumentBoundaryIntent].
diff --git a/packages/flutter/test/widgets/editable_text_test.dart b/packages/flutter/test/widgets/editable_text_test.dart
index d94fd00..74a9384 100644
--- a/packages/flutter/test/widgets/editable_text_test.dart
+++ b/packages/flutter/test/widgets/editable_text_test.dart
@@ -4682,17 +4682,41 @@
targetPlatform: defaultTargetPlatform,
);
- expect(
- selection,
- equals(
- const TextSelection(
- baseOffset: 20,
- extentOffset: 36,
- affinity: TextAffinity.upstream,
- ),
- ),
- reason: 'on $platform',
- );
+ switch (defaultTargetPlatform) {
+ // These platforms extend by line.
+ case TargetPlatform.iOS:
+ case TargetPlatform.android:
+ case TargetPlatform.fuchsia:
+ case TargetPlatform.linux:
+ case TargetPlatform.windows:
+ expect(
+ selection,
+ equals(
+ const TextSelection(
+ baseOffset: 20,
+ extentOffset: 36,
+ affinity: TextAffinity.upstream,
+ ),
+ ),
+ reason: 'on $platform',
+ );
+ break;
+
+ // Mac expands by line.
+ case TargetPlatform.macOS:
+ expect(
+ selection,
+ equals(
+ const TextSelection(
+ baseOffset: 20,
+ extentOffset: 54,
+ affinity: TextAffinity.upstream,
+ ),
+ ),
+ reason: 'on $platform',
+ );
+ break;
+ }
// Select All
await sendKeys(
@@ -8502,7 +8526,7 @@
expect(controller.selection.baseOffset, 17);
expect(controller.selection.extentOffset, 24);
- // Multiple expandLeftByLine shortcuts only move ot the start of the line
+ // Multiple expandLeftByLine shortcuts only move to the start of the line
// and not to the previous line.
await sendKeys(
tester,
@@ -8516,8 +8540,23 @@
targetPlatform: defaultTargetPlatform,
);
expect(controller.selection.isCollapsed, false);
- expect(controller.selection.baseOffset, 17);
- expect(controller.selection.extentOffset, 15);
+ switch (defaultTargetPlatform) {
+ // These platforms extend by line.
+ case TargetPlatform.iOS:
+ case TargetPlatform.android:
+ case TargetPlatform.fuchsia:
+ case TargetPlatform.linux:
+ case TargetPlatform.windows:
+ expect(controller.selection.baseOffset, 17);
+ expect(controller.selection.extentOffset, 15);
+ break;
+
+ // Mac expands by line.
+ case TargetPlatform.macOS:
+ expect(controller.selection.baseOffset, 15);
+ expect(controller.selection.extentOffset, 24);
+ break;
+ }
// Set the caret to the end of a line.
controller.selection = const TextSelection(
@@ -8599,6 +8638,343 @@
// On web, using keyboard for selection is handled by the browser.
}, variant: TargetPlatformVariant.all(), skip: kIsWeb); // [intended]
+ testWidgets("Mac's expand by line behavior on multiple lines", (WidgetTester tester) async {
+ const String multilineText = 'word word word\nword word\nword'; // 15 + 10 + 4;
+ final TextEditingController controller = TextEditingController(text: multilineText);
+ // word word word
+ // wo|rd word
+ // w|ord
+ controller.selection = const TextSelection(
+ baseOffset: 17,
+ extentOffset: 26,
+ affinity: TextAffinity.upstream,
+ );
+ await tester.pumpWidget(MaterialApp(
+ home: Align(
+ alignment: Alignment.topLeft,
+ child: SizedBox(
+ width: 400,
+ child: EditableText(
+ maxLines: 10,
+ controller: controller,
+ autofocus: true,
+ focusNode: focusNode,
+ style: Typography.material2018().black.subtitle1!,
+ cursorColor: Colors.blue,
+ backgroundCursorColor: Colors.grey,
+ keyboardType: TextInputType.text,
+ ),
+ ),
+ ),
+ ));
+
+ await tester.pump(); // Wait for autofocus to take effect.
+ expect(controller.selection.isCollapsed, false);
+ expect(controller.selection.baseOffset, 17);
+ expect(controller.selection.extentOffset, 26);
+
+ // Expanding right to the end of the line moves the extent on the second
+ // selected line.
+ await sendKeys(
+ tester,
+ <LogicalKeyboardKey>[
+ LogicalKeyboardKey.arrowRight,
+ ],
+ shift: true,
+ lineModifier: true,
+ targetPlatform: defaultTargetPlatform,
+ );
+ expect(controller.selection.isCollapsed, false);
+ expect(controller.selection.baseOffset, 17);
+ expect(controller.selection.extentOffset, 29);
+
+ // Expanding right again does nothing.
+ await sendKeys(
+ tester,
+ <LogicalKeyboardKey>[
+ LogicalKeyboardKey.arrowRight,
+ LogicalKeyboardKey.arrowRight,
+ LogicalKeyboardKey.arrowRight,
+ ],
+ shift: true,
+ lineModifier: true,
+ targetPlatform: defaultTargetPlatform,
+ );
+ expect(controller.selection.isCollapsed, false);
+ expect(controller.selection.baseOffset, 17);
+ expect(controller.selection.extentOffset, 29);
+
+ // Expanding left by line moves the base on the first selected line to the
+ // beginning of that line.
+ await sendKeys(
+ tester,
+ <LogicalKeyboardKey>[
+ LogicalKeyboardKey.arrowLeft,
+ ],
+ shift: true,
+ lineModifier: true,
+ targetPlatform: defaultTargetPlatform,
+ );
+ expect(controller.selection.isCollapsed, false);
+ expect(controller.selection.baseOffset, 15);
+ expect(controller.selection.extentOffset, 29);
+
+ // Expanding left again does nothing.
+ await sendKeys(
+ tester,
+ <LogicalKeyboardKey>[
+ LogicalKeyboardKey.arrowLeft,
+ LogicalKeyboardKey.arrowLeft,
+ LogicalKeyboardKey.arrowLeft,
+ ],
+ shift: true,
+ lineModifier: true,
+ targetPlatform: defaultTargetPlatform,
+ );
+ expect(controller.selection.isCollapsed, false);
+ expect(controller.selection.baseOffset, 15);
+ expect(controller.selection.extentOffset, 29);
+ },
+ // On web, using keyboard for selection is handled by the browser.
+ skip: kIsWeb, // [intended]
+ variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.macOS })
+ );
+
+ testWidgets("Mac's expand extent position", (WidgetTester tester) async {
+ const String testText = 'Now is the time for all good people to come to the aid of their country';
+ final TextEditingController controller = TextEditingController(text: testText);
+ // Start the selection in the middle somewhere.
+ controller.selection = const TextSelection.collapsed(offset: 10);
+ await tester.pumpWidget(MaterialApp(
+ home: Align(
+ alignment: Alignment.topLeft,
+ child: SizedBox(
+ width: 400,
+ child: EditableText(
+ maxLines: 10,
+ controller: controller,
+ autofocus: true,
+ focusNode: focusNode,
+ style: Typography.material2018().black.subtitle1!,
+ cursorColor: Colors.blue,
+ backgroundCursorColor: Colors.grey,
+ keyboardType: TextInputType.text,
+ ),
+ ),
+ ),
+ ));
+
+ await tester.pump(); // Wait for autofocus to take effect.
+ expect(controller.selection.isCollapsed, true);
+ expect(controller.selection.baseOffset, 10);
+
+ // With cursor in the middle of the line, cmd + left. Left end is the extent.
+ await sendKeys(
+ tester,
+ <LogicalKeyboardKey>[
+ LogicalKeyboardKey.arrowLeft,
+ ],
+ lineModifier: true,
+ shift: true,
+ targetPlatform: defaultTargetPlatform,
+ );
+ expect(
+ controller.selection,
+ equals(
+ const TextSelection(
+ baseOffset: 10,
+ extentOffset: 0,
+ affinity: TextAffinity.upstream,
+ ),
+ ),
+ );
+
+ // With cursor in the middle of the line, cmd + right. Right end is the extent.
+ controller.selection = const TextSelection.collapsed(offset: 10);
+ await tester.pump();
+ await sendKeys(
+ tester,
+ <LogicalKeyboardKey>[
+ LogicalKeyboardKey.arrowRight,
+ ],
+ lineModifier: true,
+ shift: true,
+ targetPlatform: defaultTargetPlatform,
+ );
+ expect(
+ controller.selection,
+ equals(
+ const TextSelection(
+ baseOffset: 10,
+ extentOffset: 29,
+ affinity: TextAffinity.upstream,
+ ),
+ ),
+ );
+
+ // With cursor in the middle of the line, cmd + left then cmd + right. Left end is the extent.
+ controller.selection = const TextSelection.collapsed(offset: 10);
+ await tester.pump();
+ await sendKeys(
+ tester,
+ <LogicalKeyboardKey>[
+ LogicalKeyboardKey.arrowLeft,
+ ],
+ lineModifier: true,
+ shift: true,
+ targetPlatform: defaultTargetPlatform,
+ );
+ await tester.pump();
+ await sendKeys(
+ tester,
+ <LogicalKeyboardKey>[
+ LogicalKeyboardKey.arrowRight,
+ ],
+ lineModifier: true,
+ shift: true,
+ targetPlatform: defaultTargetPlatform,
+ );
+ expect(
+ controller.selection,
+ equals(
+ const TextSelection(
+ baseOffset: 29,
+ extentOffset: 0,
+ affinity: TextAffinity.upstream,
+ ),
+ ),
+ );
+
+ // With cursor in the middle of the line, cmd + right then cmd + left. Right end is the extent.
+ controller.selection = const TextSelection.collapsed(offset: 10);
+ await tester.pump();
+ await sendKeys(
+ tester,
+ <LogicalKeyboardKey>[
+ LogicalKeyboardKey.arrowRight,
+ ],
+ lineModifier: true,
+ shift: true,
+ targetPlatform: defaultTargetPlatform,
+ );
+ await tester.pump();
+ await sendKeys(
+ tester,
+ <LogicalKeyboardKey>[
+ LogicalKeyboardKey.arrowLeft,
+ ],
+ lineModifier: true,
+ shift: true,
+ targetPlatform: defaultTargetPlatform,
+ );
+ expect(
+ controller.selection,
+ equals(
+ const TextSelection(
+ baseOffset: 0,
+ extentOffset: 29,
+ affinity: TextAffinity.upstream,
+ ),
+ ),
+ );
+
+ // With an RTL selection in the middle of the line, cmd + left. Left end is the extent.
+ controller.selection = const TextSelection(baseOffset: 12, extentOffset: 8);
+ await tester.pump();
+ await sendKeys(
+ tester,
+ <LogicalKeyboardKey>[
+ LogicalKeyboardKey.arrowLeft,
+ ],
+ lineModifier: true,
+ shift: true,
+ targetPlatform: defaultTargetPlatform,
+ );
+ expect(
+ controller.selection,
+ equals(
+ const TextSelection(
+ baseOffset: 12,
+ extentOffset: 0,
+ affinity: TextAffinity.upstream,
+ ),
+ ),
+ );
+
+ // With an RTL selection in the middle of the line, cmd + right. Left end is the extent.
+ controller.selection = const TextSelection(baseOffset: 12, extentOffset: 8);
+ await tester.pump();
+ await sendKeys(
+ tester,
+ <LogicalKeyboardKey>[
+ LogicalKeyboardKey.arrowRight,
+ ],
+ lineModifier: true,
+ shift: true,
+ targetPlatform: defaultTargetPlatform,
+ );
+ expect(
+ controller.selection,
+ equals(
+ const TextSelection(
+ baseOffset: 29,
+ extentOffset: 8,
+ affinity: TextAffinity.upstream,
+ ),
+ ),
+ );
+
+ // With an LTR selection in the middle of the line, cmd + right. Right end is the extent.
+ controller.selection = const TextSelection(baseOffset: 8, extentOffset: 12);
+ await tester.pump();
+ await sendKeys(
+ tester,
+ <LogicalKeyboardKey>[
+ LogicalKeyboardKey.arrowRight,
+ ],
+ lineModifier: true,
+ shift: true,
+ targetPlatform: defaultTargetPlatform,
+ );
+ expect(
+ controller.selection,
+ equals(
+ const TextSelection(
+ baseOffset: 8,
+ extentOffset: 29,
+ affinity: TextAffinity.upstream,
+ ),
+ ),
+ );
+
+ // With an LTR selection in the middle of the line, cmd + left. Right end is the extent.
+ controller.selection = const TextSelection(baseOffset: 8, extentOffset: 12);
+ await tester.pump();
+ await sendKeys(
+ tester,
+ <LogicalKeyboardKey>[
+ LogicalKeyboardKey.arrowLeft,
+ ],
+ lineModifier: true,
+ shift: true,
+ targetPlatform: defaultTargetPlatform,
+ );
+ expect(
+ controller.selection,
+ equals(
+ const TextSelection(
+ baseOffset: 0,
+ extentOffset: 12,
+ affinity: TextAffinity.upstream,
+ ),
+ ),
+ );
+ },
+ // On web, using keyboard for selection is handled by the browser.
+ skip: kIsWeb, // [intended]
+ variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.macOS })
+ );
+
testWidgets('expanding selection to start/end', (WidgetTester tester) async {
final TextEditingController controller = TextEditingController(text: 'word word word');
// word wo|rd| word