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