Unverified Commit 457d0044 authored by Renzo Olivares's avatar Renzo Olivares Committed by GitHub

Add double click and double click + drag gestures to SelectionArea (#124817)

Adds double click to select a word.
Adds double click + drag to select word by word.

https://user-images.githubusercontent.com/948037/234363577-941c36bc-ac42-4b7f-84aa-26b106c9ff05.mov

Partially fixes #104552
parent b3096225
...@@ -18,6 +18,9 @@ import 'layout_helper.dart'; ...@@ -18,6 +18,9 @@ import 'layout_helper.dart';
import 'object.dart'; import 'object.dart';
import 'selection.dart'; import 'selection.dart';
/// The start and end positions for a word.
typedef _WordBoundaryRecord = ({TextPosition wordStart, TextPosition wordEnd});
const String _kEllipsis = '\u2026'; const String _kEllipsis = '\u2026';
/// Used by the [RenderParagraph] to map its rendering children to their /// Used by the [RenderParagraph] to map its rendering children to their
...@@ -1329,6 +1332,8 @@ class _SelectableFragment with Selectable, ChangeNotifier implements TextLayoutM ...@@ -1329,6 +1332,8 @@ class _SelectableFragment with Selectable, ChangeNotifier implements TextLayoutM
TextPosition? _textSelectionStart; TextPosition? _textSelectionStart;
TextPosition? _textSelectionEnd; TextPosition? _textSelectionEnd;
bool _selectableContainsOriginWord = false;
LayerLink? _startHandleLayerLink; LayerLink? _startHandleLayerLink;
LayerLink? _endHandleLayerLink; LayerLink? _endHandleLayerLink;
...@@ -1397,7 +1402,17 @@ class _SelectableFragment with Selectable, ChangeNotifier implements TextLayoutM ...@@ -1397,7 +1402,17 @@ class _SelectableFragment with Selectable, ChangeNotifier implements TextLayoutM
case SelectionEventType.startEdgeUpdate: case SelectionEventType.startEdgeUpdate:
case SelectionEventType.endEdgeUpdate: case SelectionEventType.endEdgeUpdate:
final SelectionEdgeUpdateEvent edgeUpdate = event as SelectionEdgeUpdateEvent; final SelectionEdgeUpdateEvent edgeUpdate = event as SelectionEdgeUpdateEvent;
final TextGranularity granularity = event.granularity;
switch (granularity) {
case TextGranularity.character:
result = _updateSelectionEdge(edgeUpdate.globalPosition, isEnd: edgeUpdate.type == SelectionEventType.endEdgeUpdate); result = _updateSelectionEdge(edgeUpdate.globalPosition, isEnd: edgeUpdate.type == SelectionEventType.endEdgeUpdate);
case TextGranularity.word:
result = _updateSelectionEdgeByWord(edgeUpdate.globalPosition, isEnd: edgeUpdate.type == SelectionEventType.endEdgeUpdate);
case TextGranularity.document:
case TextGranularity.line:
assert(false, 'Moving the selection edge by line or document is not supported.');
}
case SelectionEventType.clear: case SelectionEventType.clear:
result = _handleClearSelection(); result = _handleClearSelection();
case SelectionEventType.selectAll: case SelectionEventType.selectAll:
...@@ -1474,6 +1489,199 @@ class _SelectableFragment with Selectable, ChangeNotifier implements TextLayoutM ...@@ -1474,6 +1489,199 @@ class _SelectableFragment with Selectable, ChangeNotifier implements TextLayoutM
return SelectionUtils.getResultBasedOnRect(_rect, localPosition); return SelectionUtils.getResultBasedOnRect(_rect, localPosition);
} }
TextPosition _closestWordBoundary(
_WordBoundaryRecord wordBoundary,
TextPosition position,
) {
final int differenceA = (position.offset - wordBoundary.wordStart.offset).abs();
final int differenceB = (position.offset - wordBoundary.wordEnd.offset).abs();
return differenceA < differenceB ? wordBoundary.wordStart : wordBoundary.wordEnd;
}
TextPosition _updateSelectionStartEdgeByWord(
_WordBoundaryRecord? wordBoundary,
TextPosition position,
TextPosition? existingSelectionStart,
TextPosition? existingSelectionEnd,
) {
TextPosition? targetPosition;
if (wordBoundary != null) {
assert(wordBoundary.wordStart.offset >= range.start && wordBoundary.wordEnd.offset <= range.end);
if (_selectableContainsOriginWord && existingSelectionStart != null && existingSelectionEnd != null) {
final bool isSamePosition = position.offset == existingSelectionEnd.offset;
final bool isSelectionInverted = existingSelectionStart.offset > existingSelectionEnd.offset;
final bool shouldSwapEdges = !isSamePosition && (isSelectionInverted != (position.offset > existingSelectionEnd.offset));
if (shouldSwapEdges) {
if (position.offset < existingSelectionEnd.offset) {
targetPosition = wordBoundary.wordStart;
} else {
targetPosition = wordBoundary.wordEnd;
}
// When the selection is inverted by the new position it is necessary to
// swap the start edge (moving edge) with the end edge (static edge) to
// maintain the origin word within the selection.
final _WordBoundaryRecord localWordBoundary = _getWordBoundaryAtPosition(existingSelectionEnd);
assert(localWordBoundary.wordStart.offset >= range.start && localWordBoundary.wordEnd.offset <= range.end);
_setSelectionPosition(existingSelectionEnd.offset == localWordBoundary.wordStart.offset ? localWordBoundary.wordEnd : localWordBoundary.wordStart, isEnd: true);
} else {
if (position.offset < existingSelectionEnd.offset) {
targetPosition = wordBoundary.wordStart;
} else if (position.offset > existingSelectionEnd.offset) {
targetPosition = wordBoundary.wordEnd;
} else {
// Keep the origin word in bounds when position is at the static edge.
targetPosition = existingSelectionStart;
}
}
} else {
if (existingSelectionEnd != null) {
// If the end edge exists and the start edge is being moved, then the
// start edge is moved to encompass the entire word at the new position.
if (position.offset < existingSelectionEnd.offset) {
targetPosition = wordBoundary.wordStart;
} else {
targetPosition = wordBoundary.wordEnd;
}
} else {
// Move the start edge to the closest word boundary.
targetPosition = _closestWordBoundary(wordBoundary, position);
}
}
} else {
// The position is not contained within the current rect. The targetPosition
// will either be at the end or beginning of the current rect. See [SelectionUtils.adjustDragOffset]
// for a more in depth explanation on this adjustment.
if (_selectableContainsOriginWord && existingSelectionStart != null && existingSelectionEnd != null) {
// When the selection is inverted by the new position it is necessary to
// swap the start edge (moving edge) with the end edge (static edge) to
// maintain the origin word within the selection.
final bool isSamePosition = position.offset == existingSelectionEnd.offset;
final bool isSelectionInverted = existingSelectionStart.offset > existingSelectionEnd.offset;
final bool shouldSwapEdges = !isSamePosition && (isSelectionInverted != (position.offset > existingSelectionEnd.offset));
if (shouldSwapEdges) {
final _WordBoundaryRecord localWordBoundary = _getWordBoundaryAtPosition(existingSelectionEnd);
assert(localWordBoundary.wordStart.offset >= range.start && localWordBoundary.wordEnd.offset <= range.end);
_setSelectionPosition(isSelectionInverted ? localWordBoundary.wordEnd : localWordBoundary.wordStart, isEnd: true);
}
}
}
return targetPosition ?? position;
}
TextPosition _updateSelectionEndEdgeByWord(
_WordBoundaryRecord? wordBoundary,
TextPosition position,
TextPosition? existingSelectionStart,
TextPosition? existingSelectionEnd,
) {
TextPosition? targetPosition;
if (wordBoundary != null) {
assert(wordBoundary.wordStart.offset >= range.start && wordBoundary.wordEnd.offset <= range.end);
if (_selectableContainsOriginWord && existingSelectionStart != null && existingSelectionEnd != null) {
final bool isSamePosition = position.offset == existingSelectionStart.offset;
final bool isSelectionInverted = existingSelectionStart.offset > existingSelectionEnd.offset;
final bool shouldSwapEdges = !isSamePosition && (isSelectionInverted != (position.offset < existingSelectionStart.offset));
if (shouldSwapEdges) {
if (position.offset < existingSelectionStart.offset) {
targetPosition = wordBoundary.wordStart;
} else {
targetPosition = wordBoundary.wordEnd;
}
// When the selection is inverted by the new position it is necessary to
// swap the end edge (moving edge) with the start edge (static edge) to
// maintain the origin word within the selection.
final _WordBoundaryRecord localWordBoundary = _getWordBoundaryAtPosition(existingSelectionStart);
assert(localWordBoundary.wordStart.offset >= range.start && localWordBoundary.wordEnd.offset <= range.end);
_setSelectionPosition(existingSelectionStart.offset == localWordBoundary.wordStart.offset ? localWordBoundary.wordEnd : localWordBoundary.wordStart, isEnd: false);
} else {
if (position.offset < existingSelectionStart.offset) {
targetPosition = wordBoundary.wordStart;
} else if (position.offset > existingSelectionStart.offset) {
targetPosition = wordBoundary.wordEnd;
} else {
// Keep the origin word in bounds when position is at the static edge.
targetPosition = existingSelectionEnd;
}
}
} else {
if (existingSelectionStart != null) {
// If the start edge exists and the end edge is being moved, then the
// end edge is moved to encompass the entire word at the new position.
if (position.offset < existingSelectionStart.offset) {
targetPosition = wordBoundary.wordStart;
} else {
targetPosition = wordBoundary.wordEnd;
}
} else {
// Move the end edge to the closest word boundary.
targetPosition = _closestWordBoundary(wordBoundary, position);
}
}
} else {
// The position is not contained within the current rect. The targetPosition
// will either be at the end or beginning of the current rect. See [SelectionUtils.adjustDragOffset]
// for a more in depth explanation on this adjustment.
if (_selectableContainsOriginWord && existingSelectionStart != null && existingSelectionEnd != null) {
// When the selection is inverted by the new position it is necessary to
// swap the end edge (moving edge) with the start edge (static edge) to
// maintain the origin word within the selection.
final bool isSamePosition = position.offset == existingSelectionStart.offset;
final bool isSelectionInverted = existingSelectionStart.offset > existingSelectionEnd.offset;
final bool shouldSwapEdges = isSelectionInverted != (position.offset < existingSelectionStart.offset) || isSamePosition;
if (shouldSwapEdges) {
final _WordBoundaryRecord localWordBoundary = _getWordBoundaryAtPosition(existingSelectionStart);
assert(localWordBoundary.wordStart.offset >= range.start && localWordBoundary.wordEnd.offset <= range.end);
_setSelectionPosition(isSelectionInverted ? localWordBoundary.wordStart : localWordBoundary.wordEnd, isEnd: false);
}
}
}
return targetPosition ?? position;
}
SelectionResult _updateSelectionEdgeByWord(Offset globalPosition, {required bool isEnd}) {
// When the start/end edges are swapped, i.e. the start is after the end, and
// the scrollable synthesizes an event for the opposite edge, this will potentially
// move the opposite edge outside of the origin word boundary and we are unable to recover.
final TextPosition? existingSelectionStart = _textSelectionStart;
final TextPosition? existingSelectionEnd = _textSelectionEnd;
_setSelectionPosition(null, isEnd: isEnd);
final Matrix4 transform = paragraph.getTransformTo(null);
transform.invert();
final Offset localPosition = MatrixUtils.transformPoint(transform, globalPosition);
if (_rect.isEmpty) {
return SelectionUtils.getResultBasedOnRect(_rect, localPosition);
}
final Offset adjustedOffset = SelectionUtils.adjustDragOffset(
_rect,
localPosition,
direction: paragraph.textDirection,
);
final TextPosition position = paragraph.getPositionForOffset(adjustedOffset);
// Check if the original local position is within the rect, if it is not then
// we do not need to look up the word boundary for that position. This is to
// maintain a selectables selection collapsed at 0 when the local position is
// not located inside its rect.
final _WordBoundaryRecord? wordBoundary = !_rect.contains(localPosition) ? null : _getWordBoundaryAtPosition(position);
final TextPosition targetPosition = _clampTextPosition(isEnd ? _updateSelectionEndEdgeByWord(wordBoundary, position, existingSelectionStart, existingSelectionEnd) : _updateSelectionStartEdgeByWord(wordBoundary, position, existingSelectionStart, existingSelectionEnd));
_setSelectionPosition(targetPosition, isEnd: isEnd);
if (targetPosition.offset == range.end) {
return SelectionResult.next;
}
if (targetPosition.offset == range.start) {
return SelectionResult.previous;
}
// TODO(chunhtai): The geometry information should not be used to determine
// selection result. This is a workaround to RenderParagraph, where it does
// not have a way to get accurate text length if its text is truncated due to
// layout constraint.
return SelectionUtils.getResultBasedOnRect(_rect, localPosition);
}
TextPosition _clampTextPosition(TextPosition position) { TextPosition _clampTextPosition(TextPosition position) {
// Affinity of range.end is upstream. // Affinity of range.end is upstream.
if (position.offset > range.end || if (position.offset > range.end ||
...@@ -1497,6 +1705,7 @@ class _SelectableFragment with Selectable, ChangeNotifier implements TextLayoutM ...@@ -1497,6 +1705,7 @@ class _SelectableFragment with Selectable, ChangeNotifier implements TextLayoutM
SelectionResult _handleClearSelection() { SelectionResult _handleClearSelection() {
_textSelectionStart = null; _textSelectionStart = null;
_textSelectionEnd = null; _textSelectionEnd = null;
_selectableContainsOriginWord = false;
return SelectionResult.none; return SelectionResult.none;
} }
...@@ -1507,20 +1716,29 @@ class _SelectableFragment with Selectable, ChangeNotifier implements TextLayoutM ...@@ -1507,20 +1716,29 @@ class _SelectableFragment with Selectable, ChangeNotifier implements TextLayoutM
} }
SelectionResult _handleSelectWord(Offset globalPosition) { SelectionResult _handleSelectWord(Offset globalPosition) {
_selectableContainsOriginWord = true;
final TextPosition position = paragraph.getPositionForOffset(paragraph.globalToLocal(globalPosition)); final TextPosition position = paragraph.getPositionForOffset(paragraph.globalToLocal(globalPosition));
if (_positionIsWithinCurrentSelection(position)) { if (_positionIsWithinCurrentSelection(position)) {
return SelectionResult.end; return SelectionResult.end;
} }
final TextRange word = paragraph.getWordBoundary(position); final _WordBoundaryRecord wordBoundary = _getWordBoundaryAtPosition(position);
assert(word.isNormalized); if (wordBoundary.wordStart.offset < range.start && wordBoundary.wordEnd.offset < range.start) {
if (word.start < range.start && word.end < range.start) {
return SelectionResult.previous; return SelectionResult.previous;
} else if (word.start > range.end && word.end > range.end) { } else if (wordBoundary.wordStart.offset > range.end && wordBoundary.wordEnd.offset > range.end) {
return SelectionResult.next; return SelectionResult.next;
} }
// Fragments are separated by placeholder span, the word boundary shouldn't // Fragments are separated by placeholder span, the word boundary shouldn't
// expand across fragments. // expand across fragments.
assert(word.start >= range.start && word.end <= range.end); assert(wordBoundary.wordStart.offset >= range.start && wordBoundary.wordEnd.offset <= range.end);
_textSelectionStart = wordBoundary.wordStart;
_textSelectionEnd = wordBoundary.wordEnd;
return SelectionResult.end;
}
_WordBoundaryRecord _getWordBoundaryAtPosition(TextPosition position) {
final TextRange word = paragraph.getWordBoundary(position);
assert(word.isNormalized);
late TextPosition start; late TextPosition start;
late TextPosition end; late TextPosition end;
if (position.offset > word.end) { if (position.offset > word.end) {
...@@ -1529,9 +1747,7 @@ class _SelectableFragment with Selectable, ChangeNotifier implements TextLayoutM ...@@ -1529,9 +1747,7 @@ class _SelectableFragment with Selectable, ChangeNotifier implements TextLayoutM
start = TextPosition(offset: word.start); start = TextPosition(offset: word.start);
end = TextPosition(offset: word.end, affinity: TextAffinity.upstream); end = TextPosition(offset: word.end, affinity: TextAffinity.upstream);
} }
_textSelectionStart = start; return (wordStart: start, wordEnd: end);
_textSelectionEnd = end;
return SelectionResult.end;
} }
SelectionResult _handleDirectionallyExtendSelection(double horizontalBaseline, bool isExtent, SelectionExtendDirection movement) { SelectionResult _handleDirectionallyExtendSelection(double horizontalBaseline, bool isExtent, SelectionExtendDirection movement) {
......
...@@ -375,26 +375,44 @@ class SelectWordSelectionEvent extends SelectionEvent { ...@@ -375,26 +375,44 @@ class SelectWordSelectionEvent extends SelectionEvent {
/// ///
/// The [globalPosition] contains the new offset of the edge. /// The [globalPosition] contains the new offset of the edge.
/// ///
/// This event is dispatched when the framework detects [DragStartDetails] in /// The [granularity] contains the granularity that the selection edge should move by.
/// Only [TextGranularity.character] and [TextGranularity.word] are currently supported.
///
/// This event is dispatched when the framework detects [TapDragStartDetails] in
/// [SelectionArea]'s gesture recognizers for mouse devices, or the selection /// [SelectionArea]'s gesture recognizers for mouse devices, or the selection
/// handles have been dragged to new locations. /// handles have been dragged to new locations.
class SelectionEdgeUpdateEvent extends SelectionEvent { class SelectionEdgeUpdateEvent extends SelectionEvent {
/// Creates a selection start edge update event. /// Creates a selection start edge update event.
/// ///
/// The [globalPosition] contains the location of the selection start edge. /// The [globalPosition] contains the location of the selection start edge.
///
/// The [granularity] contains the granularity which the selection edge should move by.
/// This value defaults to [TextGranularity.character].
const SelectionEdgeUpdateEvent.forStart({ const SelectionEdgeUpdateEvent.forStart({
required this.globalPosition required this.globalPosition,
}) : super._(SelectionEventType.startEdgeUpdate); TextGranularity? granularity
}) : granularity = granularity ?? TextGranularity.character, super._(SelectionEventType.startEdgeUpdate);
/// Creates a selection end edge update event. /// Creates a selection end edge update event.
/// ///
/// The [globalPosition] contains the new location of the selection end edge. /// The [globalPosition] contains the new location of the selection end edge.
///
/// The [granularity] contains the granularity which the selection edge should move by.
/// This value defaults to [TextGranularity.character].
const SelectionEdgeUpdateEvent.forEnd({ const SelectionEdgeUpdateEvent.forEnd({
required this.globalPosition required this.globalPosition,
}) : super._(SelectionEventType.endEdgeUpdate); TextGranularity? granularity
}) : granularity = granularity ?? TextGranularity.character, super._(SelectionEventType.endEdgeUpdate);
/// The new location of the selection edge. /// The new location of the selection edge.
final Offset globalPosition; final Offset globalPosition;
/// The granularity for which the selection moves.
///
/// Only [TextGranularity.character] and [TextGranularity.word] are currently supported.
///
/// Defaults to [TextGranularity.character].
final TextGranularity granularity;
} }
/// Extends the start or end of the selection by a given [TextGranularity]. /// Extends the start or end of the selection by a given [TextGranularity].
...@@ -686,7 +704,7 @@ class SelectionGeometry { ...@@ -686,7 +704,7 @@ class SelectionGeometry {
/// The geometry information of a selection point. /// The geometry information of a selection point.
@immutable @immutable
class SelectionPoint { class SelectionPoint with Diagnosticable {
/// Creates a selection point object. /// Creates a selection point object.
/// ///
/// All properties must not be null. /// All properties must not be null.
...@@ -730,6 +748,14 @@ class SelectionPoint { ...@@ -730,6 +748,14 @@ class SelectionPoint {
handleType, handleType,
); );
} }
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties.add(DiagnosticsProperty<Offset>('localPosition', localPosition));
properties.add(DoubleProperty('lineHeight', lineHeight));
properties.add(EnumProperty<TextSelectionHandleType>('handleType', handleType));
}
} }
/// The type of selection handle to be displayed. /// The type of selection handle to be displayed.
......
...@@ -1177,11 +1177,11 @@ class _ScrollableSelectionContainerDelegate extends MultiSelectableSelectionCont ...@@ -1177,11 +1177,11 @@ class _ScrollableSelectionContainerDelegate extends MultiSelectableSelectionCont
if (event.type == SelectionEventType.endEdgeUpdate) { if (event.type == SelectionEventType.endEdgeUpdate) {
_currentDragEndRelatedToOrigin = _inferPositionRelatedToOrigin(event.globalPosition); _currentDragEndRelatedToOrigin = _inferPositionRelatedToOrigin(event.globalPosition);
final Offset endOffset = _currentDragEndRelatedToOrigin!.translate(-deltaToOrigin.dx, -deltaToOrigin.dy); final Offset endOffset = _currentDragEndRelatedToOrigin!.translate(-deltaToOrigin.dx, -deltaToOrigin.dy);
event = SelectionEdgeUpdateEvent.forEnd(globalPosition: endOffset); event = SelectionEdgeUpdateEvent.forEnd(globalPosition: endOffset, granularity: event.granularity);
} else { } else {
_currentDragStartRelatedToOrigin = _inferPositionRelatedToOrigin(event.globalPosition); _currentDragStartRelatedToOrigin = _inferPositionRelatedToOrigin(event.globalPosition);
final Offset startOffset = _currentDragStartRelatedToOrigin!.translate(-deltaToOrigin.dx, -deltaToOrigin.dy); final Offset startOffset = _currentDragStartRelatedToOrigin!.translate(-deltaToOrigin.dx, -deltaToOrigin.dy);
event = SelectionEdgeUpdateEvent.forStart(globalPosition: startOffset); event = SelectionEdgeUpdateEvent.forStart(globalPosition: startOffset, granularity: event.granularity);
} }
final SelectionResult result = super.handleSelectionEdgeUpdate(event); final SelectionResult result = super.handleSelectionEdgeUpdate(event);
...@@ -1430,6 +1430,9 @@ class _ScrollableSelectionContainerDelegate extends MultiSelectableSelectionCont ...@@ -1430,6 +1430,9 @@ class _ScrollableSelectionContainerDelegate extends MultiSelectableSelectionCont
final Offset deltaToOrigin = _getDeltaToScrollOrigin(state); final Offset deltaToOrigin = _getDeltaToScrollOrigin(state);
final Offset startOffset = _currentDragStartRelatedToOrigin!.translate(-deltaToOrigin.dx, -deltaToOrigin.dy); final Offset startOffset = _currentDragStartRelatedToOrigin!.translate(-deltaToOrigin.dx, -deltaToOrigin.dy);
selectable.dispatchSelectionEvent(SelectionEdgeUpdateEvent.forStart(globalPosition: startOffset)); selectable.dispatchSelectionEvent(SelectionEdgeUpdateEvent.forStart(globalPosition: startOffset));
// Make sure we track that we have synthesized a start event for this selectable,
// so we don't synthesize events unnecessarily.
_selectableStartEdgeUpdateRecords[selectable] = state.position.pixels;
} }
final double? previousEndRecord = _selectableEndEdgeUpdateRecords[selectable]; final double? previousEndRecord = _selectableEndEdgeUpdateRecords[selectable];
if (_currentDragEndRelatedToOrigin != null && if (_currentDragEndRelatedToOrigin != null &&
...@@ -1438,6 +1441,9 @@ class _ScrollableSelectionContainerDelegate extends MultiSelectableSelectionCont ...@@ -1438,6 +1441,9 @@ class _ScrollableSelectionContainerDelegate extends MultiSelectableSelectionCont
final Offset deltaToOrigin = _getDeltaToScrollOrigin(state); final Offset deltaToOrigin = _getDeltaToScrollOrigin(state);
final Offset endOffset = _currentDragEndRelatedToOrigin!.translate(-deltaToOrigin.dx, -deltaToOrigin.dy); final Offset endOffset = _currentDragEndRelatedToOrigin!.translate(-deltaToOrigin.dx, -deltaToOrigin.dy);
selectable.dispatchSelectionEvent(SelectionEdgeUpdateEvent.forEnd(globalPosition: endOffset)); selectable.dispatchSelectionEvent(SelectionEdgeUpdateEvent.forEnd(globalPosition: endOffset));
// Make sure we track that we have synthesized an end event for this selectable,
// so we don't synthesize events unnecessarily.
_selectableEndEdgeUpdateRecords[selectable] = state.position.pixels;
} }
} }
......
...@@ -422,15 +422,46 @@ class SelectableRegionState extends State<SelectableRegion> with TextSelectionDe ...@@ -422,15 +422,46 @@ class SelectableRegionState extends State<SelectableRegion> with TextSelectionDe
// gestures. // gestures.
// Converts the details.consecutiveTapCount from a TapAndDrag*Details object,
// which can grow to be infinitely large, to a value between 1 and the supported
// max consecutive tap count. The value that the raw count is converted to is
// based on the default observed behavior on the native platforms.
//
// This method should be used in all instances when details.consecutiveTapCount
// would be used.
static int _getEffectiveConsecutiveTapCount(int rawCount) {
const int maxConsecutiveTap = 2;
switch (defaultTargetPlatform) {
case TargetPlatform.android:
case TargetPlatform.fuchsia:
case TargetPlatform.linux:
// From observation, these platforms reset their tap count to 0 when
// the number of consecutive taps exceeds the max consecutive tap supported.
// For example on Debian Linux with GTK, when going past a triple click,
// on the fourth click the selection is moved to the precise click
// position, on the fifth click the word at the position is selected, and
// on the sixth click the paragraph at the position is selected.
return rawCount <= maxConsecutiveTap ? rawCount : (rawCount % maxConsecutiveTap == 0 ? maxConsecutiveTap : rawCount % maxConsecutiveTap);
case TargetPlatform.iOS:
case TargetPlatform.macOS:
case TargetPlatform.windows:
// From observation, these platforms either hold their tap count at the max
// consecutive tap supported. For example on macOS, when going past a triple
// click, the selection should be retained at the paragraph that was first
// selected on triple click.
return min(rawCount, maxConsecutiveTap);
}
}
void _initMouseGestureRecognizer() { void _initMouseGestureRecognizer() {
_gestureRecognizers[PanGestureRecognizer] = GestureRecognizerFactoryWithHandlers<PanGestureRecognizer>( _gestureRecognizers[TapAndPanGestureRecognizer] = GestureRecognizerFactoryWithHandlers<TapAndPanGestureRecognizer>(
() => PanGestureRecognizer(debugOwner:this, supportedDevices: <PointerDeviceKind>{ PointerDeviceKind.mouse }), () => TapAndPanGestureRecognizer(debugOwner:this, supportedDevices: <PointerDeviceKind>{ PointerDeviceKind.mouse }),
(PanGestureRecognizer instance) { (TapAndPanGestureRecognizer instance) {
instance instance
..onDown = _startNewMouseSelectionGesture ..onTapDown = _startNewMouseSelectionGesture
..onStart = _handleMouseDragStart ..onDragStart = _handleMouseDragStart
..onUpdate = _handleMouseDragUpdate ..onDragUpdate = _handleMouseDragUpdate
..onEnd = _handleMouseDragEnd ..onDragEnd = _handleMouseDragEnd
..onCancel = _clearSelection ..onCancel = _clearSelection
..dragStartBehavior = DragStartBehavior.down; ..dragStartBehavior = DragStartBehavior.down;
}, },
...@@ -449,18 +480,36 @@ class SelectableRegionState extends State<SelectableRegion> with TextSelectionDe ...@@ -449,18 +480,36 @@ class SelectableRegionState extends State<SelectableRegion> with TextSelectionDe
); );
} }
void _startNewMouseSelectionGesture(DragDownDetails details) { void _startNewMouseSelectionGesture(TapDragDownDetails details) {
switch (_getEffectiveConsecutiveTapCount(details.consecutiveTapCount)) {
case 1:
widget.focusNode.requestFocus(); widget.focusNode.requestFocus();
hideToolbar(); hideToolbar();
_clearSelection(); _clearSelection();
case 2:
_selectWordAt(offset: details.globalPosition);
}
} }
void _handleMouseDragStart(DragStartDetails details) { void _handleMouseDragStart(TapDragStartDetails details) {
switch (_getEffectiveConsecutiveTapCount(details.consecutiveTapCount)) {
case 1:
_selectStartTo(offset: details.globalPosition); _selectStartTo(offset: details.globalPosition);
} }
}
void _handleMouseDragUpdate(DragUpdateDetails details) { void _handleMouseDragUpdate(TapDragUpdateDetails details) {
switch (_getEffectiveConsecutiveTapCount(details.consecutiveTapCount)) {
case 1:
_selectEndTo(offset: details.globalPosition, continuous: true); _selectEndTo(offset: details.globalPosition, continuous: true);
case 2:
_selectEndTo(offset: details.globalPosition, continuous: true, textGranularity: TextGranularity.word);
}
}
void _handleMouseDragEnd(TapDragEndDetails details) {
_finalizeSelection();
_updateSelectedContentIfNeeded();
} }
void _updateSelectedContentIfNeeded() { void _updateSelectedContentIfNeeded() {
...@@ -470,11 +519,6 @@ class SelectableRegionState extends State<SelectableRegion> with TextSelectionDe ...@@ -470,11 +519,6 @@ class SelectableRegionState extends State<SelectableRegion> with TextSelectionDe
} }
} }
void _handleMouseDragEnd(DragEndDetails details) {
_finalizeSelection();
_updateSelectedContentIfNeeded();
}
void _handleTouchLongPressStart(LongPressStartDetails details) { void _handleTouchLongPressStart(LongPressStartDetails details) {
HapticFeedback.selectionClick(); HapticFeedback.selectionClick();
widget.focusNode.requestFocus(); widget.focusNode.requestFocus();
...@@ -563,7 +607,7 @@ class SelectableRegionState extends State<SelectableRegion> with TextSelectionDe ...@@ -563,7 +607,7 @@ class SelectableRegionState extends State<SelectableRegion> with TextSelectionDe
/// If the selectable subtree returns a [SelectionResult.pending], this method /// If the selectable subtree returns a [SelectionResult.pending], this method
/// continues to send [SelectionEdgeUpdateEvent]s every frame until the result /// continues to send [SelectionEdgeUpdateEvent]s every frame until the result
/// is not pending or users end their gestures. /// is not pending or users end their gestures.
void _triggerSelectionEndEdgeUpdate() { void _triggerSelectionEndEdgeUpdate({TextGranularity? textGranularity}) {
// This method can be called when the drag is not in progress. This can // This method can be called when the drag is not in progress. This can
// happen if the child scrollable returns SelectionResult.pending, and // happen if the child scrollable returns SelectionResult.pending, and
// the selection area scheduled a selection update for the next frame, but // the selection area scheduled a selection update for the next frame, but
...@@ -572,14 +616,14 @@ class SelectableRegionState extends State<SelectableRegion> with TextSelectionDe ...@@ -572,14 +616,14 @@ class SelectableRegionState extends State<SelectableRegion> with TextSelectionDe
return; return;
} }
if (_selectable?.dispatchSelectionEvent( if (_selectable?.dispatchSelectionEvent(
SelectionEdgeUpdateEvent.forEnd(globalPosition: _selectionEndPosition!)) == SelectionResult.pending) { SelectionEdgeUpdateEvent.forEnd(globalPosition: _selectionEndPosition!, granularity: textGranularity)) == SelectionResult.pending) {
_scheduledSelectionEndEdgeUpdate = true; _scheduledSelectionEndEdgeUpdate = true;
SchedulerBinding.instance.addPostFrameCallback((Duration timeStamp) { SchedulerBinding.instance.addPostFrameCallback((Duration timeStamp) {
if (!_scheduledSelectionEndEdgeUpdate) { if (!_scheduledSelectionEndEdgeUpdate) {
return; return;
} }
_scheduledSelectionEndEdgeUpdate = false; _scheduledSelectionEndEdgeUpdate = false;
_triggerSelectionEndEdgeUpdate(); _triggerSelectionEndEdgeUpdate(textGranularity: textGranularity);
}); });
return; return;
} }
...@@ -617,7 +661,7 @@ class SelectableRegionState extends State<SelectableRegion> with TextSelectionDe ...@@ -617,7 +661,7 @@ class SelectableRegionState extends State<SelectableRegion> with TextSelectionDe
/// If the selectable subtree returns a [SelectionResult.pending], this method /// If the selectable subtree returns a [SelectionResult.pending], this method
/// continues to send [SelectionEdgeUpdateEvent]s every frame until the result /// continues to send [SelectionEdgeUpdateEvent]s every frame until the result
/// is not pending or users end their gestures. /// is not pending or users end their gestures.
void _triggerSelectionStartEdgeUpdate() { void _triggerSelectionStartEdgeUpdate({TextGranularity? textGranularity}) {
// This method can be called when the drag is not in progress. This can // This method can be called when the drag is not in progress. This can
// happen if the child scrollable returns SelectionResult.pending, and // happen if the child scrollable returns SelectionResult.pending, and
// the selection area scheduled a selection update for the next frame, but // the selection area scheduled a selection update for the next frame, but
...@@ -626,14 +670,14 @@ class SelectableRegionState extends State<SelectableRegion> with TextSelectionDe ...@@ -626,14 +670,14 @@ class SelectableRegionState extends State<SelectableRegion> with TextSelectionDe
return; return;
} }
if (_selectable?.dispatchSelectionEvent( if (_selectable?.dispatchSelectionEvent(
SelectionEdgeUpdateEvent.forStart(globalPosition: _selectionStartPosition!)) == SelectionResult.pending) { SelectionEdgeUpdateEvent.forStart(globalPosition: _selectionStartPosition!, granularity: textGranularity)) == SelectionResult.pending) {
_scheduledSelectionStartEdgeUpdate = true; _scheduledSelectionStartEdgeUpdate = true;
SchedulerBinding.instance.addPostFrameCallback((Duration timeStamp) { SchedulerBinding.instance.addPostFrameCallback((Duration timeStamp) {
if (!_scheduledSelectionStartEdgeUpdate) { if (!_scheduledSelectionStartEdgeUpdate) {
return; return;
} }
_scheduledSelectionStartEdgeUpdate = false; _scheduledSelectionStartEdgeUpdate = false;
_triggerSelectionStartEdgeUpdate(); _triggerSelectionStartEdgeUpdate(textGranularity: textGranularity);
}); });
return; return;
} }
...@@ -845,20 +889,24 @@ class SelectableRegionState extends State<SelectableRegion> with TextSelectionDe ...@@ -845,20 +889,24 @@ class SelectableRegionState extends State<SelectableRegion> with TextSelectionDe
/// ///
/// The `offset` is in global coordinates. /// The `offset` is in global coordinates.
/// ///
/// Provide the `textGranularity` if the selection should not move by the default
/// [TextGranularity.character]. Only [TextGranularity.character] and
/// [TextGranularity.word] are currently supported.
///
/// See also: /// See also:
/// * [_selectStartTo], which sets or updates selection start edge. /// * [_selectStartTo], which sets or updates selection start edge.
/// * [_finalizeSelection], which stops the `continuous` updates. /// * [_finalizeSelection], which stops the `continuous` updates.
/// * [_clearSelection], which clear the ongoing selection. /// * [_clearSelection], which clear the ongoing selection.
/// * [_selectWordAt], which selects a whole word at the location. /// * [_selectWordAt], which selects a whole word at the location.
/// * [selectAll], which selects the entire content. /// * [selectAll], which selects the entire content.
void _selectEndTo({required Offset offset, bool continuous = false}) { void _selectEndTo({required Offset offset, bool continuous = false, TextGranularity? textGranularity}) {
if (!continuous) { if (!continuous) {
_selectable?.dispatchSelectionEvent(SelectionEdgeUpdateEvent.forEnd(globalPosition: offset)); _selectable?.dispatchSelectionEvent(SelectionEdgeUpdateEvent.forEnd(globalPosition: offset, granularity: textGranularity));
return; return;
} }
if (_selectionEndPosition != offset) { if (_selectionEndPosition != offset) {
_selectionEndPosition = offset; _selectionEndPosition = offset;
_triggerSelectionEndEdgeUpdate(); _triggerSelectionEndEdgeUpdate(textGranularity: textGranularity);
} }
} }
...@@ -880,20 +928,24 @@ class SelectableRegionState extends State<SelectableRegion> with TextSelectionDe ...@@ -880,20 +928,24 @@ class SelectableRegionState extends State<SelectableRegion> with TextSelectionDe
/// ///
/// The `offset` is in global coordinates. /// The `offset` is in global coordinates.
/// ///
/// Provide the `textGranularity` if the selection should not move by the default
/// [TextGranularity.character]. Only [TextGranularity.character] and
/// [TextGranularity.word] are currently supported.
///
/// See also: /// See also:
/// * [_selectEndTo], which sets or updates selection end edge. /// * [_selectEndTo], which sets or updates selection end edge.
/// * [_finalizeSelection], which stops the `continuous` updates. /// * [_finalizeSelection], which stops the `continuous` updates.
/// * [_clearSelection], which clear the ongoing selection. /// * [_clearSelection], which clear the ongoing selection.
/// * [_selectWordAt], which selects a whole word at the location. /// * [_selectWordAt], which selects a whole word at the location.
/// * [selectAll], which selects the entire content. /// * [selectAll], which selects the entire content.
void _selectStartTo({required Offset offset, bool continuous = false}) { void _selectStartTo({required Offset offset, bool continuous = false, TextGranularity? textGranularity}) {
if (!continuous) { if (!continuous) {
_selectable?.dispatchSelectionEvent(SelectionEdgeUpdateEvent.forStart(globalPosition: offset)); _selectable?.dispatchSelectionEvent(SelectionEdgeUpdateEvent.forStart(globalPosition: offset, granularity: textGranularity));
return; return;
} }
if (_selectionStartPosition != offset) { if (_selectionStartPosition != offset) {
_selectionStartPosition = offset; _selectionStartPosition = offset;
_triggerSelectionStartEdgeUpdate(); _triggerSelectionStartEdgeUpdate(textGranularity: textGranularity);
} }
} }
......
...@@ -157,6 +157,7 @@ void main() { ...@@ -157,6 +157,7 @@ void main() {
// Backwards selection. // Backwards selection.
await gesture.down(textOffsetToPosition(paragraph, 3)); await gesture.down(textOffsetToPosition(paragraph, 3));
await tester.pumpAndSettle();
expect(content, isNull); expect(content, isNull);
await gesture.moveTo(textOffsetToPosition(paragraph, 0)); await gesture.moveTo(textOffsetToPosition(paragraph, 0));
await gesture.up(); await gesture.up();
......
...@@ -4,6 +4,7 @@ ...@@ -4,6 +4,7 @@
import 'dart:ui' as ui; import 'dart:ui' as ui;
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart'; import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
...@@ -106,6 +107,89 @@ void main() { ...@@ -106,6 +107,89 @@ void main() {
await gesture.up(); await gesture.up();
}); });
testWidgets('mouse can select multiple widgets on double-click drag', (WidgetTester tester) async {
await tester.pumpWidget(MaterialApp(
home: SelectionArea(
selectionControls: materialTextSelectionControls,
child: ListView.builder(
itemCount: 100,
itemBuilder: (BuildContext context, int index) {
return Text('Item $index');
},
),
),
));
await tester.pumpAndSettle();
final RenderParagraph paragraph1 = tester.renderObject<RenderParagraph>(find.descendant(of: find.text('Item 0'), matching: find.byType(RichText)));
final TestGesture gesture = await tester.startGesture(textOffsetToPosition(paragraph1, 2), kind: ui.PointerDeviceKind.mouse);
addTearDown(gesture.removePointer);
await tester.pump();
await gesture.up();
await tester.pump();
await gesture.down(textOffsetToPosition(paragraph1, 2));
await tester.pumpAndSettle();
expect(paragraph1.selections[0], const TextSelection(baseOffset: 0, extentOffset: 4));
await gesture.moveTo(textOffsetToPosition(paragraph1, 4));
await tester.pump();
expect(paragraph1.selections[0], const TextSelection(baseOffset: 0, extentOffset: 5));
final RenderParagraph paragraph2 = tester.renderObject<RenderParagraph>(find.descendant(of: find.text('Item 1'), matching: find.byType(RichText)));
await gesture.moveTo(textOffsetToPosition(paragraph2, 4));
// Should select the rest of paragraph 1.
expect(paragraph1.selections[0], const TextSelection(baseOffset: 0, extentOffset: 6));
expect(paragraph2.selections[0], const TextSelection(baseOffset: 0, extentOffset: 5));
final RenderParagraph paragraph3 = tester.renderObject<RenderParagraph>(find.descendant(of: find.text('Item 3'), matching: find.byType(RichText)));
await gesture.moveTo(textOffsetToPosition(paragraph3, 3));
expect(paragraph1.selections[0], const TextSelection(baseOffset: 0, extentOffset: 6));
expect(paragraph2.selections[0], const TextSelection(baseOffset: 0, extentOffset: 6));
expect(paragraph3.selections[0], const TextSelection(baseOffset: 0, extentOffset: 4));
await gesture.up();
}, skip: kIsWeb); // https://github.com/flutter/flutter/issues/125582.
testWidgets('mouse can select multiple widgets on double-click drag - horizontal', (WidgetTester tester) async {
await tester.pumpWidget(MaterialApp(
home: SelectionArea(
selectionControls: materialTextSelectionControls,
child: ListView.builder(
scrollDirection: Axis.horizontal,
itemCount: 100,
itemBuilder: (BuildContext context, int index) {
return Text('Item $index');
},
),
),
));
await tester.pumpAndSettle();
final RenderParagraph paragraph1 = tester.renderObject<RenderParagraph>(find.descendant(of: find.text('Item 0'), matching: find.byType(RichText)));
final TestGesture gesture = await tester.startGesture(textOffsetToPosition(paragraph1, 2), kind: ui.PointerDeviceKind.mouse);
addTearDown(gesture.removePointer);
await tester.pump();
await gesture.up();
await tester.pump();
await gesture.down(textOffsetToPosition(paragraph1, 2));
await tester.pumpAndSettle();
expect(paragraph1.selections[0], const TextSelection(baseOffset: 0, extentOffset: 4));
await gesture.moveTo(textOffsetToPosition(paragraph1, 4));
await tester.pump();
expect(paragraph1.selections[0], const TextSelection(baseOffset: 0, extentOffset: 5));
final RenderParagraph paragraph2 = tester.renderObject<RenderParagraph>(find.descendant(of: find.text('Item 1'), matching: find.byType(RichText)));
await gesture.moveTo(textOffsetToPosition(paragraph2, 5) + const Offset(0, 5));
// Should select the rest of paragraph 1.
expect(paragraph1.selections[0], const TextSelection(baseOffset: 0, extentOffset: 6));
expect(paragraph2.selections[0], const TextSelection(baseOffset: 0, extentOffset: 6));
await gesture.up();
}, skip: kIsWeb); // https://github.com/flutter/flutter/issues/125582.
testWidgets('select to scroll forward', (WidgetTester tester) async { testWidgets('select to scroll forward', (WidgetTester tester) async {
final ScrollController controller = ScrollController(); final ScrollController controller = ScrollController();
await tester.pumpWidget(MaterialApp( await tester.pumpWidget(MaterialApp(
......
...@@ -37,7 +37,7 @@ void main() { ...@@ -37,7 +37,7 @@ void main() {
}); });
group('SelectableRegion', () { group('SelectableRegion', () {
testWidgets('mouse selection sends correct events', (WidgetTester tester) async { testWidgets('mouse selection single click sends correct events', (WidgetTester tester) async {
final UniqueKey spy = UniqueKey(); final UniqueKey spy = UniqueKey();
await tester.pumpWidget( await tester.pumpWidget(
MaterialApp( MaterialApp(
...@@ -53,6 +53,7 @@ void main() { ...@@ -53,6 +53,7 @@ void main() {
final RenderSelectionSpy renderSelectionSpy = tester.renderObject<RenderSelectionSpy>(find.byKey(spy)); final RenderSelectionSpy renderSelectionSpy = tester.renderObject<RenderSelectionSpy>(find.byKey(spy));
final TestGesture gesture = await tester.startGesture(const Offset(200.0, 200.0), kind: PointerDeviceKind.mouse); final TestGesture gesture = await tester.startGesture(const Offset(200.0, 200.0), kind: PointerDeviceKind.mouse);
addTearDown(gesture.removePointer); addTearDown(gesture.removePointer);
await tester.pumpAndSettle();
renderSelectionSpy.events.clear(); renderSelectionSpy.events.clear();
await gesture.moveTo(const Offset(200.0, 100.0)); await gesture.moveTo(const Offset(200.0, 100.0));
...@@ -74,6 +75,34 @@ void main() { ...@@ -74,6 +75,34 @@ void main() {
await gesture.up(); await gesture.up();
}, skip: kIsWeb); // https://github.com/flutter/flutter/issues/102410. }, skip: kIsWeb); // https://github.com/flutter/flutter/issues/102410.
testWidgets('mouse double click sends select-word event', (WidgetTester tester) async {
final UniqueKey spy = UniqueKey();
await tester.pumpWidget(
MaterialApp(
home: SelectableRegion(
focusNode: FocusNode(),
selectionControls: materialTextSelectionControls,
child: SelectionSpy(key: spy),
),
)
);
final RenderSelectionSpy renderSelectionSpy = tester.renderObject<RenderSelectionSpy>(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.pump();
renderSelectionSpy.events.clear();
await gesture.down(const Offset(200.0, 200.0));
await tester.pump();
await gesture.up();
expect(renderSelectionSpy.events.length, 1);
expect(renderSelectionSpy.events[0], isA<SelectWordSelectionEvent>());
final SelectWordSelectionEvent selectionEvent = renderSelectionSpy.events[0] as SelectWordSelectionEvent;
expect(selectionEvent.globalPosition, const Offset(200.0, 200.0));
});
testWidgets('Does not crash when using Navigator pages', (WidgetTester tester) async { testWidgets('Does not crash when using Navigator pages', (WidgetTester tester) async {
// Regression test for https://github.com/flutter/flutter/issues/119776 // Regression test for https://github.com/flutter/flutter/issues/119776
await tester.pumpWidget( await tester.pumpWidget(
...@@ -249,6 +278,7 @@ void main() { ...@@ -249,6 +278,7 @@ void main() {
final RenderSelectionSpy renderSelectionSpy = tester.renderObject<RenderSelectionSpy>(find.byKey(spy)); final RenderSelectionSpy renderSelectionSpy = tester.renderObject<RenderSelectionSpy>(find.byKey(spy));
final TestGesture gesture = await tester.startGesture(const Offset(200.0, 200.0), kind: PointerDeviceKind.mouse); final TestGesture gesture = await tester.startGesture(const Offset(200.0, 200.0), kind: PointerDeviceKind.mouse);
addTearDown(gesture.removePointer); addTearDown(gesture.removePointer);
await tester.pumpAndSettle();
expect(renderSelectionSpy.events.length, 1); expect(renderSelectionSpy.events.length, 1);
expect(renderSelectionSpy.events[0], isA<ClearSelectionEvent>()); expect(renderSelectionSpy.events[0], isA<ClearSelectionEvent>());
}, skip: kIsWeb); // https://github.com/flutter/flutter/issues/102410. }, skip: kIsWeb); // https://github.com/flutter/flutter/issues/102410.
...@@ -506,6 +536,7 @@ void main() { ...@@ -506,6 +536,7 @@ void main() {
// Start a new drag. // Start a new drag.
await gesture.up(); await gesture.up();
await gesture.down(textOffsetToPosition(paragraph, 5)); await gesture.down(textOffsetToPosition(paragraph, 5));
await tester.pumpAndSettle();
expect(paragraph.selections.isEmpty, isTrue); expect(paragraph.selections.isEmpty, isTrue);
// Selecting across line should select to the end. // Selecting across line should select to the end.
...@@ -516,6 +547,224 @@ void main() { ...@@ -516,6 +547,224 @@ void main() {
await gesture.up(); await gesture.up();
}); });
testWidgets('mouse can select word-by-word on double click drag', (WidgetTester tester) async {
await tester.pumpWidget(
MaterialApp(
home: SelectableRegion(
focusNode: FocusNode(),
selectionControls: materialTextSelectionControls,
child: const Center(
child: Text('How are you'),
),
),
),
);
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);
addTearDown(gesture.removePointer);
await tester.pump();
await gesture.up();
await tester.pump();
await gesture.down(textOffsetToPosition(paragraph, 2));
await tester.pumpAndSettle();
expect(paragraph.selections[0], const TextSelection(baseOffset: 0, extentOffset: 3));
await gesture.moveTo(textOffsetToPosition(paragraph, 3));
await tester.pumpAndSettle();
expect(paragraph.selections[0], const TextSelection(baseOffset: 0, extentOffset: 4));
await gesture.moveTo(textOffsetToPosition(paragraph, 4));
await tester.pump();
expect(paragraph.selections[0], const TextSelection(baseOffset: 0, extentOffset: 7));
await gesture.moveTo(textOffsetToPosition(paragraph, 7));
await tester.pump();
expect(paragraph.selections[0], const TextSelection(baseOffset: 0, extentOffset: 8));
await gesture.moveTo(textOffsetToPosition(paragraph, 8));
await tester.pump();
expect(paragraph.selections[0], const TextSelection(baseOffset: 0, extentOffset: 11));
// Check backward selection.
await gesture.moveTo(textOffsetToPosition(paragraph, 1));
await tester.pump();
expect(paragraph.selections[0], const TextSelection(baseOffset: 0, extentOffset: 3));
// Start a new double-click drag.
await gesture.up();
await tester.pump();
await gesture.down(textOffsetToPosition(paragraph, 5));
await tester.pump();
await gesture.up();
expect(paragraph.selections.isEmpty, isTrue);
await tester.pump(kDoubleTapTimeout);
// Double-click.
await gesture.down(textOffsetToPosition(paragraph, 5));
await tester.pump();
await gesture.up();
await tester.pump();
await gesture.down(textOffsetToPosition(paragraph, 5));
await tester.pumpAndSettle();
expect(paragraph.selections[0], const TextSelection(baseOffset: 4, extentOffset: 7));
// 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: 4, extentOffset: 11));
await gesture.up();
}, skip: kIsWeb); // https://github.com/flutter/flutter/issues/125582.
testWidgets('mouse can select multiple widgets on double click drag', (WidgetTester tester) async {
await tester.pumpWidget(
MaterialApp(
home: SelectableRegion(
focusNode: FocusNode(),
selectionControls: materialTextSelectionControls,
child: const Column(
children: <Widget>[
Text('How are you?'),
Text('Good, and you?'),
Text('Fine, thank you.'),
],
),
),
),
);
final RenderParagraph paragraph1 = tester.renderObject<RenderParagraph>(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.pump();
await gesture.down(textOffsetToPosition(paragraph1, 2));
await tester.pumpAndSettle();
expect(paragraph1.selections[0], const TextSelection(baseOffset: 0, extentOffset: 3));
await gesture.moveTo(textOffsetToPosition(paragraph1, 4));
await tester.pump();
expect(paragraph1.selections[0], const TextSelection(baseOffset: 0, extentOffset: 7));
final RenderParagraph paragraph2 = tester.renderObject<RenderParagraph>(find.descendant(of: find.text('Good, and you?'), matching: find.byType(RichText)));
await gesture.moveTo(textOffsetToPosition(paragraph2, 5));
// Should select the rest of paragraph 1.
expect(paragraph1.selections[0], const TextSelection(baseOffset: 0, extentOffset: 12));
expect(paragraph2.selections[0], const TextSelection(baseOffset: 0, extentOffset: 6));
final RenderParagraph paragraph3 = tester.renderObject<RenderParagraph>(find.descendant(of: find.text('Fine, thank you.'), matching: find.byType(RichText)));
await gesture.moveTo(textOffsetToPosition(paragraph3, 6));
expect(paragraph1.selections[0], const TextSelection(baseOffset: 0, extentOffset: 12));
expect(paragraph2.selections[0], const TextSelection(baseOffset: 0, extentOffset: 14));
expect(paragraph3.selections[0], const TextSelection(baseOffset: 0, extentOffset: 11));
await gesture.up();
}, skip: kIsWeb); // https://github.com/flutter/flutter/issues/125582.
testWidgets('mouse can select multiple widgets on double click drag and return to origin word', (WidgetTester tester) async {
await tester.pumpWidget(
MaterialApp(
home: SelectableRegion(
focusNode: FocusNode(),
selectionControls: materialTextSelectionControls,
child: const Column(
children: <Widget>[
Text('How are you?'),
Text('Good, and you?'),
Text('Fine, thank you.'),
],
),
),
),
);
final RenderParagraph paragraph1 = tester.renderObject<RenderParagraph>(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.pump();
await gesture.down(textOffsetToPosition(paragraph1, 2));
await tester.pumpAndSettle();
expect(paragraph1.selections[0], const TextSelection(baseOffset: 0, extentOffset: 3));
await gesture.moveTo(textOffsetToPosition(paragraph1, 4));
await tester.pump();
expect(paragraph1.selections[0], const TextSelection(baseOffset: 0, extentOffset: 7));
final RenderParagraph paragraph2 = tester.renderObject<RenderParagraph>(find.descendant(of: find.text('Good, and you?'), matching: find.byType(RichText)));
await gesture.moveTo(textOffsetToPosition(paragraph2, 5));
// Should select the rest of paragraph 1.
expect(paragraph1.selections[0], const TextSelection(baseOffset: 0, extentOffset: 12));
expect(paragraph2.selections[0], const TextSelection(baseOffset: 0, extentOffset: 6));
final RenderParagraph paragraph3 = tester.renderObject<RenderParagraph>(find.descendant(of: find.text('Fine, thank you.'), matching: find.byType(RichText)));
await gesture.moveTo(textOffsetToPosition(paragraph3, 6));
expect(paragraph1.selections[0], const TextSelection(baseOffset: 0, extentOffset: 12));
expect(paragraph2.selections[0], const TextSelection(baseOffset: 0, extentOffset: 14));
expect(paragraph3.selections[0], const TextSelection(baseOffset: 0, extentOffset: 11));
await gesture.moveTo(textOffsetToPosition(paragraph2, 5));
// 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);
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);
await gesture.up();
}, skip: kIsWeb); // https://github.com/flutter/flutter/issues/125582.
testWidgets('mouse can reverse selection across multiple widgets on double click drag', (WidgetTester tester) async {
await tester.pumpWidget(
MaterialApp(
home: SelectableRegion(
focusNode: FocusNode(),
selectionControls: materialTextSelectionControls,
child: const Column(
children: <Widget>[
Text('How are you?'),
Text('Good, and you?'),
Text('Fine, thank you.'),
],
),
),
),
);
final RenderParagraph paragraph3 = tester.renderObject<RenderParagraph>(find.descendant(of: find.text('Fine, thank you.'), matching: find.byType(RichText)));
final TestGesture gesture = await tester.startGesture(textOffsetToPosition(paragraph3, 10), kind: PointerDeviceKind.mouse);
addTearDown(gesture.removePointer);
await tester.pump();
await gesture.up();
await tester.pump();
await gesture.down(textOffsetToPosition(paragraph3, 10));
await tester.pumpAndSettle();
expect(paragraph3.selections[0], const TextSelection(baseOffset: 6, extentOffset: 11));
await gesture.moveTo(textOffsetToPosition(paragraph3, 4));
await tester.pump();
expect(paragraph3.selections[0], const TextSelection(baseOffset: 11, extentOffset: 4));
final RenderParagraph paragraph2 = tester.renderObject<RenderParagraph>(find.descendant(of: find.text('Good, and you?'), matching: find.byType(RichText)));
await gesture.moveTo(textOffsetToPosition(paragraph2, 5));
expect(paragraph3.selections[0], const TextSelection(baseOffset: 11, extentOffset: 0));
expect(paragraph2.selections[0], const TextSelection(baseOffset: 14, extentOffset: 5));
final RenderParagraph paragraph1 = tester.renderObject<RenderParagraph>(find.descendant(of: find.text('How are you?'), matching: find.byType(RichText)));
await gesture.moveTo(textOffsetToPosition(paragraph1, 6));
expect(paragraph3.selections[0], const TextSelection(baseOffset: 11, extentOffset: 0));
expect(paragraph2.selections[0], const TextSelection(baseOffset: 14, extentOffset: 0));
expect(paragraph1.selections[0], const TextSelection(baseOffset: 12, extentOffset: 4));
await gesture.up();
}, skip: kIsWeb); // https://github.com/flutter/flutter/issues/125582.
testWidgets('mouse can select multiple widgets', (WidgetTester tester) async { testWidgets('mouse can select multiple widgets', (WidgetTester tester) async {
await tester.pumpWidget( await tester.pumpWidget(
MaterialApp( MaterialApp(
...@@ -2416,6 +2665,7 @@ void main() { ...@@ -2416,6 +2665,7 @@ void main() {
// Backwards selection. // Backwards selection.
await gesture.down(textOffsetToPosition(paragraph, 3)); await gesture.down(textOffsetToPosition(paragraph, 3));
await tester.pumpAndSettle();
expect(content, isNull); expect(content, isNull);
await gesture.moveTo(textOffsetToPosition(paragraph, 0)); await gesture.moveTo(textOffsetToPosition(paragraph, 0));
await gesture.up(); await gesture.up();
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment