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.
This commit is contained in:
Greg Spencer 2019-11-18 14:06:37 -08:00 committed by GitHub
parent a7367b650b
commit 21158d8337
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 585 additions and 229 deletions

View File

@ -816,15 +816,15 @@ class TextPainter {
/// <http://www.unicode.org/reports/tr29/#Word_Boundaries>. /// <http://www.unicode.org/reports/tr29/#Word_Boundaries>.
TextRange getWordBoundary(TextPosition position) { TextRange getWordBoundary(TextPosition position) {
assert(!_needsLayout); assert(!_needsLayout);
// TODO(gspencergoog): remove the List<int>-based code when the engine API return _paragraph.getWordBoundary(position);
// 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; /// 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 /// Returns the full list of [LineMetrics] that describe in detail the various

View File

@ -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 /// Displays some text in a scrollable container with a potentially blinking
/// cursor and with gesture recognizers. /// cursor and with gesture recognizers.
/// ///
@ -400,10 +441,18 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin {
static final Set<LogicalKeyboardKey> _modifierKeys = <LogicalKeyboardKey>{ static final Set<LogicalKeyboardKey> _modifierKeys = <LogicalKeyboardKey>{
LogicalKeyboardKey.shift, LogicalKeyboardKey.shift,
LogicalKeyboardKey.control, LogicalKeyboardKey.control,
LogicalKeyboardKey.alt,
};
static final Set<LogicalKeyboardKey> _macOsModifierKeys = <LogicalKeyboardKey>{
LogicalKeyboardKey.shift,
LogicalKeyboardKey.meta,
LogicalKeyboardKey.alt,
}; };
static final Set<LogicalKeyboardKey> _interestingKeys = <LogicalKeyboardKey>{ static final Set<LogicalKeyboardKey> _interestingKeys = <LogicalKeyboardKey>{
..._modifierKeys, ..._modifierKeys,
..._macOsModifierKeys,
..._nonModifierKeys, ..._nonModifierKeys,
}; };
@ -414,12 +463,12 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin {
void _handleKeyEvent(RawKeyEvent keyEvent) { void _handleKeyEvent(RawKeyEvent keyEvent) {
if (keyEvent is! RawKeyDownEvent || onSelectionChanged == null) if (keyEvent is! RawKeyDownEvent || onSelectionChanged == null)
return; return;
final Set<LogicalKeyboardKey> keysPressed = LogicalKeyboardKey.collapseSynonyms(RawKeyboard.instance.keysPressed); final Set<LogicalKeyboardKey> keysPressed = LogicalKeyboardKey.collapseSynonyms(RawKeyboard.instance.keysPressed);
final LogicalKeyboardKey key = keyEvent.logicalKey; final LogicalKeyboardKey key = keyEvent.logicalKey;
final bool isMacOS = keyEvent.data is RawKeyEventDataMacOs;
if (!_nonModifierKeys.contains(key) || if (!_nonModifierKeys.contains(key) ||
keysPressed.difference(_modifierKeys).length > 1 || keysPressed.difference(isMacOS ? _macOsModifierKeys : _modifierKeys).length > 1 ||
keysPressed.difference(_interestingKeys).isNotEmpty) { keysPressed.difference(_interestingKeys).isNotEmpty) {
// If the most recently pressed key isn't a non-modifier key, or more than // 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 // 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; 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)) { if (_movementKeys.contains(key)) {
_handleMovement(key, control: keyEvent.isControlPressed, shift: keyEvent.isShiftPressed); _handleMovement(key, wordModifier: isWordModifierPressed, lineModifier: isLineModifierPressed, shift: keyEvent.isShiftPressed);
} else if (keyEvent.isControlPressed && _shortcutKeys.contains(key)) { } else if (isShortcutModifierPressed && _shortcutKeys.contains(key)) {
// _handleShortcuts depends on being started in the same stack invocation // _handleShortcuts depends on being started in the same stack invocation
// as the _handleKeyEvent method // as the _handleKeyEvent method
_handleShortcuts(key); _handleShortcuts(key);
@ -440,9 +492,15 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin {
void _handleMovement( void _handleMovement(
LogicalKeyboardKey key, { LogicalKeyboardKey key, {
@required bool control, @required bool wordModifier,
@required bool lineModifier,
@required bool shift, @required bool shift,
}) { }) {
if (wordModifier && lineModifier) {
// If both modifiers are down, nothing happens on any of the platforms.
return;
}
TextSelection newSelection = selection; TextSelection newSelection = selection;
final bool rightArrow = key == LogicalKeyboardKey.arrowRight; final bool rightArrow = key == LogicalKeyboardKey.arrowRight;
@ -450,34 +508,80 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin {
final bool upArrow = key == LogicalKeyboardKey.arrowUp; final bool upArrow = key == LogicalKeyboardKey.arrowUp;
final bool downArrow = key == LogicalKeyboardKey.arrowDown; final bool downArrow = key == LogicalKeyboardKey.arrowDown;
// Because the user can use multiple keys to change how they select, the // Find the previous non-whitespace character
// new offset variable is threaded through these four functions and int previousNonWhitespace(int extent) {
// potentially changes after each one. int result = math.max(extent - 1, 0);
if (control) { while (result > 0 && _isWhitespace(_plainText.codeUnitAt(result))) {
// If control is pressed, we will decide which way to look for a word result -= 1;
// 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);
} }
return result;
} }
// 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 int nextNonWhitespace(int extent) {
if (rightArrow && newSelection.extentOffset < text.toPlainText().length) { 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); newSelection = newSelection.copyWith(extentOffset: newSelection.extentOffset + 1);
if (shift) { if (shift) {
_cursorResetLocation += 1; _cursorResetLocation += 1;
} }
} } else if (leftArrow && newSelection.extentOffset > 0) {
if (leftArrow && newSelection.extentOffset > 0) {
newSelection = newSelection.copyWith(extentOffset: newSelection.extentOffset - 1); newSelection = newSelection.copyWith(extentOffset: newSelection.extentOffset - 1);
if (shift) { if (shift) {
_cursorResetLocation -= 1; _cursorResetLocation -= 1;
} }
} }
}
}
// Handles moving the cursor vertically as well as taking care of the // 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 // case where the user moves the cursor to the end or beginning of the text
// and then back up or down. // 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. // case that the user wants to unhighlight some text.
if (position.offset == newSelection.extentOffset) { if (position.offset == newSelection.extentOffset) {
if (downArrow) { if (downArrow) {
newSelection = newSelection.copyWith(extentOffset: text.toPlainText().length); newSelection = newSelection.copyWith(extentOffset: _plainText.length);
} else if (upArrow) { } else if (upArrow) {
newSelection = newSelection.copyWith(extentOffset: 0); 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) { if (!shift) {
// We want to put the cursor at the correct location depending on which // We want to put the cursor at the correct location depending on which
// arrow is used while there is a selection. // arrow is used while there is a selection.
int newOffset = newSelection.extentOffset; int newOffset = newSelection.extentOffset;
if (!selection.isCollapsed) { if (!selection.isCollapsed) {
if (leftArrow) if (leftArrow) {
newOffset = newSelection.baseOffset < newSelection.extentOffset ? newSelection.baseOffset : newSelection.extentOffset; newOffset = newSelection.baseOffset < newSelection.extentOffset ? newSelection.baseOffset : newSelection.extentOffset;
else if (rightArrow) } else if (rightArrow) {
newOffset = newSelection.baseOffset > newSelection.extentOffset ? newSelection.baseOffset : newSelection.extentOffset; newOffset = newSelection.baseOffset > newSelection.extentOffset ? newSelection.baseOffset : newSelection.extentOffset;
} }
}
newSelection = TextSelection.fromPosition(TextPosition(offset: newOffset)); 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( _handleSelectionChange(
newSelection, newSelection,
SelectionChangedCause.keyboard, SelectionChangedCause.keyboard,
@ -533,22 +641,22 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin {
} }
// Handles shortcut functionality including cut, copy, paste and select all // 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 { Future<void> _handleShortcuts(LogicalKeyboardKey key) async {
assert(_shortcutKeys.contains(key), 'shortcut key $key not recognized.'); assert(_shortcutKeys.contains(key), 'shortcut key $key not recognized.');
if (key == LogicalKeyboardKey.keyC) { if (key == LogicalKeyboardKey.keyC) {
if (!selection.isCollapsed) { if (!selection.isCollapsed) {
Clipboard.setData( Clipboard.setData(
ClipboardData(text: selection.textInside(text.toPlainText()))); ClipboardData(text: selection.textInside(_plainText)));
} }
return; return;
} }
if (key == LogicalKeyboardKey.keyX) { if (key == LogicalKeyboardKey.keyX) {
if (!selection.isCollapsed) { if (!selection.isCollapsed) {
Clipboard.setData(ClipboardData(text: selection.textInside(text.toPlainText()))); Clipboard.setData(ClipboardData(text: selection.textInside(_plainText)));
textSelectionDelegate.textEditingValue = TextEditingValue( textSelectionDelegate.textEditingValue = TextEditingValue(
text: selection.textBefore(text.toPlainText()) text: selection.textBefore(_plainText)
+ selection.textAfter(text.toPlainText()), + selection.textAfter(_plainText),
selection: TextSelection.collapsed(offset: selection.start), selection: TextSelection.collapsed(offset: selection.start),
); );
} }
@ -584,15 +692,15 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin {
} }
void _handleDelete() { void _handleDelete() {
if (selection.textAfter(text.toPlainText()).isNotEmpty) { if (selection.textAfter(_plainText).isNotEmpty) {
textSelectionDelegate.textEditingValue = TextEditingValue( textSelectionDelegate.textEditingValue = TextEditingValue(
text: selection.textBefore(text.toPlainText()) text: selection.textBefore(_plainText)
+ selection.textAfter(text.toPlainText()).substring(1), + selection.textAfter(_plainText).substring(1),
selection: TextSelection.collapsed(offset: selection.start), selection: TextSelection.collapsed(offset: selection.start),
); );
} else { } else {
textSelectionDelegate.textEditingValue = TextEditingValue( textSelectionDelegate.textEditingValue = TextEditingValue(
text: selection.textBefore(text.toPlainText()), text: selection.textBefore(_plainText),
selection: TextSelection.collapsed(offset: selection.start), selection: TextSelection.collapsed(offset: selection.start),
); );
} }
@ -617,6 +725,13 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin {
_textLayoutLastMinWidth = null; _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. /// The text to display.
TextSpan get text => _textPainter.text; TextSpan get text => _textPainter.text;
final TextPainter _textPainter; final TextPainter _textPainter;
@ -624,6 +739,7 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin {
if (_textPainter.text == value) if (_textPainter.text == value)
return; return;
_textPainter.text = value; _textPainter.text = value;
_cachedPlainText = null;
markNeedsTextLayout(); markNeedsTextLayout();
markNeedsSemanticsUpdate(); markNeedsSemanticsUpdate();
} }
@ -1013,8 +1129,8 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin {
config config
..value = obscureText ..value = obscureText
? obscuringCharacter * text.toPlainText().length ? obscuringCharacter * _plainText.length
: text.toPlainText() : _plainText
..isObscured = obscureText ..isObscured = obscureText
..isMultiline = _isMultiline ..isMultiline = _isMultiline
..textDirection = textDirection ..textDirection = textDirection
@ -1124,41 +1240,13 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin {
// Check if the given text range only contains white space or separator // Check if the given text range only contains white space or separator
// characters. // 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) // [unicode separator category](https://www.compart.com/en/unicode/category/Zs)
// TODO(jonahwilliams): replace when we expose this ICU information. // TODO(jonahwilliams): replace when we expose this ICU information.
bool _onlyWhitespace(TextRange range) { bool _onlyWhitespace(TextRange range) {
for (int i = range.start; i < range.end; i++) { for (int i = range.start; i < range.end; i++) {
final int codeUnit = text.codeUnitAt(i); final int codeUnit = text.codeUnitAt(i);
switch (codeUnit) { if (!_isWhitespace(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 false;
} }
} }
@ -1338,7 +1426,7 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin {
// Set the height based on the content. // Set the height based on the content.
if (width == double.infinity) { if (width == double.infinity) {
final String text = _textPainter.text.toPlainText(); final String text = _plainText;
int lines = 1; int lines = 1;
for (int index = 0; index < text.length; index += 1) { for (int index = 0; index < text.length; index += 1) {
if (text.codeUnitAt(index) == 0x0A) // count explicit line breaks if (text.codeUnitAt(index) == 0x0A) // count explicit line breaks
@ -1550,11 +1638,25 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin {
return TextSelection.fromPosition(position); return TextSelection.fromPosition(position);
// If text is obscured, the entire sentence should be treated as one word. // If text is obscured, the entire sentence should be treated as one word.
if (obscureText) { 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); 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; Rect _caretPrototype;
void _layoutText({ double minWidth = 0.0, double maxWidth = double.infinity }) { void _layoutText({ double minWidth = 0.0, double maxWidth = double.infinity }) {

View File

@ -2975,12 +2975,66 @@ void main() {
expect(controller.selection.extent.offset, 5); expect(controller.selection.extent.offset, 5);
}, skip: isBrowser); }, 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' const String testText = 'Now is the time for\n'
'all good people\n' 'all good people\n'
'to come to the aid\n' 'to come to the aid\n'
'of their country.'; '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); final TextEditingController controller = TextEditingController(text: testText);
controller.selection = const TextSelection( controller.selection = const TextSelection(
baseOffset: 0, baseOffset: 0,
@ -3017,149 +3071,192 @@ void main() {
await tester.pump(); // Wait for autofocus to take effect. 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 // Select a few characters using shift right arrow
await sendKeys( await sendKeys(
tester,
<LogicalKeyboardKey>[ <LogicalKeyboardKey>[
LogicalKeyboardKey.arrowRight, LogicalKeyboardKey.arrowRight,
LogicalKeyboardKey.arrowRight, LogicalKeyboardKey.arrowRight,
LogicalKeyboardKey.arrowRight, LogicalKeyboardKey.arrowRight,
], ],
shift: true, shift: true,
platform: platform,
); );
expect(cause, equals(SelectionChangedCause.keyboard)); expect(cause, equals(SelectionChangedCause.keyboard), reason: 'on $platform');
expect(selection, equals(const TextSelection( expect(
selection,
equals(
const TextSelection(
baseOffset: 0, baseOffset: 0,
extentOffset: 3, extentOffset: 3,
affinity: TextAffinity.upstream, affinity: TextAffinity.upstream,
))); ),
),
reason: 'on $platform',
);
// Select fewer characters using shift left arrow // Select fewer characters using shift left arrow
await sendKeys( await sendKeys(
tester,
<LogicalKeyboardKey>[ <LogicalKeyboardKey>[
LogicalKeyboardKey.arrowLeft, LogicalKeyboardKey.arrowLeft,
LogicalKeyboardKey.arrowLeft, LogicalKeyboardKey.arrowLeft,
LogicalKeyboardKey.arrowLeft, LogicalKeyboardKey.arrowLeft,
], ],
shift: true, shift: true,
platform: platform,
); );
expect(selection, equals(const TextSelection( expect(
selection,
equals(
const TextSelection(
baseOffset: 0, baseOffset: 0,
extentOffset: 0, extentOffset: 0,
affinity: TextAffinity.upstream, affinity: TextAffinity.upstream,
))); ),
),
reason: 'on $platform',
);
// Try to select before the first character, nothing should change. // Try to select before the first character, nothing should change.
await sendKeys( await sendKeys(
tester,
<LogicalKeyboardKey>[ <LogicalKeyboardKey>[
LogicalKeyboardKey.arrowLeft, LogicalKeyboardKey.arrowLeft,
], ],
shift: true, shift: true,
platform: platform,
); );
expect(selection, equals(const TextSelection( expect(
selection,
equals(
const TextSelection(
baseOffset: 0, baseOffset: 0,
extentOffset: 0, extentOffset: 0,
affinity: TextAffinity.upstream, affinity: TextAffinity.upstream,
))); ),
),
reason: 'on $platform',
);
// Select the first two words. // Select the first two words.
await sendKeys( await sendKeys(
tester,
<LogicalKeyboardKey>[ <LogicalKeyboardKey>[
LogicalKeyboardKey.arrowRight, LogicalKeyboardKey.arrowRight,
LogicalKeyboardKey.arrowRight, LogicalKeyboardKey.arrowRight,
], ],
shift: true, shift: true,
control: true, wordModifier: true,
platform: platform,
); );
expect(selection, equals(const TextSelection( expect(
selection,
equals(
const TextSelection(
baseOffset: 0, baseOffset: 0,
extentOffset: 6, extentOffset: 6,
affinity: TextAffinity.upstream, affinity: TextAffinity.upstream,
))); ),
),
reason: 'on $platform',
);
// Unselect the second word. // Unselect the second word.
await sendKeys( await sendKeys(
tester,
<LogicalKeyboardKey>[ <LogicalKeyboardKey>[
LogicalKeyboardKey.arrowLeft, LogicalKeyboardKey.arrowLeft,
], ],
shift: true, shift: true,
control: true, wordModifier: true,
platform: platform,
); );
expect(selection, equals(const TextSelection( expect(
selection,
equals(
const TextSelection(
baseOffset: 0, baseOffset: 0,
extentOffset: 4, extentOffset: 4,
affinity: TextAffinity.upstream, affinity: TextAffinity.upstream,
))); ),
),
reason: 'on $platform',
);
// Select the next line. // Select the next line.
await sendKeys( await sendKeys(
tester,
<LogicalKeyboardKey>[ <LogicalKeyboardKey>[
LogicalKeyboardKey.arrowDown, LogicalKeyboardKey.arrowDown,
], ],
shift: true, shift: true,
platform: platform,
); );
expect(selection, equals(const TextSelection( expect(
selection,
equals(
const TextSelection(
baseOffset: 0, baseOffset: 0,
extentOffset: 20, extentOffset: 20,
affinity: TextAffinity.upstream, affinity: TextAffinity.upstream,
))); ),
),
reason: 'on $platform',
);
// Move forward one character to reset the selection. // Move forward one character to reset the selection.
await sendKeys( await sendKeys(
tester,
<LogicalKeyboardKey>[ <LogicalKeyboardKey>[
LogicalKeyboardKey.arrowRight, LogicalKeyboardKey.arrowRight,
], ],
platform: platform,
); );
expect(selection, equals(const TextSelection( expect(
selection,
equals(
const TextSelection(
baseOffset: 21, baseOffset: 21,
extentOffset: 21, extentOffset: 21,
affinity: TextAffinity.downstream, affinity: TextAffinity.downstream,
))); ),
),
reason: 'on $platform',
);
// Select the next line. // Select the next line.
await sendKeys( await sendKeys(
tester,
<LogicalKeyboardKey>[ <LogicalKeyboardKey>[
LogicalKeyboardKey.arrowDown, LogicalKeyboardKey.arrowDown,
], ],
shift: true, shift: true,
platform: platform,
); );
expect(selection, equals(const TextSelection( expect(
selection,
equals(
const TextSelection(
baseOffset: 21, baseOffset: 21,
extentOffset: 40, extentOffset: 40,
affinity: TextAffinity.downstream, affinity: TextAffinity.downstream,
))); ),
),
reason: 'on $platform',
);
// Select to the end of the string by going down. // Select to the end of the string by going down.
await sendKeys( await sendKeys(
tester,
<LogicalKeyboardKey>[ <LogicalKeyboardKey>[
LogicalKeyboardKey.arrowDown, LogicalKeyboardKey.arrowDown,
LogicalKeyboardKey.arrowDown, LogicalKeyboardKey.arrowDown,
@ -3167,166 +3264,323 @@ void main() {
LogicalKeyboardKey.arrowDown, LogicalKeyboardKey.arrowDown,
], ],
shift: true, shift: true,
platform: platform,
); );
expect(selection, equals(const TextSelection( expect(
selection,
equals(
const TextSelection(
baseOffset: 21, baseOffset: 21,
extentOffset: testText.length, extentOffset: testText.length,
affinity: TextAffinity.downstream, affinity: TextAffinity.downstream,
))); ),
),
reason: 'on $platform',
);
// Go back up one line to set selection up to part of the last line. // Go back up one line to set selection up to part of the last line.
await sendKeys( await sendKeys(
tester,
<LogicalKeyboardKey>[ <LogicalKeyboardKey>[
LogicalKeyboardKey.arrowUp, LogicalKeyboardKey.arrowUp,
], ],
shift: true, shift: true,
platform: platform,
); );
expect(selection, equals(const TextSelection( expect(
selection,
equals(
const TextSelection(
baseOffset: 21, baseOffset: 21,
extentOffset: 58, extentOffset: 58,
affinity: TextAffinity.downstream, 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 // Select All
await sendKeys( await sendKeys(
tester,
<LogicalKeyboardKey>[ <LogicalKeyboardKey>[
LogicalKeyboardKey.keyA, LogicalKeyboardKey.keyA,
], ],
control: true, shortcutModifier: true,
platform: platform,
); );
expect(selection, equals(const TextSelection( expect(
selection,
equals(
const TextSelection(
baseOffset: 0, baseOffset: 0,
extentOffset: testText.length, extentOffset: testText.length,
affinity: TextAffinity.downstream, affinity: TextAffinity.downstream,
))); ),
),
reason: 'on $platform',
);
// Jump to beginning of selection. // Jump to beginning of selection.
await sendKeys( await sendKeys(
tester,
<LogicalKeyboardKey>[ <LogicalKeyboardKey>[
LogicalKeyboardKey.arrowLeft, LogicalKeyboardKey.arrowLeft,
], ],
platform: platform,
); );
expect(selection, equals(const TextSelection( expect(
selection,
equals(
const TextSelection(
baseOffset: 0, baseOffset: 0,
extentOffset: 0, extentOffset: 0,
affinity: TextAffinity.downstream, affinity: TextAffinity.downstream,
))); ),
),
reason: 'on $platform',
);
// Jump forward three words. // Jump forward three words.
await sendKeys( await sendKeys(
tester,
<LogicalKeyboardKey>[ <LogicalKeyboardKey>[
LogicalKeyboardKey.arrowRight, LogicalKeyboardKey.arrowRight,
LogicalKeyboardKey.arrowRight, LogicalKeyboardKey.arrowRight,
LogicalKeyboardKey.arrowRight, LogicalKeyboardKey.arrowRight,
], ],
control: true, wordModifier: true,
platform: platform,
); );
expect(selection, equals(const TextSelection( expect(
selection,
equals(
const TextSelection(
baseOffset: 10, baseOffset: 10,
extentOffset: 10, extentOffset: 10,
affinity: TextAffinity.downstream, affinity: TextAffinity.downstream,
))); ),
),
reason: 'on $platform',
);
// Select some characters backward. // Select some characters backward.
await sendKeys( await sendKeys(
tester,
<LogicalKeyboardKey>[ <LogicalKeyboardKey>[
LogicalKeyboardKey.arrowLeft, LogicalKeyboardKey.arrowLeft,
LogicalKeyboardKey.arrowLeft, LogicalKeyboardKey.arrowLeft,
LogicalKeyboardKey.arrowLeft, LogicalKeyboardKey.arrowLeft,
], ],
shift: true, shift: true,
platform: platform,
); );
expect(selection, equals(const TextSelection( expect(
selection,
equals(
const TextSelection(
baseOffset: 10, baseOffset: 10,
extentOffset: 7, extentOffset: 7,
affinity: TextAffinity.downstream, affinity: TextAffinity.downstream,
))); ),
),
reason: 'on $platform',
);
// Select a word backward. // Select a word backward.
await sendKeys( await sendKeys(
tester,
<LogicalKeyboardKey>[ <LogicalKeyboardKey>[
LogicalKeyboardKey.arrowLeft, LogicalKeyboardKey.arrowLeft,
], ],
shift: true, shift: true,
control: true, wordModifier: true,
platform: platform,
); );
expect(selection, equals(const TextSelection( expect(
selection,
equals(
const TextSelection(
baseOffset: 10, baseOffset: 10,
extentOffset: 4, extentOffset: 4,
affinity: TextAffinity.downstream, affinity: TextAffinity.downstream,
))); ),
expect(controller.text, equals(testText)); ),
reason: 'on $platform',
);
expect(controller.text, equals(testText), reason: 'on $platform');
// Cut // Cut
await sendKeys( await sendKeys(
tester,
<LogicalKeyboardKey>[ <LogicalKeyboardKey>[
LogicalKeyboardKey.keyX, LogicalKeyboardKey.keyX,
], ],
control: true, shortcutModifier: true,
platform: platform,
); );
expect(selection, equals(const TextSelection( expect(
selection,
equals(
const TextSelection(
baseOffset: 10, baseOffset: 10,
extentOffset: 4, extentOffset: 4,
affinity: TextAffinity.downstream, affinity: TextAffinity.downstream,
))); ),
expect(controller.text, equals('Now time for\n' ),
reason: 'on $platform',
);
expect(
controller.text,
equals('Now time for\n'
'all good people\n' 'all good people\n'
'to come to the aid\n' 'to come to the aid\n'
'of their country.')); 'of their country.'),
expect((await Clipboard.getData(Clipboard.kTextPlain)).text, equals('is the')); reason: 'on $platform',
);
expect(
(await Clipboard.getData(Clipboard.kTextPlain)).text,
equals('is the'),
reason: 'on $platform',
);
// Paste // Paste
await sendKeys( await sendKeys(
tester,
<LogicalKeyboardKey>[ <LogicalKeyboardKey>[
LogicalKeyboardKey.keyV, LogicalKeyboardKey.keyV,
], ],
control: true, shortcutModifier: true,
platform: platform,
); );
expect(selection, equals(const TextSelection( expect(
selection,
equals(
const TextSelection(
baseOffset: 10, baseOffset: 10,
extentOffset: 4, extentOffset: 4,
affinity: TextAffinity.downstream, affinity: TextAffinity.downstream,
))); ),
expect(controller.text, equals(testText)); ),
reason: 'on $platform',
);
expect(controller.text, equals(testText), reason: 'on $platform');
// Copy All // Copy All
await sendKeys( await sendKeys(
tester,
<LogicalKeyboardKey>[ <LogicalKeyboardKey>[
LogicalKeyboardKey.keyA, LogicalKeyboardKey.keyA,
LogicalKeyboardKey.keyC, LogicalKeyboardKey.keyC,
], ],
control: true, shortcutModifier: true,
platform: platform,
); );
expect(selection, equals(const TextSelection( expect(
selection,
equals(
const TextSelection(
baseOffset: 0, baseOffset: 0,
extentOffset: testText.length, extentOffset: testText.length,
affinity: TextAffinity.downstream, affinity: TextAffinity.downstream,
))); ),
expect(controller.text, equals(testText)); ),
reason: 'on $platform',
);
expect(controller.text, equals(testText), reason: 'on $platform');
expect((await Clipboard.getData(Clipboard.kTextPlain)).text, equals(testText)); expect((await Clipboard.getData(Clipboard.kTextPlain)).text, equals(testText));
// Delete // Delete
await sendKeys( await sendKeys(
tester,
<LogicalKeyboardKey>[ <LogicalKeyboardKey>[
LogicalKeyboardKey.delete, LogicalKeyboardKey.delete,
], ],
platform: platform,
); );
expect(selection, equals(const TextSelection( expect(
selection,
equals(
const TextSelection(
baseOffset: 0, baseOffset: 0,
extentOffset: 72, extentOffset: 72,
affinity: TextAffinity.downstream, affinity: TextAffinity.downstream,
))); ),
expect(controller.text, isEmpty); ),
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 // Regression test for https://github.com/flutter/flutter/issues/31287