diff --git a/packages/flutter/lib/src/painting/text_painter.dart b/packages/flutter/lib/src/painting/text_painter.dart index b6197a2503a..0fb986126b7 100644 --- a/packages/flutter/lib/src/painting/text_painter.dart +++ b/packages/flutter/lib/src/painting/text_painter.dart @@ -816,15 +816,15 @@ class TextPainter { /// . TextRange getWordBoundary(TextPosition position) { assert(!_needsLayout); - // TODO(gspencergoog): remove the List-based code when the engine API - // returns a TextRange instead of a List. - final dynamic boundary = _paragraph.getWordBoundary(position); - if (boundary is List) { - final List 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 2a3cd83d17a..9893216f5b8 100644 --- a/packages/flutter/lib/src/rendering/editable.dart +++ b/packages/flutter/lib/src/rendering/editable.dart @@ -97,6 +97,47 @@ class TextSelectionPoint { } } +// 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 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin { static final Set _modifierKeys = { LogicalKeyboardKey.shift, LogicalKeyboardKey.control, + LogicalKeyboardKey.alt, + }; + + static final Set _macOsModifierKeys = { + LogicalKeyboardKey.shift, + LogicalKeyboardKey.meta, + LogicalKeyboardKey.alt, }; static final Set _interestingKeys = { ..._modifierKeys, + ..._macOsModifierKeys, ..._nonModifierKeys, }; @@ -414,12 +463,12 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin { void _handleKeyEvent(RawKeyEvent keyEvent) { if (keyEvent is! RawKeyDownEvent || onSelectionChanged == null) return; - final Set 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 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin { 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 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin { 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 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin { 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); - } - } - // 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; + // 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; + } + } } } + // 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 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin { // 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 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin { } } - // 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 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin { } // Handles shortcut functionality including cut, copy, paste and select all - // using control + (X, C, V, A). + // using control/command + (X, C, V, A). Future _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 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin { } 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 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin { _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 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin { if (_textPainter.text == value) return; _textPainter.text = value; + _cachedPlainText = null; markNeedsTextLayout(); markNeedsSemanticsUpdate(); } @@ -1013,8 +1129,8 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin { config ..value = obscureText - ? obscuringCharacter * text.toPlainText().length - : text.toPlainText() + ? obscuringCharacter * _plainText.length + : _plainText ..isObscured = obscureText ..isMultiline = _isMultiline ..textDirection = textDirection @@ -1124,42 +1240,14 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin { // 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 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin { // 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 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin { 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 cee5cf3f0b4..ac74d7c6d2b 100644 --- a/packages/flutter/test/widgets/editable_text_test.dart +++ b/packages/flutter/test/widgets/editable_text_test.dart @@ -2975,12 +2975,66 @@ void main() { 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 sendKeys( + WidgetTester tester, + List 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 testTextEditing(WidgetTester tester, {String platform}) async { final TextEditingController controller = TextEditingController(text: testText); controller.selection = const TextSelection( baseOffset: 0, @@ -3017,149 +3071,192 @@ void main() { await tester.pump(); // Wait for autofocus to take effect. - Future sendKeys(List 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.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.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.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.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.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.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.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.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.arrowDown, LogicalKeyboardKey.arrowDown, @@ -3167,166 +3264,323 @@ void main() { 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.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.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.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.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.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.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.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.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.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.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.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.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