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;
|
||||
case SelectionEventType.selectAll:
|
||||
case SelectionEventType.selectWord:
|
||||
case SelectionEventType.selectParagraph:
|
||||
_start = Offset.zero;
|
||||
_end = Offset.infinite;
|
||||
case SelectionEventType.granularlyExtendSelection:
|
||||
|
File diff suppressed because it is too large
Load Diff
@ -299,6 +299,12 @@ enum SelectionEventType {
|
||||
/// Used by [SelectWordSelectionEvent].
|
||||
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].
|
||||
granularlyExtendSelection,
|
||||
|
||||
@ -317,6 +323,9 @@ enum TextGranularity {
|
||||
/// Treats word as an atomic unit when moving the selection handles.
|
||||
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.
|
||||
line,
|
||||
|
||||
@ -370,6 +379,21 @@ class SelectWordSelectionEvent extends SelectionEvent {
|
||||
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.
|
||||
///
|
||||
/// An active selection contains two edges, start and end. Use the [type] to
|
||||
|
@ -1443,6 +1443,7 @@ class _ScrollableSelectionContainerDelegate extends MultiSelectableSelectionCont
|
||||
_selectableStartEdgeUpdateRecords.remove(selectable);
|
||||
case SelectionEventType.selectAll:
|
||||
case SelectionEventType.selectWord:
|
||||
case SelectionEventType.selectParagraph:
|
||||
_selectableEndEdgeUpdateRecords[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
|
||||
// would be used.
|
||||
static int _getEffectiveConsecutiveTapCount(int rawCount) {
|
||||
const int maxConsecutiveTap = 2;
|
||||
const int maxConsecutiveTap = 3;
|
||||
switch (defaultTargetPlatform) {
|
||||
case TargetPlatform.android:
|
||||
case TargetPlatform.fuchsia:
|
||||
@ -555,6 +555,8 @@ class SelectableRegionState extends State<SelectableRegion> with TextSelectionDe
|
||||
}
|
||||
case 2:
|
||||
_selectWordAt(offset: details.globalPosition);
|
||||
case 3:
|
||||
_selectParagraphAt(offset: details.globalPosition);
|
||||
}
|
||||
_updateSelectedContentIfNeeded();
|
||||
}
|
||||
@ -573,6 +575,8 @@ class SelectableRegionState extends State<SelectableRegion> with TextSelectionDe
|
||||
_selectEndTo(offset: details.globalPosition, continuous: true);
|
||||
case 2:
|
||||
_selectEndTo(offset: details.globalPosition, continuous: true, textGranularity: TextGranularity.word);
|
||||
case 3:
|
||||
_selectEndTo(offset: details.globalPosition, continuous: true, textGranularity: TextGranularity.paragraph);
|
||||
}
|
||||
_updateSelectedContentIfNeeded();
|
||||
}
|
||||
@ -997,6 +1001,7 @@ class SelectableRegionState extends State<SelectableRegion> with TextSelectionDe
|
||||
/// * [_finalizeSelection], which stops the `continuous` updates.
|
||||
/// * [_clearSelection], which clears the ongoing selection.
|
||||
/// * [_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.
|
||||
/// * [selectAll], which selects the entire content.
|
||||
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.
|
||||
/// * [_clearSelection], which clears the ongoing selection.
|
||||
/// * [_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.
|
||||
/// * [selectAll], which selects the entire content.
|
||||
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.
|
||||
///
|
||||
/// The `offset` is in global coordinates.
|
||||
///
|
||||
/// See also:
|
||||
/// * [_selectStartTo], which sets or updates selection start edge.
|
||||
/// * [_selectEndTo], which sets or updates selection end edge.
|
||||
/// * [_finalizeSelection], which stops the `continuous` updates.
|
||||
/// * [_clearSelection], which clears the ongoing selection.
|
||||
/// * [_selectWordAt], which selects a whole word at the location.
|
||||
/// * [_selectParagraphAt], which selects an entire paragraph at the location.
|
||||
/// * [selectAll], which selects the entire content.
|
||||
void _collapseSelectionAt({required Offset offset}) {
|
||||
_selectStartTo(offset: offset);
|
||||
@ -1066,6 +1075,8 @@ class SelectableRegionState extends State<SelectableRegion> with TextSelectionDe
|
||||
|
||||
/// 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
|
||||
/// change. One call [_clearSelection] first if the selection needs to be
|
||||
/// 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.
|
||||
/// * [_clearSelection], which clears the ongoing selection.
|
||||
/// * [_collapseSelectionAt], which collapses the selection at the location.
|
||||
/// * [_selectParagraphAt], which selects an entire paragraph at the location.
|
||||
/// * [selectAll], which selects the entire content.
|
||||
void _selectWordAt({required Offset offset}) {
|
||||
// There may be other selection ongoing.
|
||||
@ -1086,6 +1098,30 @@ class SelectableRegionState extends State<SelectableRegion> with TextSelectionDe
|
||||
_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.
|
||||
///
|
||||
/// This method is different from [_clearSelection] that it does not remove
|
||||
@ -1598,7 +1634,7 @@ class _SelectableRegionContainerDelegate extends MultiSelectableSelectionContain
|
||||
return result;
|
||||
}
|
||||
|
||||
/// Selects a word in a selectable at the location
|
||||
/// Selects a word in a [Selectable] at the location
|
||||
/// [SelectWordSelectionEvent.globalPosition].
|
||||
@override
|
||||
SelectionResult handleSelectWord(SelectWordSelectionEvent event) {
|
||||
@ -1613,6 +1649,21 @@ class _SelectableRegionContainerDelegate extends MultiSelectableSelectionContain
|
||||
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
|
||||
SelectionResult handleClearSelection(ClearSelectionEvent event) {
|
||||
final SelectionResult result = super.handleClearSelection(event);
|
||||
@ -1654,6 +1705,7 @@ class _SelectableRegionContainerDelegate extends MultiSelectableSelectionContain
|
||||
_hasReceivedEndEvent.remove(selectable);
|
||||
case SelectionEventType.selectAll:
|
||||
case SelectionEventType.selectWord:
|
||||
case SelectionEventType.selectParagraph:
|
||||
break;
|
||||
case SelectionEventType.granularlyExtendSelection:
|
||||
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]
|
||||
/// 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>[];
|
||||
|
||||
/// 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.
|
||||
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
|
||||
int currentSelectionEndIndex = -1;
|
||||
|
||||
/// The current selectable that contains the selection start edge.
|
||||
/// The current [Selectable] that contains the selection start edge.
|
||||
@protected
|
||||
int currentSelectionStartIndex = -1;
|
||||
|
||||
@ -1881,7 +1933,7 @@ abstract class MultiSelectableSelectionContainerDelegate extends SelectionContai
|
||||
selectable.removeListener(_handleSelectableGeometryChange);
|
||||
}
|
||||
|
||||
/// Called when this delegate finishes updating the selectables.
|
||||
/// Called when this delegate finishes updating the [Selectable]s.
|
||||
@protected
|
||||
@mustCallSuper
|
||||
void didChangeSelectables() {
|
||||
@ -1905,7 +1957,7 @@ abstract class MultiSelectableSelectionContainerDelegate extends SelectionContai
|
||||
_updateHandleLayersAndOwners();
|
||||
}
|
||||
|
||||
Rect _getBoundingBox(Selectable selectable) {
|
||||
static Rect _getBoundingBox(Selectable selectable) {
|
||||
Rect result = selectable.boundingBoxes.first;
|
||||
for (int index = 1; index < selectable.boundingBoxes.length; index += 1) {
|
||||
result = result.expandToInclude(selectable.boundingBoxes[index]);
|
||||
@ -1920,7 +1972,7 @@ abstract class MultiSelectableSelectionContainerDelegate extends SelectionContai
|
||||
@protected
|
||||
Comparator<Selectable> get compareOrder => _compareScreenOrder;
|
||||
|
||||
int _compareScreenOrder(Selectable a, Selectable b) {
|
||||
static int _compareScreenOrder(Selectable a, Selectable b) {
|
||||
final Rect rectA = MatrixUtils.transformRect(
|
||||
a.getTransformTo(null),
|
||||
_getBoundingBox(a),
|
||||
@ -1982,7 +2034,7 @@ abstract class MultiSelectableSelectionContainerDelegate extends SelectionContai
|
||||
_updateSelectionGeometry();
|
||||
}
|
||||
|
||||
/// Gets the combined selection geometry for child selectables.
|
||||
/// Gets the combined [SelectionGeometry] for child [Selectable]s.
|
||||
@protected
|
||||
SelectionGeometry getSelectionGeometry() {
|
||||
if (currentSelectionEndIndex == -1 ||
|
||||
@ -2158,7 +2210,7 @@ abstract class MultiSelectableSelectionContainerDelegate extends SelectionContai
|
||||
_endHandleLayerOwner!.pushHandleLayers(null, effectiveEndHandle);
|
||||
}
|
||||
|
||||
/// Copies the selected contents of all selectables.
|
||||
/// Copies the selected contents of all [Selectable]s.
|
||||
@override
|
||||
SelectedContent? getSelectedContent() {
|
||||
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
|
||||
SelectionResult handleSelectAll(SelectAllSelectionEvent event) {
|
||||
for (final Selectable selectable in selectables) {
|
||||
@ -2219,23 +2271,27 @@ abstract class MultiSelectableSelectionContainerDelegate extends SelectionContai
|
||||
return SelectionResult.none;
|
||||
}
|
||||
|
||||
/// Selects a word in a selectable at the location
|
||||
/// [SelectWordSelectionEvent.globalPosition].
|
||||
@protected
|
||||
SelectionResult handleSelectWord(SelectWordSelectionEvent event) {
|
||||
SelectionResult _handleSelectBoundary(SelectionEvent event) {
|
||||
assert(event is SelectWordSelectionEvent || event is SelectParagraphSelectionEvent, 'This method should only be given selection events that select text boundaries.');
|
||||
late final Offset effectiveGlobalPosition;
|
||||
if (event.type == SelectionEventType.selectWord) {
|
||||
effectiveGlobalPosition = (event as SelectWordSelectionEvent).globalPosition;
|
||||
} else if (event.type == SelectionEventType.selectParagraph) {
|
||||
effectiveGlobalPosition = (event as SelectParagraphSelectionEvent).globalPosition;
|
||||
}
|
||||
SelectionResult? lastSelectionResult;
|
||||
for (int index = 0; index < selectables.length; index += 1) {
|
||||
bool globalRectsContainsPosition = false;
|
||||
bool globalRectsContainPosition = false;
|
||||
if (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)) {
|
||||
globalRectsContainsPosition = true;
|
||||
if (globalRect.contains(effectiveGlobalPosition)) {
|
||||
globalRectsContainPosition = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (globalRectsContainsPosition) {
|
||||
if (globalRectsContainPosition) {
|
||||
final SelectionGeometry existingGeometry = selectables[index].value;
|
||||
lastSelectionResult = dispatchSelectionEventToChild(selectables[index], event);
|
||||
if (index == selectables.length - 1 && lastSelectionResult == SelectionResult.next) {
|
||||
@ -2267,7 +2323,21 @@ abstract class MultiSelectableSelectionContainerDelegate extends SelectionContai
|
||||
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
|
||||
SelectionResult handleClearSelection(ClearSelectionEvent event) {
|
||||
for (final Selectable selectable in selectables) {
|
||||
@ -2278,7 +2348,7 @@ abstract class MultiSelectableSelectionContainerDelegate extends SelectionContai
|
||||
return SelectionResult.none;
|
||||
}
|
||||
|
||||
/// Extend current selection in a certain text granularity.
|
||||
/// Extend current selection in a certain [TextGranularity].
|
||||
@protected
|
||||
SelectionResult handleGranularlyExtendSelection(GranularlyExtendSelectionEvent event) {
|
||||
assert((currentSelectionStartIndex == -1) == (currentSelectionEndIndex == -1));
|
||||
@ -2314,13 +2384,13 @@ abstract class MultiSelectableSelectionContainerDelegate extends SelectionContai
|
||||
return result;
|
||||
}
|
||||
|
||||
/// Extend current selection in a certain text granularity.
|
||||
/// Extend current selection in a certain [TextGranularity].
|
||||
@protected
|
||||
SelectionResult handleDirectionallyExtendSelection(DirectionallyExtendSelectionEvent event) {
|
||||
assert((currentSelectionStartIndex == -1) == (currentSelectionEndIndex == -1));
|
||||
if (currentSelectionStartIndex == -1) {
|
||||
currentSelectionStartIndex = currentSelectionEndIndex = switch (event.direction) {
|
||||
SelectionExtendDirection.previousLine || SelectionExtendDirection.backward => selectables.length,
|
||||
SelectionExtendDirection.previousLine || SelectionExtendDirection.backward => selectables.length - 1,
|
||||
SelectionExtendDirection.nextLine || SelectionExtendDirection.forward => 0,
|
||||
};
|
||||
}
|
||||
@ -2396,6 +2466,9 @@ abstract class MultiSelectableSelectionContainerDelegate extends SelectionContai
|
||||
case SelectionEventType.selectWord:
|
||||
_extendSelectionInProgress = false;
|
||||
result = handleSelectWord(event as SelectWordSelectionEvent);
|
||||
case SelectionEventType.selectParagraph:
|
||||
_extendSelectionInProgress = false;
|
||||
result = handleSelectParagraph(event as SelectParagraphSelectionEvent);
|
||||
case SelectionEventType.granularlyExtendSelection:
|
||||
_extendSelectionInProgress = true;
|
||||
result = handleGranularlyExtendSelection(event as GranularlyExtendSelectionEvent);
|
||||
@ -2418,7 +2491,7 @@ abstract class MultiSelectableSelectionContainerDelegate extends SelectionContai
|
||||
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,
|
||||
/// and its screen location falls into the previous selection.
|
||||
@ -2428,10 +2501,10 @@ abstract class MultiSelectableSelectionContainerDelegate extends SelectionContai
|
||||
@protected
|
||||
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
|
||||
/// treatments prior to sending the selection events.
|
||||
/// treatments prior to sending the [SelectionEvent].
|
||||
@protected
|
||||
SelectionResult dispatchSelectionEventToChild(Selectable selectable, SelectionEvent event) {
|
||||
return selectable.dispatchSelectionEvent(event);
|
||||
|
@ -2,8 +2,10 @@
|
||||
// Use of this source code is governed by a BSD-style license that can be
|
||||
// found in the LICENSE file.
|
||||
|
||||
import 'dart:math';
|
||||
import 'dart:ui' as ui show TextHeightBehavior;
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
|
||||
import 'basic.dart';
|
||||
@ -11,6 +13,7 @@ import 'default_selection_style.dart';
|
||||
import 'framework.dart';
|
||||
import 'inherited_theme.dart';
|
||||
import 'media_query.dart';
|
||||
import 'selectable_region.dart';
|
||||
import 'selection_container.dart';
|
||||
|
||||
// Examples can assume:
|
||||
@ -653,30 +656,47 @@ class Text extends StatelessWidget {
|
||||
(null, final double textScaleFactor) => TextScaler.linear(textScaleFactor),
|
||||
(null, null) => MediaQuery.textScalerOf(context),
|
||||
};
|
||||
|
||||
Widget result = RichText(
|
||||
textAlign: textAlign ?? defaultTextStyle.textAlign ?? TextAlign.start,
|
||||
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),
|
||||
selectionRegistrar: registrar,
|
||||
selectionColor: selectionColor ?? DefaultSelectionStyle.of(context).selectionColor ?? DefaultSelectionStyle.defaultColor,
|
||||
text: TextSpan(
|
||||
style: effectiveTextStyle,
|
||||
text: data,
|
||||
children: textSpan != null ? <InlineSpan>[textSpan!] : null,
|
||||
),
|
||||
);
|
||||
late Widget result;
|
||||
if (registrar != null) {
|
||||
result = MouseRegion(
|
||||
cursor: DefaultSelectionStyle.of(context).mouseCursor ?? SystemMouseCursors.text,
|
||||
child: result,
|
||||
child: _SelectableTextContainer(
|
||||
textAlign: textAlign ?? defaultTextStyle.textAlign ?? TextAlign.start,
|
||||
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,
|
||||
),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
result = RichText(
|
||||
textAlign: textAlign ?? defaultTextStyle.textAlign ?? TextAlign.start,
|
||||
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) {
|
||||
@ -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();
|
||||
}, 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 {
|
||||
final ScrollController controller = ScrollController();
|
||||
addTearDown(controller.dispose);
|
||||
|
@ -883,6 +883,323 @@ void main() {
|
||||
await gesture.up();
|
||||
}, 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 {
|
||||
final FocusNode focusNode = FocusNode();
|
||||
addTearDown(focusNode.dispose);
|
||||
|
@ -9,15 +9,7 @@ import 'package:flutter_test/flutter_test.dart';
|
||||
void main() {
|
||||
|
||||
Future<void> pumpContainer(WidgetTester tester, Widget child) async {
|
||||
await tester.pumpWidget(
|
||||
Directionality(
|
||||
textDirection: TextDirection.ltr,
|
||||
child: DefaultSelectionStyle(
|
||||
selectionColor: Colors.red,
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpWidget(MaterialApp(home: child));
|
||||
}
|
||||
|
||||
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'),
|
||||
);
|
||||
},
|
||||
)
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
@ -131,17 +123,17 @@ void main() {
|
||||
await pumpContainer(
|
||||
tester,
|
||||
SelectionContainer(
|
||||
registrar: registrar,
|
||||
delegate: delegate,
|
||||
child: Builder(
|
||||
builder: (BuildContext context) {
|
||||
return SelectionContainer(
|
||||
registrar: SelectionContainer.maybeOf(context),
|
||||
delegate: childDelegate,
|
||||
child: const Text('dummy'),
|
||||
);
|
||||
},
|
||||
)
|
||||
registrar: registrar,
|
||||
delegate: delegate,
|
||||
child: Builder(
|
||||
builder: (BuildContext context) {
|
||||
return SelectionContainer(
|
||||
registrar: SelectionContainer.maybeOf(context),
|
||||
delegate: childDelegate,
|
||||
child: const Text('dummy'),// The [Text] widget has an internal [SelectionContainer].
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
|
Loading…
x
Reference in New Issue
Block a user