Add command key bindings to macOS text editing and fix selection. (#44130)

This adds support for the command key for text selection/editing on macOS. I had ported the text editing code (in #42879), but forgot to add support for the command key itself. This also adds a test that tests the text editing on multiple platforms instead of just testing Android.

There appears to still be a bug (filed #44135) where we're losing key events sometimes on macOS, leaving some keys "stuck" on, but this PR at least allows the right key combinations to be used.
diff --git a/packages/flutter/lib/src/painting/text_painter.dart b/packages/flutter/lib/src/painting/text_painter.dart
index b6197a2..0fb9861 100644
--- a/packages/flutter/lib/src/painting/text_painter.dart
+++ b/packages/flutter/lib/src/painting/text_painter.dart
@@ -816,15 +816,15 @@
   /// <http://www.unicode.org/reports/tr29/#Word_Boundaries>.
   TextRange getWordBoundary(TextPosition position) {
     assert(!_needsLayout);
-    // TODO(gspencergoog): remove the List<int>-based code when the engine API
-    // returns a TextRange instead of a List<int>.
-    final dynamic boundary = _paragraph.getWordBoundary(position);
-    if (boundary is List<int>) {
-      final List<int> indices = boundary;
-      return TextRange(start: indices[0], end: indices[1]);
-    }
-    final TextRange range = boundary;
-    return range;
+    return _paragraph.getWordBoundary(position);
+  }
+
+  /// Returns the text range of the line at the given offset.
+  ///
+  /// The newline, if any, is included in the range.
+  TextRange getLineBoundary(TextPosition position) {
+    assert(!_needsLayout);
+    return _paragraph.getLineBoundary(position);
   }
 
   /// Returns the full list of [LineMetrics] that describe in detail the various
diff --git a/packages/flutter/lib/src/rendering/editable.dart b/packages/flutter/lib/src/rendering/editable.dart
index 2a3cd83..9893216 100644
--- a/packages/flutter/lib/src/rendering/editable.dart
+++ b/packages/flutter/lib/src/rendering/editable.dart
@@ -97,6 +97,47 @@
   }
 }
 
+// Check if the given code unit is a white space or separator
+// character.
+//
+// Includes newline characters from ASCII and separators from the
+// [unicode separator category](https://www.compart.com/en/unicode/category/Zs)
+// TODO(gspencergoog): replace when we expose this ICU information.
+bool _isWhitespace(int codeUnit) {
+  switch (codeUnit) {
+    case 0x9: // horizontal tab
+    case 0xA: // line feed
+    case 0xB: // vertical tab
+    case 0xC: // form feed
+    case 0xD: // carriage return
+    case 0x1C: // file separator
+    case 0x1D: // group separator
+    case 0x1E: // record separator
+    case 0x1F: // unit separator
+    case 0x20: // space
+    case 0xA0: // no-break space
+    case 0x1680: // ogham space mark
+    case 0x2000: // en quad
+    case 0x2001: // em quad
+    case 0x2002: // en space
+    case 0x2003: // em space
+    case 0x2004: // three-per-em space
+    case 0x2005: // four-er-em space
+    case 0x2006: // six-per-em space
+    case 0x2007: // figure space
+    case 0x2008: // punctuation space
+    case 0x2009: // thin space
+    case 0x200A: // hair space
+    case 0x202F: // narrow no-break space
+    case 0x205F: // medium mathematical space
+    case 0x3000: // ideographic space
+      break;
+    default:
+      return false;
+  }
+  return true;
+}
+
 /// Displays some text in a scrollable container with a potentially blinking
 /// cursor and with gesture recognizers.
 ///
@@ -400,10 +441,18 @@
   static final Set<LogicalKeyboardKey> _modifierKeys = <LogicalKeyboardKey>{
     LogicalKeyboardKey.shift,
     LogicalKeyboardKey.control,
+    LogicalKeyboardKey.alt,
+  };
+
+  static final Set<LogicalKeyboardKey> _macOsModifierKeys = <LogicalKeyboardKey>{
+    LogicalKeyboardKey.shift,
+    LogicalKeyboardKey.meta,
+    LogicalKeyboardKey.alt,
   };
 
   static final Set<LogicalKeyboardKey> _interestingKeys = <LogicalKeyboardKey>{
     ..._modifierKeys,
+    ..._macOsModifierKeys,
     ..._nonModifierKeys,
   };
 
@@ -414,12 +463,12 @@
   void _handleKeyEvent(RawKeyEvent keyEvent) {
     if (keyEvent is! RawKeyDownEvent || onSelectionChanged == null)
       return;
-
     final Set<LogicalKeyboardKey> keysPressed = LogicalKeyboardKey.collapseSynonyms(RawKeyboard.instance.keysPressed);
     final LogicalKeyboardKey key = keyEvent.logicalKey;
 
+    final bool isMacOS = keyEvent.data is RawKeyEventDataMacOs;
     if (!_nonModifierKeys.contains(key) ||
-        keysPressed.difference(_modifierKeys).length > 1 ||
+        keysPressed.difference(isMacOS ? _macOsModifierKeys : _modifierKeys).length > 1 ||
         keysPressed.difference(_interestingKeys).isNotEmpty) {
       // If the most recently pressed key isn't a non-modifier key, or more than
       // one non-modifier key is down, or keys other than the ones we're interested in
@@ -427,9 +476,12 @@
       return;
     }
 
+    final bool isWordModifierPressed = isMacOS ? keyEvent.isAltPressed : keyEvent.isControlPressed;
+    final bool isLineModifierPressed = isMacOS ? keyEvent.isMetaPressed : keyEvent.isAltPressed;
+    final bool isShortcutModifierPressed = isMacOS ? keyEvent.isMetaPressed : keyEvent.isControlPressed;
     if (_movementKeys.contains(key)) {
-      _handleMovement(key, control: keyEvent.isControlPressed, shift: keyEvent.isShiftPressed);
-    } else if (keyEvent.isControlPressed && _shortcutKeys.contains(key)) {
+        _handleMovement(key, wordModifier: isWordModifierPressed, lineModifier: isLineModifierPressed, shift: keyEvent.isShiftPressed);
+    } else if (isShortcutModifierPressed && _shortcutKeys.contains(key)) {
       // _handleShortcuts depends on being started in the same stack invocation
       // as the _handleKeyEvent method
       _handleShortcuts(key);
@@ -440,9 +492,15 @@
 
   void _handleMovement(
       LogicalKeyboardKey key, {
-      @required bool control,
+      @required bool wordModifier,
+      @required bool lineModifier,
       @required bool shift,
     }) {
+    if (wordModifier && lineModifier) {
+      // If both modifiers are down, nothing happens on any of the platforms.
+      return;
+    }
+
     TextSelection newSelection = selection;
 
     final bool rightArrow = key == LogicalKeyboardKey.arrowRight;
@@ -450,34 +508,80 @@
     final bool upArrow = key == LogicalKeyboardKey.arrowUp;
     final bool downArrow = key == LogicalKeyboardKey.arrowDown;
 
-    // Because the user can use multiple keys to change how they select, the
-    // new offset variable is threaded through these four functions and
-    // potentially changes after each one.
-    if (control) {
-      // If control is pressed, we will decide which way to look for a word
-      // based on which arrow is pressed.
-      if (leftArrow && newSelection.extentOffset > 2) {
-        final TextSelection textSelection = _selectWordAtOffset(TextPosition(offset: newSelection.extentOffset - 2));
-        newSelection = newSelection.copyWith(extentOffset: textSelection.baseOffset + 1);
-      } else if (rightArrow && newSelection.extentOffset < text.toPlainText().length - 2) {
-        final TextSelection textSelection = _selectWordAtOffset(TextPosition(offset: newSelection.extentOffset + 1));
-        newSelection = newSelection.copyWith(extentOffset: textSelection.extentOffset - 1);
+    // Find the previous non-whitespace character
+    int previousNonWhitespace(int extent) {
+      int result = math.max(extent - 1, 0);
+      while (result > 0 && _isWhitespace(_plainText.codeUnitAt(result))) {
+        result -= 1;
+      }
+      return result;
+    }
+
+    int nextNonWhitespace(int extent) {
+      int result = math.min(extent + 1, _plainText.length);
+      while (result < _plainText.length && _isWhitespace(_plainText.codeUnitAt(result))) {
+        result += 1;
+      }
+      return result;
+    }
+
+    if ((rightArrow || leftArrow) && !(rightArrow && leftArrow)) {
+      // Jump to begin/end of word.
+      if (wordModifier) {
+        // If control/option is pressed, we will decide which way to look for a
+        // word based on which arrow is pressed.
+        if (leftArrow) {
+          // When going left, we want to skip over any whitespace before the word,
+          // so we go back to the first non-whitespace before asking for the word
+          // boundary, since _selectWordAtOffset finds the word boundaries without
+          // including whitespace.
+          final int startPoint = previousNonWhitespace(newSelection.extentOffset);
+          final TextSelection textSelection = _selectWordAtOffset(TextPosition(offset: startPoint));
+          newSelection = newSelection.copyWith(extentOffset: textSelection.baseOffset);
+        } else {
+          // When going right, we want to skip over any whitespace after the word,
+          // so we go forward to the first non-whitespace character before asking
+          // for the word bounds, since _selectWordAtOffset finds the word
+          // boundaries without including whitespace.
+          final int startPoint = nextNonWhitespace(newSelection.extentOffset);
+          final TextSelection textSelection = _selectWordAtOffset(TextPosition(offset: startPoint));
+          newSelection = newSelection.copyWith(extentOffset: textSelection.extentOffset);
+        }
+      } else if (lineModifier) {
+        // If control/command is pressed, we will decide which way to expand to
+        // the beginning/end of the line based on which arrow is pressed.
+        if (leftArrow) {
+          // When going left, we want to skip over any whitespace before the line,
+          // so we go back to the first non-whitespace before asking for the line
+          // bounds, since _selectLineAtOffset finds the line boundaries without
+          // including whitespace (like the newline).
+          final int startPoint = previousNonWhitespace(newSelection.extentOffset);
+          final TextSelection textSelection = _selectLineAtOffset(TextPosition(offset: startPoint));
+          newSelection = newSelection.copyWith(extentOffset: textSelection.baseOffset);
+        } else {
+          // When going right, we want to skip over any whitespace after the line,
+          // so we go forward to the first non-whitespace character before asking
+          // for the line bounds, since _selectLineAtOffset finds the line
+          // boundaries without including whitespace (like the newline).
+          final int startPoint = nextNonWhitespace(newSelection.extentOffset);
+          final TextSelection textSelection = _selectLineAtOffset(TextPosition(offset: startPoint));
+          newSelection = newSelection.copyWith(extentOffset: textSelection.extentOffset);
+        }
+      } else {
+        if (rightArrow && newSelection.extentOffset < _plainText.length) {
+          newSelection = newSelection.copyWith(extentOffset: newSelection.extentOffset + 1);
+          if (shift) {
+            _cursorResetLocation += 1;
+          }
+        } else if (leftArrow && newSelection.extentOffset > 0) {
+          newSelection = newSelection.copyWith(extentOffset: newSelection.extentOffset - 1);
+          if (shift) {
+            _cursorResetLocation -= 1;
+          }
+        }
       }
     }
-    // Set the new offset to be +/- 1 depending on which arrow is pressed
-    // If shift is down, we also want to update the previous cursor location
-    if (rightArrow && newSelection.extentOffset < text.toPlainText().length) {
-      newSelection = newSelection.copyWith(extentOffset: newSelection.extentOffset + 1);
-      if (shift) {
-        _cursorResetLocation += 1;
-      }
-    }
-    if (leftArrow && newSelection.extentOffset > 0) {
-      newSelection = newSelection.copyWith(extentOffset: newSelection.extentOffset - 1);
-      if (shift) {
-        _cursorResetLocation -= 1;
-      }
-    }
+
     // Handles moving the cursor vertically as well as taking care of the
     // case where the user moves the cursor to the end or beginning of the text
     // and then back up or down.
@@ -498,7 +602,7 @@
       // case that the user wants to unhighlight some text.
       if (position.offset == newSelection.extentOffset) {
         if (downArrow) {
-          newSelection = newSelection.copyWith(extentOffset: text.toPlainText().length);
+          newSelection = newSelection.copyWith(extentOffset: _plainText.length);
         } else if (upArrow) {
           newSelection = newSelection.copyWith(extentOffset: 0);
         }
@@ -512,20 +616,24 @@
       }
     }
 
-    // Just place the collapsed selection at the new position if shift isn't down.
+    // Just place the collapsed selection at the end or beginning of the region
+    // if shift isn't down.
     if (!shift) {
       // We want to put the cursor at the correct location depending on which
       // arrow is used while there is a selection.
       int newOffset = newSelection.extentOffset;
       if (!selection.isCollapsed) {
-        if (leftArrow)
+        if (leftArrow) {
           newOffset = newSelection.baseOffset < newSelection.extentOffset ? newSelection.baseOffset : newSelection.extentOffset;
-        else if (rightArrow)
+        } else if (rightArrow) {
           newOffset = newSelection.baseOffset > newSelection.extentOffset ? newSelection.baseOffset : newSelection.extentOffset;
+        }
       }
       newSelection = TextSelection.fromPosition(TextPosition(offset: newOffset));
     }
 
+    // Update the text selection delegate so that the engine knows what we did.
+    textSelectionDelegate.textEditingValue = textSelectionDelegate.textEditingValue.copyWith(selection: newSelection);
     _handleSelectionChange(
       newSelection,
       SelectionChangedCause.keyboard,
@@ -533,22 +641,22 @@
   }
 
   // Handles shortcut functionality including cut, copy, paste and select all
-  // using control + (X, C, V, A).
+  // using control/command + (X, C, V, A).
   Future<void> _handleShortcuts(LogicalKeyboardKey key) async {
     assert(_shortcutKeys.contains(key), 'shortcut key $key not recognized.');
     if (key == LogicalKeyboardKey.keyC) {
       if (!selection.isCollapsed) {
         Clipboard.setData(
-            ClipboardData(text: selection.textInside(text.toPlainText())));
+            ClipboardData(text: selection.textInside(_plainText)));
       }
       return;
     }
     if (key == LogicalKeyboardKey.keyX) {
       if (!selection.isCollapsed) {
-        Clipboard.setData(ClipboardData(text: selection.textInside(text.toPlainText())));
+        Clipboard.setData(ClipboardData(text: selection.textInside(_plainText)));
         textSelectionDelegate.textEditingValue = TextEditingValue(
-          text: selection.textBefore(text.toPlainText())
-              + selection.textAfter(text.toPlainText()),
+          text: selection.textBefore(_plainText)
+              + selection.textAfter(_plainText),
           selection: TextSelection.collapsed(offset: selection.start),
         );
       }
@@ -584,15 +692,15 @@
   }
 
   void _handleDelete() {
-    if (selection.textAfter(text.toPlainText()).isNotEmpty) {
+    if (selection.textAfter(_plainText).isNotEmpty) {
       textSelectionDelegate.textEditingValue = TextEditingValue(
-        text: selection.textBefore(text.toPlainText())
-          + selection.textAfter(text.toPlainText()).substring(1),
+        text: selection.textBefore(_plainText)
+          + selection.textAfter(_plainText).substring(1),
         selection: TextSelection.collapsed(offset: selection.start),
       );
     } else {
       textSelectionDelegate.textEditingValue = TextEditingValue(
-        text: selection.textBefore(text.toPlainText()),
+        text: selection.textBefore(_plainText),
         selection: TextSelection.collapsed(offset: selection.start),
       );
     }
@@ -617,6 +725,13 @@
     _textLayoutLastMinWidth = null;
   }
 
+  // Retuns a cached plain text version of the text in the painter.
+  String _cachedPlainText;
+  String get _plainText {
+    _cachedPlainText ??= _textPainter.text.toPlainText();
+    return _cachedPlainText;
+  }
+
   /// The text to display.
   TextSpan get text => _textPainter.text;
   final TextPainter _textPainter;
@@ -624,6 +739,7 @@
     if (_textPainter.text == value)
       return;
     _textPainter.text = value;
+    _cachedPlainText = null;
     markNeedsTextLayout();
     markNeedsSemanticsUpdate();
   }
@@ -1013,8 +1129,8 @@
 
     config
       ..value = obscureText
-          ? obscuringCharacter * text.toPlainText().length
-          : text.toPlainText()
+          ? obscuringCharacter * _plainText.length
+          : _plainText
       ..isObscured = obscureText
       ..isMultiline = _isMultiline
       ..textDirection = textDirection
@@ -1124,42 +1240,14 @@
   // Check if the given text range only contains white space or separator
   // characters.
   //
-  // newline characters from ascii and separators from the
+  // Includes newline characters from ASCII and separators from the
   // [unicode separator category](https://www.compart.com/en/unicode/category/Zs)
   // TODO(jonahwilliams): replace when we expose this ICU information.
   bool _onlyWhitespace(TextRange range) {
     for (int i = range.start; i < range.end; i++) {
       final int codeUnit = text.codeUnitAt(i);
-      switch (codeUnit) {
-        case 0x9: // horizontal tab
-        case 0xA: // line feed
-        case 0xB: // vertical tab
-        case 0xC: // form feed
-        case 0xD: // carriage return
-        case 0x1C: // file separator
-        case 0x1D: // group separator
-        case 0x1E: // record separator
-        case 0x1F: // unit separator
-        case 0x20: // space
-        case 0xA0: // no-break space
-        case 0x1680: // ogham space mark
-        case 0x2000: // en quad
-        case 0x2001: // em quad
-        case 0x2002: // en space
-        case 0x2003: // em space
-        case 0x2004: // three-per-em space
-        case 0x2005: // four-er-em space
-        case 0x2006: // six-per-em space
-        case 0x2007: // figure space
-        case 0x2008: // punctuation space
-        case 0x2009: // thin space
-        case 0x200A: // hair space
-        case 0x202F: // narrow no-break space
-        case 0x205F: // medium mathematical space
-        case 0x3000: // ideographic space
-          break;
-        default:
-          return false;
+      if (!_isWhitespace(codeUnit)) {
+        return false;
       }
     }
     return true;
@@ -1338,7 +1426,7 @@
 
     // Set the height based on the content.
     if (width == double.infinity) {
-      final String text = _textPainter.text.toPlainText();
+      final String text = _plainText;
       int lines = 1;
       for (int index = 0; index < text.length; index += 1) {
         if (text.codeUnitAt(index) == 0x0A) // count explicit line breaks
@@ -1550,11 +1638,25 @@
       return TextSelection.fromPosition(position);
     // If text is obscured, the entire sentence should be treated as one word.
     if (obscureText) {
-      return TextSelection(baseOffset: 0, extentOffset: text.toPlainText().length);
+      return TextSelection(baseOffset: 0, extentOffset: _plainText.length);
     }
     return TextSelection(baseOffset: word.start, extentOffset: word.end);
   }
 
+  TextSelection _selectLineAtOffset(TextPosition position) {
+    assert(_textLayoutLastMaxWidth == constraints.maxWidth &&
+        _textLayoutLastMinWidth == constraints.minWidth,
+    '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);
+    }
+    return TextSelection(baseOffset: line.start, extentOffset: line.end);
+  }
+
   Rect _caretPrototype;
 
   void _layoutText({ double minWidth = 0.0, double maxWidth = double.infinity }) {
diff --git a/packages/flutter/test/widgets/editable_text_test.dart b/packages/flutter/test/widgets/editable_text_test.dart
index cee5cf3..ac74d7c 100644
--- a/packages/flutter/test/widgets/editable_text_test.dart
+++ b/packages/flutter/test/widgets/editable_text_test.dart
@@ -2975,12 +2975,66 @@
     expect(controller.selection.extent.offset, 5);
   }, skip: isBrowser);
 
-  testWidgets('keyboard text selection works as expected', (WidgetTester tester) async {
-    // Text with two separate words to select.
-    const String testText = 'Now is the time for\n'
-        'all good people\n'
-        'to come to the aid\n'
-        'of their country.';
+  const String testText = 'Now is the time for\n'
+      'all good people\n'
+      'to come to the aid\n'
+      'of their country.';
+
+  Future<void> sendKeys(
+      WidgetTester tester,
+      List<LogicalKeyboardKey> keys, {
+        bool shift = false,
+        bool wordModifier = false,
+        bool lineModifier = false,
+        bool shortcutModifier = false,
+        String platform,
+      }) async {
+    if (shift) {
+      await tester.sendKeyDownEvent(LogicalKeyboardKey.shiftLeft, platform: platform);
+    }
+    if (shortcutModifier) {
+      await tester.sendKeyDownEvent(
+          platform == 'macos' ? LogicalKeyboardKey.metaLeft : LogicalKeyboardKey.controlLeft,
+          platform: platform);
+    }
+    if (wordModifier) {
+      await tester.sendKeyDownEvent(
+          platform == 'macos' ? LogicalKeyboardKey.altLeft : LogicalKeyboardKey.controlLeft,
+          platform: platform);
+    }
+    if (lineModifier) {
+      await tester.sendKeyDownEvent(
+          platform == 'macos' ? LogicalKeyboardKey.metaLeft : LogicalKeyboardKey.altLeft,
+          platform: platform);
+    }
+    for (LogicalKeyboardKey key in keys) {
+      await tester.sendKeyEvent(key, platform: platform);
+      await tester.pump();
+    }
+    if (lineModifier) {
+      await tester.sendKeyUpEvent(
+          platform == 'macos' ? LogicalKeyboardKey.metaLeft : LogicalKeyboardKey.altLeft,
+          platform: platform);
+    }
+    if (wordModifier) {
+      await tester.sendKeyUpEvent(
+          platform == 'macos' ? LogicalKeyboardKey.altLeft : LogicalKeyboardKey.controlLeft,
+          platform: platform);
+    }
+    if (shortcutModifier) {
+      await tester.sendKeyUpEvent(
+          platform == 'macos' ? LogicalKeyboardKey.metaLeft : LogicalKeyboardKey.controlLeft,
+          platform: platform);
+    }
+    if (shift) {
+      await tester.sendKeyUpEvent(LogicalKeyboardKey.shiftLeft, platform: platform);
+    }
+    if (shift || wordModifier || lineModifier) {
+      await tester.pump();
+    }
+  }
+
+  Future<void> testTextEditing(WidgetTester tester, {String platform}) async {
     final TextEditingController controller = TextEditingController(text: testText);
     controller.selection = const TextSelection(
       baseOffset: 0,
@@ -3017,149 +3071,192 @@
 
     await tester.pump(); // Wait for autofocus to take effect.
 
-    Future<void> sendKeys(List<LogicalKeyboardKey> keys, {bool shift = false, bool control = false}) async {
-      if (shift) {
-        await tester.sendKeyDownEvent(LogicalKeyboardKey.shiftLeft);
-      }
-      if (control) {
-        await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft);
-      }
-      for (LogicalKeyboardKey key in keys) {
-        await tester.sendKeyEvent(key);
-        await tester.pump();
-      }
-      if (control) {
-        await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft);
-      }
-      if (shift) {
-        await tester.sendKeyUpEvent(LogicalKeyboardKey.shiftLeft);
-      }
-      if (shift || control) {
-        await tester.pump();
-      }
-    }
-
     // Select a few characters using shift right arrow
     await sendKeys(
+      tester,
       <LogicalKeyboardKey>[
         LogicalKeyboardKey.arrowRight,
         LogicalKeyboardKey.arrowRight,
         LogicalKeyboardKey.arrowRight,
       ],
       shift: true,
+      platform: platform,
     );
 
-    expect(cause, equals(SelectionChangedCause.keyboard));
-    expect(selection, equals(const TextSelection(
-      baseOffset: 0,
-      extentOffset: 3,
-      affinity: TextAffinity.upstream,
-    )));
+    expect(cause, equals(SelectionChangedCause.keyboard), reason: 'on $platform');
+    expect(
+      selection,
+      equals(
+        const TextSelection(
+          baseOffset: 0,
+          extentOffset: 3,
+          affinity: TextAffinity.upstream,
+        ),
+      ),
+      reason: 'on $platform',
+    );
 
     // Select fewer characters using shift left arrow
     await sendKeys(
+      tester,
       <LogicalKeyboardKey>[
         LogicalKeyboardKey.arrowLeft,
         LogicalKeyboardKey.arrowLeft,
         LogicalKeyboardKey.arrowLeft,
       ],
       shift: true,
+      platform: platform,
     );
 
-    expect(selection, equals(const TextSelection(
-      baseOffset: 0,
-      extentOffset: 0,
-      affinity: TextAffinity.upstream,
-    )));
+    expect(
+      selection,
+      equals(
+        const TextSelection(
+          baseOffset: 0,
+          extentOffset: 0,
+          affinity: TextAffinity.upstream,
+        ),
+      ),
+      reason: 'on $platform',
+    );
 
     // Try to select before the first character, nothing should change.
     await sendKeys(
+      tester,
       <LogicalKeyboardKey>[
         LogicalKeyboardKey.arrowLeft,
       ],
       shift: true,
+      platform: platform,
     );
 
-    expect(selection, equals(const TextSelection(
-      baseOffset: 0,
-      extentOffset: 0,
-      affinity: TextAffinity.upstream,
-    )));
+    expect(
+      selection,
+      equals(
+        const TextSelection(
+          baseOffset: 0,
+          extentOffset: 0,
+          affinity: TextAffinity.upstream,
+        ),
+      ),
+      reason: 'on $platform',
+    );
 
     // Select the first two words.
     await sendKeys(
+      tester,
       <LogicalKeyboardKey>[
         LogicalKeyboardKey.arrowRight,
         LogicalKeyboardKey.arrowRight,
       ],
       shift: true,
-      control: true,
+      wordModifier: true,
+      platform: platform,
     );
 
-    expect(selection, equals(const TextSelection(
-      baseOffset: 0,
-      extentOffset: 6,
-      affinity: TextAffinity.upstream,
-    )));
+    expect(
+      selection,
+      equals(
+        const TextSelection(
+          baseOffset: 0,
+          extentOffset: 6,
+          affinity: TextAffinity.upstream,
+        ),
+      ),
+      reason: 'on $platform',
+    );
 
     // Unselect the second word.
     await sendKeys(
+      tester,
       <LogicalKeyboardKey>[
         LogicalKeyboardKey.arrowLeft,
       ],
       shift: true,
-      control: true,
+      wordModifier: true,
+      platform: platform,
     );
 
-    expect(selection, equals(const TextSelection(
-      baseOffset: 0,
-      extentOffset: 4,
-      affinity: TextAffinity.upstream,
-    )));
+    expect(
+      selection,
+      equals(
+        const TextSelection(
+          baseOffset: 0,
+          extentOffset: 4,
+          affinity: TextAffinity.upstream,
+        ),
+      ),
+      reason: 'on $platform',
+    );
 
     // Select the next line.
     await sendKeys(
+      tester,
       <LogicalKeyboardKey>[
         LogicalKeyboardKey.arrowDown,
       ],
       shift: true,
+      platform: platform,
     );
 
-    expect(selection, equals(const TextSelection(
-      baseOffset: 0,
-      extentOffset: 20,
-      affinity: TextAffinity.upstream,
-    )));
+    expect(
+      selection,
+      equals(
+        const TextSelection(
+          baseOffset: 0,
+          extentOffset: 20,
+          affinity: TextAffinity.upstream,
+        ),
+      ),
+      reason: 'on $platform',
+    );
 
     // Move forward one character to reset the selection.
     await sendKeys(
+      tester,
       <LogicalKeyboardKey>[
         LogicalKeyboardKey.arrowRight,
       ],
+      platform: platform,
     );
 
-    expect(selection, equals(const TextSelection(
-      baseOffset: 21,
-      extentOffset: 21,
-      affinity: TextAffinity.downstream,
-    )));
+    expect(
+      selection,
+      equals(
+        const TextSelection(
+          baseOffset: 21,
+          extentOffset: 21,
+          affinity: TextAffinity.downstream,
+        ),
+      ),
+      reason: 'on $platform',
+    );
 
     // Select the next line.
     await sendKeys(
+      tester,
       <LogicalKeyboardKey>[
         LogicalKeyboardKey.arrowDown,
       ],
       shift: true,
+      platform: platform,
     );
 
-    expect(selection, equals(const TextSelection(
-      baseOffset: 21,
-      extentOffset: 40,
-      affinity: TextAffinity.downstream,
-    )));
+    expect(
+      selection,
+      equals(
+        const TextSelection(
+          baseOffset: 21,
+          extentOffset: 40,
+          affinity: TextAffinity.downstream,
+        ),
+      ),
+      reason: 'on $platform',
+    );
 
     // Select to the end of the string by going down.
     await sendKeys(
+      tester,
       <LogicalKeyboardKey>[
         LogicalKeyboardKey.arrowDown,
         LogicalKeyboardKey.arrowDown,
@@ -3167,166 +3264,323 @@
         LogicalKeyboardKey.arrowDown,
       ],
       shift: true,
+      platform: platform,
     );
 
-    expect(selection, equals(const TextSelection(
-      baseOffset: 21,
-      extentOffset: testText.length,
-      affinity: TextAffinity.downstream,
-    )));
+    expect(
+      selection,
+      equals(
+        const TextSelection(
+          baseOffset: 21,
+          extentOffset: testText.length,
+          affinity: TextAffinity.downstream,
+        ),
+      ),
+      reason: 'on $platform',
+    );
 
     // Go back up one line to set selection up to part of the last line.
     await sendKeys(
+      tester,
       <LogicalKeyboardKey>[
         LogicalKeyboardKey.arrowUp,
       ],
       shift: true,
+      platform: platform,
     );
 
-    expect(selection, equals(const TextSelection(
-      baseOffset: 21,
-      extentOffset: 58,
-      affinity: TextAffinity.downstream,
-    )));
+    expect(
+      selection,
+      equals(
+        const TextSelection(
+          baseOffset: 21,
+          extentOffset: 58,
+          affinity: TextAffinity.downstream,
+        ),
+      ),
+      reason: 'on $platform',
+    );
+
+    // Select to the end of the selection.
+    await sendKeys(
+      tester,
+      <LogicalKeyboardKey>[
+        LogicalKeyboardKey.arrowRight,
+      ],
+      lineModifier: true,
+      shift: true,
+      platform: platform,
+    );
+
+    expect(
+      selection,
+      equals(
+        const TextSelection(
+          baseOffset: 21,
+          extentOffset: 72,
+          affinity: TextAffinity.downstream,
+        ),
+      ),
+      reason: 'on $platform',
+    );
+
+    // Select to the beginning of the line.
+    await sendKeys(
+      tester,
+      <LogicalKeyboardKey>[
+        LogicalKeyboardKey.arrowLeft,
+      ],
+      lineModifier: true,
+      shift: true,
+      platform: platform,
+    );
+
+    expect(
+      selection,
+      equals(
+        const TextSelection(
+          baseOffset: 21,
+          extentOffset: 55,
+          affinity: TextAffinity.downstream,
+        ),
+      ),
+      reason: 'on $platform',
+    );
 
     // Select All
     await sendKeys(
+      tester,
       <LogicalKeyboardKey>[
         LogicalKeyboardKey.keyA,
       ],
-      control: true,
+      shortcutModifier: true,
+      platform: platform,
     );
 
-    expect(selection, equals(const TextSelection(
-      baseOffset: 0,
-      extentOffset: testText.length,
-      affinity: TextAffinity.downstream,
-    )));
+    expect(
+      selection,
+      equals(
+        const TextSelection(
+          baseOffset: 0,
+          extentOffset: testText.length,
+          affinity: TextAffinity.downstream,
+        ),
+      ),
+      reason: 'on $platform',
+    );
 
     // Jump to beginning of selection.
     await sendKeys(
+      tester,
       <LogicalKeyboardKey>[
         LogicalKeyboardKey.arrowLeft,
       ],
+      platform: platform,
     );
 
-    expect(selection, equals(const TextSelection(
-      baseOffset: 0,
-      extentOffset: 0,
-      affinity: TextAffinity.downstream,
-    )));
+    expect(
+      selection,
+      equals(
+        const TextSelection(
+          baseOffset: 0,
+          extentOffset: 0,
+          affinity: TextAffinity.downstream,
+        ),
+      ),
+      reason: 'on $platform',
+    );
 
     // Jump forward three words.
     await sendKeys(
+      tester,
       <LogicalKeyboardKey>[
         LogicalKeyboardKey.arrowRight,
         LogicalKeyboardKey.arrowRight,
         LogicalKeyboardKey.arrowRight,
       ],
-      control: true,
+      wordModifier: true,
+      platform: platform,
     );
 
-    expect(selection, equals(const TextSelection(
-      baseOffset: 10,
-      extentOffset: 10,
-      affinity: TextAffinity.downstream,
-    )));
+    expect(
+      selection,
+      equals(
+        const TextSelection(
+          baseOffset: 10,
+          extentOffset: 10,
+          affinity: TextAffinity.downstream,
+        ),
+      ),
+      reason: 'on $platform',
+    );
 
     // Select some characters backward.
     await sendKeys(
+      tester,
       <LogicalKeyboardKey>[
         LogicalKeyboardKey.arrowLeft,
         LogicalKeyboardKey.arrowLeft,
         LogicalKeyboardKey.arrowLeft,
       ],
       shift: true,
+      platform: platform,
     );
 
-    expect(selection, equals(const TextSelection(
-      baseOffset: 10,
-      extentOffset: 7,
-      affinity: TextAffinity.downstream,
-    )));
+    expect(
+      selection,
+      equals(
+        const TextSelection(
+          baseOffset: 10,
+          extentOffset: 7,
+          affinity: TextAffinity.downstream,
+        ),
+      ),
+      reason: 'on $platform',
+    );
 
     // Select a word backward.
     await sendKeys(
+      tester,
       <LogicalKeyboardKey>[
         LogicalKeyboardKey.arrowLeft,
       ],
       shift: true,
-      control: true,
+      wordModifier: true,
+      platform: platform,
     );
 
-    expect(selection, equals(const TextSelection(
-      baseOffset: 10,
-      extentOffset: 4,
-      affinity: TextAffinity.downstream,
-    )));
-    expect(controller.text, equals(testText));
+    expect(
+      selection,
+      equals(
+        const TextSelection(
+          baseOffset: 10,
+          extentOffset: 4,
+          affinity: TextAffinity.downstream,
+        ),
+      ),
+      reason: 'on $platform',
+    );
+    expect(controller.text, equals(testText), reason: 'on $platform');
 
     // Cut
     await sendKeys(
+      tester,
       <LogicalKeyboardKey>[
         LogicalKeyboardKey.keyX,
       ],
-      control: true,
+      shortcutModifier: true,
+      platform: platform,
     );
 
-    expect(selection, equals(const TextSelection(
-      baseOffset: 10,
-      extentOffset: 4,
-      affinity: TextAffinity.downstream,
-    )));
-    expect(controller.text, equals('Now  time for\n'
-        'all good people\n'
-        'to come to the aid\n'
-        'of their country.'));
-    expect((await Clipboard.getData(Clipboard.kTextPlain)).text, equals('is the'));
+    expect(
+      selection,
+      equals(
+        const TextSelection(
+          baseOffset: 10,
+          extentOffset: 4,
+          affinity: TextAffinity.downstream,
+        ),
+      ),
+      reason: 'on $platform',
+    );
+    expect(
+      controller.text,
+      equals('Now  time for\n'
+          'all good people\n'
+          'to come to the aid\n'
+          'of their country.'),
+      reason: 'on $platform',
+    );
+    expect(
+      (await Clipboard.getData(Clipboard.kTextPlain)).text,
+      equals('is the'),
+      reason: 'on $platform',
+    );
 
     // Paste
     await sendKeys(
+      tester,
       <LogicalKeyboardKey>[
         LogicalKeyboardKey.keyV,
       ],
-      control: true,
+      shortcutModifier: true,
+      platform: platform,
     );
 
-    expect(selection, equals(const TextSelection(
-      baseOffset: 10,
-      extentOffset: 4,
-      affinity: TextAffinity.downstream,
-    )));
-    expect(controller.text, equals(testText));
+    expect(
+      selection,
+      equals(
+        const TextSelection(
+          baseOffset: 10,
+          extentOffset: 4,
+          affinity: TextAffinity.downstream,
+        ),
+      ),
+      reason: 'on $platform',
+    );
+    expect(controller.text, equals(testText), reason: 'on $platform');
 
     // Copy All
     await sendKeys(
+      tester,
       <LogicalKeyboardKey>[
         LogicalKeyboardKey.keyA,
         LogicalKeyboardKey.keyC,
       ],
-      control: true,
+      shortcutModifier: true,
+      platform: platform,
     );
 
-    expect(selection, equals(const TextSelection(
-      baseOffset: 0,
-      extentOffset: testText.length,
-      affinity: TextAffinity.downstream,
-    )));
-    expect(controller.text, equals(testText));
+    expect(
+      selection,
+      equals(
+        const TextSelection(
+          baseOffset: 0,
+          extentOffset: testText.length,
+          affinity: TextAffinity.downstream,
+        ),
+      ),
+      reason: 'on $platform',
+    );
+    expect(controller.text, equals(testText), reason: 'on $platform');
     expect((await Clipboard.getData(Clipboard.kTextPlain)).text, equals(testText));
 
     // Delete
     await sendKeys(
+      tester,
       <LogicalKeyboardKey>[
         LogicalKeyboardKey.delete,
       ],
+      platform: platform,
     );
-    expect(selection, equals(const TextSelection(
-      baseOffset: 0,
-      extentOffset: 72,
-      affinity: TextAffinity.downstream,
-    )));
-    expect(controller.text, isEmpty);
+    expect(
+      selection,
+      equals(
+        const TextSelection(
+          baseOffset: 0,
+          extentOffset: 72,
+          affinity: TextAffinity.downstream,
+        ),
+      ),
+      reason: 'on $platform',
+    );
+    expect(controller.text, isEmpty, reason: 'on $platform');
+  }
+
+  testWidgets('keyboard text selection works as expected on linux', (WidgetTester tester) async {
+    await testTextEditing(tester, platform: 'linux');
+  });
+
+  testWidgets('keyboard text selection works as expected on android', (WidgetTester tester) async {
+    await testTextEditing(tester, platform: 'android');
+  });
+
+  testWidgets('keyboard text selection works as expected on fuchsia', (WidgetTester tester) async {
+    await testTextEditing(tester, platform: 'fuchsia');
+  });
+
+  testWidgets('keyboard text selection works as expected on macos', (WidgetTester tester) async {
+    await testTextEditing(tester, platform: 'macos');
   });
 
   // Regression test for https://github.com/flutter/flutter/issues/31287