Keyboard text selection and wordwrap (#85653)
Keyboard shortcuts near wordwrapped lines could be buggy before this fix.
diff --git a/packages/flutter/lib/src/rendering/editable.dart b/packages/flutter/lib/src/rendering/editable.dart
index eb590a0..ef8e7c0 100644
--- a/packages/flutter/lib/src/rendering/editable.dart
+++ b/packages/flutter/lib/src/rendering/editable.dart
@@ -1747,18 +1747,26 @@
return moveSelectionLeftByLine(cause);
}
- final int firstOffset = math.min(selection!.baseOffset, selection!.extentOffset);
- final int startPoint = previousCharacter(firstOffset, _plainText, false);
- final TextSelection selectedLine = _getLineAtOffset(TextPosition(offset: startPoint));
+ // If the lowest edge of the selection is at the start of a line, don't do
+ // anything.
+ // TODO(justinmc): Support selection with multiple TextAffinities.
+ // https://github.com/flutter/flutter/issues/88135
+ final TextSelection currentLine = _getLineAtOffset(TextPosition(
+ offset: selection!.start,
+ affinity: selection!.isCollapsed ? selection!.affinity : TextAffinity.downstream,
+ ));
+ if (currentLine.baseOffset == selection!.start) {
+ return;
+ }
late final TextSelection nextSelection;
if (selection!.extentOffset <= selection!.baseOffset) {
nextSelection = selection!.copyWith(
- extentOffset: selectedLine.baseOffset,
+ extentOffset: currentLine.baseOffset,
);
} else {
nextSelection = selection!.copyWith(
- baseOffset: selectedLine.baseOffset,
+ baseOffset: currentLine.baseOffset,
);
}
@@ -1867,18 +1875,30 @@
return moveSelectionRightByLine(cause);
}
- final int lastOffset = math.max(selection!.baseOffset, selection!.extentOffset);
- final int startPoint = nextCharacter(lastOffset, _plainText, false);
+ // If greatest edge is already at the end of a line, don't do anything.
+ // TODO(justinmc): Support selection with multiple TextAffinities.
+ // https://github.com/flutter/flutter/issues/88135
+ final TextSelection currentLine = _getLineAtOffset(TextPosition(
+ offset: selection!.end,
+ affinity: selection!.isCollapsed ? selection!.affinity : TextAffinity.upstream,
+ ));
+ if (currentLine.extentOffset == selection!.end) {
+ return;
+ }
+
+ final int startPoint = nextCharacter(selection!.end, _plainText, false);
final TextSelection selectedLine = _getLineAtOffset(TextPosition(offset: startPoint));
late final TextSelection nextSelection;
- if (selection!.extentOffset >= selection!.baseOffset) {
+ if (selection!.baseOffset <= selection!.extentOffset) {
nextSelection = selection!.copyWith(
extentOffset: selectedLine.extentOffset,
+ affinity: TextAffinity.upstream,
);
} else {
nextSelection = selection!.copyWith(
baseOffset: selectedLine.extentOffset,
+ affinity: TextAffinity.upstream,
);
}
@@ -1950,10 +1970,9 @@
void moveSelectionLeftByLine(SelectionChangedCause cause) {
assert(selection != null);
- // If the previous character is the edge of a line, don't do anything.
- final int previousPoint = previousCharacter(selection!.extentOffset, _plainText, true);
- final TextSelection line = _getLineAtOffset(TextPosition(offset: previousPoint));
- if (line.extentOffset == previousPoint) {
+ // If already at the left edge of the line, do nothing.
+ final TextSelection currentLine = _getLineAtOffset(selection!.extent);
+ if (currentLine.baseOffset == selection!.extentOffset) {
return;
}
@@ -2037,9 +2056,7 @@
assert(selection != null);
// If already at the right edge of the line, do nothing.
- final TextSelection currentLine = _getLineAtOffset(TextPosition(
- offset: selection!.extentOffset,
- ));
+ final TextSelection currentLine = _getLineAtOffset(selection!.extent);
if (currentLine.extentOffset == selection!.extentOffset) {
return;
}
@@ -2049,7 +2066,10 @@
// for the line bounds, since _getLineAtOffset finds the line
// boundaries without including whitespace (like the newline).
final int startPoint = nextCharacter(selection!.extentOffset, _plainText, false);
- final TextSelection selectedLine = _getLineAtOffset(TextPosition(offset: startPoint));
+ final TextSelection selectedLine = _getLineAtOffset(TextPosition(
+ offset: startPoint,
+ affinity: TextAffinity.upstream,
+ ));
final TextSelection nextSelection = TextSelection.collapsed(
offset: selectedLine.extentOffset,
affinity: TextAffinity.upstream,
@@ -3537,8 +3557,6 @@
'Last width ($_textLayoutLastMinWidth, $_textLayoutLastMaxWidth) not the same as max width constraint (${constraints.minWidth}, ${constraints.maxWidth}).',
);
final TextRange line = _textPainter.getLineBoundary(position);
- if (position.offset >= line.end)
- return TextSelection.fromPosition(position);
// If text is obscured, the entire string should be treated as one line.
if (obscureText) {
return TextSelection(baseOffset: 0, extentOffset: _plainText.length);
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 356621e..a18dc58 100644
--- a/packages/flutter/lib/src/widgets/default_text_editing_shortcuts.dart
+++ b/packages/flutter/lib/src/widgets/default_text_editing_shortcuts.dart
@@ -454,8 +454,8 @@
SingleActivator(LogicalKeyboardKey.arrowLeft, shift: true): ExtendSelectionLeftTextIntent(),
SingleActivator(LogicalKeyboardKey.arrowRight, shift: true): ExtendSelectionRightTextIntent(),
SingleActivator(LogicalKeyboardKey.arrowUp, shift: true): ExtendSelectionUpTextIntent(),
- SingleActivator(LogicalKeyboardKey.end, shift: true): ExpandSelectionRightByLineTextIntent(),
- SingleActivator(LogicalKeyboardKey.home, shift: true): ExpandSelectionLeftByLineTextIntent(),
+ SingleActivator(LogicalKeyboardKey.end, shift: true): ExtendSelectionRightByLineTextIntent(),
+ SingleActivator(LogicalKeyboardKey.home, shift: true): ExtendSelectionLeftByLineTextIntent(),
SingleActivator(LogicalKeyboardKey.keyX, control: true): CutSelectionTextIntent(),
SingleActivator(LogicalKeyboardKey.keyC, control: true): CopySelectionTextIntent(),
SingleActivator(LogicalKeyboardKey.keyV, control: true): PasteTextIntent(),
diff --git a/packages/flutter/test/widgets/editable_text_test.dart b/packages/flutter/test/widgets/editable_text_test.dart
index 3306e12..ed19cc5 100644
--- a/packages/flutter/test/widgets/editable_text_test.dart
+++ b/packages/flutter/test/widgets/editable_text_test.dart
@@ -4547,13 +4547,13 @@
const TextSelection(
baseOffset: 20,
extentOffset: 72,
- affinity: TextAffinity.downstream,
+ affinity: TextAffinity.upstream,
),
),
reason: 'on $platform',
);
- // Select to the beginning of the first line.
+ // Can't move left by line because we're already at the beginning of a line.
await sendKeys(
tester,
<LogicalKeyboardKey>[
@@ -4568,9 +4568,9 @@
selection,
equals(
const TextSelection(
- baseOffset: 0,
+ baseOffset: 20,
extentOffset: 72,
- affinity: TextAffinity.downstream,
+ affinity: TextAffinity.upstream,
),
),
reason: 'on $platform',
@@ -4592,7 +4592,7 @@
const TextSelection(
baseOffset: 0,
extentOffset: testText.length,
- affinity: TextAffinity.downstream,
+ affinity: TextAffinity.upstream,
),
),
reason: 'on $platform',
@@ -7891,6 +7891,153 @@
// On web, using keyboard for selection is handled by the browser.
}, variant: TargetPlatformVariant.all(), skip: kIsWeb); // [intended]
+ testWidgets('navigating multiline text', (WidgetTester tester) async {
+ const String multilineText = 'word word word\nword word\nword';
+ final TextEditingController controller = TextEditingController(text: multilineText);
+ // wo|rd wo|rd
+ controller.selection = const TextSelection(
+ baseOffset: 17,
+ extentOffset: 22,
+ 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(platform: TargetPlatform.android).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, 22);
+
+ // Multiple expandRightByLine shortcuts only move to the end of the line and
+ // not to the next line.
+ 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, 24);
+
+ // Multiple expandLeftByLine shortcuts only move ot the start of the line
+ // and not to the previous line.
+ 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, 24);
+
+ // Set the caret to the end of a line.
+ controller.selection = const TextSelection(
+ baseOffset: 24,
+ extentOffset: 24,
+ affinity: TextAffinity.upstream,
+ );
+ await tester.pump();
+ expect(controller.selection.isCollapsed, true);
+ expect(controller.selection.baseOffset, 24);
+ expect(controller.selection.extentOffset, 24);
+
+ // Can't expand right by line any further.
+ await sendKeys(
+ tester,
+ <LogicalKeyboardKey>[
+ LogicalKeyboardKey.arrowRight,
+ ],
+ shift: true,
+ lineModifier: true,
+ targetPlatform: defaultTargetPlatform,
+ );
+ expect(controller.selection.isCollapsed, true);
+ expect(controller.selection.baseOffset, 24);
+ expect(controller.selection.extentOffset, 24);
+
+ // Can select the entire line from the end.
+ await sendKeys(
+ tester,
+ <LogicalKeyboardKey>[
+ LogicalKeyboardKey.arrowLeft,
+ ],
+ shift: true,
+ lineModifier: true,
+ targetPlatform: defaultTargetPlatform,
+ );
+ expect(controller.selection.isCollapsed, false);
+ expect(controller.selection.baseOffset, 24);
+ expect(controller.selection.extentOffset, 15);
+
+ // Set the caret to the start of a line.
+ controller.selection = const TextSelection(
+ baseOffset: 15,
+ extentOffset: 15,
+ affinity: TextAffinity.upstream,
+ );
+ await tester.pump();
+ expect(controller.selection.isCollapsed, true);
+ expect(controller.selection.baseOffset, 15);
+ expect(controller.selection.extentOffset, 15);
+
+ // Can't expand let any further.
+ await sendKeys(
+ tester,
+ <LogicalKeyboardKey>[
+ LogicalKeyboardKey.arrowLeft,
+ ],
+ shift: true,
+ lineModifier: true,
+ targetPlatform: defaultTargetPlatform,
+ );
+ expect(controller.selection.isCollapsed, true);
+ expect(controller.selection.baseOffset, 15);
+ expect(controller.selection.extentOffset, 15);
+
+ // Can select the entire line from the start.
+ await sendKeys(
+ tester,
+ <LogicalKeyboardKey>[
+ LogicalKeyboardKey.arrowRight,
+ ],
+ shift: true,
+ lineModifier: true,
+ targetPlatform: defaultTargetPlatform,
+ );
+ expect(controller.selection.isCollapsed, false);
+ expect(controller.selection.baseOffset, 15);
+ expect(controller.selection.extentOffset, 24);
+ // On web, using keyboard for selection is handled by the browser.
+ }, variant: TargetPlatformVariant.all(), skip: kIsWeb); // [intended]
+
testWidgets('expanding selection to start/end', (WidgetTester tester) async {
final TextEditingController controller = TextEditingController(text: 'word word word');
// word wo|rd| word