From 734c3c4f8d5d7ff79c17810f8e43ed94cf66f4de Mon Sep 17 00:00:00 2001 From: Justin McCandless Date: Fri, 4 Feb 2022 13:20:03 -0800 Subject: [PATCH] Text editing shift + tap + drag interaction (#95213) Supports the desktop text editing interaction of holding shift, tapping the field, and dragging to modify the selection. --- .../lib/src/widgets/text_selection.dart | 117 +++++- .../test/cupertino/text_field_test.dart | 388 ++++++++++++++++++ .../test/material/text_field_test.dart | 388 ++++++++++++++++++ 3 files changed, 872 insertions(+), 21 deletions(-) diff --git a/packages/flutter/lib/src/widgets/text_selection.dart b/packages/flutter/lib/src/widgets/text_selection.dart index 7525c8821f..e1995e197e 100644 --- a/packages/flutter/lib/src/widgets/text_selection.dart +++ b/packages/flutter/lib/src/widgets/text_selection.dart @@ -1026,9 +1026,23 @@ class TextSelectionGestureDetectorBuilder { // The viewport offset pixels of the [RenderEditable] at the last drag start. double _dragStartViewportOffset = 0.0; + // Returns true iff either shift key is currently down. + bool get _isShiftPressed { + return HardwareKeyboard.instance.logicalKeysPressed + .any({ + LogicalKeyboardKey.shiftLeft, + LogicalKeyboardKey.shiftRight, + }.contains); + } + // True iff a tap + shift has been detected but the tap has not yet come up. bool _isShiftTapping = false; + // For a shift + tap + drag gesture, the TextSelection at the point of the + // tap. Mac uses this value to reset to the original selection when an + // inversion of the base and offset happens. + TextSelection? _shiftTapDragSelection; + /// Handler for [TextSelectionGestureDetector.onTapDown]. /// /// By default, it forwards the tap to [RenderEditable.handleTapDown] and sets @@ -1050,12 +1064,7 @@ class TextSelectionGestureDetectorBuilder { || kind == PointerDeviceKind.stylus; // Handle shift + click selection if needed. - final bool isShiftPressed = HardwareKeyboard.instance.logicalKeysPressed - .any({ - LogicalKeyboardKey.shiftLeft, - LogicalKeyboardKey.shiftRight, - }.contains); - if (isShiftPressed && renderEditable.selection?.baseOffset != null) { + if (_isShiftPressed && renderEditable.selection?.baseOffset != null) { _isShiftTapping = true; switch (defaultTargetPlatform) { case TargetPlatform.iOS: @@ -1290,10 +1299,27 @@ class TextSelectionGestureDetectorBuilder { || kind == PointerDeviceKind.touch || kind == PointerDeviceKind.stylus; - renderEditable.selectPositionAt( - from: details.globalPosition, - cause: SelectionChangedCause.drag, - ); + if (_isShiftPressed && renderEditable.selection != null && renderEditable.selection!.isValid) { + _isShiftTapping = true; + switch (defaultTargetPlatform) { + case TargetPlatform.iOS: + case TargetPlatform.macOS: + _expandSelection(details.globalPosition, SelectionChangedCause.drag); + break; + case TargetPlatform.android: + case TargetPlatform.fuchsia: + case TargetPlatform.linux: + case TargetPlatform.windows: + _extendSelection(details.globalPosition, SelectionChangedCause.drag); + break; + } + _shiftTapDragSelection = renderEditable.selection; + } else { + renderEditable.selectPositionAt( + from: details.globalPosition, + cause: SelectionChangedCause.drag, + ); + } _dragStartViewportOffset = renderEditable.offset.pixels; } @@ -1312,28 +1338,77 @@ class TextSelectionGestureDetectorBuilder { if (!delegate.selectionEnabled) return; - // Adjust the drag start offset for possible viewport offset changes. - final Offset startOffset = renderEditable.maxLines == 1 - ? Offset(renderEditable.offset.pixels - _dragStartViewportOffset, 0.0) - : Offset(0.0, renderEditable.offset.pixels - _dragStartViewportOffset); + if (!_isShiftTapping) { + // Adjust the drag start offset for possible viewport offset changes. + final Offset startOffset = renderEditable.maxLines == 1 + ? Offset(renderEditable.offset.pixels - _dragStartViewportOffset, 0.0) + : Offset(0.0, renderEditable.offset.pixels - _dragStartViewportOffset); - renderEditable.selectPositionAt( - from: startDetails.globalPosition - startOffset, - to: updateDetails.globalPosition, - cause: SelectionChangedCause.drag, - ); + return renderEditable.selectPositionAt( + from: startDetails.globalPosition - startOffset, + to: updateDetails.globalPosition, + cause: SelectionChangedCause.drag, + ); + } + + if (_shiftTapDragSelection!.isCollapsed + || (defaultTargetPlatform != TargetPlatform.iOS + && defaultTargetPlatform != TargetPlatform.macOS)) { + return _extendSelection(updateDetails.globalPosition, SelectionChangedCause.drag); + } + + // If the drag inverts the selection, Mac and iOS revert to the initial + // selection. + final TextSelection selection = editableText.textEditingValue.selection; + final TextPosition nextExtent = renderEditable.getPositionForPoint(updateDetails.globalPosition); + final bool isShiftTapDragSelectionForward = + _shiftTapDragSelection!.baseOffset < _shiftTapDragSelection!.extentOffset; + final bool isInverted = isShiftTapDragSelectionForward + ? nextExtent.offset < _shiftTapDragSelection!.baseOffset + : nextExtent.offset > _shiftTapDragSelection!.baseOffset; + if (isInverted && selection.baseOffset == _shiftTapDragSelection!.baseOffset) { + editableText.userUpdateTextEditingValue( + editableText.textEditingValue.copyWith( + selection: TextSelection( + baseOffset: _shiftTapDragSelection!.extentOffset, + extentOffset: nextExtent.offset, + ), + ), + SelectionChangedCause.drag, + ); + } else if (!isInverted + && nextExtent.offset != _shiftTapDragSelection!.baseOffset + && selection.baseOffset != _shiftTapDragSelection!.baseOffset) { + editableText.userUpdateTextEditingValue( + editableText.textEditingValue.copyWith( + selection: TextSelection( + baseOffset: _shiftTapDragSelection!.baseOffset, + extentOffset: nextExtent.offset, + ), + ), + SelectionChangedCause.drag, + ); + } else { + _extendSelection(updateDetails.globalPosition, SelectionChangedCause.drag); + } } /// Handler for [TextSelectionGestureDetector.onDragSelectionEnd]. /// - /// By default, it services as place holder to enable subclass override. + /// By default, it simply cleans up the state used for handling certain + /// built-in behaviors. /// /// See also: /// /// * [TextSelectionGestureDetector.onDragSelectionEnd], which triggers this /// callback. @protected - void onDragSelectionEnd(DragEndDetails details) {/* Subclass should override this method if needed. */} + void onDragSelectionEnd(DragEndDetails details) { + if (_isShiftTapping) { + _isShiftTapping = false; + _shiftTapDragSelection = null; + } + } /// Returns a [TextSelectionGestureDetector] configured with the handlers /// provided by this builder. diff --git a/packages/flutter/test/cupertino/text_field_test.dart b/packages/flutter/test/cupertino/text_field_test.dart index 1a2a858124..26a11b59a2 100644 --- a/packages/flutter/test/cupertino/text_field_test.dart +++ b/packages/flutter/test/cupertino/text_field_test.dart @@ -5052,4 +5052,392 @@ void main() { expect(controller.selection.baseOffset, 13); expect(controller.selection.extentOffset, 4); }, variant: const TargetPlatformVariant({ TargetPlatform.android, TargetPlatform.fuchsia, TargetPlatform.linux, TargetPlatform.windows })); + + testWidgets('can shift + tap + drag to select with a keyboard (Apple platforms)', (WidgetTester tester) async { + final TextEditingController controller = TextEditingController( + text: 'Atwater Peel Sherbrooke Bonaventure', + ); + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: CupertinoTextField( + controller: controller, + ), + ), + ), + ); + + await tester.tapAt(textOffsetToPosition(tester, 8)); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 8); + expect(controller.selection.extentOffset, 8); + + await tester.pump(kDoubleTapTimeout); + await tester.sendKeyDownEvent(LogicalKeyboardKey.shift); + final TestGesture gesture = + await tester.startGesture( + textOffsetToPosition(tester, 23), + pointer: 7, + kind: PointerDeviceKind.mouse, + ); + addTearDown(gesture.removePointer); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 8); + expect(controller.selection.extentOffset, 23); + + // Expand the selection a bit. + await gesture.moveTo(textOffsetToPosition(tester, 28)); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 8); + expect(controller.selection.extentOffset, 28); + + // Move back to the original selection. + await gesture.moveTo(textOffsetToPosition(tester, 23)); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 8); + expect(controller.selection.extentOffset, 23); + + // Collapse the selection. + await gesture.moveTo(textOffsetToPosition(tester, 8)); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 8); + expect(controller.selection.extentOffset, 8); + + // Invert the selection. The base jumps to the original extent. + await gesture.moveTo(textOffsetToPosition(tester, 7)); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 23); + expect(controller.selection.extentOffset, 7); + + // Continuing to move in the inverted direction expands the selection. + await gesture.moveTo(textOffsetToPosition(tester, 4)); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 23); + expect(controller.selection.extentOffset, 4); + + // Move back to the original base. + await gesture.moveTo(textOffsetToPosition(tester, 8)); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 23); + expect(controller.selection.extentOffset, 8); + + // Continue to move past the original base, which will cause the selection + // to invert back to the original orientation. + await gesture.moveTo(textOffsetToPosition(tester, 9)); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 8); + expect(controller.selection.extentOffset, 9); + + // Continuing to select in this direction selects just like it did + // originally. + await gesture.moveTo(textOffsetToPosition(tester, 24)); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 8); + expect(controller.selection.extentOffset, 24); + + // Releasing the shift key has no effect; the selection continues as the + // mouse continues to move. + await tester.sendKeyUpEvent(LogicalKeyboardKey.shift); + expect(controller.selection.baseOffset, 8); + expect(controller.selection.extentOffset, 24); + await gesture.moveTo(textOffsetToPosition(tester, 26)); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 8); + expect(controller.selection.extentOffset, 26); + + await gesture.up(); + expect(controller.selection.baseOffset, 8); + expect(controller.selection.extentOffset, 26); + }, variant: const TargetPlatformVariant({ TargetPlatform.iOS, TargetPlatform.macOS })); + + testWidgets('can shift + tap + drag to select with a keyboard (non-Apple platforms)', (WidgetTester tester) async { + final TextEditingController controller = TextEditingController( + text: 'Atwater Peel Sherbrooke Bonaventure', + ); + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: CupertinoTextField( + controller: controller, + ), + ), + ), + ); + + await tester.tapAt(textOffsetToPosition(tester, 8)); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 8); + expect(controller.selection.extentOffset, 8); + + await tester.pump(kDoubleTapTimeout); + await tester.sendKeyDownEvent(LogicalKeyboardKey.shift); + final TestGesture gesture = + await tester.startGesture( + textOffsetToPosition(tester, 23), + pointer: 7, + kind: PointerDeviceKind.mouse, + ); + addTearDown(gesture.removePointer); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 8); + expect(controller.selection.extentOffset, 23); + + // Expand the selection a bit. + await gesture.moveTo(textOffsetToPosition(tester, 28)); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 8); + expect(controller.selection.extentOffset, 28); + + // Move back to the original selection. + await gesture.moveTo(textOffsetToPosition(tester, 23)); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 8); + expect(controller.selection.extentOffset, 23); + + // Collapse the selection. + await gesture.moveTo(textOffsetToPosition(tester, 8)); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 8); + expect(controller.selection.extentOffset, 8); + + // Invert the selection. The original selection is not restored like on iOS + // and Mac. + await gesture.moveTo(textOffsetToPosition(tester, 7)); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 8); + expect(controller.selection.extentOffset, 7); + + // Continuing to move in the inverted direction expands the selection. + await gesture.moveTo(textOffsetToPosition(tester, 4)); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 8); + expect(controller.selection.extentOffset, 4); + + // Move back to the original base. + await gesture.moveTo(textOffsetToPosition(tester, 8)); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 8); + expect(controller.selection.extentOffset, 8); + + // Continue to move past the original base. + await gesture.moveTo(textOffsetToPosition(tester, 9)); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 8); + expect(controller.selection.extentOffset, 9); + + // Continuing to select in this direction selects just like it did + // originally. + await gesture.moveTo(textOffsetToPosition(tester, 24)); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 8); + expect(controller.selection.extentOffset, 24); + + // Releasing the shift key has no effect; the selection continues as the + // mouse continues to move. + await tester.sendKeyUpEvent(LogicalKeyboardKey.shift); + expect(controller.selection.baseOffset, 8); + expect(controller.selection.extentOffset, 24); + await gesture.moveTo(textOffsetToPosition(tester, 26)); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 8); + expect(controller.selection.extentOffset, 26); + + await gesture.up(); + expect(controller.selection.baseOffset, 8); + expect(controller.selection.extentOffset, 26); + }, variant: const TargetPlatformVariant({ TargetPlatform.linux, TargetPlatform.android, TargetPlatform.fuchsia, TargetPlatform.windows })); + + testWidgets('can shift + tap + drag to select with a keyboard, reversed (Apple platforms)', (WidgetTester tester) async { + final TextEditingController controller = TextEditingController( + text: 'Atwater Peel Sherbrooke Bonaventure', + ); + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: CupertinoTextField( + controller: controller, + ), + ), + ), + ); + + // Make a selection from right to left. + await tester.tapAt(textOffsetToPosition(tester, 23)); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 23); + expect(controller.selection.extentOffset, 23); + await tester.pump(kDoubleTapTimeout); + await tester.sendKeyDownEvent(LogicalKeyboardKey.shift); + final TestGesture gesture = + await tester.startGesture( + textOffsetToPosition(tester, 8), + pointer: 7, + kind: PointerDeviceKind.mouse, + ); + addTearDown(gesture.removePointer); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 23); + expect(controller.selection.extentOffset, 8); + + // Expand the selection a bit. + await gesture.moveTo(textOffsetToPosition(tester, 5)); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 23); + expect(controller.selection.extentOffset, 5); + + // Move back to the original selection. + await gesture.moveTo(textOffsetToPosition(tester, 8)); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 23); + expect(controller.selection.extentOffset, 8); + + // Collapse the selection. + await gesture.moveTo(textOffsetToPosition(tester, 23)); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 23); + expect(controller.selection.extentOffset, 23); + + // Invert the selection. The base jumps to the original extent. + await gesture.moveTo(textOffsetToPosition(tester, 24)); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 8); + expect(controller.selection.extentOffset, 24); + + // Continuing to move in the inverted direction expands the selection. + await gesture.moveTo(textOffsetToPosition(tester, 27)); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 8); + expect(controller.selection.extentOffset, 27); + + // Move back to the original base. + await gesture.moveTo(textOffsetToPosition(tester, 23)); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 8); + expect(controller.selection.extentOffset, 23); + + // Continue to move past the original base, which will cause the selection + // to invert back to the original orientation. + await gesture.moveTo(textOffsetToPosition(tester, 22)); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 23); + expect(controller.selection.extentOffset, 22); + + // Continuing to select in this direction selects just like it did + // originally. + await gesture.moveTo(textOffsetToPosition(tester, 16)); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 23); + expect(controller.selection.extentOffset, 16); + + // Releasing the shift key has no effect; the selection continues as the + // mouse continues to move. + await tester.sendKeyUpEvent(LogicalKeyboardKey.shift); + expect(controller.selection.baseOffset, 23); + expect(controller.selection.extentOffset, 16); + await gesture.moveTo(textOffsetToPosition(tester, 14)); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 23); + expect(controller.selection.extentOffset, 14); + + await gesture.up(); + expect(controller.selection.baseOffset, 23); + expect(controller.selection.extentOffset, 14); + }, variant: const TargetPlatformVariant({ TargetPlatform.iOS, TargetPlatform.macOS })); + + testWidgets('can shift + tap + drag to select with a keyboard, reversed (non-Apple platforms)', (WidgetTester tester) async { + final TextEditingController controller = TextEditingController( + text: 'Atwater Peel Sherbrooke Bonaventure', + ); + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: CupertinoTextField( + controller: controller, + ), + ), + ), + ); + + // Make a selection from right to left. + await tester.tapAt(textOffsetToPosition(tester, 23)); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 23); + expect(controller.selection.extentOffset, 23); + await tester.pump(kDoubleTapTimeout); + await tester.sendKeyDownEvent(LogicalKeyboardKey.shift); + final TestGesture gesture = + await tester.startGesture( + textOffsetToPosition(tester, 8), + pointer: 7, + kind: PointerDeviceKind.mouse, + ); + addTearDown(gesture.removePointer); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 23); + expect(controller.selection.extentOffset, 8); + + // Expand the selection a bit. + await gesture.moveTo(textOffsetToPosition(tester, 5)); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 23); + expect(controller.selection.extentOffset, 5); + + // Move back to the original selection. + await gesture.moveTo(textOffsetToPosition(tester, 8)); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 23); + expect(controller.selection.extentOffset, 8); + + // Collapse the selection. + await gesture.moveTo(textOffsetToPosition(tester, 23)); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 23); + expect(controller.selection.extentOffset, 23); + + // Invert the selection. The selection is not restored like it would be on + // iOS and Mac. + await gesture.moveTo(textOffsetToPosition(tester, 24)); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 23); + expect(controller.selection.extentOffset, 24); + + // Continuing to move in the inverted direction expands the selection. + await gesture.moveTo(textOffsetToPosition(tester, 27)); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 23); + expect(controller.selection.extentOffset, 27); + + // Move back to the original base. + await gesture.moveTo(textOffsetToPosition(tester, 23)); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 23); + expect(controller.selection.extentOffset, 23); + + // Continue to move past the original base. + await gesture.moveTo(textOffsetToPosition(tester, 22)); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 23); + expect(controller.selection.extentOffset, 22); + + // Continuing to select in this direction selects just like it did + // originally. + await gesture.moveTo(textOffsetToPosition(tester, 16)); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 23); + expect(controller.selection.extentOffset, 16); + + // Releasing the shift key has no effect; the selection continues as the + // mouse continues to move. + await tester.sendKeyUpEvent(LogicalKeyboardKey.shift); + expect(controller.selection.baseOffset, 23); + expect(controller.selection.extentOffset, 16); + await gesture.moveTo(textOffsetToPosition(tester, 14)); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 23); + expect(controller.selection.extentOffset, 14); + + await gesture.up(); + expect(controller.selection.baseOffset, 23); + expect(controller.selection.extentOffset, 14); + }, variant: const TargetPlatformVariant({ TargetPlatform.linux, TargetPlatform.android, TargetPlatform.fuchsia, TargetPlatform.windows })); } diff --git a/packages/flutter/test/material/text_field_test.dart b/packages/flutter/test/material/text_field_test.dart index 204d5ec22e..15674c5c40 100644 --- a/packages/flutter/test/material/text_field_test.dart +++ b/packages/flutter/test/material/text_field_test.dart @@ -10557,4 +10557,392 @@ void main() { expect(controller.selection.baseOffset, 13); expect(controller.selection.extentOffset, 4); }, variant: const TargetPlatformVariant({ TargetPlatform.android, TargetPlatform.fuchsia, TargetPlatform.linux, TargetPlatform.windows })); + + testWidgets('can shift + tap + drag to select with a keyboard (Apple platforms)', (WidgetTester tester) async { + final TextEditingController controller = TextEditingController( + text: 'Atwater Peel Sherbrooke Bonaventure', + ); + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center( + child: TextField(controller: controller), + ), + ), + ), + ); + + await tester.tapAt(textOffsetToPosition(tester, 8)); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 8); + expect(controller.selection.extentOffset, 8); + + await tester.pump(kDoubleTapTimeout); + await tester.sendKeyDownEvent(LogicalKeyboardKey.shift); + final TestGesture gesture = + await tester.startGesture( + textOffsetToPosition(tester, 23), + pointer: 7, + kind: PointerDeviceKind.mouse, + ); + addTearDown(gesture.removePointer); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 8); + expect(controller.selection.extentOffset, 23); + + // Expand the selection a bit. + await gesture.moveTo(textOffsetToPosition(tester, 28)); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 8); + expect(controller.selection.extentOffset, 28); + + // Move back to the original selection. + await gesture.moveTo(textOffsetToPosition(tester, 23)); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 8); + expect(controller.selection.extentOffset, 23); + + // Collapse the selection. + await gesture.moveTo(textOffsetToPosition(tester, 8)); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 8); + expect(controller.selection.extentOffset, 8); + + // Invert the selection. The base jumps to the original extent. + await gesture.moveTo(textOffsetToPosition(tester, 7)); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 23); + expect(controller.selection.extentOffset, 7); + + // Continuing to move in the inverted direction expands the selection. + await gesture.moveTo(textOffsetToPosition(tester, 4)); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 23); + expect(controller.selection.extentOffset, 4); + + // Move back to the original base. + await gesture.moveTo(textOffsetToPosition(tester, 8)); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 23); + expect(controller.selection.extentOffset, 8); + + // Continue to move past the original base, which will cause the selection + // to invert back to the original orientation. + await gesture.moveTo(textOffsetToPosition(tester, 9)); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 8); + expect(controller.selection.extentOffset, 9); + + // Continuing to select in this direction selects just like it did + // originally. + await gesture.moveTo(textOffsetToPosition(tester, 24)); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 8); + expect(controller.selection.extentOffset, 24); + + // Releasing the shift key has no effect; the selection continues as the + // mouse continues to move. + await tester.sendKeyUpEvent(LogicalKeyboardKey.shift); + expect(controller.selection.baseOffset, 8); + expect(controller.selection.extentOffset, 24); + await gesture.moveTo(textOffsetToPosition(tester, 26)); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 8); + expect(controller.selection.extentOffset, 26); + + await gesture.up(); + expect(controller.selection.baseOffset, 8); + expect(controller.selection.extentOffset, 26); + }, variant: const TargetPlatformVariant({ TargetPlatform.iOS, TargetPlatform.macOS })); + + testWidgets('can shift + tap + drag to select with a keyboard (non-Apple platforms)', (WidgetTester tester) async { + final TextEditingController controller = TextEditingController( + text: 'Atwater Peel Sherbrooke Bonaventure', + ); + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center( + child: TextField(controller: controller), + ), + ), + ), + ); + + await tester.tapAt(textOffsetToPosition(tester, 8)); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 8); + expect(controller.selection.extentOffset, 8); + + await tester.pump(kDoubleTapTimeout); + await tester.sendKeyDownEvent(LogicalKeyboardKey.shift); + final TestGesture gesture = + await tester.startGesture( + textOffsetToPosition(tester, 23), + pointer: 7, + kind: PointerDeviceKind.mouse, + ); + addTearDown(gesture.removePointer); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 8); + expect(controller.selection.extentOffset, 23); + + // Expand the selection a bit. + await gesture.moveTo(textOffsetToPosition(tester, 28)); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 8); + expect(controller.selection.extentOffset, 28); + + // Move back to the original selection. + await gesture.moveTo(textOffsetToPosition(tester, 23)); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 8); + expect(controller.selection.extentOffset, 23); + + // Collapse the selection. + await gesture.moveTo(textOffsetToPosition(tester, 8)); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 8); + expect(controller.selection.extentOffset, 8); + + // Invert the selection. The original selection is not restored like on iOS + // and Mac. + await gesture.moveTo(textOffsetToPosition(tester, 7)); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 8); + expect(controller.selection.extentOffset, 7); + + // Continuing to move in the inverted direction expands the selection. + await gesture.moveTo(textOffsetToPosition(tester, 4)); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 8); + expect(controller.selection.extentOffset, 4); + + // Move back to the original base. + await gesture.moveTo(textOffsetToPosition(tester, 8)); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 8); + expect(controller.selection.extentOffset, 8); + + // Continue to move past the original base. + await gesture.moveTo(textOffsetToPosition(tester, 9)); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 8); + expect(controller.selection.extentOffset, 9); + + // Continuing to select in this direction selects just like it did + // originally. + await gesture.moveTo(textOffsetToPosition(tester, 24)); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 8); + expect(controller.selection.extentOffset, 24); + + // Releasing the shift key has no effect; the selection continues as the + // mouse continues to move. + await tester.sendKeyUpEvent(LogicalKeyboardKey.shift); + expect(controller.selection.baseOffset, 8); + expect(controller.selection.extentOffset, 24); + await gesture.moveTo(textOffsetToPosition(tester, 26)); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 8); + expect(controller.selection.extentOffset, 26); + + await gesture.up(); + expect(controller.selection.baseOffset, 8); + expect(controller.selection.extentOffset, 26); + }, variant: const TargetPlatformVariant({ TargetPlatform.linux, TargetPlatform.android, TargetPlatform.fuchsia, TargetPlatform.windows })); + + testWidgets('can shift + tap + drag to select with a keyboard, reversed (Apple platforms)', (WidgetTester tester) async { + final TextEditingController controller = TextEditingController( + text: 'Atwater Peel Sherbrooke Bonaventure', + ); + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center( + child: TextField(controller: controller), + ), + ), + ), + ); + + // Make a selection from right to left. + await tester.tapAt(textOffsetToPosition(tester, 23)); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 23); + expect(controller.selection.extentOffset, 23); + await tester.pump(kDoubleTapTimeout); + await tester.sendKeyDownEvent(LogicalKeyboardKey.shift); + final TestGesture gesture = + await tester.startGesture( + textOffsetToPosition(tester, 8), + pointer: 7, + kind: PointerDeviceKind.mouse, + ); + addTearDown(gesture.removePointer); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 23); + expect(controller.selection.extentOffset, 8); + + // Expand the selection a bit. + await gesture.moveTo(textOffsetToPosition(tester, 5)); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 23); + expect(controller.selection.extentOffset, 5); + + // Move back to the original selection. + await gesture.moveTo(textOffsetToPosition(tester, 8)); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 23); + expect(controller.selection.extentOffset, 8); + + // Collapse the selection. + await gesture.moveTo(textOffsetToPosition(tester, 23)); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 23); + expect(controller.selection.extentOffset, 23); + + // Invert the selection. The base jumps to the original extent. + await gesture.moveTo(textOffsetToPosition(tester, 24)); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 8); + expect(controller.selection.extentOffset, 24); + + // Continuing to move in the inverted direction expands the selection. + await gesture.moveTo(textOffsetToPosition(tester, 27)); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 8); + expect(controller.selection.extentOffset, 27); + + // Move back to the original base. + await gesture.moveTo(textOffsetToPosition(tester, 23)); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 8); + expect(controller.selection.extentOffset, 23); + + // Continue to move past the original base, which will cause the selection + // to invert back to the original orientation. + await gesture.moveTo(textOffsetToPosition(tester, 22)); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 23); + expect(controller.selection.extentOffset, 22); + + // Continuing to select in this direction selects just like it did + // originally. + await gesture.moveTo(textOffsetToPosition(tester, 16)); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 23); + expect(controller.selection.extentOffset, 16); + + // Releasing the shift key has no effect; the selection continues as the + // mouse continues to move. + await tester.sendKeyUpEvent(LogicalKeyboardKey.shift); + expect(controller.selection.baseOffset, 23); + expect(controller.selection.extentOffset, 16); + await gesture.moveTo(textOffsetToPosition(tester, 14)); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 23); + expect(controller.selection.extentOffset, 14); + + await gesture.up(); + expect(controller.selection.baseOffset, 23); + expect(controller.selection.extentOffset, 14); + }, variant: const TargetPlatformVariant({ TargetPlatform.iOS, TargetPlatform.macOS })); + + testWidgets('can shift + tap + drag to select with a keyboard, reversed (non-Apple platforms)', (WidgetTester tester) async { + final TextEditingController controller = TextEditingController( + text: 'Atwater Peel Sherbrooke Bonaventure', + ); + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center( + child: TextField(controller: controller), + ), + ), + ), + ); + + // Make a selection from right to left. + await tester.tapAt(textOffsetToPosition(tester, 23)); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 23); + expect(controller.selection.extentOffset, 23); + await tester.pump(kDoubleTapTimeout); + await tester.sendKeyDownEvent(LogicalKeyboardKey.shift); + final TestGesture gesture = + await tester.startGesture( + textOffsetToPosition(tester, 8), + pointer: 7, + kind: PointerDeviceKind.mouse, + ); + addTearDown(gesture.removePointer); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 23); + expect(controller.selection.extentOffset, 8); + + // Expand the selection a bit. + await gesture.moveTo(textOffsetToPosition(tester, 5)); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 23); + expect(controller.selection.extentOffset, 5); + + // Move back to the original selection. + await gesture.moveTo(textOffsetToPosition(tester, 8)); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 23); + expect(controller.selection.extentOffset, 8); + + // Collapse the selection. + await gesture.moveTo(textOffsetToPosition(tester, 23)); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 23); + expect(controller.selection.extentOffset, 23); + + // Invert the selection. The selection is not restored like it would be on + // iOS and Mac. + await gesture.moveTo(textOffsetToPosition(tester, 24)); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 23); + expect(controller.selection.extentOffset, 24); + + // Continuing to move in the inverted direction expands the selection. + await gesture.moveTo(textOffsetToPosition(tester, 27)); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 23); + expect(controller.selection.extentOffset, 27); + + // Move back to the original base. + await gesture.moveTo(textOffsetToPosition(tester, 23)); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 23); + expect(controller.selection.extentOffset, 23); + + // Continue to move past the original base. + await gesture.moveTo(textOffsetToPosition(tester, 22)); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 23); + expect(controller.selection.extentOffset, 22); + + // Continuing to select in this direction selects just like it did + // originally. + await gesture.moveTo(textOffsetToPosition(tester, 16)); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 23); + expect(controller.selection.extentOffset, 16); + + // Releasing the shift key has no effect; the selection continues as the + // mouse continues to move. + await tester.sendKeyUpEvent(LogicalKeyboardKey.shift); + expect(controller.selection.baseOffset, 23); + expect(controller.selection.extentOffset, 16); + await gesture.moveTo(textOffsetToPosition(tester, 14)); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 23); + expect(controller.selection.extentOffset, 14); + + await gesture.up(); + expect(controller.selection.baseOffset, 23); + expect(controller.selection.extentOffset, 14); + }, variant: const TargetPlatformVariant({ TargetPlatform.linux, TargetPlatform.android, TargetPlatform.fuchsia, TargetPlatform.windows })); }