From 3900d42bfa9231bcfe4d585152f2b062c488a445 Mon Sep 17 00:00:00 2001 From: jslavitz Date: Thu, 23 Aug 2018 17:16:44 -0700 Subject: [PATCH] Control, Shift and Arrow Key functionality for Chromebook (#20204) * added keyboard functionatliy to android builds * Added tests * almost ready for review * ready for review * Fixes * final comments * final commit * removing raw keyboard changes * removing raw keyboard changes * removing raw keyboard changes * actual last commit * fixed the imports * a few more changes * A few more changes * a few changes * Final changes * Final changes2 * final actual commit for real * final actual commit for real2 * final actual commit for real3 * final actual commit for real4 * final * final 2 * f * f2 * fin * fin 2 * fin3 * fin4 --- .../flutter/lib/src/rendering/editable.dart | 194 +++++++++++ .../test/material/text_field_test.dart | 310 ++++++++++++++++++ 2 files changed, 504 insertions(+) diff --git a/packages/flutter/lib/src/rendering/editable.dart b/packages/flutter/lib/src/rendering/editable.dart index 10b166a482..17391e2679 100644 --- a/packages/flutter/lib/src/rendering/editable.dart +++ b/packages/flutter/lib/src/rendering/editable.dart @@ -198,6 +198,186 @@ class RenderEditable extends RenderBox { Rect _lastCaretRect; + static const int _kLeftArrowCode = 21; + static const int _kRightArrowCode = 22; + static const int _kUpArrowCode = 19; + static const int _kDownArrowCode = 20; + + // 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; + + // 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 + + // TODO(goderbauer): doesn't handle extended grapheme clusters with more than one Unicode scalar value (https://github.com/flutter/flutter/issues/13404). + void _handleKeyEvent(RawKeyEvent keyEvent){ + if (defaultTargetPlatform != TargetPlatform.android) + return; + + if (keyEvent is RawKeyUpEvent) + return; + + final RawKeyEventDataAndroid rawAndroidEvent = keyEvent.data; + final int pressedKeyCode = rawAndroidEvent.keyCode; + final int pressedKeyMetaState = rawAndroidEvent.metaState; + + 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; + + // 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; + } + } + + // Handles full word traversal using control. + int _handleControl(bool rightArrow, bool leftArrow, bool ctrl, int newOffset) { + // 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(new TextPosition(offset: _extentOffset - 2)); + newOffset = textSelection.baseOffset + 1; + } else if (rightArrow && _extentOffset < text.text.length - 2) { + final TextSelection textSelection = _selectWordAtOffset(new TextPosition(offset: _extentOffset + 1)); + newOffset = 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.text.length) { + newOffset += 1; + if (shift) + _previousCursorLocation += 1; + } + if (leftArrow && _extentOffset > 0) { + newOffset -= 1; + if (shift) + _previousCursorLocation -= 1; + } + return newOffset; + } + + // 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) { + // 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 Offset caretOffset = _textPainter.getOffsetForCaret(new TextPosition(offset: _extentOffset), _caretPrototype); + final Offset caretOffsetTranslated = caretOffset.translate(0.0, verticalOffset); + final TextPosition position = _textPainter.getPositionForOffset(caretOffsetTranslated); + + // To account for the possibility where the user vertically highlights + // 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.text.length; + else if (upArrow) + newOffset = 0; + _resetCursor = shift; + } else if (_resetCursor && shift) { + newOffset = _previousCursorLocation; + _resetCursor = false; + } else { + newOffset = position.offset; + _previousCursorLocation = newOffset; + } + 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) { + onSelectionChanged( + new TextSelection( + baseOffset: _baseOffset, + extentOffset: newOffset + ), + this, + SelectionChangedCause.keyboard, + ); + } else { + onSelectionChanged( + new TextSelection( + baseOffset: newOffset, + extentOffset: _baseOffset + ), + this, + SelectionChangedCause.keyboard, + ); + } + } else { + // We want to put the cursor at the correct location depending on which + // arrow is used while there is a selection. + if (!selection.isCollapsed) { + if (leftArrow) + newOffset = _baseOffset < _extentOffset ? _baseOffset : _extentOffset; + else if (rightArrow) + newOffset = _baseOffset > _extentOffset ? _baseOffset : _extentOffset; + } + onSelectionChanged( + new TextSelection.fromPosition( + new TextPosition( + offset: newOffset + ) + ), + this, + SelectionChangedCause.keyboard, + ); + } + return newOffset; + } + /// Marks the render object as needing to be laid out again and have its text /// metrics recomputed. /// @@ -300,11 +480,23 @@ class RenderEditable extends RenderBox { /// Whether the editable is currently focused. bool get hasFocus => _hasFocus; bool _hasFocus; + bool _listenerAttached = false; set hasFocus(bool value) { assert(value != null); if (_hasFocus == value) return; _hasFocus = value; + if (_hasFocus) { + assert(!_listenerAttached); + RawKeyboard.instance.addListener(_handleKeyEvent); + _listenerAttached = true; + } + else { + assert(_listenerAttached); + RawKeyboard.instance.removeListener(_handleKeyEvent); + _listenerAttached = false; + } + markNeedsSemanticsUpdate(); } @@ -574,6 +766,8 @@ class RenderEditable extends RenderBox { void detach() { _offset.removeListener(markNeedsPaint); _showCursor.removeListener(markNeedsPaint); + if (_listenerAttached) + RawKeyboard.instance.removeListener(_handleKeyEvent); super.detach(); } diff --git a/packages/flutter/test/material/text_field_test.dart b/packages/flutter/test/material/text_field_test.dart index 644012c244..336002dd43 100644 --- a/packages/flutter/test/material/text_field_test.dart +++ b/packages/flutter/test/material/text_field_test.dart @@ -1764,6 +1764,316 @@ void main() { semantics.dispose(); }); + void sendFakeKeyEvent(Map data) { + BinaryMessages.handlePlatformMessage( + SystemChannels.keyEvent.name, + SystemChannels.keyEvent.codec.encodeMessage(data), + (ByteData data) { }, + ); + } + + void sendKeyEventWithCode(int code, bool down, bool shiftDown, bool ctrlDown) { + + int metaState = shiftDown ? 1 : 0; + if (ctrlDown) + metaState |= 1 << 12; + + sendFakeKeyEvent({ + 'type': down ? 'keydown' : 'keyup', + 'keymap': 'android', + 'keyCode' : code, + 'hidUsage': 0x04, + 'codePoint': 0x64, + 'metaState': metaState, + }); + } + + group('Keyboard Tests', (){ + TextEditingController controller; + + setUp( () { + controller = new TextEditingController(); + }); + + MaterialApp setupWidget() { + + final FocusNode focusNode = new FocusNode(); + controller = new TextEditingController(); + + return new MaterialApp( + home: Material( + child: new RawKeyboardListener( + focusNode: focusNode, + onKey: null, + child: TextField( + controller: controller, + maxLines: 3, + ), + ) , + ), + ); + } + + testWidgets('Shift test 1', (WidgetTester tester) async{ + + await tester.pumpWidget(setupWidget()); + const String testValue = 'a big house'; + await tester.enterText(find.byType(TextField), testValue); + + await tester.idle(); + await tester.tap(find.byType(TextField)); + await tester.pumpAndSettle(); + + sendKeyEventWithCode(22, true, true, false); // RIGHT_ARROW keydown, SHIFT_ON + expect(controller.selection.extentOffset - controller.selection.baseOffset, 1); + }); + + testWidgets('Control Shift test', (WidgetTester tester) async{ + await tester.pumpWidget(setupWidget()); + const String testValue = 'their big house'; + await tester.enterText(find.byType(TextField), testValue); + + await tester.idle(); + await tester.tap(find.byType(TextField)); + await tester.pumpAndSettle(); + await tester.pumpAndSettle(); + sendKeyEventWithCode(22, true, true, true); // RIGHT_ARROW keydown SHIFT_ON, CONTROL_ON + + await tester.pumpAndSettle(); + + expect(controller.selection.extentOffset - controller.selection.baseOffset, 5); + }); + + testWidgets('Down and up test', (WidgetTester tester) async{ + await tester.pumpWidget(setupWidget()); + const String testValue = 'a big house'; + await tester.enterText(find.byType(TextField), testValue); + + await tester.idle(); + await tester.tap(find.byType(TextField)); + await tester.pumpAndSettle(); + + sendKeyEventWithCode(20, true, true, false); // DOWN_ARROW keydown + await tester.pumpAndSettle(); + + expect(controller.selection.extentOffset - controller.selection.baseOffset, 11); + + sendKeyEventWithCode(20, false, true, false); // DOWN_ARROW keyup + await tester.pumpAndSettle(); + sendKeyEventWithCode(19, true, true, false); // UP_ARROW keydown + await tester.pumpAndSettle(); + + expect(controller.selection.extentOffset - controller.selection.baseOffset, 0); + }); + + + testWidgets('Down and up test 2', (WidgetTester tester) async{ + await tester.pumpWidget(setupWidget()); + const String testValue = 'a big house\njumped over a mouse\nOne more line yay'; // 11 \n 19 + await tester.enterText(find.byType(TextField), testValue); + + await tester.idle(); + await tester.tap(find.byType(TextField)); + await tester.pumpAndSettle(); + + for (int i = 0; i < 5; i += 1) { + sendKeyEventWithCode(22, true, false, false); // RIGHT_ARROW keydown + await tester.pumpAndSettle(); + sendKeyEventWithCode(22, false, false, false); // RIGHT_ARROW keyup + await tester.pumpAndSettle(); + } + sendKeyEventWithCode(20, true, true, false); // DOWN_ARROW keydown + await tester.pumpAndSettle(); + sendKeyEventWithCode(20, false, true, false); // DOWN_ARROW keyup + await tester.pumpAndSettle(); + + expect(controller.selection.extentOffset - controller.selection.baseOffset, 12); + + sendKeyEventWithCode(20, true, true, false); // DOWN_ARROW keydown + await tester.pumpAndSettle(); + sendKeyEventWithCode(20, false, true, false); // DOWN_ARROW keyup + await tester.pumpAndSettle(); + + expect(controller.selection.extentOffset - controller.selection.baseOffset, 32); + + sendKeyEventWithCode(19, true, true, false); // UP_ARROW keydown + await tester.pumpAndSettle(); + sendKeyEventWithCode(19, false, true, false); // UP_ARROW keyup + await tester.pumpAndSettle(); + + expect(controller.selection.extentOffset - controller.selection.baseOffset, 12); + + sendKeyEventWithCode(19, true, true, false); // UP_ARROW keydown + await tester.pumpAndSettle(); + sendKeyEventWithCode(19, false, true, false); // UP_ARROW keyup + await tester.pumpAndSettle(); + + expect(controller.selection.extentOffset - controller.selection.baseOffset, 0); + + sendKeyEventWithCode(19, true, true, false); // UP_ARROW keydown + await tester.pumpAndSettle(); + sendKeyEventWithCode(19, false, true, false); // UP_ARROW keyup + await tester.pumpAndSettle(); + + expect(controller.selection.extentOffset - controller.selection.baseOffset, 5); + }); + }); + + testWidgets('Changing positions of text fields', (WidgetTester tester) async{ + + final FocusNode focusNode = new FocusNode(); + final List events = []; + + final TextEditingController c1 = new TextEditingController(); + final TextEditingController c2 = new TextEditingController(); + final Key key1 = new UniqueKey(); + final Key key2 = new UniqueKey(); + + await tester.pumpWidget( + new MaterialApp( + home: + Material( + child: new RawKeyboardListener( + focusNode: focusNode, + onKey: events.add, + child: new Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + TextField( + key: key1, + controller: c1, + maxLines: 3, + ), + TextField( + key: key2, + controller: c2, + maxLines: 3, + ), + ], + ), + ), + ), + ), + ); + + const String testValue = 'a big house'; + await tester.enterText(find.byType(TextField).first, testValue); + + await tester.idle(); + await tester.tap(find.byType(TextField).first); + await tester.pumpAndSettle(); + + for (int i = 0; i < 5; i += 1) { + sendKeyEventWithCode(22, true, true, false); // RIGHT_ARROW keydown + await tester.pumpAndSettle(); + } + + expect(c1.selection.extentOffset - c1.selection.baseOffset, 5); + + await tester.pumpWidget( + new MaterialApp( + home: + Material( + child: new RawKeyboardListener( + focusNode: focusNode, + onKey: events.add, + child: new Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + TextField( + key: key2, + controller: c2, + maxLines: 3, + ), + TextField( + key: key1, + controller: c1, + maxLines: 3, + ), + ], + ), + ), + ), + ), + ); + + for (int i = 0; i < 5; i += 1) { + sendKeyEventWithCode(22, true, true, false); // RIGHT_ARROW keydown + await tester.pumpAndSettle(); + } + + expect(c1.selection.extentOffset - c1.selection.baseOffset, 10); + }); + + + testWidgets('Changing focus test', (WidgetTester tester) async { + final FocusNode focusNode = new FocusNode(); + final List events = []; + + final TextEditingController c1 = new TextEditingController(); + final TextEditingController c2 = new TextEditingController(); + final Key key1 = new UniqueKey(); + final Key key2 = new UniqueKey(); + + await tester.pumpWidget( + new MaterialApp( + home: + Material( + child: new RawKeyboardListener( + focusNode: focusNode, + onKey: events.add, + child: new Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + TextField( + key: key1, + controller: c1, + maxLines: 3, + ), + TextField( + key: key2, + controller: c2, + maxLines: 3, + ), + ], + ), + ), + ), + ), + ); + + await tester.idle(); + await tester.tap(find.byType(TextField).first); + + const String testValue = 'a big house'; + await tester.enterText(find.byType(TextField).first, testValue); + + await tester.pumpAndSettle(); + + for (int i = 0; i < 5; i += 1) { + sendKeyEventWithCode(22, true, true, false); // RIGHT_ARROW keydown + await tester.pumpAndSettle(); + } + + expect(c1.selection.extentOffset - c1.selection.baseOffset, 5); + expect(c2.selection.extentOffset - c2.selection.baseOffset, 0); + + await tester.idle(); + await tester.tap(find.byType(TextField).last); + + await tester.enterText(find.byType(TextField).last, testValue); + + await tester.pumpAndSettle(); + + for (int i = 0; i < 5; i += 1) { + sendKeyEventWithCode(22, true, true, false); // RIGHT_ARROW keydown + await tester.pumpAndSettle(); + } + + expect(c1.selection.extentOffset - c1.selection.baseOffset, 0); + expect(c2.selection.extentOffset - c2.selection.baseOffset, 5); + }); + testWidgets('Caret works when maxLines is null', (WidgetTester tester) async { final TextEditingController controller = new TextEditingController();