Unverified Commit 21158d83 authored by Greg Spencer's avatar Greg Spencer Committed by GitHub

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.
parent a7367b65
...@@ -816,15 +816,15 @@ class TextPainter { ...@@ -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>) { /// Returns the text range of the line at the given offset.
final List<int> indices = boundary; ///
return TextRange(start: indices[0], end: indices[1]); /// The newline, if any, is included in the range.
} TextRange getLineBoundary(TextPosition position) {
final TextRange range = boundary; assert(!_needsLayout);
return range; 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
......
...@@ -97,6 +97,47 @@ class TextSelectionPoint { ...@@ -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 { ...@@ -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 { ...@@ -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 { ...@@ -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 { ...@@ -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 { ...@@ -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) { return result;
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) { int nextNonWhitespace(int extent) {
final TextSelection textSelection = _selectWordAtOffset(TextPosition(offset: newSelection.extentOffset + 1)); int result = math.min(extent + 1, _plainText.length);
newSelection = newSelection.copyWith(extentOffset: textSelection.extentOffset - 1); while (result < _plainText.length && _isWhitespace(_plainText.codeUnitAt(result))) {
} result += 1;
} }
// Set the new offset to be +/- 1 depending on which arrow is pressed return result;
// If shift is down, we also want to update the previous cursor location }
if (rightArrow && newSelection.extentOffset < text.toPlainText().length) {
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 { ...@@ -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 { ...@@ -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 { ...@@ -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 { ...@@ -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 { ...@@ -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 { ...@@ -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 { ...@@ -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 { ...@@ -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 { ...@@ -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 { ...@@ -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 }) {
......
...@@ -2975,12 +2975,66 @@ void main() { ...@@ -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() { ...@@ -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() { ...@@ -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
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment