Unverified Commit a7aa6616 authored by Greg Spencer's avatar Greg Spencer Committed by GitHub

Re-implement hardware keyboard text selection. (#42879)

This re-implements keyboard text selection so that it will work on platforms other than Android (e.g. macOS, Linux, etc.).

Also, fixed a number of bugs in editing selection via a hardware keyboard (unable to select backwards, incorrect conversion to ASCII when cutting to clipboard, lack of support for CTRL-SHIFT-ARROW word selection, etc.).

Did not address the keyboard locale issues that remain, or add platform specific switches for the bindings. All that will need some more design work before implementing them.

Related Issues
Fixes #31951
parent f7ce5ae3
......@@ -240,6 +240,8 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin {
static const String obscuringCharacter = '•';
/// Called when the selection changes.
///
/// If this is null, then selection changes will be ignored.
SelectionChangedHandler onSelectionChanged;
double _textLayoutLastMaxWidth;
......@@ -350,142 +352,148 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin {
.contains(endOffset + effectiveOffset);
}
static const int _kLeftArrowCode = 21;
static const int _kRightArrowCode = 22;
static const int _kUpArrowCode = 19;
static const int _kDownArrowCode = 20;
static const int _kXKeyCode = 52;
static const int _kCKeyCode = 31;
static const int _kVKeyCode = 50;
static const int _kAKeyCode = 29;
static const int _kDelKeyCode = 112;
// The extent offset of the current selection
int _extentOffset = -1;
// The base offset of the current selection
int _baseOffset = -1;
// Holds the last location the user selected in the case that he selects all
// the way to the end or beginning of the field.
int _previousCursorLocation = -1;
// Holds the last cursor location the user selected in the case the user tries
// to select vertically past the end or beginning of the field. If they do,
// then we need to keep the old cursor location so that we can go back to it
// if they change their minds. Only used for moving selection up and down in a
// multi-line text field when selecting using the keyboard.
int _cursorResetLocation = -1;
// Whether we should reset the location of the cursor in the case the user
// selects all the way to the end or the beginning of a field.
bool _resetCursor = false;
static const int _kShiftMask = 1; // https://developer.android.com/reference/android/view/KeyEvent.html#META_SHIFT_ON
static const int _kControlMask = 1 << 12; // https://developer.android.com/reference/android/view/KeyEvent.html#META_CTRL_ON
// Call through to onSelectionChanged only if the given nextSelection is
// different from the existing selection.
void _handlePotentialSelectionChange(
// tries to select vertically past the end or beginning of the field. If they
// do, then we need to keep the old cursor location so that we can go back to
// it if they change their minds. Only used for resetting selection up and
// down in a multi-line text field when selecting using the keyboard.
bool _wasSelectingVerticallyWithKeyboard = false;
// Call through to onSelectionChanged.
void _handleSelectionChange(
TextSelection nextSelection,
SelectionChangedCause cause,
) {
if (nextSelection == selection) {
// Changes made by the keyboard can sometimes be "out of band" for listening
// components, so always send those events, even if we didn't think it
// changed.
if (nextSelection == selection && cause != SelectionChangedCause.keyboard) {
return;
}
if (onSelectionChanged != null) {
onSelectionChanged(nextSelection, this, cause);
}
}
static final Set<LogicalKeyboardKey> _movementKeys = <LogicalKeyboardKey>{
LogicalKeyboardKey.arrowRight,
LogicalKeyboardKey.arrowLeft,
LogicalKeyboardKey.arrowUp,
LogicalKeyboardKey.arrowDown,
};
static final Set<LogicalKeyboardKey> _shortcutKeys = <LogicalKeyboardKey>{
LogicalKeyboardKey.keyA,
LogicalKeyboardKey.keyC,
LogicalKeyboardKey.keyV,
LogicalKeyboardKey.keyX,
LogicalKeyboardKey.delete,
};
static final Set<LogicalKeyboardKey> _nonModifierKeys = <LogicalKeyboardKey>{
..._shortcutKeys,
..._movementKeys,
};
static final Set<LogicalKeyboardKey> _modifierKeys = <LogicalKeyboardKey>{
LogicalKeyboardKey.shift,
LogicalKeyboardKey.control,
};
static final Set<LogicalKeyboardKey> _interestingKeys = <LogicalKeyboardKey>{
..._modifierKeys,
..._nonModifierKeys,
};
// TODO(goderbauer): doesn't handle extended grapheme clusters with more than one Unicode scalar value (https://github.com/flutter/flutter/issues/13404).
// This is because some of this code depends upon counting the length of the
// string using Unicode scalar values, rather than using the number of
// extended grapheme clusters (a.k.a. "characters" in the end user's mind).
void _handleKeyEvent(RawKeyEvent keyEvent) {
// Only handle key events on Android.
if (keyEvent.data is! RawKeyEventDataAndroid)
if (keyEvent is! RawKeyDownEvent || onSelectionChanged == null)
return;
if (keyEvent is RawKeyUpEvent)
return;
final Set<LogicalKeyboardKey> keysPressed = LogicalKeyboardKey.collapseSynonyms(RawKeyboard.instance.keysPressed);
final LogicalKeyboardKey key = keyEvent.logicalKey;
final RawKeyEventDataAndroid rawAndroidEvent = keyEvent.data;
final int pressedKeyCode = rawAndroidEvent.keyCode;
final int pressedKeyMetaState = rawAndroidEvent.metaState;
if (!_nonModifierKeys.contains(key) ||
keysPressed.difference(_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
// are pressed, just ignore the keypress.
return;
}
if (selection.isCollapsed) {
_extentOffset = selection.extentOffset;
_baseOffset = selection.baseOffset;
}
// Update current key states
final bool shift = pressedKeyMetaState & _kShiftMask > 0;
final bool ctrl = pressedKeyMetaState & _kControlMask > 0;
final bool rightArrow = pressedKeyCode == _kRightArrowCode;
final bool leftArrow = pressedKeyCode == _kLeftArrowCode;
final bool upArrow = pressedKeyCode == _kUpArrowCode;
final bool downArrow = pressedKeyCode == _kDownArrowCode;
final bool arrow = leftArrow || rightArrow || upArrow || downArrow;
final bool aKey = pressedKeyCode == _kAKeyCode;
final bool xKey = pressedKeyCode == _kXKeyCode;
final bool vKey = pressedKeyCode == _kVKeyCode;
final bool cKey = pressedKeyCode == _kCKeyCode;
final bool del = pressedKeyCode == _kDelKeyCode;
// We will only move select or more the caret if an arrow is pressed
if (arrow) {
int newOffset = _extentOffset;
// Because the user can use multiple keys to change how he selects
// the new offset variable is threaded through these four functions
// and potentially changes after each one.
if (ctrl)
newOffset = _handleControl(rightArrow, leftArrow, ctrl, newOffset);
newOffset = _handleHorizontalArrows(rightArrow, leftArrow, shift, newOffset);
if (downArrow || upArrow)
newOffset = _handleVerticalArrows(upArrow, downArrow, shift, newOffset);
newOffset = _handleShift(rightArrow, leftArrow, shift, newOffset);
_extentOffset = newOffset;
} else if (ctrl && (xKey || vKey || cKey || aKey)) {
// _handleShortcuts depends on being started in the same stack invocation as the _handleKeyEvent method
_handleShortcuts(pressedKeyCode);
}
if (del)
if (_movementKeys.contains(key)) {
_handleMovement(key, control: keyEvent.isControlPressed, shift: keyEvent.isShiftPressed);
} else if (keyEvent.isControlPressed && _shortcutKeys.contains(key)) {
// _handleShortcuts depends on being started in the same stack invocation
// as the _handleKeyEvent method
_handleShortcuts(key);
} else if (key == LogicalKeyboardKey.delete) {
_handleDelete();
}
}
// Handles full word traversal using control.
int _handleControl(bool rightArrow, bool leftArrow, bool ctrl, int newOffset) {
void _handleMovement(
LogicalKeyboardKey key, {
@required bool control,
@required bool shift,
}) {
TextSelection newSelection = selection;
final bool rightArrow = key == LogicalKeyboardKey.arrowRight;
final bool leftArrow = key == LogicalKeyboardKey.arrowLeft;
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 && _extentOffset > 2) {
final TextSelection textSelection = _selectWordAtOffset(TextPosition(offset: _extentOffset - 2));
newOffset = textSelection.baseOffset + 1;
} else if (rightArrow && _extentOffset < text.toPlainText().length - 2) {
final TextSelection textSelection = _selectWordAtOffset(TextPosition(offset: _extentOffset + 1));
newOffset = textSelection.extentOffset - 1;
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 newOffset;
}
int _handleHorizontalArrows(bool rightArrow, bool leftArrow, bool shift, int newOffset) {
// 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 && _extentOffset < text.toPlainText().length) {
newOffset += 1;
if (shift)
_previousCursorLocation += 1;
if (rightArrow && newSelection.extentOffset < text.toPlainText().length) {
newSelection = newSelection.copyWith(extentOffset: newSelection.extentOffset + 1);
if (shift) {
_cursorResetLocation += 1;
}
if (leftArrow && _extentOffset > 0) {
newOffset -= 1;
if (shift)
_previousCursorLocation -= 1;
}
return newOffset;
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.
int _handleVerticalArrows(bool upArrow, bool downArrow, bool shift, int newOffset) {
if (downArrow || upArrow) {
// The caret offset gives a location in the upper left hand corner of
// the caret so the middle of the line above is a half line above that
// point and the line below is 1.5 lines below that point.
final double plh = _textPainter.preferredLineHeight;
final double verticalOffset = upArrow ? -0.5 * plh : 1.5 * plh;
final double preferredLineHeight = _textPainter.preferredLineHeight;
final double verticalOffset = upArrow ? -0.5 * preferredLineHeight : 1.5 * preferredLineHeight;
final Offset caretOffset = _textPainter.getOffsetForCaret(TextPosition(offset: _extentOffset), _caretPrototype);
final Offset caretOffset = _textPainter.getOffsetForCaret(TextPosition(offset: newSelection.extentOffset), _caretPrototype);
final Offset caretOffsetTranslated = caretOffset.translate(0.0, verticalOffset);
final TextPosition position = _textPainter.getPositionForOffset(caretOffsetTranslated);
......@@ -493,90 +501,65 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin {
// all the way to the top or bottom of the text, we hold the previous
// cursor location. This allows us to restore to this position in the
// case that the user wants to unhighlight some text.
if (position.offset == _extentOffset) {
if (downArrow)
newOffset = text.toPlainText().length;
else if (upArrow)
newOffset = 0;
_resetCursor = shift;
} else if (_resetCursor && shift) {
newOffset = _previousCursorLocation;
_resetCursor = false;
if (position.offset == newSelection.extentOffset) {
if (downArrow) {
newSelection = newSelection.copyWith(extentOffset: text.toPlainText().length);
} else if (upArrow) {
newSelection = newSelection.copyWith(extentOffset: 0);
}
_wasSelectingVerticallyWithKeyboard = shift;
} else if (_wasSelectingVerticallyWithKeyboard && shift) {
newSelection = newSelection.copyWith(extentOffset: _cursorResetLocation);
_wasSelectingVerticallyWithKeyboard = false;
} else {
newOffset = position.offset;
_previousCursorLocation = newOffset;
newSelection = newSelection.copyWith(extentOffset: position.offset);
_cursorResetLocation = newSelection.extentOffset;
}
return newOffset;
}
// Handles the selection of text or removal of the selection and placing
// of the caret.
int _handleShift(bool rightArrow, bool leftArrow, bool shift, int newOffset) {
if (onSelectionChanged == null)
return newOffset;
// In the text_selection class, a TextSelection is defined such that the
// base offset is always less than the extent offset.
if (shift) {
if (_baseOffset < newOffset) {
_handlePotentialSelectionChange(
TextSelection(
baseOffset: _baseOffset,
extentOffset: newOffset,
),
SelectionChangedCause.keyboard,
);
} else {
_handlePotentialSelectionChange(
TextSelection(
baseOffset: newOffset,
extentOffset: _baseOffset,
),
SelectionChangedCause.keyboard,
);
}
} else {
// Just place the collapsed selection at the new position 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)
newOffset = _baseOffset < _extentOffset ? _baseOffset : _extentOffset;
newOffset = newSelection.baseOffset < newSelection.extentOffset ? newSelection.baseOffset : newSelection.extentOffset;
else if (rightArrow)
newOffset = _baseOffset > _extentOffset ? _baseOffset : _extentOffset;
newOffset = newSelection.baseOffset > newSelection.extentOffset ? newSelection.baseOffset : newSelection.extentOffset;
}
_handlePotentialSelectionChange(
TextSelection.fromPosition(
TextPosition(
offset: newOffset
)
),
newSelection = TextSelection.fromPosition(TextPosition(offset: newOffset));
}
_handleSelectionChange(
newSelection,
SelectionChangedCause.keyboard,
);
}
return newOffset;
}
// Handles shortcut functionality including cut, copy, paste and select all
// using control + (X, C, V, A).
Future<void> _handleShortcuts(int pressedKeyCode) async {
switch (pressedKeyCode) {
case _kCKeyCode:
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())));
}
break;
case _kXKeyCode:
return;
}
if (key == LogicalKeyboardKey.keyX) {
if (!selection.isCollapsed) {
Clipboard.setData(
ClipboardData(text: selection.textInside(text.toPlainText())));
Clipboard.setData(ClipboardData(text: selection.textInside(text.toPlainText())));
textSelectionDelegate.textEditingValue = TextEditingValue(
text: selection.textBefore(text.toPlainText())
+ selection.textAfter(text.toPlainText()),
selection: TextSelection.collapsed(offset: selection.start),
);
}
break;
case _kVKeyCode:
return;
}
if (key == LogicalKeyboardKey.keyV) {
// Snapshot the input before using `await`.
// See https://github.com/flutter/flutter/issues/11427
final TextEditingValue value = textSelectionDelegate.textEditingValue;
......@@ -591,20 +574,17 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin {
),
);
}
break;
case _kAKeyCode:
_baseOffset = 0;
_extentOffset = textSelectionDelegate.textEditingValue.text.length;
_handlePotentialSelectionChange(
TextSelection(
return;
}
if (key == LogicalKeyboardKey.keyA) {
_handleSelectionChange(
selection.copyWith(
baseOffset: 0,
extentOffset: textSelectionDelegate.textEditingValue.text.length,
),
SelectionChangedCause.keyboard,
);
break;
default:
assert(false);
return;
}
}
......@@ -1066,7 +1046,7 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin {
}
void _handleSetSelection(TextSelection selection) {
_handlePotentialSelectionChange(selection, SelectionChangedCause.keyboard);
_handleSelectionChange(selection, SelectionChangedCause.keyboard);
}
void _handleMoveCursorForwardByCharacter(bool extentSelection) {
......@@ -1074,7 +1054,7 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin {
if (extentOffset == null)
return;
final int baseOffset = !extentSelection ? extentOffset : _selection.baseOffset;
_handlePotentialSelectionChange(
_handleSelectionChange(
TextSelection(baseOffset: baseOffset, extentOffset: extentOffset), SelectionChangedCause.keyboard,
);
}
......@@ -1084,7 +1064,7 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin {
if (extentOffset == null)
return;
final int baseOffset = !extentSelection ? extentOffset : _selection.baseOffset;
_handlePotentialSelectionChange(
_handleSelectionChange(
TextSelection(baseOffset: baseOffset, extentOffset: extentOffset), SelectionChangedCause.keyboard,
);
}
......@@ -1097,7 +1077,7 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin {
if (nextWord == null)
return;
final int baseOffset = extentSelection ? _selection.baseOffset : nextWord.start;
_handlePotentialSelectionChange(
_handleSelectionChange(
TextSelection(
baseOffset: baseOffset,
extentOffset: nextWord.start,
......@@ -1114,7 +1094,7 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin {
if (previousWord == null)
return;
final int baseOffset = extentSelection ? _selection.baseOffset : previousWord.start;
_handlePotentialSelectionChange(
_handleSelectionChange(
TextSelection(
baseOffset: baseOffset,
extentOffset: previousWord.start,
......@@ -1475,7 +1455,9 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin {
assert(cause != null);
assert(from != null);
_layoutText(minWidth: constraints.minWidth, maxWidth: constraints.maxWidth);
if (onSelectionChanged != null) {
if (onSelectionChanged == null) {
return;
}
final TextPosition fromPosition = _textPainter.getPositionForOffset(globalToLocal(from - _paintOffset));
final TextPosition toPosition = to == null
? null
......@@ -1494,10 +1476,7 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin {
affinity: fromPosition.affinity,
);
// Call [onSelectionChanged] only when the selection actually changed.
if (newSelection != _selection) {
_handlePotentialSelectionChange(newSelection, cause);
}
}
_handleSelectionChange(newSelection, cause);
}
/// Select a word around the location of the last tap down.
......@@ -1517,13 +1496,15 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin {
assert(cause != null);
assert(from != null);
_layoutText(minWidth: constraints.minWidth, maxWidth: constraints.maxWidth);
if (onSelectionChanged != null) {
if (onSelectionChanged == null) {
return;
}
final TextPosition firstPosition = _textPainter.getPositionForOffset(globalToLocal(from - _paintOffset));
final TextSelection firstWord = _selectWordAtOffset(firstPosition);
final TextSelection lastWord = to == null ?
firstWord : _selectWordAtOffset(_textPainter.getPositionForOffset(globalToLocal(to - _paintOffset)));
_handlePotentialSelectionChange(
_handleSelectionChange(
TextSelection(
baseOffset: firstWord.base.offset,
extentOffset: lastWord.extent.offset,
......@@ -1532,7 +1513,6 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin {
cause,
);
}
}
/// Move the selection to the beginning or end of a word.
///
......@@ -1541,22 +1521,23 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin {
assert(cause != null);
_layoutText(minWidth: constraints.minWidth, maxWidth: constraints.maxWidth);
assert(_lastTapDownPosition != null);
if (onSelectionChanged != null) {
if (onSelectionChanged == null) {
return;
}
final TextPosition position = _textPainter.getPositionForOffset(globalToLocal(_lastTapDownPosition - _paintOffset));
final TextRange word = _textPainter.getWordBoundary(position);
if (position.offset - word.start <= 1) {
_handlePotentialSelectionChange(
_handleSelectionChange(
TextSelection.collapsed(offset: word.start, affinity: TextAffinity.downstream),
cause,
);
} else {
_handlePotentialSelectionChange(
_handleSelectionChange(
TextSelection.collapsed(offset: word.end, affinity: TextAffinity.upstream),
cause,
);
}
}
}
TextSelection _selectWordAtOffset(TextPosition position) {
assert(_textLayoutLastMaxWidth == constraints.maxWidth &&
......
......@@ -238,6 +238,21 @@ class LogicalKeyboardKey extends KeyboardKey {
return result == null ? <LogicalKeyboardKey>{} : <LogicalKeyboardKey>{result};
}
/// Takes a set of keys, and returns the same set, but with any keys that have
/// synonyms replaced.
///
/// It is used, for example, to make sets of keys with members like
/// [controlRight] and [controlLeft] and convert that set to contain just
/// [control], so that the question "is any control key down?" can be asked.
static Set<LogicalKeyboardKey> collapseSynonyms(Set<LogicalKeyboardKey> input) {
final Set<LogicalKeyboardKey> result = <LogicalKeyboardKey>{};
for (LogicalKeyboardKey key in input) {
final LogicalKeyboardKey synonym = _synonyms[key];
result.add(synonym ?? key);
}
return result;
}
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
......
......@@ -3655,8 +3655,9 @@ void main() {
await tester.pumpAndSettle();
await tester.sendKeyDownEvent(LogicalKeyboardKey.shift);
await tester.sendKeyDownEvent(LogicalKeyboardKey.arrowLeft);
expect(controller.selection.extentOffset - controller.selection.baseOffset, 1);
await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft);
await tester.sendKeyUpEvent(LogicalKeyboardKey.shift);
expect(controller.selection.extentOffset - controller.selection.baseOffset, -1);
});
testWidgets('Shift test 2', (WidgetTester tester) async {
......@@ -3709,7 +3710,7 @@ void main() {
await tester.sendKeyDownEvent(LogicalKeyboardKey.arrowUp);
await tester.pumpAndSettle();
expect(controller.selection.extentOffset - controller.selection.baseOffset, 11);
expect(controller.selection.extentOffset - controller.selection.baseOffset, -11);
await tester.sendKeyUpEvent(LogicalKeyboardKey.arrowUp);
await tester.sendKeyUpEvent(LogicalKeyboardKey.shift);
......@@ -3774,7 +3775,7 @@ void main() {
await tester.sendKeyUpEvent(LogicalKeyboardKey.shift);
await tester.pumpAndSettle();
expect(controller.selection.extentOffset - controller.selection.baseOffset, 5);
expect(controller.selection.extentOffset - controller.selection.baseOffset, -5);
});
testWidgets('Read only keyboard selection test', (WidgetTester tester) async {
......@@ -3794,7 +3795,7 @@ void main() {
await tester.sendKeyDownEvent(LogicalKeyboardKey.shift);
await tester.sendKeyDownEvent(LogicalKeyboardKey.arrowLeft);
expect(controller.selection.extentOffset - controller.selection.baseOffset, 1);
expect(controller.selection.extentOffset - controller.selection.baseOffset, -1);
});
});
......@@ -3867,8 +3868,8 @@ void main() {
await tester.sendKeyUpEvent(LogicalKeyboardKey.controlRight);
await tester.pumpAndSettle();
const String expected = 'a biga big house\njumped over a mouse';
expect(find.text(expected), findsOneWidget);
const String expected = 'a big a bighouse\njumped over a mouse';
expect(find.text(expected), findsOneWidget, reason: 'Because text contains ${controller.text}');
});
testWidgets('Cut test', (WidgetTester tester) async {
......@@ -4100,7 +4101,7 @@ void main() {
}
await tester.sendKeyUpEvent(LogicalKeyboardKey.shift);
expect(c1.selection.extentOffset - c1.selection.baseOffset, 5);
expect(c1.selection.extentOffset - c1.selection.baseOffset, -5);
await tester.pumpWidget(
MaterialApp(
......@@ -4136,7 +4137,7 @@ void main() {
}
await tester.sendKeyUpEvent(LogicalKeyboardKey.shift);
expect(c1.selection.extentOffset - c1.selection.baseOffset, 10);
expect(c1.selection.extentOffset - c1.selection.baseOffset, -10);
});
......@@ -4193,7 +4194,7 @@ void main() {
}
await tester.sendKeyUpEvent(LogicalKeyboardKey.shift);
expect(c1.selection.extentOffset - c1.selection.baseOffset, 5);
expect(c1.selection.extentOffset - c1.selection.baseOffset, -5);
expect(c2.selection.extentOffset - c2.selection.baseOffset, 0);
await tester.enterText(find.byType(TextField).last, testValue);
......@@ -4212,7 +4213,7 @@ void main() {
await tester.sendKeyUpEvent(LogicalKeyboardKey.shift);
expect(c1.selection.extentOffset - c1.selection.baseOffset, 0);
expect(c2.selection.extentOffset - c2.selection.baseOffset, 5);
expect(c2.selection.extentOffset - c2.selection.baseOffset, -5);
});
testWidgets('Caret works when maxLines is null', (WidgetTester tester) async {
......
......@@ -12,7 +12,7 @@ import '../rendering/mock_canvas.dart';
import '../rendering/recording_canvas.dart';
import 'rendering_tester.dart';
class FakeEditableTextState extends TextSelectionDelegate {
class FakeEditableTextState with TextSelectionDelegate {
@override
TextEditingValue get textEditingValue { return const TextEditingValue(); }
......
......@@ -60,5 +60,51 @@ void main() {
expect(LogicalKeyboardKey.altRight.synonyms.first, equals(LogicalKeyboardKey.alt));
expect(LogicalKeyboardKey.metaRight.synonyms.first, equals(LogicalKeyboardKey.meta));
});
test('Synonyms get collapsed properly.', () async {
expect(LogicalKeyboardKey.collapseSynonyms(<LogicalKeyboardKey>{}), isEmpty);
expect(
LogicalKeyboardKey.collapseSynonyms(<LogicalKeyboardKey>{
LogicalKeyboardKey.shiftLeft,
LogicalKeyboardKey.controlLeft,
LogicalKeyboardKey.altLeft,
LogicalKeyboardKey.metaLeft,
}),
equals(<LogicalKeyboardKey>{
LogicalKeyboardKey.shift,
LogicalKeyboardKey.control,
LogicalKeyboardKey.alt,
LogicalKeyboardKey.meta,
}));
expect(
LogicalKeyboardKey.collapseSynonyms(<LogicalKeyboardKey>{
LogicalKeyboardKey.shiftRight,
LogicalKeyboardKey.controlRight,
LogicalKeyboardKey.altRight,
LogicalKeyboardKey.metaRight,
}),
equals(<LogicalKeyboardKey>{
LogicalKeyboardKey.shift,
LogicalKeyboardKey.control,
LogicalKeyboardKey.alt,
LogicalKeyboardKey.meta,
}));
expect(
LogicalKeyboardKey.collapseSynonyms(<LogicalKeyboardKey>{
LogicalKeyboardKey.shiftLeft,
LogicalKeyboardKey.controlLeft,
LogicalKeyboardKey.altLeft,
LogicalKeyboardKey.metaLeft,
LogicalKeyboardKey.shiftRight,
LogicalKeyboardKey.controlRight,
LogicalKeyboardKey.altRight,
LogicalKeyboardKey.metaRight,
}),
equals(<LogicalKeyboardKey>{
LogicalKeyboardKey.shift,
LogicalKeyboardKey.control,
LogicalKeyboardKey.alt,
LogicalKeyboardKey.meta,
}));
});
});
}
......@@ -26,7 +26,27 @@ enum HandlePositionInViewport {
leftEdge, rightEdge, within,
}
class MockClipboard {
Object _clipboardData = <String, dynamic>{
'text': null,
};
Future<dynamic> handleMethodCall(MethodCall methodCall) async {
switch (methodCall.method) {
case 'Clipboard.getData':
return _clipboardData;
case 'Clipboard.setData':
_clipboardData = methodCall.arguments;
break;
}
}
}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
final MockClipboard mockClipboard = MockClipboard();
SystemChannels.platform.setMockMethodCallHandler(mockClipboard.handleMethodCall);
setUp(() {
debugResetSemanticsIdCounter();
controller = TextEditingController();
......@@ -785,12 +805,7 @@ void main() {
// Populate a fake clipboard.
const String clipboardContent = 'Dobunezumi mitai ni utsukushiku naritai';
SystemChannels.platform
.setMockMethodCallHandler((MethodCall methodCall) async {
if (methodCall.method == 'Clipboard.getData')
return const <String, dynamic>{'text': clipboardContent};
return null;
});
Clipboard.setData(const ClipboardData(text: clipboardContent));
// Long-press to bring up the text editing controls.
final Finder textFinder = find.byType(EditableText);
......@@ -2760,6 +2775,360 @@ 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.';
final TextEditingController controller = TextEditingController(text: testText);
controller.selection = const TextSelection(
baseOffset: 0,
extentOffset: 0,
affinity: TextAffinity.upstream,
);
TextSelection selection;
SelectionChangedCause cause;
await tester.pumpWidget(MaterialApp(
home: Align(
alignment: Alignment.topLeft,
child: SizedBox(
width: 400,
child: EditableText(
maxLines: 10,
controller: controller,
showSelectionHandles: true,
autofocus: true,
focusNode: FocusNode(),
style: Typography(platform: TargetPlatform.android).black.subhead,
cursorColor: Colors.blue,
backgroundCursorColor: Colors.grey,
selectionControls: materialTextSelectionControls,
keyboardType: TextInputType.text,
textAlign: TextAlign.right,
onSelectionChanged: (TextSelection newSelection, SelectionChangedCause newCause) {
selection = newSelection;
cause = newCause;
},
),
),
),
));
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(
<LogicalKeyboardKey>[
LogicalKeyboardKey.arrowRight,
LogicalKeyboardKey.arrowRight,
LogicalKeyboardKey.arrowRight,
],
shift: true,
);
expect(cause, equals(SelectionChangedCause.keyboard));
expect(selection, equals(const TextSelection(
baseOffset: 0,
extentOffset: 3,
affinity: TextAffinity.upstream,
)));
// Select fewer characters using shift left arrow
await sendKeys(
<LogicalKeyboardKey>[
LogicalKeyboardKey.arrowLeft,
LogicalKeyboardKey.arrowLeft,
LogicalKeyboardKey.arrowLeft,
],
shift: true,
);
expect(selection, equals(const TextSelection(
baseOffset: 0,
extentOffset: 0,
affinity: TextAffinity.upstream,
)));
// Try to select before the first character, nothing should change.
await sendKeys(
<LogicalKeyboardKey>[
LogicalKeyboardKey.arrowLeft,
],
shift: true,
);
expect(selection, equals(const TextSelection(
baseOffset: 0,
extentOffset: 0,
affinity: TextAffinity.upstream,
)));
// Select the first two words.
await sendKeys(
<LogicalKeyboardKey>[
LogicalKeyboardKey.arrowRight,
LogicalKeyboardKey.arrowRight,
],
shift: true,
control: true,
);
expect(selection, equals(const TextSelection(
baseOffset: 0,
extentOffset: 6,
affinity: TextAffinity.upstream,
)));
// Unselect the second word.
await sendKeys(
<LogicalKeyboardKey>[
LogicalKeyboardKey.arrowLeft,
],
shift: true,
control: true,
);
expect(selection, equals(const TextSelection(
baseOffset: 0,
extentOffset: 4,
affinity: TextAffinity.upstream,
)));
// Select the next line.
await sendKeys(
<LogicalKeyboardKey>[
LogicalKeyboardKey.arrowDown,
],
shift: true,
);
expect(selection, equals(const TextSelection(
baseOffset: 0,
extentOffset: 20,
affinity: TextAffinity.upstream,
)));
// Move forward one character to reset the selection.
await sendKeys(
<LogicalKeyboardKey>[
LogicalKeyboardKey.arrowRight,
],
);
expect(selection, equals(const TextSelection(
baseOffset: 21,
extentOffset: 21,
affinity: TextAffinity.downstream,
)));
// Select the next line.
await sendKeys(
<LogicalKeyboardKey>[
LogicalKeyboardKey.arrowDown,
],
shift: true,
);
expect(selection, equals(const TextSelection(
baseOffset: 21,
extentOffset: 40,
affinity: TextAffinity.downstream,
)));
// Select to the end of the string by going down.
await sendKeys(
<LogicalKeyboardKey>[
LogicalKeyboardKey.arrowDown,
LogicalKeyboardKey.arrowDown,
LogicalKeyboardKey.arrowDown,
LogicalKeyboardKey.arrowDown,
],
shift: true,
);
expect(selection, equals(const TextSelection(
baseOffset: 21,
extentOffset: testText.length,
affinity: TextAffinity.downstream,
)));
// Go back up one line to set selection up to part of the last line.
await sendKeys(
<LogicalKeyboardKey>[
LogicalKeyboardKey.arrowUp,
],
shift: true,
);
expect(selection, equals(const TextSelection(
baseOffset: 21,
extentOffset: 58,
affinity: TextAffinity.downstream,
)));
// Select All
await sendKeys(
<LogicalKeyboardKey>[
LogicalKeyboardKey.keyA,
],
control: true,
);
expect(selection, equals(const TextSelection(
baseOffset: 0,
extentOffset: testText.length,
affinity: TextAffinity.downstream,
)));
// Jump to beginning of selection.
await sendKeys(
<LogicalKeyboardKey>[
LogicalKeyboardKey.arrowLeft,
],
);
expect(selection, equals(const TextSelection(
baseOffset: 0,
extentOffset: 0,
affinity: TextAffinity.downstream,
)));
// Jump forward three words.
await sendKeys(
<LogicalKeyboardKey>[
LogicalKeyboardKey.arrowRight,
LogicalKeyboardKey.arrowRight,
LogicalKeyboardKey.arrowRight,
],
control: true,
);
expect(selection, equals(const TextSelection(
baseOffset: 10,
extentOffset: 10,
affinity: TextAffinity.downstream,
)));
// Select some characters backward.
await sendKeys(
<LogicalKeyboardKey>[
LogicalKeyboardKey.arrowLeft,
LogicalKeyboardKey.arrowLeft,
LogicalKeyboardKey.arrowLeft,
],
shift: true,
);
expect(selection, equals(const TextSelection(
baseOffset: 10,
extentOffset: 7,
affinity: TextAffinity.downstream,
)));
// Select a word backward.
await sendKeys(
<LogicalKeyboardKey>[
LogicalKeyboardKey.arrowLeft,
],
shift: true,
control: true,
);
expect(selection, equals(const TextSelection(
baseOffset: 10,
extentOffset: 4,
affinity: TextAffinity.downstream,
)));
expect(controller.text, equals(testText));
// Cut
await sendKeys(
<LogicalKeyboardKey>[
LogicalKeyboardKey.keyX,
],
control: true,
);
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'));
// Paste
await sendKeys(
<LogicalKeyboardKey>[
LogicalKeyboardKey.keyV,
],
control: true,
);
expect(selection, equals(const TextSelection(
baseOffset: 10,
extentOffset: 4,
affinity: TextAffinity.downstream,
)));
expect(controller.text, equals(testText));
// Copy All
await sendKeys(
<LogicalKeyboardKey>[
LogicalKeyboardKey.keyA,
LogicalKeyboardKey.keyC,
],
control: true,
);
expect(selection, equals(const TextSelection(
baseOffset: 0,
extentOffset: testText.length,
affinity: TextAffinity.downstream,
)));
expect(controller.text, equals(testText));
expect((await Clipboard.getData(Clipboard.kTextPlain)).text, equals(testText));
// Delete
await sendKeys(
<LogicalKeyboardKey>[
LogicalKeyboardKey.delete,
],
);
expect(selection, equals(const TextSelection(
baseOffset: 0,
extentOffset: 72,
affinity: TextAffinity.downstream,
)));
expect(controller.text, isEmpty);
});
// Regression test for https://github.com/flutter/flutter/issues/31287
testWidgets('iOS text selection handle visibility', (WidgetTester tester) async {
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
......
......@@ -1278,7 +1278,7 @@ void main() {
await tester.sendKeyDownEvent(LogicalKeyboardKey.shift);
await tester.sendKeyDownEvent(LogicalKeyboardKey.arrowLeft);
expect(controller.selection.extentOffset - controller.selection.baseOffset, 1);
expect(controller.selection.extentOffset - controller.selection.baseOffset, -1);
});
testWidgets('Shift test 2', (WidgetTester tester) async {
......@@ -1302,7 +1302,7 @@ void main() {
await tester.pumpAndSettle();
expect(controller.selection.extentOffset - controller.selection.baseOffset, 5);
expect(controller.selection.extentOffset - controller.selection.baseOffset, -5);
});
testWidgets('Down and up test', (WidgetTester tester) async {
......@@ -1312,7 +1312,7 @@ void main() {
await tester.sendKeyDownEvent(LogicalKeyboardKey.arrowUp);
await tester.pumpAndSettle();
expect(controller.selection.extentOffset - controller.selection.baseOffset, 11);
expect(controller.selection.extentOffset - controller.selection.baseOffset, -11);
await tester.sendKeyUpEvent(LogicalKeyboardKey.arrowUp);
await tester.sendKeyUpEvent(LogicalKeyboardKey.shift);
......@@ -1371,7 +1371,7 @@ void main() {
await tester.sendKeyUpEvent(LogicalKeyboardKey.shift);
await tester.pumpAndSettle();
expect(controller.selection.extentOffset - controller.selection.baseOffset, 5);
expect(controller.selection.extentOffset - controller.selection.baseOffset, -5);
});
});
......@@ -1516,7 +1516,7 @@ void main() {
await tester.sendKeyUpEvent(LogicalKeyboardKey.shift);
await tester.pumpAndSettle();
expect(c1.selection.extentOffset - c1.selection.baseOffset, 5);
expect(c1.selection.extentOffset - c1.selection.baseOffset, -5);
await tester.pumpWidget(
MaterialApp(
......@@ -1555,7 +1555,7 @@ void main() {
editableTextWidget = tester.widget(find.byType(EditableText).last);
c1 = editableTextWidget.controller;
expect(c1.selection.extentOffset - c1.selection.baseOffset, 10);
expect(c1.selection.extentOffset - c1.selection.baseOffset, -6);
});
......@@ -1610,7 +1610,7 @@ void main() {
await tester.sendKeyUpEvent(LogicalKeyboardKey.shift);
await tester.pumpAndSettle();
expect(c1.selection.extentOffset - c1.selection.baseOffset, 5);
expect(c1.selection.extentOffset - c1.selection.baseOffset, -5);
expect(c2.selection.extentOffset - c2.selection.baseOffset, 0);
await tester.tap(find.byType(SelectableText).last);
......@@ -1625,7 +1625,7 @@ void main() {
await tester.pumpAndSettle();
expect(c1.selection.extentOffset - c1.selection.baseOffset, 0);
expect(c2.selection.extentOffset - c2.selection.baseOffset, 5);
expect(c2.selection.extentOffset - c2.selection.baseOffset, -5);
});
testWidgets('Caret works when maxLines is null', (WidgetTester tester) async {
......
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