diff --git a/examples/api/lib/material/selectable_region/selectable_region.0.dart b/examples/api/lib/material/selectable_region/selectable_region.0.dart index 276cfd23a1..da20ccf695 100644 --- a/examples/api/lib/material/selectable_region/selectable_region.0.dart +++ b/examples/api/lib/material/selectable_region/selectable_region.0.dart @@ -157,6 +157,7 @@ class _RenderSelectableAdapter extends RenderProxyBox with Selectable, Selection hasContent: true, startSelectionPoint: isReversed ? secondSelectionPoint : firstSelectionPoint, endSelectionPoint: isReversed ? firstSelectionPoint : secondSelectionPoint, + selectionRects: [selectionRect], ); } } diff --git a/packages/flutter/lib/src/rendering/paragraph.dart b/packages/flutter/lib/src/rendering/paragraph.dart index e9b0402e3b..4500a05017 100644 --- a/packages/flutter/lib/src/rendering/paragraph.dart +++ b/packages/flutter/lib/src/rendering/paragraph.dart @@ -1335,6 +1335,14 @@ class _SelectableFragment with Selectable, ChangeNotifier implements TextLayoutM : paragraph._getOffsetForPosition(TextPosition(offset: selectionEnd)); final bool flipHandles = isReversed != (TextDirection.rtl == paragraph.textDirection); final Matrix4 paragraphToFragmentTransform = getTransformToParagraph()..invert(); + final TextSelection selection = TextSelection( + baseOffset: selectionStart, + extentOffset: selectionEnd, + ); + final List selectionRects = []; + for (final TextBox textBox in paragraph.getBoxesForSelection(selection)) { + selectionRects.add(textBox.toRect()); + } return SelectionGeometry( startSelectionPoint: SelectionPoint( localPosition: MatrixUtils.transformPoint(paragraphToFragmentTransform, startOffsetInParagraphCoordinates), @@ -1346,6 +1354,7 @@ class _SelectableFragment with Selectable, ChangeNotifier implements TextLayoutM lineHeight: paragraph._textPainter.preferredLineHeight, handleType: flipHandles ? TextSelectionHandleType.left : TextSelectionHandleType.right, ), + selectionRects: selectionRects, status: _textSelectionStart!.offset == _textSelectionEnd!.offset ? SelectionStatus.collapsed : SelectionStatus.uncollapsed, diff --git a/packages/flutter/lib/src/rendering/selection.dart b/packages/flutter/lib/src/rendering/selection.dart index 38a47d7ce3..beaf5a0270 100644 --- a/packages/flutter/lib/src/rendering/selection.dart +++ b/packages/flutter/lib/src/rendering/selection.dart @@ -576,8 +576,8 @@ enum SelectionStatus { /// The geometry of the current selection. /// /// This includes details such as the locations of the selection start and end, -/// line height, etc. This information is used for drawing selection controls -/// for mobile platforms. +/// line height, the rects that encompass the selection, etc. This information +/// is used for drawing selection controls for mobile platforms. /// /// The positions in geometry are in local coordinates of the [SelectionHandler] /// or [Selectable]. @@ -590,6 +590,7 @@ class SelectionGeometry { const SelectionGeometry({ this.startSelectionPoint, this.endSelectionPoint, + this.selectionRects = const [], required this.status, required this.hasContent, }) : assert((startSelectionPoint == null && endSelectionPoint == null) || status != SelectionStatus.none); @@ -627,6 +628,10 @@ class SelectionGeometry { /// The status of ongoing selection in the [Selectable] or [SelectionHandler]. final SelectionStatus status; + /// The rects in the local coordinates of the containing [Selectable] that + /// represent the selection if there is any. + final List selectionRects; + /// Whether there is any selectable content in the [Selectable] or /// [SelectionHandler]. final bool hasContent; @@ -638,12 +643,14 @@ class SelectionGeometry { SelectionGeometry copyWith({ SelectionPoint? startSelectionPoint, SelectionPoint? endSelectionPoint, + List? selectionRects, SelectionStatus? status, bool? hasContent, }) { return SelectionGeometry( startSelectionPoint: startSelectionPoint ?? this.startSelectionPoint, endSelectionPoint: endSelectionPoint ?? this.endSelectionPoint, + selectionRects: selectionRects ?? this.selectionRects, status: status ?? this.status, hasContent: hasContent ?? this.hasContent, ); @@ -660,6 +667,7 @@ class SelectionGeometry { return other is SelectionGeometry && other.startSelectionPoint == startSelectionPoint && other.endSelectionPoint == endSelectionPoint + && other.selectionRects == selectionRects && other.status == status && other.hasContent == hasContent; } @@ -669,6 +677,7 @@ class SelectionGeometry { return Object.hash( startSelectionPoint, endSelectionPoint, + selectionRects, status, hasContent, ); diff --git a/packages/flutter/lib/src/widgets/selectable_region.dart b/packages/flutter/lib/src/widgets/selectable_region.dart index 290f5261d7..3f49cfbd46 100644 --- a/packages/flutter/lib/src/widgets/selectable_region.dart +++ b/packages/flutter/lib/src/widgets/selectable_region.dart @@ -263,7 +263,7 @@ class SelectableRegion extends StatefulWidget { required final VoidCallback onCopy, required final VoidCallback onSelectAll, }) { - final bool canCopy = selectionGeometry.hasSelection; + final bool canCopy = selectionGeometry.status == SelectionStatus.uncollapsed; final bool canSelectAll = selectionGeometry.hasContent; // Determine which buttons will appear so that the order and total number is @@ -489,12 +489,62 @@ class SelectableRegionState extends State with TextSelectionDe _updateSelectedContentIfNeeded(); } + bool _positionIsOnActiveSelection({required Offset globalPosition}) { + for (final Rect selectionRect in _selectionDelegate.value.selectionRects) { + final Matrix4 transform = _selectable!.getTransformTo(null); + final Rect globalRect = MatrixUtils.transformRect(transform, selectionRect); + if (globalRect.contains(globalPosition)) { + return true; + } + } + return false; + } + void _handleRightClickDown(TapDownDetails details) { + final Offset? previousSecondaryTapDownPosition = lastSecondaryTapDownPosition; + final bool toolbarIsVisible = _selectionOverlay?.toolbarIsVisible ?? false; lastSecondaryTapDownPosition = details.globalPosition; widget.focusNode.requestFocus(); - _selectWordAt(offset: details.globalPosition); - _showHandles(); - _showToolbar(location: details.globalPosition); + switch (defaultTargetPlatform) { + case TargetPlatform.android: + case TargetPlatform.fuchsia: + case TargetPlatform.windows: + // If lastSecondaryTapDownPosition is within the current selection then + // keep the current selection, if not then collapse it. + final bool lastSecondaryTapDownPositionWasOnActiveSelection = _positionIsOnActiveSelection(globalPosition: details.globalPosition); + if (!lastSecondaryTapDownPositionWasOnActiveSelection) { + _selectStartTo(offset: lastSecondaryTapDownPosition!); + _selectEndTo(offset: lastSecondaryTapDownPosition!); + } + _showHandles(); + _showToolbar(location: lastSecondaryTapDownPosition); + case TargetPlatform.iOS: + _selectWordAt(offset: lastSecondaryTapDownPosition!); + _showHandles(); + _showToolbar(location: lastSecondaryTapDownPosition); + case TargetPlatform.macOS: + if (previousSecondaryTapDownPosition == lastSecondaryTapDownPosition && toolbarIsVisible) { + hideToolbar(); + return; + } + _selectWordAt(offset: lastSecondaryTapDownPosition!); + _showHandles(); + _showToolbar(location: lastSecondaryTapDownPosition); + case TargetPlatform.linux: + if (toolbarIsVisible) { + hideToolbar(); + return; + } + // If lastSecondaryTapDownPosition is within the current selection then + // keep the current selection, if not then collapse it. + final bool lastSecondaryTapDownPositionWasOnActiveSelection = _positionIsOnActiveSelection(globalPosition: details.globalPosition); + if (!lastSecondaryTapDownPositionWasOnActiveSelection) { + _selectStartTo(offset: lastSecondaryTapDownPosition!); + _selectEndTo(offset: lastSecondaryTapDownPosition!); + } + _showHandles(); + _showToolbar(location: lastSecondaryTapDownPosition); + } _updateSelectedContentIfNeeded(); } @@ -1770,9 +1820,30 @@ abstract class MultiSelectableSelectionContainerDelegate extends SelectionContai } } + // Need to collect selection rects from selectables ranging from the + // currentSelectionStartIndex to the currentSelectionEndIndex. + final List selectionRects = []; + final Rect? drawableArea = hasSize ? Rect + .fromLTWH(0, 0, containerSize.width, containerSize.height) : null; + for (int index = currentSelectionStartIndex; index <= currentSelectionEndIndex; index++) { + final List currSelectableSelectionRects = selectables[index].value.selectionRects; + final List selectionRectsWithinDrawableArea = currSelectableSelectionRects.map((Rect selectionRect) { + final Matrix4 transform = getTransformFrom(selectables[index]); + final Rect localRect = MatrixUtils.transformRect(transform, selectionRect); + if (drawableArea != null) { + return drawableArea.intersect(localRect); + } + return localRect; + }).where((Rect selectionRect) { + return selectionRect.isFinite && !selectionRect.isEmpty; + }).toList(); + selectionRects.addAll(selectionRectsWithinDrawableArea); + } + return SelectionGeometry( startSelectionPoint: startPoint, endSelectionPoint: endPoint, + selectionRects: selectionRects, status: startGeometry != endGeometry ? SelectionStatus.uncollapsed : startGeometry.status, diff --git a/packages/flutter/lib/src/widgets/text_selection.dart b/packages/flutter/lib/src/widgets/text_selection.dart index 6c58a6da8c..ff5a717310 100644 --- a/packages/flutter/lib/src/widgets/text_selection.dart +++ b/packages/flutter/lib/src/widgets/text_selection.dart @@ -562,15 +562,13 @@ class TextSelectionOverlay { /// Whether the handles are currently visible. bool get handlesAreVisible => _selectionOverlay._handles != null && handlesVisible; - /// Whether the toolbar is currently visible. - /// - /// Includes both the text selection toolbar and the spell check menu. + /// {@macro flutter.widgets.SelectionOverlay.toolbarIsVisible} /// /// See also: /// /// * [spellCheckToolbarIsVisible], which is only whether the spell check menu /// specifically is visible. - bool get toolbarIsVisible => _selectionOverlay._toolbarIsVisible; + bool get toolbarIsVisible => _selectionOverlay.toolbarIsVisible; /// Whether the magnifier is currently visible. bool get magnifierIsVisible => _selectionOverlay._magnifierController.shown; @@ -984,7 +982,12 @@ class SelectionOverlay { /// {@macro flutter.widgets.magnifier.TextMagnifierConfiguration.details} final TextMagnifierConfiguration magnifierConfiguration; - bool get _toolbarIsVisible { + /// {@template flutter.widgets.SelectionOverlay.toolbarIsVisible} + /// Whether the toolbar is currently visible. + /// + /// Includes both the text selection toolbar and the spell check menu. + /// {@endtemplate} + bool get toolbarIsVisible { return selectionControls is TextSelectionHandleControls ? _contextMenuController.isShown || _spellCheckToolbarController.isShown : _toolbar != null || _spellCheckToolbarController.isShown; @@ -1001,7 +1004,7 @@ class SelectionOverlay { /// [MagnifierController.shown]. /// {@endtemplate} void showMagnifier(MagnifierInfo initialMagnifierInfo) { - if (_toolbarIsVisible) { + if (toolbarIsVisible) { hideToolbar(); } diff --git a/packages/flutter/test/widgets/selectable_region_test.dart b/packages/flutter/test/widgets/selectable_region_test.dart index e9297b5d50..af9dfdb354 100644 --- a/packages/flutter/test/widgets/selectable_region_test.dart +++ b/packages/flutter/test/widgets/selectable_region_test.dart @@ -560,6 +560,403 @@ void main() { await gesture.up(); }); + testWidgets( + 'right-click mouse can select word at position on Apple platforms', + (WidgetTester tester) async { + Set buttonTypes = {}; + final UniqueKey toolbarKey = UniqueKey(); + await tester.pumpWidget( + MaterialApp( + home: SelectableRegion( + focusNode: FocusNode(), + selectionControls: materialTextSelectionHandleControls, + contextMenuBuilder: ( + BuildContext context, + SelectableRegionState selectableRegionState, + ) { + buttonTypes = selectableRegionState.contextMenuButtonItems + .map((ContextMenuButtonItem buttonItem) => buttonItem.type) + .toSet(); + return SizedBox.shrink(key: toolbarKey); + }, + child: const Center( + child: Text('How are you'), + ), + ), + ), + ); + + expect(buttonTypes.isEmpty, true); + expect(find.byKey(toolbarKey), findsNothing); + + 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); + addTearDown(gesture.removePointer); + await tester.pump(); + expect(paragraph.selections[0], const TextSelection(baseOffset: 0, extentOffset: 3)); + + await gesture.up(); + await tester.pump(); + + expect(buttonTypes, contains(ContextMenuButtonType.copy)); + expect(buttonTypes, contains(ContextMenuButtonType.selectAll)); + expect(find.byKey(toolbarKey), findsOneWidget); + + await gesture.down(textOffsetToPosition(paragraph, 6)); + await tester.pump(); + expect(paragraph.selections[0], const TextSelection(baseOffset: 4, extentOffset: 7)); + + await gesture.up(); + await tester.pump(); + + expect(buttonTypes, contains(ContextMenuButtonType.copy)); + expect(buttonTypes, contains(ContextMenuButtonType.selectAll)); + expect(find.byKey(toolbarKey), findsOneWidget); + + await gesture.down(textOffsetToPosition(paragraph, 9)); + await tester.pump(); + expect(paragraph.selections[0], const TextSelection(baseOffset: 8, extentOffset: 11)); + + await gesture.up(); + await tester.pump(); + + expect(buttonTypes, contains(ContextMenuButtonType.copy)); + expect(buttonTypes, contains(ContextMenuButtonType.selectAll)); + expect(find.byKey(toolbarKey), findsOneWidget); + + // Clear selection. + await tester.tapAt(textOffsetToPosition(paragraph, 1)); + await tester.pump(); + expect(paragraph.selections.isEmpty, true); + expect(find.byKey(toolbarKey), findsNothing); + }, + variant: const TargetPlatformVariant({ TargetPlatform.iOS, TargetPlatform.macOS }), + skip: kIsWeb, // [intended] Web uses its native context menu. + ); + + testWidgets( + 'right-click mouse at the same position as previous right-click toggles the context menu on macOS', + (WidgetTester tester) async { + Set buttonTypes = {}; + final UniqueKey toolbarKey = UniqueKey(); + await tester.pumpWidget( + MaterialApp( + home: SelectableRegion( + focusNode: FocusNode(), + selectionControls: materialTextSelectionHandleControls, + contextMenuBuilder: ( + BuildContext context, + SelectableRegionState selectableRegionState, + ) { + buttonTypes = selectableRegionState.contextMenuButtonItems + .map((ContextMenuButtonItem buttonItem) => buttonItem.type) + .toSet(); + return SizedBox.shrink(key: toolbarKey); + }, + child: const Center( + child: Text('How are you'), + ), + ), + ), + ); + + expect(buttonTypes.isEmpty, true); + expect(find.byKey(toolbarKey), findsNothing); + + 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); + addTearDown(gesture.removePointer); + await tester.pump(); + expect(paragraph.selections[0], const TextSelection(baseOffset: 0, extentOffset: 3)); + + await gesture.up(); + await tester.pump(); + + expect(buttonTypes, contains(ContextMenuButtonType.copy)); + expect(buttonTypes, contains(ContextMenuButtonType.selectAll)); + expect(find.byKey(toolbarKey), findsOneWidget); + + await gesture.down(textOffsetToPosition(paragraph, 2)); + await tester.pump(); + expect(paragraph.selections[0], const TextSelection(baseOffset: 0, extentOffset: 3)); + + await gesture.up(); + await tester.pump(); + + // Right-click at same position will toggle the context menu off. + expect(buttonTypes, contains(ContextMenuButtonType.copy)); + expect(buttonTypes, contains(ContextMenuButtonType.selectAll)); + expect(find.byKey(toolbarKey), findsNothing); + + await gesture.down(textOffsetToPosition(paragraph, 9)); + await tester.pump(); + expect(paragraph.selections[0], const TextSelection(baseOffset: 8, extentOffset: 11)); + + await gesture.up(); + await tester.pump(); + + expect(buttonTypes, contains(ContextMenuButtonType.copy)); + expect(buttonTypes, contains(ContextMenuButtonType.selectAll)); + expect(find.byKey(toolbarKey), findsOneWidget); + + await gesture.down(textOffsetToPosition(paragraph, 9)); + await tester.pump(); + expect(paragraph.selections[0], const TextSelection(baseOffset: 8, extentOffset: 11)); + + await gesture.up(); + await tester.pump(); + + // Right-click at same position will toggle the context menu off. + expect(buttonTypes, contains(ContextMenuButtonType.copy)); + expect(buttonTypes, contains(ContextMenuButtonType.selectAll)); + expect(find.byKey(toolbarKey), findsNothing); + + await gesture.down(textOffsetToPosition(paragraph, 6)); + await tester.pump(); + expect(paragraph.selections[0], const TextSelection(baseOffset: 4, extentOffset: 7)); + + await gesture.up(); + await tester.pump(); + + expect(buttonTypes, contains(ContextMenuButtonType.copy)); + expect(buttonTypes, contains(ContextMenuButtonType.selectAll)); + expect(find.byKey(toolbarKey), findsOneWidget); + + // Clear selection. + await tester.tapAt(textOffsetToPosition(paragraph, 1)); + await tester.pump(); + expect(paragraph.selections.isEmpty, true); + expect(find.byKey(toolbarKey), findsNothing); + }, + variant: TargetPlatformVariant.only(TargetPlatform.macOS), + skip: kIsWeb, // [intended] Web uses its native context menu. + ); + + testWidgets( + 'right-click mouse shows the context menu at position on Android, Fucshia, and Windows', + (WidgetTester tester) async { + Set buttonTypes = {}; + final UniqueKey toolbarKey = UniqueKey(); + await tester.pumpWidget( + MaterialApp( + home: SelectableRegion( + focusNode: FocusNode(), + selectionControls: materialTextSelectionHandleControls, + contextMenuBuilder: ( + BuildContext context, + SelectableRegionState selectableRegionState, + ) { + buttonTypes = selectableRegionState.contextMenuButtonItems + .map((ContextMenuButtonItem buttonItem) => buttonItem.type) + .toSet(); + return SizedBox.shrink(key: toolbarKey); + }, + child: const Center( + child: Text('How are you'), + ), + ), + ), + ); + + expect(buttonTypes.isEmpty, true); + expect(find.byKey(toolbarKey), findsNothing); + + 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); + addTearDown(gesture.removePointer); + await tester.pump(); + // Selection is collapsed so none is reported. + expect(paragraph.selections.isEmpty, true); + + await gesture.up(); + await tester.pump(); + + 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); + + await gesture.up(); + await tester.pump(); + + expect(buttonTypes.length, 1); + expect(buttonTypes, contains(ContextMenuButtonType.selectAll)); + expect(find.byKey(toolbarKey), findsOneWidget); + + await gesture.down(textOffsetToPosition(paragraph, 9)); + await tester.pump(); + expect(paragraph.selections.isEmpty, true); + + await gesture.up(); + await tester.pump(); + + expect(buttonTypes.length, 1); + expect(buttonTypes, contains(ContextMenuButtonType.selectAll)); + expect(find.byKey(toolbarKey), findsOneWidget); + + // Clear selection. + await tester.tapAt(textOffsetToPosition(paragraph, 1)); + await tester.pump(); + expect(paragraph.selections.isEmpty, true); + 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 tester.pump(); + await dragGesture.moveTo(textOffsetToPosition(paragraph, 5)); + await tester.pump(); + expect(paragraph.selections[0], const TextSelection(baseOffset: 0, extentOffset: 5)); + await dragGesture.up(); + await tester.pump(); + + // Right click on previous selection should not collapse the selection. + await gesture.down(textOffsetToPosition(paragraph, 2)); + await tester.pump(); + await gesture.up(); + await tester.pump(); + expect(paragraph.selections[0], const TextSelection(baseOffset: 0, extentOffset: 5)); + expect(find.byKey(toolbarKey), findsOneWidget); + + // Right click anywhere outside previous selection should collapse the + // selection. + await gesture.down(textOffsetToPosition(paragraph, 7)); + await tester.pump(); + await gesture.up(); + await tester.pump(); + expect(paragraph.selections.isEmpty, true); + expect(find.byKey(toolbarKey), findsOneWidget); + + // Clear selection. + await tester.tapAt(textOffsetToPosition(paragraph, 1)); + await tester.pump(); + expect(paragraph.selections.isEmpty, true); + expect(find.byKey(toolbarKey), findsNothing); + }, + variant: const TargetPlatformVariant({ TargetPlatform.android, TargetPlatform.fuchsia, TargetPlatform.windows }), + skip: kIsWeb, // [intended] Web uses its native context menu. + ); + + testWidgets( + 'right-click mouse toggles the context menu on Linux', + (WidgetTester tester) async { + Set buttonTypes = {}; + final UniqueKey toolbarKey = UniqueKey(); + await tester.pumpWidget( + MaterialApp( + home: SelectableRegion( + focusNode: FocusNode(), + selectionControls: materialTextSelectionHandleControls, + contextMenuBuilder: ( + BuildContext context, + SelectableRegionState selectableRegionState, + ) { + buttonTypes = selectableRegionState.contextMenuButtonItems + .map((ContextMenuButtonItem buttonItem) => buttonItem.type) + .toSet(); + return SizedBox.shrink(key: toolbarKey); + }, + child: const Center( + child: Text('How are you'), + ), + ), + ), + ); + + expect(buttonTypes.isEmpty, true); + expect(find.byKey(toolbarKey), findsNothing); + + 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); + addTearDown(gesture.removePointer); + await tester.pump(); + // Selection is collapsed so none is reported. + expect(paragraph.selections.isEmpty, true); + + await gesture.up(); + await tester.pump(); + + // Context menu toggled on. + 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); + + await gesture.up(); + await tester.pump(); + + // Context menu toggled off. + expect(find.byKey(toolbarKey), findsNothing); + + await gesture.down(textOffsetToPosition(paragraph, 9)); + await tester.pump(); + expect(paragraph.selections.isEmpty, true); + + await gesture.up(); + await tester.pump(); + + // Context menu toggled on. + expect(buttonTypes.length, 1); + expect(buttonTypes, contains(ContextMenuButtonType.selectAll)); + expect(find.byKey(toolbarKey), findsOneWidget); + + // Clear selection. + await tester.tapAt(textOffsetToPosition(paragraph, 1)); + await tester.pump(); + expect(paragraph.selections.isEmpty, true); + expect(find.byKey(toolbarKey), findsNothing); + + final TestGesture dragGesture = await tester.startGesture(textOffsetToPosition(paragraph, 0), kind: PointerDeviceKind.mouse); + addTearDown(dragGesture.removePointer); + await tester.pump(); + await dragGesture.moveTo(textOffsetToPosition(paragraph, 5)); + await tester.pump(); + expect(paragraph.selections[0], const TextSelection(baseOffset: 0, extentOffset: 5)); + await dragGesture.up(); + await tester.pump(); + + // Right click on previous selection should not collapse the selection. + await gesture.down(textOffsetToPosition(paragraph, 2)); + await tester.pump(); + await gesture.up(); + await tester.pump(); + expect(paragraph.selections[0], const TextSelection(baseOffset: 0, extentOffset: 5)); + expect(find.byKey(toolbarKey), findsOneWidget); + + // Right click anywhere outside previous selection should first toggle the context + // menu off. + await gesture.down(textOffsetToPosition(paragraph, 7)); + await tester.pump(); + await gesture.up(); + await tester.pump(); + expect(paragraph.selections[0], const TextSelection(baseOffset: 0, extentOffset: 5)); + expect(find.byKey(toolbarKey), findsNothing); + + // Right click again should collapse the selection and toggle the context + // menu on. + await gesture.down(textOffsetToPosition(paragraph, 7)); + await tester.pump(); + await gesture.up(); + await tester.pump(); + expect(paragraph.selections.isEmpty, true); + expect(find.byKey(toolbarKey), findsOneWidget); + + // Clear selection. + await tester.tapAt(textOffsetToPosition(paragraph, 1)); + await tester.pump(); + expect(paragraph.selections.isEmpty, true); + expect(find.byKey(toolbarKey), findsNothing); + }, + variant: TargetPlatformVariant.only(TargetPlatform.linux), + skip: kIsWeb, // [intended] Web uses its native context menu. + ); + testWidgets('can copy a selection made with the mouse', (WidgetTester tester) async { await tester.pumpWidget( MaterialApp( @@ -808,6 +1205,7 @@ void main() { // Should select "Hello". expect(paragraph.selections[0], const TextSelection(baseOffset: 124, extentOffset: 129)); }, + variant: TargetPlatformVariant.only(TargetPlatform.macOS), skip: isBrowser, // https://github.com/flutter/flutter/issues/61020 );