Implement SelectionArea triple click gestures (#144563)
This change adds support for triple click to select a paragraph at the clicked position and triple click + drag to extend the selection paragraph-by-paragraph when using the SelectionArea widget. This PR also: * Makes `Text` widgets a `SelectionContainer` if a parent `SelectionRegistrar` exists. * Fixes issues with selectable ordering involving `WidgetSpan`s. Fixes: https://github.com/flutter/flutter/issues/104552
This commit is contained in:
parent
6fe56362e5
commit
2a37c6f307
@ -185,6 +185,7 @@ class _RenderSelectableAdapter extends RenderProxyBox with Selectable, Selection
|
|||||||
_start = _end = null;
|
_start = _end = null;
|
||||||
case SelectionEventType.selectAll:
|
case SelectionEventType.selectAll:
|
||||||
case SelectionEventType.selectWord:
|
case SelectionEventType.selectWord:
|
||||||
|
case SelectionEventType.selectParagraph:
|
||||||
_start = Offset.zero;
|
_start = Offset.zero;
|
||||||
_end = Offset.infinite;
|
_end = Offset.infinite;
|
||||||
case SelectionEventType.granularlyExtendSelection:
|
case SelectionEventType.granularlyExtendSelection:
|
||||||
|
File diff suppressed because it is too large
Load Diff
@ -299,6 +299,12 @@ enum SelectionEventType {
|
|||||||
/// Used by [SelectWordSelectionEvent].
|
/// Used by [SelectWordSelectionEvent].
|
||||||
selectWord,
|
selectWord,
|
||||||
|
|
||||||
|
/// An event to select a paragraph at the location
|
||||||
|
/// [SelectParagraphSelectionEvent.globalPosition].
|
||||||
|
///
|
||||||
|
/// Used by [SelectParagraphSelectionEvent].
|
||||||
|
selectParagraph,
|
||||||
|
|
||||||
/// An event that extends the selection by a specific [TextGranularity].
|
/// An event that extends the selection by a specific [TextGranularity].
|
||||||
granularlyExtendSelection,
|
granularlyExtendSelection,
|
||||||
|
|
||||||
@ -317,6 +323,9 @@ enum TextGranularity {
|
|||||||
/// Treats word as an atomic unit when moving the selection handles.
|
/// Treats word as an atomic unit when moving the selection handles.
|
||||||
word,
|
word,
|
||||||
|
|
||||||
|
/// Treats a paragraph as an atomic unit when moving the selection handles.
|
||||||
|
paragraph,
|
||||||
|
|
||||||
/// Treats each line break as an atomic unit when moving the selection handles.
|
/// Treats each line break as an atomic unit when moving the selection handles.
|
||||||
line,
|
line,
|
||||||
|
|
||||||
@ -370,6 +379,21 @@ class SelectWordSelectionEvent extends SelectionEvent {
|
|||||||
final Offset globalPosition;
|
final Offset globalPosition;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Selects the entire paragraph at the location.
|
||||||
|
///
|
||||||
|
/// This event can be sent as the result of a triple click to select.
|
||||||
|
class SelectParagraphSelectionEvent extends SelectionEvent {
|
||||||
|
/// Creates a select paragraph event at the [globalPosition].
|
||||||
|
const SelectParagraphSelectionEvent({required this.globalPosition, this.absorb = false}): super._(SelectionEventType.selectParagraph);
|
||||||
|
|
||||||
|
/// The position in global coordinates to select paragraph at.
|
||||||
|
final Offset globalPosition;
|
||||||
|
|
||||||
|
/// Whether the selectable receiving the event should be absorbed into
|
||||||
|
/// an encompassing paragraph.
|
||||||
|
final bool absorb;
|
||||||
|
}
|
||||||
|
|
||||||
/// Updates a selection edge.
|
/// Updates a selection edge.
|
||||||
///
|
///
|
||||||
/// An active selection contains two edges, start and end. Use the [type] to
|
/// An active selection contains two edges, start and end. Use the [type] to
|
||||||
|
@ -1443,6 +1443,7 @@ class _ScrollableSelectionContainerDelegate extends MultiSelectableSelectionCont
|
|||||||
_selectableStartEdgeUpdateRecords.remove(selectable);
|
_selectableStartEdgeUpdateRecords.remove(selectable);
|
||||||
case SelectionEventType.selectAll:
|
case SelectionEventType.selectAll:
|
||||||
case SelectionEventType.selectWord:
|
case SelectionEventType.selectWord:
|
||||||
|
case SelectionEventType.selectParagraph:
|
||||||
_selectableEndEdgeUpdateRecords[selectable] = state.position.pixels;
|
_selectableEndEdgeUpdateRecords[selectable] = state.position.pixels;
|
||||||
_selectableStartEdgeUpdateRecords[selectable] = state.position.pixels;
|
_selectableStartEdgeUpdateRecords[selectable] = state.position.pixels;
|
||||||
}
|
}
|
||||||
|
@ -486,7 +486,7 @@ class SelectableRegionState extends State<SelectableRegion> with TextSelectionDe
|
|||||||
// This method should be used in all instances when details.consecutiveTapCount
|
// This method should be used in all instances when details.consecutiveTapCount
|
||||||
// would be used.
|
// would be used.
|
||||||
static int _getEffectiveConsecutiveTapCount(int rawCount) {
|
static int _getEffectiveConsecutiveTapCount(int rawCount) {
|
||||||
const int maxConsecutiveTap = 2;
|
const int maxConsecutiveTap = 3;
|
||||||
switch (defaultTargetPlatform) {
|
switch (defaultTargetPlatform) {
|
||||||
case TargetPlatform.android:
|
case TargetPlatform.android:
|
||||||
case TargetPlatform.fuchsia:
|
case TargetPlatform.fuchsia:
|
||||||
@ -555,6 +555,8 @@ class SelectableRegionState extends State<SelectableRegion> with TextSelectionDe
|
|||||||
}
|
}
|
||||||
case 2:
|
case 2:
|
||||||
_selectWordAt(offset: details.globalPosition);
|
_selectWordAt(offset: details.globalPosition);
|
||||||
|
case 3:
|
||||||
|
_selectParagraphAt(offset: details.globalPosition);
|
||||||
}
|
}
|
||||||
_updateSelectedContentIfNeeded();
|
_updateSelectedContentIfNeeded();
|
||||||
}
|
}
|
||||||
@ -573,6 +575,8 @@ class SelectableRegionState extends State<SelectableRegion> with TextSelectionDe
|
|||||||
_selectEndTo(offset: details.globalPosition, continuous: true);
|
_selectEndTo(offset: details.globalPosition, continuous: true);
|
||||||
case 2:
|
case 2:
|
||||||
_selectEndTo(offset: details.globalPosition, continuous: true, textGranularity: TextGranularity.word);
|
_selectEndTo(offset: details.globalPosition, continuous: true, textGranularity: TextGranularity.word);
|
||||||
|
case 3:
|
||||||
|
_selectEndTo(offset: details.globalPosition, continuous: true, textGranularity: TextGranularity.paragraph);
|
||||||
}
|
}
|
||||||
_updateSelectedContentIfNeeded();
|
_updateSelectedContentIfNeeded();
|
||||||
}
|
}
|
||||||
@ -997,6 +1001,7 @@ class SelectableRegionState extends State<SelectableRegion> with TextSelectionDe
|
|||||||
/// * [_finalizeSelection], which stops the `continuous` updates.
|
/// * [_finalizeSelection], which stops the `continuous` updates.
|
||||||
/// * [_clearSelection], which clears the ongoing selection.
|
/// * [_clearSelection], which clears the ongoing selection.
|
||||||
/// * [_selectWordAt], which selects a whole word at the location.
|
/// * [_selectWordAt], which selects a whole word at the location.
|
||||||
|
/// * [_selectParagraphAt], which selects an entire paragraph at the location.
|
||||||
/// * [_collapseSelectionAt], which collapses the selection at the location.
|
/// * [_collapseSelectionAt], which collapses the selection at the location.
|
||||||
/// * [selectAll], which selects the entire content.
|
/// * [selectAll], which selects the entire content.
|
||||||
void _selectEndTo({required Offset offset, bool continuous = false, TextGranularity? textGranularity}) {
|
void _selectEndTo({required Offset offset, bool continuous = false, TextGranularity? textGranularity}) {
|
||||||
@ -1037,6 +1042,7 @@ class SelectableRegionState extends State<SelectableRegion> with TextSelectionDe
|
|||||||
/// * [_finalizeSelection], which stops the `continuous` updates.
|
/// * [_finalizeSelection], which stops the `continuous` updates.
|
||||||
/// * [_clearSelection], which clears the ongoing selection.
|
/// * [_clearSelection], which clears the ongoing selection.
|
||||||
/// * [_selectWordAt], which selects a whole word at the location.
|
/// * [_selectWordAt], which selects a whole word at the location.
|
||||||
|
/// * [_selectParagraphAt], which selects an entire paragraph at the location.
|
||||||
/// * [_collapseSelectionAt], which collapses the selection at the location.
|
/// * [_collapseSelectionAt], which collapses the selection at the location.
|
||||||
/// * [selectAll], which selects the entire content.
|
/// * [selectAll], which selects the entire content.
|
||||||
void _selectStartTo({required Offset offset, bool continuous = false, TextGranularity? textGranularity}) {
|
void _selectStartTo({required Offset offset, bool continuous = false, TextGranularity? textGranularity}) {
|
||||||
@ -1052,12 +1058,15 @@ class SelectableRegionState extends State<SelectableRegion> with TextSelectionDe
|
|||||||
|
|
||||||
/// Collapses the selection at the given `offset` location.
|
/// Collapses the selection at the given `offset` location.
|
||||||
///
|
///
|
||||||
|
/// The `offset` is in global coordinates.
|
||||||
|
///
|
||||||
/// See also:
|
/// See also:
|
||||||
/// * [_selectStartTo], which sets or updates selection start edge.
|
/// * [_selectStartTo], which sets or updates selection start edge.
|
||||||
/// * [_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 clears the ongoing selection.
|
/// * [_clearSelection], which clears the ongoing selection.
|
||||||
/// * [_selectWordAt], which selects a whole word at the location.
|
/// * [_selectWordAt], which selects a whole word at the location.
|
||||||
|
/// * [_selectParagraphAt], which selects an entire paragraph at the location.
|
||||||
/// * [selectAll], which selects the entire content.
|
/// * [selectAll], which selects the entire content.
|
||||||
void _collapseSelectionAt({required Offset offset}) {
|
void _collapseSelectionAt({required Offset offset}) {
|
||||||
_selectStartTo(offset: offset);
|
_selectStartTo(offset: offset);
|
||||||
@ -1066,6 +1075,8 @@ class SelectableRegionState extends State<SelectableRegion> with TextSelectionDe
|
|||||||
|
|
||||||
/// Selects a whole word at the `offset` location.
|
/// Selects a whole word at the `offset` location.
|
||||||
///
|
///
|
||||||
|
/// The `offset` is in global coordinates.
|
||||||
|
///
|
||||||
/// If the whole word is already in the current selection, selection won't
|
/// If the whole word is already in the current selection, selection won't
|
||||||
/// change. One call [_clearSelection] first if the selection needs to be
|
/// change. One call [_clearSelection] first if the selection needs to be
|
||||||
/// updated even if the word is already covered by the current selection.
|
/// updated even if the word is already covered by the current selection.
|
||||||
@ -1079,6 +1090,7 @@ class SelectableRegionState extends State<SelectableRegion> with TextSelectionDe
|
|||||||
/// * [_finalizeSelection], which stops the `continuous` updates.
|
/// * [_finalizeSelection], which stops the `continuous` updates.
|
||||||
/// * [_clearSelection], which clears the ongoing selection.
|
/// * [_clearSelection], which clears the ongoing selection.
|
||||||
/// * [_collapseSelectionAt], which collapses the selection at the location.
|
/// * [_collapseSelectionAt], which collapses the selection at the location.
|
||||||
|
/// * [_selectParagraphAt], which selects an entire paragraph at the location.
|
||||||
/// * [selectAll], which selects the entire content.
|
/// * [selectAll], which selects the entire content.
|
||||||
void _selectWordAt({required Offset offset}) {
|
void _selectWordAt({required Offset offset}) {
|
||||||
// There may be other selection ongoing.
|
// There may be other selection ongoing.
|
||||||
@ -1086,6 +1098,30 @@ class SelectableRegionState extends State<SelectableRegion> with TextSelectionDe
|
|||||||
_selectable?.dispatchSelectionEvent(SelectWordSelectionEvent(globalPosition: offset));
|
_selectable?.dispatchSelectionEvent(SelectWordSelectionEvent(globalPosition: offset));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Selects the entire paragraph at the `offset` location.
|
||||||
|
///
|
||||||
|
/// The `offset` is in global coordinates.
|
||||||
|
///
|
||||||
|
/// If the paragraph is already in the current selection, selection won't
|
||||||
|
/// change. One call [_clearSelection] first if the selection needs to be
|
||||||
|
/// updated even if the paragraph is already covered by the current selection.
|
||||||
|
///
|
||||||
|
/// One can also use [_selectEndTo] or [_selectStartTo] to adjust the selection
|
||||||
|
/// edges after calling this method.
|
||||||
|
///
|
||||||
|
/// See also:
|
||||||
|
/// * [_selectStartTo], which sets or updates selection start edge.
|
||||||
|
/// * [_selectEndTo], which sets or updates selection end edge.
|
||||||
|
/// * [_finalizeSelection], which stops the `continuous` updates.
|
||||||
|
/// * [_clearSelection], which clear the ongoing selection.
|
||||||
|
/// * [_selectWordAt], which selects a whole word at the location.
|
||||||
|
/// * [selectAll], which selects the entire content.
|
||||||
|
void _selectParagraphAt({required Offset offset}) {
|
||||||
|
// There may be other selection ongoing.
|
||||||
|
_finalizeSelection();
|
||||||
|
_selectable?.dispatchSelectionEvent(SelectParagraphSelectionEvent(globalPosition: offset));
|
||||||
|
}
|
||||||
|
|
||||||
/// Stops any ongoing selection updates.
|
/// Stops any ongoing selection updates.
|
||||||
///
|
///
|
||||||
/// This method is different from [_clearSelection] that it does not remove
|
/// This method is different from [_clearSelection] that it does not remove
|
||||||
@ -1598,7 +1634,7 @@ class _SelectableRegionContainerDelegate extends MultiSelectableSelectionContain
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Selects a word in a selectable at the location
|
/// Selects a word in a [Selectable] at the location
|
||||||
/// [SelectWordSelectionEvent.globalPosition].
|
/// [SelectWordSelectionEvent.globalPosition].
|
||||||
@override
|
@override
|
||||||
SelectionResult handleSelectWord(SelectWordSelectionEvent event) {
|
SelectionResult handleSelectWord(SelectWordSelectionEvent event) {
|
||||||
@ -1613,6 +1649,21 @@ class _SelectableRegionContainerDelegate extends MultiSelectableSelectionContain
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Selects a paragraph in a [Selectable] at the location
|
||||||
|
/// [SelectParagraphSelectionEvent.globalPosition].
|
||||||
|
@override
|
||||||
|
SelectionResult handleSelectParagraph(SelectParagraphSelectionEvent event) {
|
||||||
|
final SelectionResult result = super.handleSelectParagraph(event);
|
||||||
|
if (currentSelectionStartIndex != -1) {
|
||||||
|
_hasReceivedStartEvent.add(selectables[currentSelectionStartIndex]);
|
||||||
|
}
|
||||||
|
if (currentSelectionEndIndex != -1) {
|
||||||
|
_hasReceivedEndEvent.add(selectables[currentSelectionEndIndex]);
|
||||||
|
}
|
||||||
|
_updateLastEdgeEventsFromGeometries();
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
SelectionResult handleClearSelection(ClearSelectionEvent event) {
|
SelectionResult handleClearSelection(ClearSelectionEvent event) {
|
||||||
final SelectionResult result = super.handleClearSelection(event);
|
final SelectionResult result = super.handleClearSelection(event);
|
||||||
@ -1654,6 +1705,7 @@ class _SelectableRegionContainerDelegate extends MultiSelectableSelectionContain
|
|||||||
_hasReceivedEndEvent.remove(selectable);
|
_hasReceivedEndEvent.remove(selectable);
|
||||||
case SelectionEventType.selectAll:
|
case SelectionEventType.selectAll:
|
||||||
case SelectionEventType.selectWord:
|
case SelectionEventType.selectWord:
|
||||||
|
case SelectionEventType.selectParagraph:
|
||||||
break;
|
break;
|
||||||
case SelectionEventType.granularlyExtendSelection:
|
case SelectionEventType.granularlyExtendSelection:
|
||||||
case SelectionEventType.directionallyExtendSelection:
|
case SelectionEventType.directionallyExtendSelection:
|
||||||
@ -1709,7 +1761,7 @@ class _SelectableRegionContainerDelegate extends MultiSelectableSelectionContain
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// An abstract base class for updating multiple selectable children.
|
/// An abstract base class for updating multiple [Selectable] children.
|
||||||
///
|
///
|
||||||
/// This class provide basic [SelectionEvent] handling and child [Selectable]
|
/// This class provide basic [SelectionEvent] handling and child [Selectable]
|
||||||
/// updating. The subclass needs to implement [ensureChildUpdated] to ensure
|
/// updating. The subclass needs to implement [ensureChildUpdated] to ensure
|
||||||
@ -1725,7 +1777,7 @@ abstract class MultiSelectableSelectionContainerDelegate extends SelectionContai
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Gets the list of selectables this delegate is managing.
|
/// Gets the list of [Selectable]s this delegate is managing.
|
||||||
List<Selectable> selectables = <Selectable>[];
|
List<Selectable> selectables = <Selectable>[];
|
||||||
|
|
||||||
/// The number of additional pixels added to the selection handle drawable
|
/// The number of additional pixels added to the selection handle drawable
|
||||||
@ -1741,11 +1793,11 @@ abstract class MultiSelectableSelectionContainerDelegate extends SelectionContai
|
|||||||
/// This was an eyeballed value to create smooth user experiences.
|
/// This was an eyeballed value to create smooth user experiences.
|
||||||
static const double _kSelectionHandleDrawableAreaPadding = 5.0;
|
static const double _kSelectionHandleDrawableAreaPadding = 5.0;
|
||||||
|
|
||||||
/// The current selectable that contains the selection end edge.
|
/// The current [Selectable] that contains the selection end edge.
|
||||||
@protected
|
@protected
|
||||||
int currentSelectionEndIndex = -1;
|
int currentSelectionEndIndex = -1;
|
||||||
|
|
||||||
/// The current selectable that contains the selection start edge.
|
/// The current [Selectable] that contains the selection start edge.
|
||||||
@protected
|
@protected
|
||||||
int currentSelectionStartIndex = -1;
|
int currentSelectionStartIndex = -1;
|
||||||
|
|
||||||
@ -1881,7 +1933,7 @@ abstract class MultiSelectableSelectionContainerDelegate extends SelectionContai
|
|||||||
selectable.removeListener(_handleSelectableGeometryChange);
|
selectable.removeListener(_handleSelectableGeometryChange);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Called when this delegate finishes updating the selectables.
|
/// Called when this delegate finishes updating the [Selectable]s.
|
||||||
@protected
|
@protected
|
||||||
@mustCallSuper
|
@mustCallSuper
|
||||||
void didChangeSelectables() {
|
void didChangeSelectables() {
|
||||||
@ -1905,7 +1957,7 @@ abstract class MultiSelectableSelectionContainerDelegate extends SelectionContai
|
|||||||
_updateHandleLayersAndOwners();
|
_updateHandleLayersAndOwners();
|
||||||
}
|
}
|
||||||
|
|
||||||
Rect _getBoundingBox(Selectable selectable) {
|
static Rect _getBoundingBox(Selectable selectable) {
|
||||||
Rect result = selectable.boundingBoxes.first;
|
Rect result = selectable.boundingBoxes.first;
|
||||||
for (int index = 1; index < selectable.boundingBoxes.length; index += 1) {
|
for (int index = 1; index < selectable.boundingBoxes.length; index += 1) {
|
||||||
result = result.expandToInclude(selectable.boundingBoxes[index]);
|
result = result.expandToInclude(selectable.boundingBoxes[index]);
|
||||||
@ -1920,7 +1972,7 @@ abstract class MultiSelectableSelectionContainerDelegate extends SelectionContai
|
|||||||
@protected
|
@protected
|
||||||
Comparator<Selectable> get compareOrder => _compareScreenOrder;
|
Comparator<Selectable> get compareOrder => _compareScreenOrder;
|
||||||
|
|
||||||
int _compareScreenOrder(Selectable a, Selectable b) {
|
static int _compareScreenOrder(Selectable a, Selectable b) {
|
||||||
final Rect rectA = MatrixUtils.transformRect(
|
final Rect rectA = MatrixUtils.transformRect(
|
||||||
a.getTransformTo(null),
|
a.getTransformTo(null),
|
||||||
_getBoundingBox(a),
|
_getBoundingBox(a),
|
||||||
@ -1982,7 +2034,7 @@ abstract class MultiSelectableSelectionContainerDelegate extends SelectionContai
|
|||||||
_updateSelectionGeometry();
|
_updateSelectionGeometry();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Gets the combined selection geometry for child selectables.
|
/// Gets the combined [SelectionGeometry] for child [Selectable]s.
|
||||||
@protected
|
@protected
|
||||||
SelectionGeometry getSelectionGeometry() {
|
SelectionGeometry getSelectionGeometry() {
|
||||||
if (currentSelectionEndIndex == -1 ||
|
if (currentSelectionEndIndex == -1 ||
|
||||||
@ -2158,7 +2210,7 @@ abstract class MultiSelectableSelectionContainerDelegate extends SelectionContai
|
|||||||
_endHandleLayerOwner!.pushHandleLayers(null, effectiveEndHandle);
|
_endHandleLayerOwner!.pushHandleLayers(null, effectiveEndHandle);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Copies the selected contents of all selectables.
|
/// Copies the selected contents of all [Selectable]s.
|
||||||
@override
|
@override
|
||||||
SelectedContent? getSelectedContent() {
|
SelectedContent? getSelectedContent() {
|
||||||
final List<SelectedContent> selections = <SelectedContent>[];
|
final List<SelectedContent> selections = <SelectedContent>[];
|
||||||
@ -2208,7 +2260,7 @@ abstract class MultiSelectableSelectionContainerDelegate extends SelectionContai
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Selects all contents of all selectables.
|
/// Selects all contents of all [Selectable]s.
|
||||||
@protected
|
@protected
|
||||||
SelectionResult handleSelectAll(SelectAllSelectionEvent event) {
|
SelectionResult handleSelectAll(SelectAllSelectionEvent event) {
|
||||||
for (final Selectable selectable in selectables) {
|
for (final Selectable selectable in selectables) {
|
||||||
@ -2219,23 +2271,27 @@ abstract class MultiSelectableSelectionContainerDelegate extends SelectionContai
|
|||||||
return SelectionResult.none;
|
return SelectionResult.none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Selects a word in a selectable at the location
|
SelectionResult _handleSelectBoundary(SelectionEvent event) {
|
||||||
/// [SelectWordSelectionEvent.globalPosition].
|
assert(event is SelectWordSelectionEvent || event is SelectParagraphSelectionEvent, 'This method should only be given selection events that select text boundaries.');
|
||||||
@protected
|
late final Offset effectiveGlobalPosition;
|
||||||
SelectionResult handleSelectWord(SelectWordSelectionEvent event) {
|
if (event.type == SelectionEventType.selectWord) {
|
||||||
|
effectiveGlobalPosition = (event as SelectWordSelectionEvent).globalPosition;
|
||||||
|
} else if (event.type == SelectionEventType.selectParagraph) {
|
||||||
|
effectiveGlobalPosition = (event as SelectParagraphSelectionEvent).globalPosition;
|
||||||
|
}
|
||||||
SelectionResult? lastSelectionResult;
|
SelectionResult? lastSelectionResult;
|
||||||
for (int index = 0; index < selectables.length; index += 1) {
|
for (int index = 0; index < selectables.length; index += 1) {
|
||||||
bool globalRectsContainsPosition = false;
|
bool globalRectsContainPosition = false;
|
||||||
if (selectables[index].boundingBoxes.isNotEmpty) {
|
if (selectables[index].boundingBoxes.isNotEmpty) {
|
||||||
for (final Rect rect in selectables[index].boundingBoxes) {
|
for (final Rect rect in selectables[index].boundingBoxes) {
|
||||||
final Rect globalRect = MatrixUtils.transformRect(selectables[index].getTransformTo(null), rect);
|
final Rect globalRect = MatrixUtils.transformRect(selectables[index].getTransformTo(null), rect);
|
||||||
if (globalRect.contains(event.globalPosition)) {
|
if (globalRect.contains(effectiveGlobalPosition)) {
|
||||||
globalRectsContainsPosition = true;
|
globalRectsContainPosition = true;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (globalRectsContainsPosition) {
|
if (globalRectsContainPosition) {
|
||||||
final SelectionGeometry existingGeometry = selectables[index].value;
|
final SelectionGeometry existingGeometry = selectables[index].value;
|
||||||
lastSelectionResult = dispatchSelectionEventToChild(selectables[index], event);
|
lastSelectionResult = dispatchSelectionEventToChild(selectables[index], event);
|
||||||
if (index == selectables.length - 1 && lastSelectionResult == SelectionResult.next) {
|
if (index == selectables.length - 1 && lastSelectionResult == SelectionResult.next) {
|
||||||
@ -2267,7 +2323,21 @@ abstract class MultiSelectableSelectionContainerDelegate extends SelectionContai
|
|||||||
return SelectionResult.end;
|
return SelectionResult.end;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Removes the selection of all selectables this delegate manages.
|
/// Selects a word in a [Selectable] at the location
|
||||||
|
/// [SelectWordSelectionEvent.globalPosition].
|
||||||
|
@protected
|
||||||
|
SelectionResult handleSelectWord(SelectWordSelectionEvent event) {
|
||||||
|
return _handleSelectBoundary(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Selects a paragraph in a [Selectable] at the location
|
||||||
|
/// [SelectParagraphSelectionEvent.globalPosition].
|
||||||
|
@protected
|
||||||
|
SelectionResult handleSelectParagraph(SelectParagraphSelectionEvent event) {
|
||||||
|
return _handleSelectBoundary(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Removes the selection of all [Selectable]s this delegate manages.
|
||||||
@protected
|
@protected
|
||||||
SelectionResult handleClearSelection(ClearSelectionEvent event) {
|
SelectionResult handleClearSelection(ClearSelectionEvent event) {
|
||||||
for (final Selectable selectable in selectables) {
|
for (final Selectable selectable in selectables) {
|
||||||
@ -2278,7 +2348,7 @@ abstract class MultiSelectableSelectionContainerDelegate extends SelectionContai
|
|||||||
return SelectionResult.none;
|
return SelectionResult.none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Extend current selection in a certain text granularity.
|
/// Extend current selection in a certain [TextGranularity].
|
||||||
@protected
|
@protected
|
||||||
SelectionResult handleGranularlyExtendSelection(GranularlyExtendSelectionEvent event) {
|
SelectionResult handleGranularlyExtendSelection(GranularlyExtendSelectionEvent event) {
|
||||||
assert((currentSelectionStartIndex == -1) == (currentSelectionEndIndex == -1));
|
assert((currentSelectionStartIndex == -1) == (currentSelectionEndIndex == -1));
|
||||||
@ -2314,13 +2384,13 @@ abstract class MultiSelectableSelectionContainerDelegate extends SelectionContai
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Extend current selection in a certain text granularity.
|
/// Extend current selection in a certain [TextGranularity].
|
||||||
@protected
|
@protected
|
||||||
SelectionResult handleDirectionallyExtendSelection(DirectionallyExtendSelectionEvent event) {
|
SelectionResult handleDirectionallyExtendSelection(DirectionallyExtendSelectionEvent event) {
|
||||||
assert((currentSelectionStartIndex == -1) == (currentSelectionEndIndex == -1));
|
assert((currentSelectionStartIndex == -1) == (currentSelectionEndIndex == -1));
|
||||||
if (currentSelectionStartIndex == -1) {
|
if (currentSelectionStartIndex == -1) {
|
||||||
currentSelectionStartIndex = currentSelectionEndIndex = switch (event.direction) {
|
currentSelectionStartIndex = currentSelectionEndIndex = switch (event.direction) {
|
||||||
SelectionExtendDirection.previousLine || SelectionExtendDirection.backward => selectables.length,
|
SelectionExtendDirection.previousLine || SelectionExtendDirection.backward => selectables.length - 1,
|
||||||
SelectionExtendDirection.nextLine || SelectionExtendDirection.forward => 0,
|
SelectionExtendDirection.nextLine || SelectionExtendDirection.forward => 0,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -2396,6 +2466,9 @@ abstract class MultiSelectableSelectionContainerDelegate extends SelectionContai
|
|||||||
case SelectionEventType.selectWord:
|
case SelectionEventType.selectWord:
|
||||||
_extendSelectionInProgress = false;
|
_extendSelectionInProgress = false;
|
||||||
result = handleSelectWord(event as SelectWordSelectionEvent);
|
result = handleSelectWord(event as SelectWordSelectionEvent);
|
||||||
|
case SelectionEventType.selectParagraph:
|
||||||
|
_extendSelectionInProgress = false;
|
||||||
|
result = handleSelectParagraph(event as SelectParagraphSelectionEvent);
|
||||||
case SelectionEventType.granularlyExtendSelection:
|
case SelectionEventType.granularlyExtendSelection:
|
||||||
_extendSelectionInProgress = true;
|
_extendSelectionInProgress = true;
|
||||||
result = handleGranularlyExtendSelection(event as GranularlyExtendSelectionEvent);
|
result = handleGranularlyExtendSelection(event as GranularlyExtendSelectionEvent);
|
||||||
@ -2418,7 +2491,7 @@ abstract class MultiSelectableSelectionContainerDelegate extends SelectionContai
|
|||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Ensures the selectable child has received up to date selection event.
|
/// Ensures the [Selectable] child has received up to date selection event.
|
||||||
///
|
///
|
||||||
/// This method is called when a new [Selectable] is added to the delegate,
|
/// This method is called when a new [Selectable] is added to the delegate,
|
||||||
/// and its screen location falls into the previous selection.
|
/// and its screen location falls into the previous selection.
|
||||||
@ -2428,10 +2501,10 @@ abstract class MultiSelectableSelectionContainerDelegate extends SelectionContai
|
|||||||
@protected
|
@protected
|
||||||
void ensureChildUpdated(Selectable selectable);
|
void ensureChildUpdated(Selectable selectable);
|
||||||
|
|
||||||
/// Dispatches a selection event to a specific selectable.
|
/// Dispatches a selection event to a specific [Selectable].
|
||||||
///
|
///
|
||||||
/// Override this method if subclasses need to generate additional events or
|
/// Override this method if subclasses need to generate additional events or
|
||||||
/// treatments prior to sending the selection events.
|
/// treatments prior to sending the [SelectionEvent].
|
||||||
@protected
|
@protected
|
||||||
SelectionResult dispatchSelectionEventToChild(Selectable selectable, SelectionEvent event) {
|
SelectionResult dispatchSelectionEventToChild(Selectable selectable, SelectionEvent event) {
|
||||||
return selectable.dispatchSelectionEvent(event);
|
return selectable.dispatchSelectionEvent(event);
|
||||||
|
@ -2,8 +2,10 @@
|
|||||||
// Use of this source code is governed by a BSD-style license that can be
|
// Use of this source code is governed by a BSD-style license that can be
|
||||||
// found in the LICENSE file.
|
// found in the LICENSE file.
|
||||||
|
|
||||||
|
import 'dart:math';
|
||||||
import 'dart:ui' as ui show TextHeightBehavior;
|
import 'dart:ui' as ui show TextHeightBehavior;
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/rendering.dart';
|
import 'package:flutter/rendering.dart';
|
||||||
|
|
||||||
import 'basic.dart';
|
import 'basic.dart';
|
||||||
@ -11,6 +13,7 @@ import 'default_selection_style.dart';
|
|||||||
import 'framework.dart';
|
import 'framework.dart';
|
||||||
import 'inherited_theme.dart';
|
import 'inherited_theme.dart';
|
||||||
import 'media_query.dart';
|
import 'media_query.dart';
|
||||||
|
import 'selectable_region.dart';
|
||||||
import 'selection_container.dart';
|
import 'selection_container.dart';
|
||||||
|
|
||||||
// Examples can assume:
|
// Examples can assume:
|
||||||
@ -653,8 +656,11 @@ class Text extends StatelessWidget {
|
|||||||
(null, final double textScaleFactor) => TextScaler.linear(textScaleFactor),
|
(null, final double textScaleFactor) => TextScaler.linear(textScaleFactor),
|
||||||
(null, null) => MediaQuery.textScalerOf(context),
|
(null, null) => MediaQuery.textScalerOf(context),
|
||||||
};
|
};
|
||||||
|
late Widget result;
|
||||||
Widget result = RichText(
|
if (registrar != null) {
|
||||||
|
result = MouseRegion(
|
||||||
|
cursor: DefaultSelectionStyle.of(context).mouseCursor ?? SystemMouseCursors.text,
|
||||||
|
child: _SelectableTextContainer(
|
||||||
textAlign: textAlign ?? defaultTextStyle.textAlign ?? TextAlign.start,
|
textAlign: textAlign ?? defaultTextStyle.textAlign ?? TextAlign.start,
|
||||||
textDirection: textDirection, // RichText uses Directionality.of to obtain a default if this is null.
|
textDirection: textDirection, // RichText uses Directionality.of to obtain a default if this is null.
|
||||||
locale: locale, // RichText uses Localizations.localeOf to obtain a default if this is null
|
locale: locale, // RichText uses Localizations.localeOf to obtain a default if this is null
|
||||||
@ -665,18 +671,32 @@ class Text extends StatelessWidget {
|
|||||||
strutStyle: strutStyle,
|
strutStyle: strutStyle,
|
||||||
textWidthBasis: textWidthBasis ?? defaultTextStyle.textWidthBasis,
|
textWidthBasis: textWidthBasis ?? defaultTextStyle.textWidthBasis,
|
||||||
textHeightBehavior: textHeightBehavior ?? defaultTextStyle.textHeightBehavior ?? DefaultTextHeightBehavior.maybeOf(context),
|
textHeightBehavior: textHeightBehavior ?? defaultTextStyle.textHeightBehavior ?? DefaultTextHeightBehavior.maybeOf(context),
|
||||||
selectionRegistrar: registrar,
|
|
||||||
selectionColor: selectionColor ?? DefaultSelectionStyle.of(context).selectionColor ?? DefaultSelectionStyle.defaultColor,
|
selectionColor: selectionColor ?? DefaultSelectionStyle.of(context).selectionColor ?? DefaultSelectionStyle.defaultColor,
|
||||||
text: TextSpan(
|
text: TextSpan(
|
||||||
style: effectiveTextStyle,
|
style: effectiveTextStyle,
|
||||||
text: data,
|
text: data,
|
||||||
children: textSpan != null ? <InlineSpan>[textSpan!] : null,
|
children: textSpan != null ? <InlineSpan>[textSpan!] : null,
|
||||||
),
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
if (registrar != null) {
|
} else {
|
||||||
result = MouseRegion(
|
result = RichText(
|
||||||
cursor: DefaultSelectionStyle.of(context).mouseCursor ?? SystemMouseCursors.text,
|
textAlign: textAlign ?? defaultTextStyle.textAlign ?? TextAlign.start,
|
||||||
child: result,
|
textDirection: textDirection, // RichText uses Directionality.of to obtain a default if this is null.
|
||||||
|
locale: locale, // RichText uses Localizations.localeOf to obtain a default if this is null
|
||||||
|
softWrap: softWrap ?? defaultTextStyle.softWrap,
|
||||||
|
overflow: overflow ?? effectiveTextStyle?.overflow ?? defaultTextStyle.overflow,
|
||||||
|
textScaler: textScaler,
|
||||||
|
maxLines: maxLines ?? defaultTextStyle.maxLines,
|
||||||
|
strutStyle: strutStyle,
|
||||||
|
textWidthBasis: textWidthBasis ?? defaultTextStyle.textWidthBasis,
|
||||||
|
textHeightBehavior: textHeightBehavior ?? defaultTextStyle.textHeightBehavior ?? DefaultTextHeightBehavior.maybeOf(context),
|
||||||
|
selectionColor: selectionColor ?? DefaultSelectionStyle.of(context).selectionColor ?? DefaultSelectionStyle.defaultColor,
|
||||||
|
text: TextSpan(
|
||||||
|
style: effectiveTextStyle,
|
||||||
|
text: data,
|
||||||
|
children: textSpan != null ? <InlineSpan>[textSpan!] : null,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (semanticsLabel != null) {
|
if (semanticsLabel != null) {
|
||||||
@ -713,3 +733,671 @@ class Text extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _SelectableTextContainer extends StatefulWidget {
|
||||||
|
const _SelectableTextContainer({
|
||||||
|
required this.text,
|
||||||
|
required this.textAlign,
|
||||||
|
this.textDirection,
|
||||||
|
required this.softWrap,
|
||||||
|
required this.overflow,
|
||||||
|
required this.textScaler,
|
||||||
|
this.maxLines,
|
||||||
|
this.locale,
|
||||||
|
this.strutStyle,
|
||||||
|
required this.textWidthBasis,
|
||||||
|
this.textHeightBehavior,
|
||||||
|
required this.selectionColor,
|
||||||
|
});
|
||||||
|
|
||||||
|
final InlineSpan text;
|
||||||
|
final TextAlign textAlign;
|
||||||
|
final TextDirection? textDirection;
|
||||||
|
final bool softWrap;
|
||||||
|
final TextOverflow overflow;
|
||||||
|
final TextScaler textScaler;
|
||||||
|
final int? maxLines;
|
||||||
|
final Locale? locale;
|
||||||
|
final StrutStyle? strutStyle;
|
||||||
|
final TextWidthBasis textWidthBasis;
|
||||||
|
final ui.TextHeightBehavior? textHeightBehavior;
|
||||||
|
final Color selectionColor;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_SelectableTextContainer> createState() => _SelectableTextContainerState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SelectableTextContainerState extends State<_SelectableTextContainer> {
|
||||||
|
late final _SelectableTextContainerDelegate _selectionDelegate;
|
||||||
|
final GlobalKey _textKey = GlobalKey();
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_selectionDelegate = _SelectableTextContainerDelegate(_textKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_selectionDelegate.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return SelectionContainer(
|
||||||
|
delegate: _selectionDelegate,
|
||||||
|
// Use [_RichText] wrapper so the underlying [RenderParagraph] can register
|
||||||
|
// its [Selectable]s to the [SelectionContainer] created by this widget.
|
||||||
|
child: _RichText(
|
||||||
|
textKey: _textKey,
|
||||||
|
textAlign: widget.textAlign,
|
||||||
|
textDirection: widget.textDirection,
|
||||||
|
locale: widget.locale,
|
||||||
|
softWrap: widget.softWrap,
|
||||||
|
overflow: widget.overflow,
|
||||||
|
textScaler: widget.textScaler,
|
||||||
|
maxLines: widget.maxLines,
|
||||||
|
strutStyle: widget.strutStyle,
|
||||||
|
textWidthBasis: widget.textWidthBasis,
|
||||||
|
textHeightBehavior: widget.textHeightBehavior,
|
||||||
|
selectionColor: widget.selectionColor,
|
||||||
|
text: widget.text,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _RichText extends StatelessWidget {
|
||||||
|
const _RichText({
|
||||||
|
this.textKey,
|
||||||
|
required this.text,
|
||||||
|
required this.textAlign,
|
||||||
|
this.textDirection,
|
||||||
|
required this.softWrap,
|
||||||
|
required this.overflow,
|
||||||
|
required this.textScaler,
|
||||||
|
this.maxLines,
|
||||||
|
this.locale,
|
||||||
|
this.strutStyle,
|
||||||
|
required this.textWidthBasis,
|
||||||
|
this.textHeightBehavior,
|
||||||
|
required this.selectionColor,
|
||||||
|
});
|
||||||
|
|
||||||
|
final GlobalKey? textKey;
|
||||||
|
final InlineSpan text;
|
||||||
|
final TextAlign textAlign;
|
||||||
|
final TextDirection? textDirection;
|
||||||
|
final bool softWrap;
|
||||||
|
final TextOverflow overflow;
|
||||||
|
final TextScaler textScaler;
|
||||||
|
final int? maxLines;
|
||||||
|
final Locale? locale;
|
||||||
|
final StrutStyle? strutStyle;
|
||||||
|
final TextWidthBasis textWidthBasis;
|
||||||
|
final ui.TextHeightBehavior? textHeightBehavior;
|
||||||
|
final Color selectionColor;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final SelectionRegistrar? registrar = SelectionContainer.maybeOf(context);
|
||||||
|
return RichText(
|
||||||
|
key: textKey,
|
||||||
|
textAlign: textAlign,
|
||||||
|
textDirection: textDirection,
|
||||||
|
locale: locale,
|
||||||
|
softWrap: softWrap,
|
||||||
|
overflow: overflow,
|
||||||
|
textScaler: textScaler,
|
||||||
|
maxLines: maxLines,
|
||||||
|
strutStyle: strutStyle,
|
||||||
|
textWidthBasis: textWidthBasis,
|
||||||
|
textHeightBehavior: textHeightBehavior,
|
||||||
|
selectionRegistrar: registrar,
|
||||||
|
selectionColor: selectionColor,
|
||||||
|
text: text,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// In practice some selectables like widgetspan shift several pixels. So when
|
||||||
|
// the vertical position diff is within the threshold, compare the horizontal
|
||||||
|
// position to make the compareScreenOrder function more robust.
|
||||||
|
const double _kSelectableVerticalComparingThreshold = 3.0;
|
||||||
|
|
||||||
|
class _SelectableTextContainerDelegate extends MultiSelectableSelectionContainerDelegate {
|
||||||
|
_SelectableTextContainerDelegate(
|
||||||
|
GlobalKey textKey,
|
||||||
|
) : _textKey = textKey;
|
||||||
|
|
||||||
|
final GlobalKey _textKey;
|
||||||
|
RenderParagraph get paragraph => _textKey.currentContext!.findRenderObject()! as RenderParagraph;
|
||||||
|
|
||||||
|
@override
|
||||||
|
SelectionResult handleSelectParagraph(SelectParagraphSelectionEvent event) {
|
||||||
|
final SelectionResult result = _handleSelectParagraph(event);
|
||||||
|
if (currentSelectionStartIndex != -1) {
|
||||||
|
_hasReceivedStartEvent.add(selectables[currentSelectionStartIndex]);
|
||||||
|
}
|
||||||
|
if (currentSelectionEndIndex != -1) {
|
||||||
|
_hasReceivedEndEvent.add(selectables[currentSelectionEndIndex]);
|
||||||
|
}
|
||||||
|
_updateLastEdgeEventsFromGeometries();
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
SelectionResult _handleSelectParagraph(SelectParagraphSelectionEvent event) {
|
||||||
|
if (event.absorb) {
|
||||||
|
for (int index = 0; index < selectables.length; index += 1) {
|
||||||
|
dispatchSelectionEventToChild(selectables[index], event);
|
||||||
|
}
|
||||||
|
currentSelectionStartIndex = 0;
|
||||||
|
currentSelectionEndIndex = selectables.length - 1;
|
||||||
|
return SelectionResult.next;
|
||||||
|
}
|
||||||
|
|
||||||
|
// First pass, if the position is on a placeholder then dispatch the selection
|
||||||
|
// event to the [Selectable] at the location and terminate.
|
||||||
|
for (int index = 0; index < selectables.length; index += 1) {
|
||||||
|
final bool selectableIsPlaceholder = !paragraph.selectableBelongsToParagraph(selectables[index]);
|
||||||
|
if (selectableIsPlaceholder && selectables[index].boundingBoxes.isNotEmpty) {
|
||||||
|
for (final Rect rect in selectables[index].boundingBoxes) {
|
||||||
|
final Rect globalRect = MatrixUtils.transformRect(selectables[index].getTransformTo(null), rect);
|
||||||
|
if (globalRect.contains(event.globalPosition)) {
|
||||||
|
currentSelectionStartIndex = currentSelectionEndIndex = index;
|
||||||
|
return dispatchSelectionEventToChild(selectables[index], event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SelectionResult? lastSelectionResult;
|
||||||
|
bool foundStart = false;
|
||||||
|
int? lastNextIndex;
|
||||||
|
for (int index = 0; index < selectables.length; index += 1) {
|
||||||
|
if (!paragraph.selectableBelongsToParagraph(selectables[index])) {
|
||||||
|
if (foundStart) {
|
||||||
|
final SelectionEvent synthesizedEvent = SelectParagraphSelectionEvent(globalPosition: event.globalPosition, absorb: true);
|
||||||
|
final SelectionResult result = dispatchSelectionEventToChild(selectables[index], synthesizedEvent);
|
||||||
|
if (selectables.length - 1 == index) {
|
||||||
|
currentSelectionEndIndex = index;
|
||||||
|
_flushInactiveSelections();
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
final SelectionGeometry existingGeometry = selectables[index].value;
|
||||||
|
lastSelectionResult = dispatchSelectionEventToChild(selectables[index], event);
|
||||||
|
if (index == selectables.length - 1 && lastSelectionResult == SelectionResult.next) {
|
||||||
|
if (foundStart) {
|
||||||
|
currentSelectionEndIndex = index;
|
||||||
|
} else {
|
||||||
|
currentSelectionStartIndex = currentSelectionEndIndex = index;
|
||||||
|
}
|
||||||
|
return SelectionResult.next;
|
||||||
|
}
|
||||||
|
if (lastSelectionResult == SelectionResult.next) {
|
||||||
|
if (selectables[index].value == existingGeometry && !foundStart) {
|
||||||
|
lastNextIndex = index;
|
||||||
|
}
|
||||||
|
if (selectables[index].value != existingGeometry && !foundStart) {
|
||||||
|
assert(selectables[index].boundingBoxes.isNotEmpty);
|
||||||
|
assert(selectables[index].value.selectionRects.isNotEmpty);
|
||||||
|
final bool selectionAtStartOfSelectable = selectables[index].boundingBoxes[0].overlaps(selectables[index].value.selectionRects[0]);
|
||||||
|
int startIndex = 0;
|
||||||
|
if (lastNextIndex != null && selectionAtStartOfSelectable) {
|
||||||
|
startIndex = lastNextIndex + 1;
|
||||||
|
} else {
|
||||||
|
startIndex = lastNextIndex == null && selectionAtStartOfSelectable ? 0 : index;
|
||||||
|
}
|
||||||
|
for (int i = startIndex; i < index; i += 1) {
|
||||||
|
final SelectionEvent synthesizedEvent = SelectParagraphSelectionEvent(globalPosition: event.globalPosition, absorb: true);
|
||||||
|
dispatchSelectionEventToChild(selectables[i], synthesizedEvent);
|
||||||
|
}
|
||||||
|
currentSelectionStartIndex = startIndex;
|
||||||
|
foundStart = true;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (index == 0 && lastSelectionResult == SelectionResult.previous) {
|
||||||
|
return SelectionResult.previous;
|
||||||
|
}
|
||||||
|
if (selectables[index].value != existingGeometry) {
|
||||||
|
if (!foundStart && lastNextIndex == null) {
|
||||||
|
currentSelectionStartIndex = 0;
|
||||||
|
for (int i = 0; i < index; i += 1) {
|
||||||
|
final SelectionEvent synthesizedEvent = SelectParagraphSelectionEvent(globalPosition: event.globalPosition, absorb: true);
|
||||||
|
dispatchSelectionEventToChild(selectables[i], synthesizedEvent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
currentSelectionEndIndex = index;
|
||||||
|
// Geometry has changed as a result of select paragraph, need to clear the
|
||||||
|
// selection of other selectables to keep selection in sync.
|
||||||
|
_flushInactiveSelections();
|
||||||
|
}
|
||||||
|
return SelectionResult.end;
|
||||||
|
}
|
||||||
|
assert(lastSelectionResult == null);
|
||||||
|
return SelectionResult.end;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Initializes the selection of the selectable children.
|
||||||
|
///
|
||||||
|
/// The goal is to find the selectable child that contains the selection edge.
|
||||||
|
/// Returns [SelectionResult.end] if the selection edge ends on any of the
|
||||||
|
/// children. Otherwise, it returns [SelectionResult.previous] if the selection
|
||||||
|
/// does not reach any of its children. Returns [SelectionResult.next]
|
||||||
|
/// if the selection reaches the end of its children.
|
||||||
|
///
|
||||||
|
/// Ideally, this method should only be called twice at the beginning of the
|
||||||
|
/// drag selection, once for start edge update event, once for end edge update
|
||||||
|
/// event.
|
||||||
|
SelectionResult _initSelection(SelectionEdgeUpdateEvent event, {required bool isEnd}) {
|
||||||
|
assert((isEnd && currentSelectionEndIndex == -1) || (!isEnd && currentSelectionStartIndex == -1));
|
||||||
|
SelectionResult? finalResult;
|
||||||
|
// Begin the search for the selection edge at the opposite edge if it exists.
|
||||||
|
final bool hasOppositeEdge = isEnd ? currentSelectionStartIndex != -1 : currentSelectionEndIndex != -1;
|
||||||
|
int newIndex = switch ((isEnd, hasOppositeEdge)) {
|
||||||
|
(true, true) => currentSelectionStartIndex,
|
||||||
|
(true, false) => 0,
|
||||||
|
(false, true) => currentSelectionEndIndex,
|
||||||
|
(false, false) => 0,
|
||||||
|
};
|
||||||
|
bool? forward;
|
||||||
|
late SelectionResult currentSelectableResult;
|
||||||
|
// This loop sends the selection event to one of the following to determine
|
||||||
|
// the direction of the search.
|
||||||
|
// - The opposite edge index if it exists.
|
||||||
|
// - Index 0 if the opposite edge index does not exist.
|
||||||
|
//
|
||||||
|
// If the result is `SelectionResult.next`, this loop look backward.
|
||||||
|
// Otherwise, it looks forward.
|
||||||
|
//
|
||||||
|
// The terminate condition are:
|
||||||
|
// 1. the selectable returns end, pending, none.
|
||||||
|
// 2. the selectable returns previous when looking forward.
|
||||||
|
// 2. the selectable returns next when looking backward.
|
||||||
|
while (newIndex < selectables.length && newIndex >= 0 && finalResult == null) {
|
||||||
|
currentSelectableResult = dispatchSelectionEventToChild(selectables[newIndex], event);
|
||||||
|
switch (currentSelectableResult) {
|
||||||
|
case SelectionResult.end:
|
||||||
|
case SelectionResult.pending:
|
||||||
|
case SelectionResult.none:
|
||||||
|
finalResult = currentSelectableResult;
|
||||||
|
case SelectionResult.next:
|
||||||
|
if (forward == false) {
|
||||||
|
newIndex += 1;
|
||||||
|
finalResult = SelectionResult.end;
|
||||||
|
} else if (newIndex == selectables.length - 1) {
|
||||||
|
finalResult = currentSelectableResult;
|
||||||
|
} else {
|
||||||
|
forward = true;
|
||||||
|
newIndex += 1;
|
||||||
|
}
|
||||||
|
case SelectionResult.previous:
|
||||||
|
if (forward ?? false) {
|
||||||
|
newIndex -= 1;
|
||||||
|
finalResult = SelectionResult.end;
|
||||||
|
} else if (newIndex == 0) {
|
||||||
|
finalResult = currentSelectableResult;
|
||||||
|
} else {
|
||||||
|
forward = false;
|
||||||
|
newIndex -= 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (isEnd) {
|
||||||
|
currentSelectionEndIndex = newIndex;
|
||||||
|
} else {
|
||||||
|
currentSelectionStartIndex = newIndex;
|
||||||
|
}
|
||||||
|
_flushInactiveSelections();
|
||||||
|
return finalResult!;
|
||||||
|
}
|
||||||
|
|
||||||
|
SelectionResult _adjustSelection(SelectionEdgeUpdateEvent event, {required bool isEnd}) {
|
||||||
|
assert(() {
|
||||||
|
if (isEnd) {
|
||||||
|
assert(currentSelectionEndIndex < selectables.length && currentSelectionEndIndex >= 0);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
assert(currentSelectionStartIndex < selectables.length && currentSelectionStartIndex >= 0);
|
||||||
|
return true;
|
||||||
|
}());
|
||||||
|
SelectionResult? finalResult;
|
||||||
|
// Determines if the edge being adjusted is within the current viewport.
|
||||||
|
// - If so, we begin the search for the new selection edge position at the
|
||||||
|
// currentSelectionEndIndex/currentSelectionStartIndex.
|
||||||
|
// - If not, we attempt to locate the new selection edge starting from
|
||||||
|
// the opposite end.
|
||||||
|
// - If neither edge is in the current viewport, the search for the new
|
||||||
|
// selection edge position begins at 0.
|
||||||
|
//
|
||||||
|
// This can happen when there is a scrollable child and the edge being adjusted
|
||||||
|
// has been scrolled out of view.
|
||||||
|
final bool isCurrentEdgeWithinViewport = isEnd ? value.endSelectionPoint != null : value.startSelectionPoint != null;
|
||||||
|
final bool isOppositeEdgeWithinViewport = isEnd ? value.startSelectionPoint != null : value.endSelectionPoint != null;
|
||||||
|
int newIndex = switch ((isEnd, isCurrentEdgeWithinViewport, isOppositeEdgeWithinViewport)) {
|
||||||
|
(true, true, true) => currentSelectionEndIndex,
|
||||||
|
(true, true, false) => currentSelectionEndIndex,
|
||||||
|
(true, false, true) => currentSelectionStartIndex,
|
||||||
|
(true, false, false) => 0,
|
||||||
|
(false, true, true) => currentSelectionStartIndex,
|
||||||
|
(false, true, false) => currentSelectionStartIndex,
|
||||||
|
(false, false, true) => currentSelectionEndIndex,
|
||||||
|
(false, false, false) => 0,
|
||||||
|
};
|
||||||
|
bool? forward;
|
||||||
|
late SelectionResult currentSelectableResult;
|
||||||
|
// This loop sends the selection event to one of the following to determine
|
||||||
|
// the direction of the search.
|
||||||
|
// - currentSelectionEndIndex/currentSelectionStartIndex if the current edge
|
||||||
|
// is in the current viewport.
|
||||||
|
// - The opposite edge index if the current edge is not in the current viewport.
|
||||||
|
// - Index 0 if neither edge is in the current viewport.
|
||||||
|
//
|
||||||
|
// If the result is `SelectionResult.next`, this loop look backward.
|
||||||
|
// Otherwise, it looks forward.
|
||||||
|
//
|
||||||
|
// The terminate condition are:
|
||||||
|
// 1. the selectable returns end, pending, none.
|
||||||
|
// 2. the selectable returns previous when looking forward.
|
||||||
|
// 2. the selectable returns next when looking backward.
|
||||||
|
while (newIndex < selectables.length && newIndex >= 0 && finalResult == null) {
|
||||||
|
currentSelectableResult = dispatchSelectionEventToChild(selectables[newIndex], event);
|
||||||
|
switch (currentSelectableResult) {
|
||||||
|
case SelectionResult.end:
|
||||||
|
case SelectionResult.pending:
|
||||||
|
case SelectionResult.none:
|
||||||
|
finalResult = currentSelectableResult;
|
||||||
|
case SelectionResult.next:
|
||||||
|
if (forward == false) {
|
||||||
|
newIndex += 1;
|
||||||
|
finalResult = SelectionResult.end;
|
||||||
|
} else if (newIndex == selectables.length - 1) {
|
||||||
|
finalResult = currentSelectableResult;
|
||||||
|
} else {
|
||||||
|
forward = true;
|
||||||
|
newIndex += 1;
|
||||||
|
}
|
||||||
|
case SelectionResult.previous:
|
||||||
|
if (forward ?? false) {
|
||||||
|
newIndex -= 1;
|
||||||
|
finalResult = SelectionResult.end;
|
||||||
|
} else if (newIndex == 0) {
|
||||||
|
finalResult = currentSelectableResult;
|
||||||
|
} else {
|
||||||
|
forward = false;
|
||||||
|
newIndex -= 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (isEnd) {
|
||||||
|
final bool forwardSelection = currentSelectionEndIndex >= currentSelectionStartIndex;
|
||||||
|
if (forward != null && ((!forwardSelection && forward && newIndex >= currentSelectionStartIndex) || (forwardSelection && !forward && newIndex <= currentSelectionStartIndex))) {
|
||||||
|
currentSelectionStartIndex = currentSelectionEndIndex;
|
||||||
|
}
|
||||||
|
currentSelectionEndIndex = newIndex;
|
||||||
|
} else {
|
||||||
|
final bool forwardSelection = currentSelectionEndIndex >= currentSelectionStartIndex;
|
||||||
|
if (forward != null && ((!forwardSelection && !forward && newIndex <= currentSelectionEndIndex) || (forwardSelection && forward && newIndex >= currentSelectionEndIndex))) {
|
||||||
|
currentSelectionEndIndex = currentSelectionStartIndex;
|
||||||
|
}
|
||||||
|
currentSelectionStartIndex = newIndex;
|
||||||
|
}
|
||||||
|
_flushInactiveSelections();
|
||||||
|
return finalResult!;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The compare function this delegate used for determining the selection
|
||||||
|
/// order of the [Selectable]s.
|
||||||
|
///
|
||||||
|
/// Sorts the [Selectable]s by their top left [Rect].
|
||||||
|
@override
|
||||||
|
Comparator<Selectable> get compareOrder => _compareScreenOrder;
|
||||||
|
|
||||||
|
static int _compareScreenOrder(Selectable a, Selectable b) {
|
||||||
|
// Attempt to sort the selectables under a [_SelectableTextContainerDelegate]
|
||||||
|
// by the top left rect.
|
||||||
|
final Rect rectA = MatrixUtils.transformRect(
|
||||||
|
a.getTransformTo(null),
|
||||||
|
a.boundingBoxes.first,
|
||||||
|
);
|
||||||
|
final Rect rectB = MatrixUtils.transformRect(
|
||||||
|
b.getTransformTo(null),
|
||||||
|
b.boundingBoxes.first,
|
||||||
|
);
|
||||||
|
final int result = _compareVertically(rectA, rectB);
|
||||||
|
if (result != 0) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
return _compareHorizontally(rectA, rectB);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compares two rectangles in the screen order solely by their vertical
|
||||||
|
/// positions.
|
||||||
|
///
|
||||||
|
/// Returns positive if a is lower, negative if a is higher, 0 if their
|
||||||
|
/// order can't be determine solely by their vertical position.
|
||||||
|
static int _compareVertically(Rect a, Rect b) {
|
||||||
|
// The rectangles overlap so defer to horizontal comparison.
|
||||||
|
if ((a.top - b.top < _kSelectableVerticalComparingThreshold && a.bottom - b.bottom > - _kSelectableVerticalComparingThreshold) ||
|
||||||
|
(b.top - a.top < _kSelectableVerticalComparingThreshold && b.bottom - a.bottom > - _kSelectableVerticalComparingThreshold)) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if ((a.top - b.top).abs() > _kSelectableVerticalComparingThreshold) {
|
||||||
|
return a.top > b.top ? 1 : -1;
|
||||||
|
}
|
||||||
|
return a.bottom > b.bottom ? 1 : -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compares two rectangles in the screen order by their horizontal positions
|
||||||
|
/// assuming one of the rectangles enclose the other rect vertically.
|
||||||
|
///
|
||||||
|
/// Returns positive if a is lower, negative if a is higher.
|
||||||
|
static int _compareHorizontally(Rect a, Rect b) {
|
||||||
|
// a encloses b.
|
||||||
|
if (a.left - b.left < precisionErrorTolerance && a.right - b.right > - precisionErrorTolerance) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
// b encloses a.
|
||||||
|
if (b.left - a.left < precisionErrorTolerance && b.right - a.right > - precisionErrorTolerance) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
if ((a.left - b.left).abs() > precisionErrorTolerance) {
|
||||||
|
return a.left > b.left ? 1 : -1;
|
||||||
|
}
|
||||||
|
return a.right > b.right ? 1 : -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// From [SelectableRegion].
|
||||||
|
|
||||||
|
// Clears the selection on all selectables not in the range of
|
||||||
|
// currentSelectionStartIndex..currentSelectionEndIndex.
|
||||||
|
//
|
||||||
|
// If one of the edges does not exist, then this method will clear the selection
|
||||||
|
// in all selectables except the existing edge.
|
||||||
|
//
|
||||||
|
// If neither of the edges exist this method immediately returns.
|
||||||
|
void _flushInactiveSelections() {
|
||||||
|
if (currentSelectionStartIndex == -1 && currentSelectionEndIndex == -1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (currentSelectionStartIndex == -1 || currentSelectionEndIndex == -1) {
|
||||||
|
final int skipIndex = currentSelectionStartIndex == -1 ? currentSelectionEndIndex : currentSelectionStartIndex;
|
||||||
|
selectables
|
||||||
|
.where((Selectable target) => target != selectables[skipIndex])
|
||||||
|
.forEach((Selectable target) => dispatchSelectionEventToChild(target, const ClearSelectionEvent()));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final int skipStart = min(currentSelectionStartIndex, currentSelectionEndIndex);
|
||||||
|
final int skipEnd = max(currentSelectionStartIndex, currentSelectionEndIndex);
|
||||||
|
for (int index = 0; index < selectables.length; index += 1) {
|
||||||
|
if (index >= skipStart && index <= skipEnd) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
dispatchSelectionEventToChild(selectables[index], const ClearSelectionEvent());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final Set<Selectable> _hasReceivedStartEvent = <Selectable>{};
|
||||||
|
final Set<Selectable> _hasReceivedEndEvent = <Selectable>{};
|
||||||
|
|
||||||
|
Offset? _lastStartEdgeUpdateGlobalPosition;
|
||||||
|
Offset? _lastEndEdgeUpdateGlobalPosition;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void remove(Selectable selectable) {
|
||||||
|
_hasReceivedStartEvent.remove(selectable);
|
||||||
|
_hasReceivedEndEvent.remove(selectable);
|
||||||
|
super.remove(selectable);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _updateLastEdgeEventsFromGeometries() {
|
||||||
|
if (currentSelectionStartIndex != -1 && selectables[currentSelectionStartIndex].value.hasSelection) {
|
||||||
|
final Selectable start = selectables[currentSelectionStartIndex];
|
||||||
|
final Offset localStartEdge = start.value.startSelectionPoint!.localPosition +
|
||||||
|
Offset(0, - start.value.startSelectionPoint!.lineHeight / 2);
|
||||||
|
_lastStartEdgeUpdateGlobalPosition = MatrixUtils.transformPoint(start.getTransformTo(null), localStartEdge);
|
||||||
|
}
|
||||||
|
if (currentSelectionEndIndex != -1 && selectables[currentSelectionEndIndex].value.hasSelection) {
|
||||||
|
final Selectable end = selectables[currentSelectionEndIndex];
|
||||||
|
final Offset localEndEdge = end.value.endSelectionPoint!.localPosition +
|
||||||
|
Offset(0, -end.value.endSelectionPoint!.lineHeight / 2);
|
||||||
|
_lastEndEdgeUpdateGlobalPosition = MatrixUtils.transformPoint(end.getTransformTo(null), localEndEdge);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
SelectionResult handleSelectAll(SelectAllSelectionEvent event) {
|
||||||
|
final SelectionResult result = super.handleSelectAll(event);
|
||||||
|
for (final Selectable selectable in selectables) {
|
||||||
|
_hasReceivedStartEvent.add(selectable);
|
||||||
|
_hasReceivedEndEvent.add(selectable);
|
||||||
|
}
|
||||||
|
// Synthesize last update event so the edge updates continue to work.
|
||||||
|
_updateLastEdgeEventsFromGeometries();
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Selects a word in a selectable at the location
|
||||||
|
/// [SelectWordSelectionEvent.globalPosition].
|
||||||
|
@override
|
||||||
|
SelectionResult handleSelectWord(SelectWordSelectionEvent event) {
|
||||||
|
final SelectionResult result = super.handleSelectWord(event);
|
||||||
|
if (currentSelectionStartIndex != -1) {
|
||||||
|
_hasReceivedStartEvent.add(selectables[currentSelectionStartIndex]);
|
||||||
|
}
|
||||||
|
if (currentSelectionEndIndex != -1) {
|
||||||
|
_hasReceivedEndEvent.add(selectables[currentSelectionEndIndex]);
|
||||||
|
}
|
||||||
|
_updateLastEdgeEventsFromGeometries();
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
SelectionResult handleClearSelection(ClearSelectionEvent event) {
|
||||||
|
final SelectionResult result = super.handleClearSelection(event);
|
||||||
|
_hasReceivedStartEvent.clear();
|
||||||
|
_hasReceivedEndEvent.clear();
|
||||||
|
_lastStartEdgeUpdateGlobalPosition = null;
|
||||||
|
_lastEndEdgeUpdateGlobalPosition = null;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
SelectionResult handleSelectionEdgeUpdate(SelectionEdgeUpdateEvent event) {
|
||||||
|
if (event.type == SelectionEventType.endEdgeUpdate) {
|
||||||
|
_lastEndEdgeUpdateGlobalPosition = event.globalPosition;
|
||||||
|
} else {
|
||||||
|
_lastStartEdgeUpdateGlobalPosition = event.globalPosition;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.granularity == TextGranularity.paragraph) {
|
||||||
|
if (event.type == SelectionEventType.endEdgeUpdate) {
|
||||||
|
return currentSelectionEndIndex == -1 ? _initSelection(event, isEnd: true) : _adjustSelection(event, isEnd: true);
|
||||||
|
}
|
||||||
|
return currentSelectionStartIndex == -1 ? _initSelection(event, isEnd: false) : _adjustSelection(event, isEnd: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
return super.handleSelectionEdgeUpdate(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_hasReceivedStartEvent.clear();
|
||||||
|
_hasReceivedEndEvent.clear();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
SelectionResult dispatchSelectionEventToChild(Selectable selectable, SelectionEvent event) {
|
||||||
|
switch (event.type) {
|
||||||
|
case SelectionEventType.startEdgeUpdate:
|
||||||
|
_hasReceivedStartEvent.add(selectable);
|
||||||
|
ensureChildUpdated(selectable);
|
||||||
|
case SelectionEventType.endEdgeUpdate:
|
||||||
|
_hasReceivedEndEvent.add(selectable);
|
||||||
|
ensureChildUpdated(selectable);
|
||||||
|
case SelectionEventType.clear:
|
||||||
|
_hasReceivedStartEvent.remove(selectable);
|
||||||
|
_hasReceivedEndEvent.remove(selectable);
|
||||||
|
case SelectionEventType.selectAll:
|
||||||
|
case SelectionEventType.selectWord:
|
||||||
|
case SelectionEventType.selectParagraph:
|
||||||
|
break;
|
||||||
|
case SelectionEventType.granularlyExtendSelection:
|
||||||
|
case SelectionEventType.directionallyExtendSelection:
|
||||||
|
_hasReceivedStartEvent.add(selectable);
|
||||||
|
_hasReceivedEndEvent.add(selectable);
|
||||||
|
ensureChildUpdated(selectable);
|
||||||
|
}
|
||||||
|
return super.dispatchSelectionEventToChild(selectable, event);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void ensureChildUpdated(Selectable selectable) {
|
||||||
|
if (_lastEndEdgeUpdateGlobalPosition != null && _hasReceivedEndEvent.add(selectable)) {
|
||||||
|
final SelectionEdgeUpdateEvent synthesizedEvent = SelectionEdgeUpdateEvent.forEnd(
|
||||||
|
globalPosition: _lastEndEdgeUpdateGlobalPosition!,
|
||||||
|
);
|
||||||
|
if (currentSelectionEndIndex == -1) {
|
||||||
|
handleSelectionEdgeUpdate(synthesizedEvent);
|
||||||
|
}
|
||||||
|
selectable.dispatchSelectionEvent(synthesizedEvent);
|
||||||
|
}
|
||||||
|
if (_lastStartEdgeUpdateGlobalPosition != null && _hasReceivedStartEvent.add(selectable)) {
|
||||||
|
final SelectionEdgeUpdateEvent synthesizedEvent = SelectionEdgeUpdateEvent.forStart(
|
||||||
|
globalPosition: _lastStartEdgeUpdateGlobalPosition!,
|
||||||
|
);
|
||||||
|
if (currentSelectionStartIndex == -1) {
|
||||||
|
handleSelectionEdgeUpdate(synthesizedEvent);
|
||||||
|
}
|
||||||
|
selectable.dispatchSelectionEvent(synthesizedEvent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didChangeSelectables() {
|
||||||
|
if (_lastEndEdgeUpdateGlobalPosition != null) {
|
||||||
|
handleSelectionEdgeUpdate(
|
||||||
|
SelectionEdgeUpdateEvent.forEnd(
|
||||||
|
globalPosition: _lastEndEdgeUpdateGlobalPosition!,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (_lastStartEdgeUpdateGlobalPosition != null) {
|
||||||
|
handleSelectionEdgeUpdate(
|
||||||
|
SelectionEdgeUpdateEvent.forStart(
|
||||||
|
globalPosition: _lastStartEdgeUpdateGlobalPosition!,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
final Set<Selectable> selectableSet = selectables.toSet();
|
||||||
|
_hasReceivedEndEvent.removeWhere((Selectable selectable) => !selectableSet.contains(selectable));
|
||||||
|
_hasReceivedStartEvent.removeWhere((Selectable selectable) => !selectableSet.contains(selectable));
|
||||||
|
super.didChangeSelectables();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -190,6 +190,112 @@ void main() {
|
|||||||
await gesture.up();
|
await gesture.up();
|
||||||
}, skip: kIsWeb); // https://github.com/flutter/flutter/issues/125582.
|
}, skip: kIsWeb); // https://github.com/flutter/flutter/issues/125582.
|
||||||
|
|
||||||
|
testWidgets('mouse can select multiple widgets on triple-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.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: 6));
|
||||||
|
|
||||||
|
final RenderParagraph paragraph2 = tester.renderObject<RenderParagraph>(find.descendant(of: find.text('Item 1'), matching: find.byType(RichText)));
|
||||||
|
expect(paragraph2.selections.isEmpty, isTrue);
|
||||||
|
await gesture.moveTo(textOffsetToPosition(paragraph2, 4));
|
||||||
|
// Should select paragraph 2.
|
||||||
|
expect(paragraph1.selections[0], const TextSelection(baseOffset: 0, extentOffset: 6));
|
||||||
|
expect(paragraph2.selections[0], const TextSelection(baseOffset: 0, extentOffset: 6));
|
||||||
|
|
||||||
|
final RenderParagraph paragraph3 = tester.renderObject<RenderParagraph>(find.descendant(of: find.text('Item 3'), matching: find.byType(RichText)));
|
||||||
|
expect(paragraph3.selections.isEmpty, isTrue);
|
||||||
|
await gesture.moveTo(textOffsetToPosition(paragraph3, 3));
|
||||||
|
// Should select paragraph 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: 6));
|
||||||
|
|
||||||
|
final RenderParagraph paragraph4 = tester.renderObject<RenderParagraph>(find.descendant(of: find.text('Item 4'), matching: find.byType(RichText)));
|
||||||
|
expect(paragraph4.selections.isEmpty, isTrue);
|
||||||
|
await gesture.moveTo(textOffsetToPosition(paragraph4, 3));
|
||||||
|
// Should select paragraph 4.
|
||||||
|
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: 6));
|
||||||
|
expect(paragraph4.selections[0], const TextSelection(baseOffset: 0, extentOffset: 6));
|
||||||
|
|
||||||
|
await gesture.up();
|
||||||
|
}, skip: kIsWeb); // https://github.com/flutter/flutter/issues/125582.
|
||||||
|
|
||||||
|
testWidgets('mouse can select multiple widgets on triple-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.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: 6));
|
||||||
|
|
||||||
|
final RenderParagraph paragraph2 = tester.renderObject<RenderParagraph>(find.descendant(of: find.text('Item 1'), matching: find.byType(RichText)));
|
||||||
|
expect(paragraph2.selections.isEmpty, isTrue);
|
||||||
|
await gesture.moveTo(textOffsetToPosition(paragraph2, 5) + const Offset(0, 50));
|
||||||
|
// Should select paragraph 2.
|
||||||
|
expect(paragraph1.selections[0], const TextSelection(baseOffset: 0, extentOffset: 6));
|
||||||
|
expect(paragraph2.selections[0], const TextSelection(baseOffset: 0, extentOffset: 6));
|
||||||
|
|
||||||
|
final RenderParagraph paragraph3 = tester.renderObject<RenderParagraph>(find.descendant(of: find.text('Item 2'), matching: find.byType(RichText)));
|
||||||
|
expect(paragraph3.selections.isEmpty, isTrue);
|
||||||
|
await gesture.moveTo(textOffsetToPosition(paragraph3, 5) + const Offset(0, 50));
|
||||||
|
// Should select paragraph 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: 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();
|
||||||
addTearDown(controller.dispose);
|
addTearDown(controller.dispose);
|
||||||
|
@ -883,6 +883,323 @@ void main() {
|
|||||||
await gesture.up();
|
await gesture.up();
|
||||||
}, skip: kIsWeb); // https://github.com/flutter/flutter/issues/125582.
|
}, skip: kIsWeb); // https://github.com/flutter/flutter/issues/125582.
|
||||||
|
|
||||||
|
testWidgets('mouse can select paragraph-by-paragraph on triple click drag', (WidgetTester tester) async {
|
||||||
|
const String longText = 'Hello world this is some long piece of text '
|
||||||
|
'that will represent a long paragraph, when triple clicking this block '
|
||||||
|
'of text all of it will be selected.\n'
|
||||||
|
'This will be the start of a new line. When triple clicking this block '
|
||||||
|
'of text all of it should be selected.';
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
home: SelectableRegion(
|
||||||
|
focusNode: FocusNode(),
|
||||||
|
selectionControls: materialTextSelectionControls,
|
||||||
|
child: const Center(
|
||||||
|
child: Text(longText),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
final RenderParagraph paragraph = tester.renderObject<RenderParagraph>(find.descendant(of: find.text(longText), 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.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: 150));
|
||||||
|
|
||||||
|
await gesture.moveTo(textOffsetToPosition(paragraph, 155));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(paragraph.selections[0], const TextSelection(baseOffset: 0, extentOffset: 257));
|
||||||
|
|
||||||
|
await gesture.moveTo(textOffsetToPosition(paragraph, 170));
|
||||||
|
await tester.pump();
|
||||||
|
expect(paragraph.selections[0], const TextSelection(baseOffset: 0, extentOffset: 257));
|
||||||
|
|
||||||
|
// Check backward selection.
|
||||||
|
await gesture.moveTo(textOffsetToPosition(paragraph, 1));
|
||||||
|
await tester.pump();
|
||||||
|
expect(paragraph.selections[0], const TextSelection(baseOffset: 0, extentOffset: 150));
|
||||||
|
|
||||||
|
// Start a new triple-click drag.
|
||||||
|
await gesture.up();
|
||||||
|
await tester.pumpAndSettle(kDoubleTapTimeout);
|
||||||
|
await gesture.down(textOffsetToPosition(paragraph, 151));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await gesture.up();
|
||||||
|
expect(paragraph.selections.isNotEmpty, isTrue);
|
||||||
|
expect(paragraph.selections.length, 1);
|
||||||
|
expect(paragraph.selections.first, const TextSelection.collapsed(offset: 151));
|
||||||
|
await tester.pump(kDoubleTapTimeout);
|
||||||
|
|
||||||
|
// Triple-click.
|
||||||
|
await gesture.down(textOffsetToPosition(paragraph, 151));
|
||||||
|
await tester.pump();
|
||||||
|
await gesture.up();
|
||||||
|
await tester.pump();
|
||||||
|
await gesture.down(textOffsetToPosition(paragraph, 151));
|
||||||
|
await tester.pump();
|
||||||
|
await gesture.up();
|
||||||
|
await tester.pump();
|
||||||
|
await gesture.down(textOffsetToPosition(paragraph, 151));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(paragraph.selections[0], const TextSelection(baseOffset: 150, extentOffset: 257));
|
||||||
|
|
||||||
|
// 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: 257, extentOffset: 0));
|
||||||
|
await gesture.up();
|
||||||
|
}, skip: kIsWeb); // https://github.com/flutter/flutter/issues/125582.
|
||||||
|
|
||||||
|
testWidgets('mouse can select multiple widgets on triple click drag when selecting inside a WidgetSpan', (WidgetTester tester) async {
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
home: SelectableRegion(
|
||||||
|
focusNode: FocusNode(),
|
||||||
|
selectionControls: materialTextSelectionControls,
|
||||||
|
child: const Text.rich(
|
||||||
|
WidgetSpan(
|
||||||
|
child: Column(
|
||||||
|
children: <Widget>[
|
||||||
|
Text('Text widget A.'),
|
||||||
|
Text('Text widget B.'),
|
||||||
|
Text('Text widget C.'),
|
||||||
|
Text('Text widget D.'),
|
||||||
|
Text('Text widget E.'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
final RenderParagraph paragraphC = tester.renderObject<RenderParagraph>(find.descendant(of: find.textContaining('Text widget C.'), matching: find.byType(RichText)));
|
||||||
|
final TestGesture gesture = await tester.startGesture(textOffsetToPosition(paragraphC, 2), kind: PointerDeviceKind.mouse);
|
||||||
|
addTearDown(gesture.removePointer);
|
||||||
|
await tester.pump();
|
||||||
|
await gesture.up();
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
await gesture.down(textOffsetToPosition(paragraphC, 2));
|
||||||
|
await tester.pump();
|
||||||
|
await gesture.up();
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
await gesture.down(textOffsetToPosition(paragraphC, 2));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(paragraphC.selections[0], const TextSelection(baseOffset: 0, extentOffset: 14));
|
||||||
|
|
||||||
|
await gesture.moveTo(textOffsetToPosition(paragraphC, 7));
|
||||||
|
await tester.pump();
|
||||||
|
expect(paragraphC.selections[0], const TextSelection(baseOffset: 0, extentOffset: 14));
|
||||||
|
|
||||||
|
final RenderParagraph paragraphE = tester.renderObject<RenderParagraph>(find.descendant(of: find.textContaining('Text widget E.'), matching: find.byType(RichText)));
|
||||||
|
final RenderParagraph paragraphD = tester.renderObject<RenderParagraph>(find.descendant(of: find.textContaining('Text widget D.'), matching: find.byType(RichText)));
|
||||||
|
await gesture.moveTo(textOffsetToPosition(paragraphE, 5));
|
||||||
|
// Should select line C-E.
|
||||||
|
expect(paragraphC.selections[0], const TextSelection(baseOffset: 0, extentOffset: 14));
|
||||||
|
expect(paragraphD.selections[0], const TextSelection(baseOffset: 0, extentOffset: 14));
|
||||||
|
expect(paragraphE.selections[0], const TextSelection(baseOffset: 0, extentOffset: 14));
|
||||||
|
|
||||||
|
await gesture.up();
|
||||||
|
}, skip: kIsWeb); // https://github.com/flutter/flutter/issues/125582.
|
||||||
|
|
||||||
|
testWidgets('mouse can select multiple widgets on triple click drag', (WidgetTester tester) async {
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
home: SelectableRegion(
|
||||||
|
focusNode: FocusNode(),
|
||||||
|
selectionControls: materialTextSelectionControls,
|
||||||
|
child: const Column(
|
||||||
|
children: <Widget>[
|
||||||
|
Text('How are you?\nThis is the first text widget.'),
|
||||||
|
Text('Good, and you?\nThis is the second text widget.'),
|
||||||
|
Text('Fine, thank you.\nThis is the third text widget.'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
final RenderParagraph paragraph1 = tester.renderObject<RenderParagraph>(find.descendant(of: find.textContaining('first text widget'), 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.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: 13));
|
||||||
|
|
||||||
|
await gesture.moveTo(textOffsetToPosition(paragraph1, 14));
|
||||||
|
await tester.pump();
|
||||||
|
expect(paragraph1.selections[0], const TextSelection(baseOffset: 0, extentOffset: 43));
|
||||||
|
|
||||||
|
final RenderParagraph paragraph2 = tester.renderObject<RenderParagraph>(find.descendant(of: find.textContaining('second text widget'), matching: find.byType(RichText)));
|
||||||
|
await gesture.moveTo(textOffsetToPosition(paragraph2, 5));
|
||||||
|
// Should select line 1 of text widget 2.
|
||||||
|
expect(paragraph1.selections[0], const TextSelection(baseOffset: 0, extentOffset: 43));
|
||||||
|
expect(paragraph2.selections[0], const TextSelection(baseOffset: 0, extentOffset: 15));
|
||||||
|
|
||||||
|
await gesture.moveTo(textOffsetToPosition(paragraph2, 16));
|
||||||
|
// Should select the rest of text widget 2.
|
||||||
|
expect(paragraph1.selections[0], const TextSelection(baseOffset: 0, extentOffset: 43));
|
||||||
|
expect(paragraph2.selections[0], const TextSelection(baseOffset: 0, extentOffset: 46));
|
||||||
|
|
||||||
|
final RenderParagraph paragraph3 = tester.renderObject<RenderParagraph>(find.descendant(of: find.textContaining('third text widget'), matching: find.byType(RichText)));
|
||||||
|
await gesture.moveTo(textOffsetToPosition(paragraph3, 6));
|
||||||
|
// Should select line 1 of text widget 3.
|
||||||
|
expect(paragraph1.selections[0], const TextSelection(baseOffset: 0, extentOffset: 43));
|
||||||
|
expect(paragraph2.selections[0], const TextSelection(baseOffset: 0, extentOffset: 46));
|
||||||
|
expect(paragraph3.selections[0], const TextSelection(baseOffset: 0, extentOffset: 17));
|
||||||
|
|
||||||
|
await gesture.moveTo(textOffsetToPosition(paragraph3, 18));
|
||||||
|
// Should select the rest of text widget 3.
|
||||||
|
expect(paragraph1.selections[0], const TextSelection(baseOffset: 0, extentOffset: 43));
|
||||||
|
expect(paragraph2.selections[0], const TextSelection(baseOffset: 0, extentOffset: 46));
|
||||||
|
expect(paragraph3.selections[0], const TextSelection(baseOffset: 0, extentOffset: 47));
|
||||||
|
|
||||||
|
await gesture.up();
|
||||||
|
}, skip: kIsWeb); // https://github.com/flutter/flutter/issues/125582.
|
||||||
|
|
||||||
|
testWidgets('mouse can select multiple widgets on triple click drag and return to origin paragraph', (WidgetTester tester) async {
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
home: SelectableRegion(
|
||||||
|
focusNode: FocusNode(),
|
||||||
|
selectionControls: materialTextSelectionControls,
|
||||||
|
child: const Column(
|
||||||
|
children: <Widget>[
|
||||||
|
Text('How are you?\nThis is the first text widget.'),
|
||||||
|
Text('Good, and you?\nThis is the second text widget.'),
|
||||||
|
Text('Fine, thank you.\nThis is the third text widget.'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
final RenderParagraph paragraph2 = tester.renderObject<RenderParagraph>(find.descendant(of: find.textContaining('second text widget'), matching: find.byType(RichText)));
|
||||||
|
final TestGesture gesture = await tester.startGesture(textOffsetToPosition(paragraph2, 2), kind: PointerDeviceKind.mouse);
|
||||||
|
addTearDown(gesture.removePointer);
|
||||||
|
await tester.pump();
|
||||||
|
await gesture.up();
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
await gesture.down(textOffsetToPosition(paragraph2, 2));
|
||||||
|
await tester.pump();
|
||||||
|
await gesture.up();
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
await gesture.down(textOffsetToPosition(paragraph2, 2));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
// Should select line 1 of text widget 2.
|
||||||
|
expect(paragraph2.selections[0], const TextSelection(baseOffset: 0, extentOffset: 15));
|
||||||
|
|
||||||
|
final RenderParagraph paragraph1 = tester.renderObject<RenderParagraph>(find.descendant(of: find.textContaining('first text widget'), matching: find.byType(RichText)));
|
||||||
|
|
||||||
|
// Should select line 2 of text widget 1.
|
||||||
|
await gesture.moveTo(textOffsetToPosition(paragraph1, 14));
|
||||||
|
await tester.pump();
|
||||||
|
expect(paragraph1.selections[0], const TextSelection(baseOffset: 43, extentOffset: 13));
|
||||||
|
expect(paragraph2.selections[0], const TextSelection(baseOffset: 15, extentOffset: 0));
|
||||||
|
|
||||||
|
final RenderParagraph paragraph3 = tester.renderObject<RenderParagraph>(find.descendant(of: find.textContaining('third text widget'), matching: find.byType(RichText)));
|
||||||
|
await gesture.moveTo(textOffsetToPosition(paragraph1, 5));
|
||||||
|
// Should select rest of text widget 1.
|
||||||
|
expect(paragraph1.selections[0], const TextSelection(baseOffset: 43, extentOffset: 0));
|
||||||
|
expect(paragraph2.selections[0], const TextSelection(baseOffset: 15, extentOffset: 0));
|
||||||
|
|
||||||
|
await gesture.moveTo(textOffsetToPosition(paragraph2, 2));
|
||||||
|
// Should clear the selection on paragraph 1 and return to the origin paragraph.
|
||||||
|
expect(paragraph1.selections.isEmpty, true);
|
||||||
|
expect(paragraph2.selections[0], const TextSelection(baseOffset: 15, extentOffset: 0));
|
||||||
|
|
||||||
|
await gesture.moveTo(textOffsetToPosition(paragraph3, 6));
|
||||||
|
// Should select line 1 of text widget 3.
|
||||||
|
expect(paragraph1.selections.isEmpty, true);
|
||||||
|
expect(paragraph2.selections[0], const TextSelection(baseOffset: 0, extentOffset: 46));
|
||||||
|
expect(paragraph3.selections[0], const TextSelection(baseOffset: 0, extentOffset: 17));
|
||||||
|
|
||||||
|
await gesture.moveTo(textOffsetToPosition(paragraph3, 18));
|
||||||
|
// Should select line 2 of text widget 3.
|
||||||
|
expect(paragraph1.selections.isEmpty, true);
|
||||||
|
expect(paragraph2.selections[0], const TextSelection(baseOffset: 0, extentOffset: 46));
|
||||||
|
expect(paragraph3.selections[0], const TextSelection(baseOffset: 0, extentOffset: 47));
|
||||||
|
|
||||||
|
await gesture.moveTo(textOffsetToPosition(paragraph2, 5));
|
||||||
|
// Should clear the selection on paragraph 3 and return to the origin paragraph.
|
||||||
|
expect(paragraph1.selections.isEmpty, true);
|
||||||
|
expect(paragraph2.selections[0], const TextSelection(baseOffset: 0, extentOffset: 15));
|
||||||
|
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 triple click drag', (WidgetTester tester) async {
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
home: SelectableRegion(
|
||||||
|
focusNode: FocusNode(),
|
||||||
|
selectionControls: materialTextSelectionControls,
|
||||||
|
child: const Column(
|
||||||
|
children: <Widget>[
|
||||||
|
Text('How are you?\nThis is the first text widget.'),
|
||||||
|
Text('Good, and you?\nThis is the second text widget.'),
|
||||||
|
Text('Fine, thank you.\nThis is the third text widget.'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
final RenderParagraph paragraph3 = tester.renderObject<RenderParagraph>(find.descendant(of: find.textContaining('Fine, thank you.'), matching: find.byType(RichText)));
|
||||||
|
final TestGesture gesture = await tester.startGesture(textOffsetToPosition(paragraph3, 18), kind: PointerDeviceKind.mouse);
|
||||||
|
addTearDown(gesture.removePointer);
|
||||||
|
await tester.pump();
|
||||||
|
await gesture.up();
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
await gesture.down(textOffsetToPosition(paragraph3, 18));
|
||||||
|
await tester.pump();
|
||||||
|
await gesture.up();
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
await gesture.down(textOffsetToPosition(paragraph3, 18));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(paragraph3.selections[0], const TextSelection(baseOffset: 17, extentOffset: 47));
|
||||||
|
|
||||||
|
await gesture.moveTo(textOffsetToPosition(paragraph3, 4));
|
||||||
|
await tester.pump();
|
||||||
|
expect(paragraph3.selections[0], const TextSelection(baseOffset: 47, extentOffset: 0));
|
||||||
|
|
||||||
|
final RenderParagraph paragraph2 = tester.renderObject<RenderParagraph>(find.descendant(of: find.textContaining('Good, and you?'), matching: find.byType(RichText)));
|
||||||
|
await gesture.moveTo(textOffsetToPosition(paragraph2, 5));
|
||||||
|
expect(paragraph3.selections[0], const TextSelection(baseOffset: 47, extentOffset: 0));
|
||||||
|
expect(paragraph2.selections[0], const TextSelection(baseOffset: 46, extentOffset: 0));
|
||||||
|
|
||||||
|
final RenderParagraph paragraph1 = tester.renderObject<RenderParagraph>(find.descendant(of: find.textContaining('How are you?'), matching: find.byType(RichText)));
|
||||||
|
await gesture.moveTo(textOffsetToPosition(paragraph1, 6));
|
||||||
|
expect(paragraph3.selections[0], const TextSelection(baseOffset: 47, extentOffset: 0));
|
||||||
|
expect(paragraph2.selections[0], const TextSelection(baseOffset: 46, extentOffset: 0));
|
||||||
|
expect(paragraph1.selections[0], const TextSelection(baseOffset: 43, extentOffset: 0));
|
||||||
|
|
||||||
|
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 {
|
||||||
final FocusNode focusNode = FocusNode();
|
final FocusNode focusNode = FocusNode();
|
||||||
addTearDown(focusNode.dispose);
|
addTearDown(focusNode.dispose);
|
||||||
|
@ -9,15 +9,7 @@ import 'package:flutter_test/flutter_test.dart';
|
|||||||
void main() {
|
void main() {
|
||||||
|
|
||||||
Future<void> pumpContainer(WidgetTester tester, Widget child) async {
|
Future<void> pumpContainer(WidgetTester tester, Widget child) async {
|
||||||
await tester.pumpWidget(
|
await tester.pumpWidget(MaterialApp(home: child));
|
||||||
Directionality(
|
|
||||||
textDirection: TextDirection.ltr,
|
|
||||||
child: DefaultSelectionStyle(
|
|
||||||
selectionColor: Colors.red,
|
|
||||||
child: child,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
testWidgets('updates its registrar and delegate based on the number of selectables', (WidgetTester tester) async {
|
testWidgets('updates its registrar and delegate based on the number of selectables', (WidgetTester tester) async {
|
||||||
@ -89,7 +81,7 @@ void main() {
|
|||||||
child: const Text('dummy'),
|
child: const Text('dummy'),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
)
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
@ -138,10 +130,10 @@ void main() {
|
|||||||
return SelectionContainer(
|
return SelectionContainer(
|
||||||
registrar: SelectionContainer.maybeOf(context),
|
registrar: SelectionContainer.maybeOf(context),
|
||||||
delegate: childDelegate,
|
delegate: childDelegate,
|
||||||
child: const Text('dummy'),
|
child: const Text('dummy'),// The [Text] widget has an internal [SelectionContainer].
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
)
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
|
Loading…
x
Reference in New Issue
Block a user