Selection area right click behavior should match native (#128224)
This change updates `SelectableRegion`s right-click gesture to match native platform behavior. Before: Right-click gesture selects word at position and opens context menu (All Platforms) After: - Linux, toggles context menu on/off, and collapses selection when click was not on an active selection (uncollapsed). - Windows, Android, Fuchsia, shows context menu at right-clicked position (unless the click is at an active selection). - macOS, toggles the context menu if right click was at the same position as the previous / or selects word at position and opens context menu. - iOS, selects word at position and opens context menu. This change also prevents the `copy` menu button from being shown when there is a collapsed selection (nothing to copy). Fixes #117561
This commit is contained in:
parent
c40baf47c5
commit
b36ef583fb
@ -157,6 +157,7 @@ class _RenderSelectableAdapter extends RenderProxyBox with Selectable, Selection
|
|||||||
hasContent: true,
|
hasContent: true,
|
||||||
startSelectionPoint: isReversed ? secondSelectionPoint : firstSelectionPoint,
|
startSelectionPoint: isReversed ? secondSelectionPoint : firstSelectionPoint,
|
||||||
endSelectionPoint: isReversed ? firstSelectionPoint : secondSelectionPoint,
|
endSelectionPoint: isReversed ? firstSelectionPoint : secondSelectionPoint,
|
||||||
|
selectionRects: <Rect>[selectionRect],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1335,6 +1335,14 @@ class _SelectableFragment with Selectable, ChangeNotifier implements TextLayoutM
|
|||||||
: paragraph._getOffsetForPosition(TextPosition(offset: selectionEnd));
|
: paragraph._getOffsetForPosition(TextPosition(offset: selectionEnd));
|
||||||
final bool flipHandles = isReversed != (TextDirection.rtl == paragraph.textDirection);
|
final bool flipHandles = isReversed != (TextDirection.rtl == paragraph.textDirection);
|
||||||
final Matrix4 paragraphToFragmentTransform = getTransformToParagraph()..invert();
|
final Matrix4 paragraphToFragmentTransform = getTransformToParagraph()..invert();
|
||||||
|
final TextSelection selection = TextSelection(
|
||||||
|
baseOffset: selectionStart,
|
||||||
|
extentOffset: selectionEnd,
|
||||||
|
);
|
||||||
|
final List<Rect> selectionRects = <Rect>[];
|
||||||
|
for (final TextBox textBox in paragraph.getBoxesForSelection(selection)) {
|
||||||
|
selectionRects.add(textBox.toRect());
|
||||||
|
}
|
||||||
return SelectionGeometry(
|
return SelectionGeometry(
|
||||||
startSelectionPoint: SelectionPoint(
|
startSelectionPoint: SelectionPoint(
|
||||||
localPosition: MatrixUtils.transformPoint(paragraphToFragmentTransform, startOffsetInParagraphCoordinates),
|
localPosition: MatrixUtils.transformPoint(paragraphToFragmentTransform, startOffsetInParagraphCoordinates),
|
||||||
@ -1346,6 +1354,7 @@ class _SelectableFragment with Selectable, ChangeNotifier implements TextLayoutM
|
|||||||
lineHeight: paragraph._textPainter.preferredLineHeight,
|
lineHeight: paragraph._textPainter.preferredLineHeight,
|
||||||
handleType: flipHandles ? TextSelectionHandleType.left : TextSelectionHandleType.right,
|
handleType: flipHandles ? TextSelectionHandleType.left : TextSelectionHandleType.right,
|
||||||
),
|
),
|
||||||
|
selectionRects: selectionRects,
|
||||||
status: _textSelectionStart!.offset == _textSelectionEnd!.offset
|
status: _textSelectionStart!.offset == _textSelectionEnd!.offset
|
||||||
? SelectionStatus.collapsed
|
? SelectionStatus.collapsed
|
||||||
: SelectionStatus.uncollapsed,
|
: SelectionStatus.uncollapsed,
|
||||||
|
@ -576,8 +576,8 @@ enum SelectionStatus {
|
|||||||
/// The geometry of the current selection.
|
/// The geometry of the current selection.
|
||||||
///
|
///
|
||||||
/// This includes details such as the locations of the selection start and end,
|
/// This includes details such as the locations of the selection start and end,
|
||||||
/// line height, etc. This information is used for drawing selection controls
|
/// line height, the rects that encompass the selection, etc. This information
|
||||||
/// for mobile platforms.
|
/// is used for drawing selection controls for mobile platforms.
|
||||||
///
|
///
|
||||||
/// The positions in geometry are in local coordinates of the [SelectionHandler]
|
/// The positions in geometry are in local coordinates of the [SelectionHandler]
|
||||||
/// or [Selectable].
|
/// or [Selectable].
|
||||||
@ -590,6 +590,7 @@ class SelectionGeometry {
|
|||||||
const SelectionGeometry({
|
const SelectionGeometry({
|
||||||
this.startSelectionPoint,
|
this.startSelectionPoint,
|
||||||
this.endSelectionPoint,
|
this.endSelectionPoint,
|
||||||
|
this.selectionRects = const <Rect>[],
|
||||||
required this.status,
|
required this.status,
|
||||||
required this.hasContent,
|
required this.hasContent,
|
||||||
}) : assert((startSelectionPoint == null && endSelectionPoint == null) || status != SelectionStatus.none);
|
}) : assert((startSelectionPoint == null && endSelectionPoint == null) || status != SelectionStatus.none);
|
||||||
@ -627,6 +628,10 @@ class SelectionGeometry {
|
|||||||
/// The status of ongoing selection in the [Selectable] or [SelectionHandler].
|
/// The status of ongoing selection in the [Selectable] or [SelectionHandler].
|
||||||
final SelectionStatus status;
|
final SelectionStatus status;
|
||||||
|
|
||||||
|
/// The rects in the local coordinates of the containing [Selectable] that
|
||||||
|
/// represent the selection if there is any.
|
||||||
|
final List<Rect> selectionRects;
|
||||||
|
|
||||||
/// Whether there is any selectable content in the [Selectable] or
|
/// Whether there is any selectable content in the [Selectable] or
|
||||||
/// [SelectionHandler].
|
/// [SelectionHandler].
|
||||||
final bool hasContent;
|
final bool hasContent;
|
||||||
@ -638,12 +643,14 @@ class SelectionGeometry {
|
|||||||
SelectionGeometry copyWith({
|
SelectionGeometry copyWith({
|
||||||
SelectionPoint? startSelectionPoint,
|
SelectionPoint? startSelectionPoint,
|
||||||
SelectionPoint? endSelectionPoint,
|
SelectionPoint? endSelectionPoint,
|
||||||
|
List<Rect>? selectionRects,
|
||||||
SelectionStatus? status,
|
SelectionStatus? status,
|
||||||
bool? hasContent,
|
bool? hasContent,
|
||||||
}) {
|
}) {
|
||||||
return SelectionGeometry(
|
return SelectionGeometry(
|
||||||
startSelectionPoint: startSelectionPoint ?? this.startSelectionPoint,
|
startSelectionPoint: startSelectionPoint ?? this.startSelectionPoint,
|
||||||
endSelectionPoint: endSelectionPoint ?? this.endSelectionPoint,
|
endSelectionPoint: endSelectionPoint ?? this.endSelectionPoint,
|
||||||
|
selectionRects: selectionRects ?? this.selectionRects,
|
||||||
status: status ?? this.status,
|
status: status ?? this.status,
|
||||||
hasContent: hasContent ?? this.hasContent,
|
hasContent: hasContent ?? this.hasContent,
|
||||||
);
|
);
|
||||||
@ -660,6 +667,7 @@ class SelectionGeometry {
|
|||||||
return other is SelectionGeometry
|
return other is SelectionGeometry
|
||||||
&& other.startSelectionPoint == startSelectionPoint
|
&& other.startSelectionPoint == startSelectionPoint
|
||||||
&& other.endSelectionPoint == endSelectionPoint
|
&& other.endSelectionPoint == endSelectionPoint
|
||||||
|
&& other.selectionRects == selectionRects
|
||||||
&& other.status == status
|
&& other.status == status
|
||||||
&& other.hasContent == hasContent;
|
&& other.hasContent == hasContent;
|
||||||
}
|
}
|
||||||
@ -669,6 +677,7 @@ class SelectionGeometry {
|
|||||||
return Object.hash(
|
return Object.hash(
|
||||||
startSelectionPoint,
|
startSelectionPoint,
|
||||||
endSelectionPoint,
|
endSelectionPoint,
|
||||||
|
selectionRects,
|
||||||
status,
|
status,
|
||||||
hasContent,
|
hasContent,
|
||||||
);
|
);
|
||||||
|
@ -263,7 +263,7 @@ class SelectableRegion extends StatefulWidget {
|
|||||||
required final VoidCallback onCopy,
|
required final VoidCallback onCopy,
|
||||||
required final VoidCallback onSelectAll,
|
required final VoidCallback onSelectAll,
|
||||||
}) {
|
}) {
|
||||||
final bool canCopy = selectionGeometry.hasSelection;
|
final bool canCopy = selectionGeometry.status == SelectionStatus.uncollapsed;
|
||||||
final bool canSelectAll = selectionGeometry.hasContent;
|
final bool canSelectAll = selectionGeometry.hasContent;
|
||||||
|
|
||||||
// Determine which buttons will appear so that the order and total number is
|
// Determine which buttons will appear so that the order and total number is
|
||||||
@ -489,12 +489,62 @@ class SelectableRegionState extends State<SelectableRegion> with TextSelectionDe
|
|||||||
_updateSelectedContentIfNeeded();
|
_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) {
|
void _handleRightClickDown(TapDownDetails details) {
|
||||||
|
final Offset? previousSecondaryTapDownPosition = lastSecondaryTapDownPosition;
|
||||||
|
final bool toolbarIsVisible = _selectionOverlay?.toolbarIsVisible ?? false;
|
||||||
lastSecondaryTapDownPosition = details.globalPosition;
|
lastSecondaryTapDownPosition = details.globalPosition;
|
||||||
widget.focusNode.requestFocus();
|
widget.focusNode.requestFocus();
|
||||||
_selectWordAt(offset: details.globalPosition);
|
switch (defaultTargetPlatform) {
|
||||||
_showHandles();
|
case TargetPlatform.android:
|
||||||
_showToolbar(location: details.globalPosition);
|
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();
|
_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<Rect> selectionRects = <Rect>[];
|
||||||
|
final Rect? drawableArea = hasSize ? Rect
|
||||||
|
.fromLTWH(0, 0, containerSize.width, containerSize.height) : null;
|
||||||
|
for (int index = currentSelectionStartIndex; index <= currentSelectionEndIndex; index++) {
|
||||||
|
final List<Rect> currSelectableSelectionRects = selectables[index].value.selectionRects;
|
||||||
|
final List<Rect> 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(
|
return SelectionGeometry(
|
||||||
startSelectionPoint: startPoint,
|
startSelectionPoint: startPoint,
|
||||||
endSelectionPoint: endPoint,
|
endSelectionPoint: endPoint,
|
||||||
|
selectionRects: selectionRects,
|
||||||
status: startGeometry != endGeometry
|
status: startGeometry != endGeometry
|
||||||
? SelectionStatus.uncollapsed
|
? SelectionStatus.uncollapsed
|
||||||
: startGeometry.status,
|
: startGeometry.status,
|
||||||
|
@ -562,15 +562,13 @@ class TextSelectionOverlay {
|
|||||||
/// Whether the handles are currently visible.
|
/// Whether the handles are currently visible.
|
||||||
bool get handlesAreVisible => _selectionOverlay._handles != null && handlesVisible;
|
bool get handlesAreVisible => _selectionOverlay._handles != null && handlesVisible;
|
||||||
|
|
||||||
/// Whether the toolbar is currently visible.
|
/// {@macro flutter.widgets.SelectionOverlay.toolbarIsVisible}
|
||||||
///
|
|
||||||
/// Includes both the text selection toolbar and the spell check menu.
|
|
||||||
///
|
///
|
||||||
/// See also:
|
/// See also:
|
||||||
///
|
///
|
||||||
/// * [spellCheckToolbarIsVisible], which is only whether the spell check menu
|
/// * [spellCheckToolbarIsVisible], which is only whether the spell check menu
|
||||||
/// specifically is visible.
|
/// specifically is visible.
|
||||||
bool get toolbarIsVisible => _selectionOverlay._toolbarIsVisible;
|
bool get toolbarIsVisible => _selectionOverlay.toolbarIsVisible;
|
||||||
|
|
||||||
/// Whether the magnifier is currently visible.
|
/// Whether the magnifier is currently visible.
|
||||||
bool get magnifierIsVisible => _selectionOverlay._magnifierController.shown;
|
bool get magnifierIsVisible => _selectionOverlay._magnifierController.shown;
|
||||||
@ -984,7 +982,12 @@ class SelectionOverlay {
|
|||||||
/// {@macro flutter.widgets.magnifier.TextMagnifierConfiguration.details}
|
/// {@macro flutter.widgets.magnifier.TextMagnifierConfiguration.details}
|
||||||
final TextMagnifierConfiguration magnifierConfiguration;
|
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
|
return selectionControls is TextSelectionHandleControls
|
||||||
? _contextMenuController.isShown || _spellCheckToolbarController.isShown
|
? _contextMenuController.isShown || _spellCheckToolbarController.isShown
|
||||||
: _toolbar != null || _spellCheckToolbarController.isShown;
|
: _toolbar != null || _spellCheckToolbarController.isShown;
|
||||||
@ -1001,7 +1004,7 @@ class SelectionOverlay {
|
|||||||
/// [MagnifierController.shown].
|
/// [MagnifierController.shown].
|
||||||
/// {@endtemplate}
|
/// {@endtemplate}
|
||||||
void showMagnifier(MagnifierInfo initialMagnifierInfo) {
|
void showMagnifier(MagnifierInfo initialMagnifierInfo) {
|
||||||
if (_toolbarIsVisible) {
|
if (toolbarIsVisible) {
|
||||||
hideToolbar();
|
hideToolbar();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -560,6 +560,403 @@ void main() {
|
|||||||
await gesture.up();
|
await gesture.up();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets(
|
||||||
|
'right-click mouse can select word at position on Apple platforms',
|
||||||
|
(WidgetTester tester) async {
|
||||||
|
Set<ContextMenuButtonType> buttonTypes = <ContextMenuButtonType>{};
|
||||||
|
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<RenderParagraph>(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>{ 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<ContextMenuButtonType> buttonTypes = <ContextMenuButtonType>{};
|
||||||
|
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<RenderParagraph>(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<ContextMenuButtonType> buttonTypes = <ContextMenuButtonType>{};
|
||||||
|
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<RenderParagraph>(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>{ 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<ContextMenuButtonType> buttonTypes = <ContextMenuButtonType>{};
|
||||||
|
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<RenderParagraph>(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 {
|
testWidgets('can copy a selection made with the mouse', (WidgetTester tester) async {
|
||||||
await tester.pumpWidget(
|
await tester.pumpWidget(
|
||||||
MaterialApp(
|
MaterialApp(
|
||||||
@ -808,6 +1205,7 @@ void main() {
|
|||||||
// Should select "Hello".
|
// Should select "Hello".
|
||||||
expect(paragraph.selections[0], const TextSelection(baseOffset: 124, extentOffset: 129));
|
expect(paragraph.selections[0], const TextSelection(baseOffset: 124, extentOffset: 129));
|
||||||
},
|
},
|
||||||
|
variant: TargetPlatformVariant.only(TargetPlatform.macOS),
|
||||||
skip: isBrowser, // https://github.com/flutter/flutter/issues/61020
|
skip: isBrowser, // https://github.com/flutter/flutter/issues/61020
|
||||||
);
|
);
|
||||||
|
|
||||||
|
Loading…
x
Reference in New Issue
Block a user