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>.
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

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
/// cursor and with gesture recognizers.
///
@ -400,10 +441,18 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin {
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 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin {
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 @@ 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<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 @@ 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 }) {

View File

@ -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<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 @@ void main() {
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 @@ 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>[
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