From 21ad7122a18174d44fe3da879b225ddbafbc9dce Mon Sep 17 00:00:00 2001 From: Renzo Olivares Date: Wed, 27 Sep 2023 18:42:16 -0700 Subject: [PATCH] Implement SelectionArea single click/tap gestures (#132682) This change collapses the selection at the clicked/tapped location on single click down for desktop platforms, and on single click/tap up for mobile platforms to match native. This is a change from how `SelectionArea` previously worked. Before this change a single click down would clear the selection. From observing a native browser it looks like when tapping on static text the selection is not cleared but collapsed. A user can still attain the selection from static text using the `window.getSelection` API. https://jsfiddle.net/juepasn3/11/ You can try this demo out here to observe this behavior yourself. When clicking on static text the selection will change. This change also allows `Paragraph.selections` to return selections that are collapsed. This for testing purposes to confirm where the selection has been collapsed. Partially fixes: #129583 --- ...ectable_region_toolbar_builder.0_test.dart | 7 +- .../flutter/lib/src/rendering/paragraph.dart | 11 +- .../flutter/lib/src/widgets/scrollable.dart | 4 +- .../lib/src/widgets/selectable_region.dart | 139 ++++++-- .../test/material/selection_area_test.dart | 10 +- .../test/rendering/paragraph_test.dart | 3 +- .../test/widgets/selectable_region_test.dart | 300 ++++++++++++++---- 7 files changed, 375 insertions(+), 99 deletions(-) diff --git a/examples/api/test/material/context_menu/selectable_region_toolbar_builder.0_test.dart b/examples/api/test/material/context_menu/selectable_region_toolbar_builder.0_test.dart index 31b89ee89dd..a7147873fbe 100644 --- a/examples/api/test/material/context_menu/selectable_region_toolbar_builder.0_test.dart +++ b/examples/api/test/material/context_menu/selectable_region_toolbar_builder.0_test.dart @@ -24,6 +24,9 @@ void main() { // Right clicking the Text in the SelectionArea shows the custom context // menu. + final TestGesture primaryMouseButtonGesture = await tester.createGesture( + kind: PointerDeviceKind.mouse, + ); final TestGesture gesture = await tester.startGesture( tester.getCenter(find.text(example.text)), kind: PointerDeviceKind.mouse, @@ -37,7 +40,9 @@ void main() { expect(find.text('Print'), findsOneWidget); // Tap to dismiss. - await tester.tapAt(tester.getCenter(find.byType(Scaffold))); + await primaryMouseButtonGesture.down(tester.getCenter(find.byType(Scaffold))); + await tester.pump(); + await primaryMouseButtonGesture.up(); await tester.pumpAndSettle(); expect(find.byType(AdaptiveTextSelectionToolbar), findsNothing); diff --git a/packages/flutter/lib/src/rendering/paragraph.dart b/packages/flutter/lib/src/rendering/paragraph.dart index 13b17af3033..9ac2d9a499b 100644 --- a/packages/flutter/lib/src/rendering/paragraph.dart +++ b/packages/flutter/lib/src/rendering/paragraph.dart @@ -132,7 +132,7 @@ mixin RenderInlineChildrenContainerDefaults on RenderBox, ContainerRenderObjectM ui.PlaceholderAlignment.belowBaseline || ui.PlaceholderAlignment.bottom || ui.PlaceholderAlignment.middle || - ui.PlaceholderAlignment.top => null, + ui.PlaceholderAlignment.top => null, ui.PlaceholderAlignment.baseline => child.getDistanceToBaseline(span.baseline!), }, ); @@ -351,8 +351,7 @@ class RenderParagraph extends RenderBox with ContainerRenderObjectMixin results = []; for (final _SelectableFragment fragment in _lastSelectableFragments!) { if (fragment._textSelectionStart != null && - fragment._textSelectionEnd != null && - fragment._textSelectionStart!.offset != fragment._textSelectionEnd!.offset) { + fragment._textSelectionEnd != null) { results.add( TextSelection( baseOffset: fragment._textSelectionStart!.offset, @@ -1309,9 +1308,9 @@ class RenderParagraph extends RenderBox with ContainerRenderObjectMixin with TickerProviderStateMixin, R if (oldWidget.controller == null) { // The old controller was null, meaning the fallback cannot be null. // Dispose of the fallback. - assert(_fallbackScrollController != null); + assert(_fallbackScrollController != null); assert(widget.controller != null); _fallbackScrollController!.detach(position); _fallbackScrollController!.dispose(); @@ -1954,7 +1954,7 @@ class TwoDimensionalScrollableState extends State { if (oldWidget.horizontalDetails.controller == null) { // The old controller was null, meaning the fallback cannot be null. // Dispose of the fallback. - assert(_horizontalFallbackController != null); + assert(_horizontalFallbackController != null); assert(widget.horizontalDetails.controller != null); _horizontalFallbackController!.dispose(); _horizontalFallbackController = null; diff --git a/packages/flutter/lib/src/widgets/selectable_region.dart b/packages/flutter/lib/src/widgets/selectable_region.dart index 4b972c081e3..70369b8b3a0 100644 --- a/packages/flutter/lib/src/widgets/selectable_region.dart +++ b/packages/flutter/lib/src/widgets/selectable_region.dart @@ -352,7 +352,8 @@ class SelectableRegionState extends State with TextSelectionDe _showToolbar(location: details.globalPosition); } } else { - _clearSelection(); + hideToolbar(); + _collapseSelectionAt(offset: details.globalPosition); } }; instance.onSecondaryTapDown = _handleRightClickDown; @@ -472,6 +473,7 @@ class SelectableRegionState extends State with TextSelectionDe (TapAndPanGestureRecognizer instance) { instance ..onTapDown = _startNewMouseSelectionGesture + ..onTapUp = _handleMouseTapUp ..onDragStart = _handleMouseDragStart ..onDragUpdate = _handleMouseDragUpdate ..onDragEnd = _handleMouseDragEnd @@ -498,7 +500,17 @@ class SelectableRegionState extends State with TextSelectionDe case 1: widget.focusNode.requestFocus(); hideToolbar(); - _clearSelection(); + switch (defaultTargetPlatform) { + case TargetPlatform.android: + case TargetPlatform.fuchsia: + case TargetPlatform.iOS: + // On mobile platforms the selection is set on tap up. + break; + case TargetPlatform.macOS: + case TargetPlatform.linux: + case TargetPlatform.windows: + _collapseSelectionAt(offset: details.globalPosition); + } case 2: _selectWordAt(offset: details.globalPosition); } @@ -528,6 +540,24 @@ class SelectableRegionState extends State with TextSelectionDe _updateSelectedContentIfNeeded(); } + void _handleMouseTapUp(TapDragUpDetails details) { + switch (_getEffectiveConsecutiveTapCount(details.consecutiveTapCount)) { + case 1: + switch (defaultTargetPlatform) { + case TargetPlatform.android: + case TargetPlatform.fuchsia: + case TargetPlatform.iOS: + _collapseSelectionAt(offset: details.globalPosition); + case TargetPlatform.macOS: + case TargetPlatform.linux: + case TargetPlatform.windows: + // On desktop platforms the selection is set on tap down. + break; + } + } + _updateSelectedContentIfNeeded(); + } + void _updateSelectedContentIfNeeded() { if (_lastSelectedContent?.plainText != _selectable?.getSelectedContent()?.plainText) { _lastSelectedContent = _selectable?.getSelectedContent(); @@ -586,8 +616,7 @@ class SelectableRegionState extends State with TextSelectionDe // keep the current selection, if not then collapse it. final bool lastSecondaryTapDownPositionWasOnActiveSelection = _positionIsOnActiveSelection(globalPosition: details.globalPosition); if (!lastSecondaryTapDownPositionWasOnActiveSelection) { - _selectStartTo(offset: lastSecondaryTapDownPosition!); - _selectEndTo(offset: lastSecondaryTapDownPosition!); + _collapseSelectionAt(offset: lastSecondaryTapDownPosition!); } _showHandles(); _showToolbar(location: lastSecondaryTapDownPosition); @@ -612,8 +641,7 @@ class SelectableRegionState extends State with TextSelectionDe // keep the current selection, if not then collapse it. final bool lastSecondaryTapDownPositionWasOnActiveSelection = _positionIsOnActiveSelection(globalPosition: details.globalPosition); if (!lastSecondaryTapDownPositionWasOnActiveSelection) { - _selectStartTo(offset: lastSecondaryTapDownPosition!); - _selectEndTo(offset: lastSecondaryTapDownPosition!); + _collapseSelectionAt(offset: lastSecondaryTapDownPosition!); } _showHandles(); _showToolbar(location: lastSecondaryTapDownPosition); @@ -925,8 +953,9 @@ class SelectableRegionState extends State with TextSelectionDe /// See also: /// * [_selectStartTo], which sets or updates selection start edge. /// * [_finalizeSelection], which stops the `continuous` updates. - /// * [_clearSelection], which clear the ongoing selection. + /// * [_clearSelection], which clears the ongoing selection. /// * [_selectWordAt], which selects a whole word at the location. + /// * [_collapseSelectionAt], which collapses the selection at the location. /// * [selectAll], which selects the entire content. void _selectEndTo({required Offset offset, bool continuous = false, TextGranularity? textGranularity}) { if (!continuous) { @@ -964,8 +993,9 @@ class SelectableRegionState extends State with TextSelectionDe /// See also: /// * [_selectEndTo], which sets or updates selection end edge. /// * [_finalizeSelection], which stops the `continuous` updates. - /// * [_clearSelection], which clear the ongoing selection. + /// * [_clearSelection], which clears the ongoing selection. /// * [_selectWordAt], which selects a whole word at the location. + /// * [_collapseSelectionAt], which collapses the selection at the location. /// * [selectAll], which selects the entire content. void _selectStartTo({required Offset offset, bool continuous = false, TextGranularity? textGranularity}) { if (!continuous) { @@ -978,6 +1008,20 @@ class SelectableRegionState extends State with TextSelectionDe } } + /// Collapses the selection at the given `offset` location. + /// + /// See also: + /// * [_selectStartTo], which sets or updates selection start edge. + /// * [_selectEndTo], which sets or updates selection end edge. + /// * [_finalizeSelection], which stops the `continuous` updates. + /// * [_clearSelection], which clears the ongoing selection. + /// * [_selectWordAt], which selects a whole word at the location. + /// * [selectAll], which selects the entire content. + void _collapseSelectionAt({required Offset offset}) { + _selectStartTo(offset: offset); + _selectEndTo(offset: offset); + } + /// Selects a whole word at the `offset` location. /// /// If the whole word is already in the current selection, selection won't @@ -991,7 +1035,8 @@ class SelectableRegionState extends State with TextSelectionDe /// * [_selectStartTo], which sets or updates selection start edge. /// * [_selectEndTo], which sets or updates selection end edge. /// * [_finalizeSelection], which stops the `continuous` updates. - /// * [_clearSelection], which clear the ongoing selection. + /// * [_clearSelection], which clears the ongoing selection. + /// * [_collapseSelectionAt], which collapses the selection at the location. /// * [selectAll], which selects the entire content. void _selectWordAt({required Offset offset}) { // There may be other selection ongoing. @@ -1881,7 +1926,7 @@ abstract class MultiSelectableSelectionContainerDelegate extends SelectionContai SelectionPoint? startPoint; if (startGeometry.startSelectionPoint != null) { - final Matrix4 startTransform = getTransformFrom(selectables[startIndexWalker]); + final Matrix4 startTransform = getTransformFrom(selectables[startIndexWalker]); final Offset start = MatrixUtils.transformPoint(startTransform, startGeometry.startSelectionPoint!.localPosition); // It can be NaN if it is detached or off-screen. if (start.isFinite) { @@ -1902,7 +1947,7 @@ abstract class MultiSelectableSelectionContainerDelegate extends SelectionContai } SelectionPoint? endPoint; if (endGeometry.endSelectionPoint != null) { - final Matrix4 endTransform = getTransformFrom(selectables[endIndexWalker]); + final Matrix4 endTransform = getTransformFrom(selectables[endIndexWalker]); final Offset end = MatrixUtils.transformPoint(endTransform, endGeometry.endSelectionPoint!.localPosition); // It can be NaN if it is detached or off-screen. if (end.isFinite) { @@ -1986,8 +2031,8 @@ abstract class MultiSelectableSelectionContainerDelegate extends SelectionContai final Rect? drawableArea = hasSize ? Rect .fromLTWH(0, 0, containerSize.width, containerSize.height) .inflate(_kSelectionHandleDrawableAreaPadding) : null; - final bool hideStartHandle = value.startSelectionPoint == null || drawableArea == null || !drawableArea.contains(value.startSelectionPoint!.localPosition); - final bool hideEndHandle = value.endSelectionPoint == null || drawableArea == null|| !drawableArea.contains(value.endSelectionPoint!.localPosition); + final bool hideStartHandle = value.startSelectionPoint == null || drawableArea == null || !drawableArea.contains(value.startSelectionPoint!.localPosition); + final bool hideEndHandle = value.endSelectionPoint == null || drawableArea == null|| !drawableArea.contains(value.endSelectionPoint!.localPosition); effectiveStartHandle = hideStartHandle ? null : _startHandleLayer; effectiveEndHandle = hideEndHandle ? null : _endHandleLayer; } @@ -2047,6 +2092,34 @@ abstract class MultiSelectableSelectionContainerDelegate extends SelectionContai ); } + // Clears the selection on all selectables not in the range of + // currentSelectionStartIndex..currentSelectionEndIndex. + // + // If one of the edges does not exist, then this method will clear the selection + // in all selectables except the existing edge. + // + // If neither of the edges exist this method immediately returns. + void _flushInactiveSelections() { + if (currentSelectionStartIndex == -1 && currentSelectionEndIndex == -1) { + return; + } + if (currentSelectionStartIndex == -1 || currentSelectionEndIndex == -1) { + final int skipIndex = currentSelectionStartIndex == -1 ? currentSelectionEndIndex : currentSelectionStartIndex; + selectables + .where((Selectable target) => target != selectables[skipIndex]) + .forEach((Selectable target) => dispatchSelectionEventToChild(target, const ClearSelectionEvent())); + return; + } + final int skipStart = min(currentSelectionStartIndex, currentSelectionEndIndex); + final int skipEnd = max(currentSelectionStartIndex, currentSelectionEndIndex); + for (int index = 0; index < selectables.length; index += 1) { + if (index >= skipStart && index <= skipEnd) { + continue; + } + dispatchSelectionEventToChild(selectables[index], const ClearSelectionEvent()); + } + } + /// Selects all contents of all selectables. @protected SelectionResult handleSelectAll(SelectAllSelectionEvent event) { @@ -2290,7 +2363,7 @@ abstract class MultiSelectableSelectionContainerDelegate extends SelectionContai bool hasFoundEdgeIndex = false; SelectionResult? result; for (int index = 0; index < selectables.length && !hasFoundEdgeIndex; index += 1) { - final Selectable child = selectables[index]; + final Selectable child = selectables[index]; final SelectionResult childResult = dispatchSelectionEventToChild(child, event); switch (childResult) { case SelectionResult.next: @@ -2323,6 +2396,7 @@ abstract class MultiSelectableSelectionContainerDelegate extends SelectionContai } else { currentSelectionStartIndex = newIndex; } + _flushInactiveSelections(); // The result can only be null if the loop went through the entire list // without any of the selection returned end or previous. In this case, the // caller of this method needs to find the next selectable in their list. @@ -2345,13 +2419,39 @@ abstract class MultiSelectableSelectionContainerDelegate extends SelectionContai return true; }()); SelectionResult? finalResult; - int newIndex = isEnd ? currentSelectionEndIndex : currentSelectionStartIndex; + // Determines if the edge being adjusted is within the current viewport. + // - If so, we begin the search for the new selection edge position at the + // currentSelectionEndIndex/currentSelectionStartIndex. + // - If not, we attempt to locate the new selection edge starting from + // the opposite end. + // - If neither edge is in the current viewport, the search for the new + // selection edge position begins at 0. + // + // This can happen when there is a scrollable child and the edge being adjusted + // has been scrolled out of view. + final bool isCurrentEdgeWithinViewport = isEnd ? _selectionGeometry.endSelectionPoint != null : _selectionGeometry.startSelectionPoint != null; + final bool isOppositeEdgeWithinViewport = isEnd ? _selectionGeometry.startSelectionPoint != null : _selectionGeometry.endSelectionPoint != null; + int newIndex = switch ((isEnd, isCurrentEdgeWithinViewport, isOppositeEdgeWithinViewport)) { + (true, true, true) => currentSelectionEndIndex, + (true, true, false) => currentSelectionEndIndex, + (true, false, true) => currentSelectionStartIndex, + (true, false, false) => 0, + (false, true, true) => currentSelectionStartIndex, + (false, true, false) => currentSelectionStartIndex, + (false, false, true) => currentSelectionEndIndex, + (false, false, false) => 0, + }; bool? forward; late SelectionResult currentSelectableResult; - // This loop sends the selection event to the - // currentSelectionEndIndex/currentSelectionStartIndex to determine the - // direction of the search. If the result is `SelectionResult.next`, this - // loop look backward. Otherwise, it looks forward. + // This loop sends the selection event to one of the following to determine + // the direction of the search. + // - currentSelectionEndIndex/currentSelectionStartIndex if the current edge + // is in the current viewport. + // - The opposite edge index if the current edge is not in the current viewport. + // - Index 0 if neither edge is in the current viewport. + // + // If the result is `SelectionResult.next`, this loop look backward. + // Otherwise, it looks forward. // // The terminate condition are: // 1. the selectable returns end, pending, none. @@ -2391,6 +2491,7 @@ abstract class MultiSelectableSelectionContainerDelegate extends SelectionContai } else { currentSelectionStartIndex = newIndex; } + _flushInactiveSelections(); return finalResult!; } } diff --git a/packages/flutter/test/material/selection_area_test.dart b/packages/flutter/test/material/selection_area_test.dart index 8edb4b68d4b..521a3ff66ec 100644 --- a/packages/flutter/test/material/selection_area_test.dart +++ b/packages/flutter/test/material/selection_area_test.dart @@ -224,8 +224,14 @@ void main() { // Backwards selection. await gesture.down(textOffsetToPosition(paragraph, 3)); - await tester.pumpAndSettle(); - expect(content, isNull); + await tester.pump(); + await gesture.up(); + await tester.pumpAndSettle(kDoubleTapTimeout); + expect(content, isNotNull); + expect(content!.plainText, ''); + + await gesture.down(textOffsetToPosition(paragraph, 3)); + await tester.pump(); await gesture.moveTo(textOffsetToPosition(paragraph, 0)); await gesture.up(); await tester.pump(); diff --git a/packages/flutter/test/rendering/paragraph_test.dart b/packages/flutter/test/rendering/paragraph_test.dart index a85d83ae394..06aa6a151a3 100644 --- a/packages/flutter/test/rendering/paragraph_test.dart +++ b/packages/flutter/test/rendering/paragraph_test.dart @@ -978,7 +978,8 @@ void main() { granularity: TextGranularity.word, ), ); - expect(paragraph.selections.length, 0); // how []are you + expect(paragraph.selections.length, 1); // how []are you + expect(paragraph.selections[0], const TextSelection.collapsed(offset: 4)); // Equivalent to sending shift + alt + arrow-left. registrar.selectables[0].dispatchSelectionEvent( diff --git a/packages/flutter/test/widgets/selectable_region_test.dart b/packages/flutter/test/widgets/selectable_region_test.dart index 717ba3d5f18..4be55348f5d 100644 --- a/packages/flutter/test/widgets/selectable_region_test.dart +++ b/packages/flutter/test/widgets/selectable_region_test.dart @@ -281,7 +281,7 @@ void main() { semantics.dispose(); }); - testWidgetsWithLeakTracking('mouse selection always cancels previous selection', (WidgetTester tester) async { + testWidgets('mouse single-click selection collapses the selection', (WidgetTester tester) async { final UniqueKey spy = UniqueKey(); final FocusNode focusNode = FocusNode(); addTearDown(focusNode.dispose); @@ -300,9 +300,14 @@ void main() { final RenderSelectionSpy renderSelectionSpy = tester.renderObject(find.byKey(spy)); final TestGesture gesture = await tester.startGesture(const Offset(200.0, 200.0), kind: PointerDeviceKind.mouse); addTearDown(gesture.removePointer); + await tester.pump(); + await gesture.up(); await tester.pumpAndSettle(); - expect(renderSelectionSpy.events.length, 1); - expect(renderSelectionSpy.events[0], isA()); + expect(renderSelectionSpy.events.length, 2); + expect(renderSelectionSpy.events[0], isA()); + expect((renderSelectionSpy.events[0] as SelectionEdgeUpdateEvent).type, SelectionEventType.startEdgeUpdate); + expect(renderSelectionSpy.events[1], isA()); + expect((renderSelectionSpy.events[1] as SelectionEdgeUpdateEvent).type, SelectionEventType.endEdgeUpdate); }, skip: kIsWeb); // https://github.com/flutter/flutter/issues/102410. testWidgetsWithLeakTracking('touch long press sends select-word event', (WidgetTester tester) async { @@ -474,7 +479,7 @@ void main() { await tester.pump(const Duration(milliseconds: 500)); await gesture.up(); expect( - renderSelectionSpy.events.every((SelectionEvent element) => element is ClearSelectionEvent), + renderSelectionSpy.events.every((SelectionEvent element) => element is SelectionEdgeUpdateEvent), isTrue, ); }); @@ -543,7 +548,7 @@ void main() { }, variant: TargetPlatformVariant.all()); group('SelectionArea integration', () { - testWidgetsWithLeakTracking('mouse can select single text', (WidgetTester tester) async { + testWidgets('mouse can select single text on desktop platforms', (WidgetTester tester) async { final FocusNode focusNode = FocusNode(); addTearDown(focusNode.dispose); @@ -574,13 +579,17 @@ void main() { // Check backward selection. await gesture.moveTo(textOffsetToPosition(paragraph, 1)); await tester.pump(); + expect(paragraph.selections.isEmpty, isFalse); expect(paragraph.selections[0], const TextSelection(baseOffset: 2, extentOffset: 1)); // Start a new drag. await gesture.up(); + await tester.pumpAndSettle(); + await gesture.down(textOffsetToPosition(paragraph, 5)); await tester.pumpAndSettle(); - expect(paragraph.selections.isEmpty, isTrue); + expect(paragraph.selections.isEmpty, isFalse); + expect(paragraph.selections[0], const TextSelection.collapsed(offset: 5)); // Selecting across line should select to the end. await gesture.moveTo(textOffsetToPosition(paragraph, 5) + const Offset(0.0, 200.0)); @@ -588,7 +597,60 @@ void main() { expect(paragraph.selections[0], const TextSelection(baseOffset: 5, extentOffset: 11)); await gesture.up(); - }); + }, variant: TargetPlatformVariant.desktop()); + + testWidgetsWithLeakTracking('mouse can select single text on mobile platforms', (WidgetTester tester) async { + final FocusNode focusNode = FocusNode(); + addTearDown(focusNode.dispose); + + await tester.pumpWidget( + MaterialApp( + home: SelectableRegion( + focusNode: focusNode, + selectionControls: materialTextSelectionControls, + child: const Center( + child: Text('How are you'), + ), + ), + ), + ); + final RenderParagraph paragraph = tester.renderObject(find.descendant(of: find.text('How are you'), matching: find.byType(RichText))); + final TestGesture gesture = await tester.startGesture(textOffsetToPosition(paragraph, 2), kind: PointerDeviceKind.mouse); + addTearDown(gesture.removePointer); + await tester.pump(); + + await gesture.moveTo(textOffsetToPosition(paragraph, 4)); + await tester.pump(); + expect(paragraph.selections[0], const TextSelection(baseOffset: 2, extentOffset: 4)); + + await gesture.moveTo(textOffsetToPosition(paragraph, 6)); + await tester.pump(); + expect(paragraph.selections[0], const TextSelection(baseOffset: 2, extentOffset: 6)); + + // Check backward selection. + await gesture.moveTo(textOffsetToPosition(paragraph, 1)); + await tester.pump(); + expect(paragraph.selections.isEmpty, isFalse); + expect(paragraph.selections[0], const TextSelection(baseOffset: 2, extentOffset: 1)); + + // Start a new drag. + await gesture.up(); + await tester.pumpAndSettle(); + + await gesture.down(textOffsetToPosition(paragraph, 5)); + await tester.pumpAndSettle(); + await gesture.moveTo(textOffsetToPosition(paragraph, 6)); + await tester.pump(); + expect(paragraph.selections.isEmpty, isFalse); + expect(paragraph.selections[0], const TextSelection(baseOffset: 5, extentOffset: 6)); + + // Selecting across line should select to the end. + await gesture.moveTo(textOffsetToPosition(paragraph, 5) + const Offset(0.0, 200.0)); + await tester.pump(); + expect(paragraph.selections[0], const TextSelection(baseOffset: 5, extentOffset: 11)); + + await gesture.up(); + }, variant: TargetPlatformVariant.mobile()); testWidgetsWithLeakTracking('mouse can select word-by-word on double click drag', (WidgetTester tester) async { final FocusNode focusNode = FocusNode(); @@ -643,7 +705,8 @@ void main() { await gesture.down(textOffsetToPosition(paragraph, 5)); await tester.pump(); await gesture.up(); - expect(paragraph.selections.isEmpty, isTrue); + expect(paragraph.selections.isEmpty, isFalse); + expect(paragraph.selections[0], const TextSelection.collapsed(offset: 5)); await tester.pump(kDoubleTapTimeout); // Double-click. @@ -761,13 +824,13 @@ void main() { // Should clear the selection on paragraph 3. expect(paragraph1.selections[0], const TextSelection(baseOffset: 0, extentOffset: 12)); expect(paragraph2.selections[0], const TextSelection(baseOffset: 0, extentOffset: 6)); - expect(paragraph3.selections.isEmpty, true); + expect(paragraph3.selections.isEmpty, isTrue); await gesture.moveTo(textOffsetToPosition(paragraph1, 4)); // Should clear the selection on paragraph 2. expect(paragraph1.selections[0], const TextSelection(baseOffset: 0, extentOffset: 7)); - expect(paragraph2.selections.isEmpty, true); - expect(paragraph3.selections.isEmpty, true); + expect(paragraph2.selections.isEmpty, isTrue); + expect(paragraph3.selections.isEmpty, isTrue); await gesture.up(); }, skip: kIsWeb); // https://github.com/flutter/flutter/issues/125582. @@ -863,6 +926,52 @@ void main() { await gesture.up(); }); + testWidgetsWithLeakTracking('collapsing selection should clear selection of all other selectables', (WidgetTester tester) async { + final FocusNode focusNode = FocusNode(); + addTearDown(focusNode.dispose); + + await tester.pumpWidget( + MaterialApp( + home: SelectableRegion( + focusNode: focusNode, + selectionControls: materialTextSelectionControls, + child: const Column( + children: [ + Text('How are you?'), + Text('Good, and you?'), + Text('Fine, thank you.'), + ], + ), + ), + ), + ); + final RenderParagraph paragraph1 = tester.renderObject(find.descendant(of: find.text('How are you?'), matching: find.byType(RichText))); + final TestGesture gesture = await tester.startGesture(textOffsetToPosition(paragraph1, 2), kind: PointerDeviceKind.mouse); + addTearDown(gesture.removePointer); + await tester.pump(); + await gesture.up(); + await tester.pumpAndSettle(); + expect(paragraph1.selections[0], const TextSelection.collapsed(offset: 2)); + + final RenderParagraph paragraph2 = tester.renderObject(find.descendant(of: find.text('Good, and you?'), matching: find.byType(RichText))); + await gesture.down(textOffsetToPosition(paragraph2, 5)); + await tester.pump(); + await gesture.up(); + await tester.pumpAndSettle(); + expect(paragraph1.selections.isEmpty, isTrue); + expect(paragraph2.selections[0], const TextSelection.collapsed(offset: 5)); + + final RenderParagraph paragraph3 = tester.renderObject(find.descendant(of: find.text('Fine, thank you.'), matching: find.byType(RichText))); + await gesture.down(textOffsetToPosition(paragraph3, 13)); + await tester.pump(); + await gesture.up(); + await tester.pumpAndSettle(); + + expect(paragraph1.selections.isEmpty, isTrue); + expect(paragraph2.selections.isEmpty, isTrue); + expect(paragraph3.selections[0], const TextSelection.collapsed(offset: 13)); + }); + testWidgetsWithLeakTracking('mouse can work with disabled container', (WidgetTester tester) async { final FocusNode focusNode = FocusNode(); addTearDown(focusNode.dispose); @@ -1108,10 +1217,11 @@ void main() { expect(buttonTypes, contains(ContextMenuButtonType.selectAll)); expect(find.byKey(toolbarKey), findsOneWidget); - // Clear selection. + // Collapse selection. await tester.tapAt(textOffsetToPosition(paragraph, 9)); await tester.pump(); - expect(paragraph.selections.isEmpty, true); + expect(paragraph.selections.isEmpty, isFalse); + expect(paragraph.selections[0], const TextSelection.collapsed(offset: 9)); expect(find.byKey(toolbarKey), findsNothing); }, variant: TargetPlatformVariant.only(TargetPlatform.iOS), @@ -1151,7 +1261,9 @@ void main() { expect(find.byKey(toolbarKey), findsNothing); final RenderParagraph paragraph = tester.renderObject(find.descendant(of: find.text('How are you'), matching: find.byType(RichText))); + final TestGesture primaryMouseButtonGesture = await tester.createGesture(kind: PointerDeviceKind.mouse); final TestGesture gesture = await tester.startGesture(textOffsetToPosition(paragraph, 2), kind: PointerDeviceKind.mouse, buttons: kSecondaryMouseButton); + addTearDown(primaryMouseButtonGesture.removePointer); addTearDown(gesture.removePointer); await tester.pump(); expect(paragraph.selections[0], const TextSelection(baseOffset: 0, extentOffset: 3)); @@ -1185,10 +1297,14 @@ void main() { expect(buttonTypes, contains(ContextMenuButtonType.selectAll)); expect(find.byKey(toolbarKey), findsOneWidget); - // Clear selection. - await tester.tapAt(textOffsetToPosition(paragraph, 1)); + // Collapse selection. + await primaryMouseButtonGesture.down(textOffsetToPosition(paragraph, 1)); await tester.pump(); - expect(paragraph.selections.isEmpty, true); + await primaryMouseButtonGesture.up(); + await tester.pumpAndSettle(); + // Selection is collapsed. + expect(paragraph.selections.isEmpty, false); + expect(paragraph.selections[0], const TextSelection.collapsed(offset: 1)); expect(find.byKey(toolbarKey), findsNothing); }, variant: const TargetPlatformVariant({ TargetPlatform.iOS, TargetPlatform.macOS }), @@ -1229,6 +1345,8 @@ void main() { final RenderParagraph paragraph = tester.renderObject(find.descendant(of: find.text('How are you'), matching: find.byType(RichText))); final TestGesture gesture = await tester.startGesture(textOffsetToPosition(paragraph, 2), kind: PointerDeviceKind.mouse, buttons: kSecondaryMouseButton); + final TestGesture primaryMouseButtonGesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + addTearDown(primaryMouseButtonGesture.removePointer); addTearDown(gesture.removePointer); await tester.pump(); expect(paragraph.selections[0], const TextSelection(baseOffset: 0, extentOffset: 3)); @@ -1286,10 +1404,14 @@ void main() { expect(buttonTypes, contains(ContextMenuButtonType.selectAll)); expect(find.byKey(toolbarKey), findsOneWidget); - // Clear selection. - await tester.tapAt(textOffsetToPosition(paragraph, 1)); + // Collapse selection. + await primaryMouseButtonGesture.down(textOffsetToPosition(paragraph, 1)); await tester.pump(); - expect(paragraph.selections.isEmpty, true); + await primaryMouseButtonGesture.up(); + await tester.pumpAndSettle(); + // Selection is collapsed. + expect(paragraph.selections.isEmpty, false); + expect(paragraph.selections[0], const TextSelection.collapsed(offset: 1)); expect(find.byKey(toolbarKey), findsNothing); }, variant: TargetPlatformVariant.only(TargetPlatform.macOS), @@ -1330,21 +1452,24 @@ void main() { final RenderParagraph paragraph = tester.renderObject(find.descendant(of: find.text('How are you'), matching: find.byType(RichText))); final TestGesture gesture = await tester.startGesture(textOffsetToPosition(paragraph, 2), kind: PointerDeviceKind.mouse, buttons: kSecondaryMouseButton); + final TestGesture primaryMouseButtonGesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + addTearDown(primaryMouseButtonGesture.removePointer); addTearDown(gesture.removePointer); await tester.pump(); - // Selection is collapsed so none is reported. - expect(paragraph.selections.isEmpty, true); - await gesture.up(); await tester.pump(); + // Selection is collapsed. + expect(paragraph.selections.isEmpty, false); + expect(paragraph.selections[0], const TextSelection.collapsed(offset: 2)); expect(buttonTypes.length, 1); expect(buttonTypes, contains(ContextMenuButtonType.selectAll)); expect(find.byKey(toolbarKey), findsOneWidget); await gesture.down(textOffsetToPosition(paragraph, 6)); await tester.pump(); - expect(paragraph.selections.isEmpty, true); + expect(paragraph.selections.isEmpty, false); + expect(paragraph.selections[0], const TextSelection.collapsed(offset: 6)); await gesture.up(); await tester.pump(); @@ -1355,7 +1480,8 @@ void main() { await gesture.down(textOffsetToPosition(paragraph, 9)); await tester.pump(); - expect(paragraph.selections.isEmpty, true); + expect(paragraph.selections.isEmpty, false); + expect(paragraph.selections[0], const TextSelection.collapsed(offset: 9)); await gesture.up(); await tester.pump(); @@ -1364,20 +1490,23 @@ void main() { expect(buttonTypes, contains(ContextMenuButtonType.selectAll)); expect(find.byKey(toolbarKey), findsOneWidget); - // Clear selection. - await tester.tapAt(textOffsetToPosition(paragraph, 1)); + // Collapse selection. + await primaryMouseButtonGesture.down(textOffsetToPosition(paragraph, 1)); await tester.pump(); - expect(paragraph.selections.isEmpty, true); + await primaryMouseButtonGesture.up(); + await tester.pumpAndSettle(kDoubleTapTimeout); + // Selection is collapsed. + expect(paragraph.selections.isEmpty, false); + expect(paragraph.selections[0], const TextSelection.collapsed(offset: 1)); expect(find.byKey(toolbarKey), findsNothing); // Create an uncollapsed selection by dragging. - final TestGesture dragGesture = await tester.startGesture(textOffsetToPosition(paragraph, 0), kind: PointerDeviceKind.mouse); - addTearDown(dragGesture.removePointer); + await primaryMouseButtonGesture.down(textOffsetToPosition(paragraph, 0)); await tester.pump(); - await dragGesture.moveTo(textOffsetToPosition(paragraph, 5)); + await primaryMouseButtonGesture.moveTo(textOffsetToPosition(paragraph, 5)); await tester.pump(); expect(paragraph.selections[0], const TextSelection(baseOffset: 0, extentOffset: 5)); - await dragGesture.up(); + await primaryMouseButtonGesture.up(); await tester.pump(); // Right click on previous selection should not collapse the selection. @@ -1394,13 +1523,18 @@ void main() { await tester.pump(); await gesture.up(); await tester.pump(); - expect(paragraph.selections.isEmpty, true); + expect(paragraph.selections.isEmpty, false); + expect(paragraph.selections[0], const TextSelection.collapsed(offset: 7)); expect(find.byKey(toolbarKey), findsOneWidget); - // Clear selection. - await tester.tapAt(textOffsetToPosition(paragraph, 1)); + // Collapse selection. + await primaryMouseButtonGesture.down(textOffsetToPosition(paragraph, 1)); await tester.pump(); - expect(paragraph.selections.isEmpty, true); + await primaryMouseButtonGesture.up(); + await tester.pumpAndSettle(); + // Selection is collapsed. + expect(paragraph.selections.isEmpty, false); + expect(paragraph.selections[0], const TextSelection.collapsed(offset: 1)); expect(find.byKey(toolbarKey), findsNothing); }, variant: const TargetPlatformVariant({ TargetPlatform.android, TargetPlatform.fuchsia, TargetPlatform.windows }), @@ -1441,13 +1575,15 @@ void main() { final RenderParagraph paragraph = tester.renderObject(find.descendant(of: find.text('How are you'), matching: find.byType(RichText))); final TestGesture gesture = await tester.startGesture(textOffsetToPosition(paragraph, 2), kind: PointerDeviceKind.mouse, buttons: kSecondaryMouseButton); + final TestGesture primaryMouseButtonGesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + addTearDown(primaryMouseButtonGesture.removePointer); addTearDown(gesture.removePointer); await tester.pump(); - // Selection is collapsed so none is reported. - expect(paragraph.selections.isEmpty, true); - await gesture.up(); await tester.pump(); + // Selection is collapsed. + expect(paragraph.selections.isEmpty, false); + expect(paragraph.selections[0], const TextSelection.collapsed(offset: 2)); // Context menu toggled on. expect(buttonTypes.length, 1); @@ -1456,17 +1592,18 @@ void main() { await gesture.down(textOffsetToPosition(paragraph, 6)); await tester.pump(); - expect(paragraph.selections.isEmpty, true); - await gesture.up(); await tester.pump(); + expect(paragraph.selections.isEmpty, false); + expect(paragraph.selections[0], const TextSelection.collapsed(offset: 2)); - // Context menu toggled off. + // Context menu toggled off. Selection remains the same. expect(find.byKey(toolbarKey), findsNothing); await gesture.down(textOffsetToPosition(paragraph, 9)); await tester.pump(); - expect(paragraph.selections.isEmpty, true); + expect(paragraph.selections.isEmpty, false); + expect(paragraph.selections[0], const TextSelection.collapsed(offset: 9)); await gesture.up(); await tester.pump(); @@ -1476,19 +1613,22 @@ void main() { expect(buttonTypes, contains(ContextMenuButtonType.selectAll)); expect(find.byKey(toolbarKey), findsOneWidget); - // Clear selection. - await tester.tapAt(textOffsetToPosition(paragraph, 1)); + // Collapse selection. + await primaryMouseButtonGesture.down(textOffsetToPosition(paragraph, 1)); await tester.pump(); - expect(paragraph.selections.isEmpty, true); + await primaryMouseButtonGesture.up(); + await tester.pumpAndSettle(kDoubleTapTimeout); + // Selection is collapsed. + expect(paragraph.selections.isEmpty, false); + expect(paragraph.selections[0], const TextSelection.collapsed(offset: 1)); expect(find.byKey(toolbarKey), findsNothing); - final TestGesture dragGesture = await tester.startGesture(textOffsetToPosition(paragraph, 0), kind: PointerDeviceKind.mouse); - addTearDown(dragGesture.removePointer); + await primaryMouseButtonGesture.down(textOffsetToPosition(paragraph, 0)); await tester.pump(); - await dragGesture.moveTo(textOffsetToPosition(paragraph, 5)); + await primaryMouseButtonGesture.moveTo(textOffsetToPosition(paragraph, 5)); await tester.pump(); expect(paragraph.selections[0], const TextSelection(baseOffset: 0, extentOffset: 5)); - await dragGesture.up(); + await primaryMouseButtonGesture.up(); await tester.pump(); // Right click on previous selection should not collapse the selection. @@ -1514,13 +1654,18 @@ void main() { await tester.pump(); await gesture.up(); await tester.pump(); - expect(paragraph.selections.isEmpty, true); + expect(paragraph.selections.isEmpty, false); + expect(paragraph.selections[0], const TextSelection.collapsed(offset: 7)); expect(find.byKey(toolbarKey), findsOneWidget); - // Clear selection. - await tester.tapAt(textOffsetToPosition(paragraph, 1)); + // Collapse selection. + await primaryMouseButtonGesture.down(textOffsetToPosition(paragraph, 1)); await tester.pump(); - expect(paragraph.selections.isEmpty, true); + await primaryMouseButtonGesture.up(); + await tester.pumpAndSettle(); + // Selection is collapsed. + expect(paragraph.selections.isEmpty, false); + expect(paragraph.selections[0], const TextSelection.collapsed(offset: 1)); expect(find.byKey(toolbarKey), findsNothing); }, variant: TargetPlatformVariant.only(TargetPlatform.linux), @@ -2414,7 +2559,9 @@ void main() { expect(paragraph1.selections.length, 1); expect(paragraph1.selections[0].start, 2); expect(paragraph1.selections[0].end, 12); - expect(paragraph2.selections.length, 0); + expect(paragraph2.selections.length, 1); + expect(paragraph2.selections[0].start, 0); + expect(paragraph2.selections[0].end, 0); await sendKeyCombination(tester, SingleActivator(LogicalKeyboardKey.arrowLeft, shift: true, alt: alt, control: control)); await tester.pump(); @@ -2424,7 +2571,9 @@ void main() { expect(paragraph1.selections.length, 1); expect(paragraph1.selections[0].start, 2); expect(paragraph1.selections[0].end, 8); - expect(paragraph2.selections.length, 0); + expect(paragraph2.selections.length, 1); + expect(paragraph2.selections[0].start, 0); + expect(paragraph2.selections[0].end, 0); }, variant: TargetPlatformVariant.all()); testWidgetsWithLeakTracking('can use keyboard to granularly extend selection - line', (WidgetTester tester) async { @@ -2507,7 +2656,9 @@ void main() { expect(paragraph1.selections.length, 1); expect(paragraph1.selections[0].start, 2); expect(paragraph1.selections[0].end, 12); - expect(paragraph2.selections.length, 0); + expect(paragraph2.selections.length, 1); + expect(paragraph2.selections[0].start, 0); + expect(paragraph2.selections[0].end, 0); await sendKeyCombination(tester, SingleActivator(LogicalKeyboardKey.arrowLeft, shift: true, alt: alt, meta: meta)); await tester.pump(); @@ -2594,8 +2745,12 @@ void main() { expect(paragraph1.selections.length, 1); expect(paragraph1.selections[0].start, 0); expect(paragraph1.selections[0].end, 2); - expect(paragraph2.selections.length, 0); - expect(paragraph3.selections.length, 0); + expect(paragraph2.selections.length, 1); + expect(paragraph2.selections[0].start, 0); + expect(paragraph2.selections[0].end, 0); + expect(paragraph3.selections.length, 1); + expect(paragraph3.selections[0].start, 0); + expect(paragraph3.selections[0].end, 0); }, variant: TargetPlatformVariant.all()); testWidgetsWithLeakTracking('can use keyboard to directionally extend selection', (WidgetTester tester) async { @@ -2666,7 +2821,9 @@ void main() { expect(paragraph2.selections.length, 1); expect(paragraph2.selections[0].start, 2); expect(paragraph2.selections[0].end, 6); - expect(paragraph3.selections.length, 0); + expect(paragraph3.selections.length, 1); + expect(paragraph3.selections[0].start, 0); + expect(paragraph3.selections[0].end, 0); await sendKeyCombination(tester, const SingleActivator(LogicalKeyboardKey.arrowUp, shift: true)); await tester.pump(); @@ -3017,8 +3174,14 @@ void main() { // Backwards selection. await mouseGesture.down(textOffsetToPosition(paragraph, 3)); - await tester.pumpAndSettle(); - expect(content, isNull); + await tester.pump(); + await mouseGesture.up(); + await tester.pumpAndSettle(kDoubleTapTimeout); + expect(content, isNotNull); + expect(content!.plainText, ''); + + await mouseGesture.down(textOffsetToPosition(paragraph, 3)); + await tester.pump(); await mouseGesture.moveTo(textOffsetToPosition(paragraph, 0)); await tester.pumpAndSettle(); @@ -3045,9 +3208,10 @@ void main() { // Called on tap. await mouseGesture.down(textOffsetToPosition(paragraph, 0)); await tester.pumpAndSettle(); - expect(content, isNull); await mouseGesture.up(); - await tester.pumpAndSettle(); + await tester.pumpAndSettle(kDoubleTapTimeout); + expect(content, isNotNull); + expect(content!.plainText, ''); // With touch gestures. @@ -3224,7 +3388,7 @@ void main() { expect(paragraph2.selections.length, 1); expect(paragraph2.selections[0].start, 0); expect(paragraph2.selections[0].end, 8); - expect(paragraph3.selections.length, 0); + expect(paragraph3.selections.length, 1); expect(content, isNotNull); expect(content!.plainText, 'w are you?Good, an'); @@ -3233,8 +3397,8 @@ void main() { expect(paragraph1.selections.length, 1); expect(paragraph1.selections[0].start, 2); expect(paragraph1.selections[0].end, 7); - expect(paragraph2.selections.length, 0); - expect(paragraph3.selections.length, 0); + expect(paragraph2.selections.length, 1); + expect(paragraph3.selections.length, 1); expect(content, isNotNull); expect(content!.plainText, 'w are'); @@ -3243,8 +3407,8 @@ void main() { expect(paragraph1.selections.length, 1); expect(paragraph1.selections[0].start, 0); expect(paragraph1.selections[0].end, 2); - expect(paragraph2.selections.length, 0); - expect(paragraph3.selections.length, 0); + expect(paragraph2.selections.length, 1); + expect(paragraph3.selections.length, 1); expect(content, isNotNull); expect(content!.plainText, 'Ho'); });