From 2a37c6f30723e22c8fe224cc83a7e61b7d8919dc Mon Sep 17 00:00:00 2001 From: Renzo Olivares Date: Tue, 2 Apr 2024 14:10:52 -0700 Subject: [PATCH] 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 --- .../selectable_region.0.dart | 1 + .../flutter/lib/src/rendering/paragraph.dart | 1325 +++++++++++++++-- .../flutter/lib/src/rendering/selection.dart | 24 + .../flutter/lib/src/widgets/scrollable.dart | 1 + .../lib/src/widgets/selectable_region.dart | 127 +- packages/flutter/lib/src/widgets/text.dart | 730 ++++++++- .../widgets/scrollable_selection_test.dart | 106 ++ .../test/widgets/selectable_region_test.dart | 317 ++++ .../widgets/selection_container_test.dart | 34 +- 9 files changed, 2438 insertions(+), 227 deletions(-) diff --git a/examples/api/lib/material/selectable_region/selectable_region.0.dart b/examples/api/lib/material/selectable_region/selectable_region.0.dart index 33bfe031b1..3442f72c8f 100644 --- a/examples/api/lib/material/selectable_region/selectable_region.0.dart +++ b/examples/api/lib/material/selectable_region/selectable_region.0.dart @@ -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: diff --git a/packages/flutter/lib/src/rendering/paragraph.dart b/packages/flutter/lib/src/rendering/paragraph.dart index 42ca5655b6..f91343b2d0 100644 --- a/packages/flutter/lib/src/rendering/paragraph.dart +++ b/packages/flutter/lib/src/rendering/paragraph.dart @@ -18,8 +18,16 @@ import 'layout_helper.dart'; import 'object.dart'; import 'selection.dart'; -/// The start and end positions for a word. -typedef _WordBoundaryRecord = ({TextPosition wordStart, TextPosition wordEnd}); +/// The start and end positions for a text boundary. +typedef _TextBoundaryRecord = ({TextPosition boundaryStart, TextPosition boundaryEnd}); + +/// Signature for a function that determines the [_TextBoundaryRecord] at the given +/// [TextPosition]. +typedef _TextBoundaryAtPosition = _TextBoundaryRecord Function(TextPosition position); + +/// Signature for a function that determines the [_TextBoundaryRecord] at the given +/// [TextPosition], for the given [String]. +typedef _TextBoundaryAtPositionInText = _TextBoundaryRecord Function(TextPosition position, String text); const String _kEllipsis = '\u2026'; @@ -445,6 +453,15 @@ class RenderParagraph extends RenderBox with ContainerRenderObjectMixin= range.start && textBoundary.boundaryEnd.offset <= range.end); + if (_selectableContainsOriginTextBoundary && existingSelectionStart != null && existingSelectionEnd != null) { + final bool isSamePosition = position.offset == existingSelectionEnd.offset; + final bool isSelectionInverted = existingSelectionStart.offset > existingSelectionEnd.offset; + final bool shouldSwapEdges = !isSamePosition && (isSelectionInverted != (position.offset > existingSelectionEnd.offset)); + if (shouldSwapEdges) { + if (position.offset < existingSelectionEnd.offset) { + targetPosition = textBoundary.boundaryStart; + } else { + targetPosition = textBoundary.boundaryEnd; + } + // When the selection is inverted by the new position it is necessary to + // swap the start edge (moving edge) with the end edge (static edge) to + // maintain the origin text boundary within the selection. + final _TextBoundaryRecord localTextBoundary = getTextBoundary(existingSelectionEnd); + assert(localTextBoundary.boundaryStart.offset >= range.start && localTextBoundary.boundaryEnd.offset <= range.end); + _setSelectionPosition(existingSelectionEnd.offset == localTextBoundary.boundaryStart.offset ? localTextBoundary.boundaryEnd : localTextBoundary.boundaryStart, isEnd: true); + } else { + if (position.offset < existingSelectionEnd.offset) { + targetPosition = textBoundary.boundaryStart; + } else if (position.offset > existingSelectionEnd.offset) { + targetPosition = textBoundary.boundaryEnd; + } else { + // Keep the origin text boundary in bounds when position is at the static edge. + targetPosition = existingSelectionStart; + } + } + } else { + if (existingSelectionEnd != null) { + // If the end edge exists and the start edge is being moved, then the + // start edge is moved to encompass the entire text boundary at the new position. + if (position.offset < existingSelectionEnd.offset) { + targetPosition = textBoundary.boundaryStart; + } else { + targetPosition = textBoundary.boundaryEnd; + } + } else { + // Move the start edge to the closest text boundary. + targetPosition = _closestTextBoundary(textBoundary, position); + } + } + } else { + // The position is not contained within the current rect. The targetPosition + // will either be at the end or beginning of the current rect. See [SelectionUtils.adjustDragOffset] + // for a more in depth explanation on this adjustment. + if (_selectableContainsOriginTextBoundary && existingSelectionStart != null && existingSelectionEnd != null) { + // When the selection is inverted by the new position it is necessary to + // swap the start edge (moving edge) with the end edge (static edge) to + // maintain the origin text boundary within the selection. + final bool isSamePosition = position.offset == existingSelectionEnd.offset; + final bool isSelectionInverted = existingSelectionStart.offset > existingSelectionEnd.offset; + final bool shouldSwapEdges = !isSamePosition && (isSelectionInverted != (position.offset > existingSelectionEnd.offset)); + + if (shouldSwapEdges) { + final _TextBoundaryRecord localTextBoundary = getTextBoundary(existingSelectionEnd); + assert(localTextBoundary.boundaryStart.offset >= range.start && localTextBoundary.boundaryEnd.offset <= range.end); + _setSelectionPosition(isSelectionInverted ? localTextBoundary.boundaryEnd : localTextBoundary.boundaryStart, isEnd: true); + } + } + } + return targetPosition ?? position; + } + + TextPosition _updateSelectionEndEdgeByTextBoundary( + _TextBoundaryRecord? textBoundary, + _TextBoundaryAtPosition getTextBoundary, + TextPosition position, + TextPosition? existingSelectionStart, + TextPosition? existingSelectionEnd, + ) { + TextPosition? targetPosition; + if (textBoundary != null) { + assert(textBoundary.boundaryStart.offset >= range.start && textBoundary.boundaryEnd.offset <= range.end); + if (_selectableContainsOriginTextBoundary && existingSelectionStart != null && existingSelectionEnd != null) { + final bool isSamePosition = position.offset == existingSelectionStart.offset; + final bool isSelectionInverted = existingSelectionStart.offset > existingSelectionEnd.offset; + final bool shouldSwapEdges = !isSamePosition && (isSelectionInverted != (position.offset < existingSelectionStart.offset)); + if (shouldSwapEdges) { + if (position.offset < existingSelectionStart.offset) { + targetPosition = textBoundary.boundaryStart; + } else { + targetPosition = textBoundary.boundaryEnd; + } + // When the selection is inverted by the new position it is necessary to + // swap the end edge (moving edge) with the start edge (static edge) to + // maintain the origin text boundary within the selection. + final _TextBoundaryRecord localTextBoundary = getTextBoundary(existingSelectionStart); + assert(localTextBoundary.boundaryStart.offset >= range.start && localTextBoundary.boundaryEnd.offset <= range.end); + _setSelectionPosition(existingSelectionStart.offset == localTextBoundary.boundaryStart.offset ? localTextBoundary.boundaryEnd : localTextBoundary.boundaryStart, isEnd: false); + } else { + if (position.offset < existingSelectionStart.offset) { + targetPosition = textBoundary.boundaryStart; + } else if (position.offset > existingSelectionStart.offset) { + targetPosition = textBoundary.boundaryEnd; + } else { + // Keep the origin text boundary in bounds when position is at the static edge. + targetPosition = existingSelectionEnd; + } + } + } else { + if (existingSelectionStart != null) { + // If the start edge exists and the end edge is being moved, then the + // end edge is moved to encompass the entire text boundary at the new position. + if (position.offset < existingSelectionStart.offset) { + targetPosition = textBoundary.boundaryStart; + } else { + targetPosition = textBoundary.boundaryEnd; + } + } else { + // Move the end edge to the closest text boundary. + targetPosition = _closestTextBoundary(textBoundary, position); + } + } + } else { + // The position is not contained within the current rect. The targetPosition + // will either be at the end or beginning of the current rect. See [SelectionUtils.adjustDragOffset] + // for a more in depth explanation on this adjustment. + if (_selectableContainsOriginTextBoundary && existingSelectionStart != null && existingSelectionEnd != null) { + // When the selection is inverted by the new position it is necessary to + // swap the end edge (moving edge) with the start edge (static edge) to + // maintain the origin text boundary within the selection. + final bool isSamePosition = position.offset == existingSelectionStart.offset; + final bool isSelectionInverted = existingSelectionStart.offset > existingSelectionEnd.offset; + final bool shouldSwapEdges = isSelectionInverted != (position.offset < existingSelectionStart.offset) || isSamePosition; + if (shouldSwapEdges) { + final _TextBoundaryRecord localTextBoundary = getTextBoundary(existingSelectionStart); + assert(localTextBoundary.boundaryStart.offset >= range.start && localTextBoundary.boundaryEnd.offset <= range.end); + _setSelectionPosition(isSelectionInverted ? localTextBoundary.boundaryStart : localTextBoundary.boundaryEnd, isEnd: false); + } + } + } + return targetPosition ?? position; + } + + SelectionResult _updateSelectionEdgeByTextBoundary(Offset globalPosition, {required bool isEnd, required _TextBoundaryAtPosition getTextBoundary}) { + // When the start/end edges are swapped, i.e. the start is after the end, and + // the scrollable synthesizes an event for the opposite edge, this will potentially + // move the opposite edge outside of the origin text boundary and we are unable to recover. + final TextPosition? existingSelectionStart = _textSelectionStart; + final TextPosition? existingSelectionEnd = _textSelectionEnd; + + _setSelectionPosition(null, isEnd: isEnd); + final Matrix4 transform = paragraph.getTransformTo(null); + transform.invert(); + final Offset localPosition = MatrixUtils.transformPoint(transform, globalPosition); + if (_rect.isEmpty) { + return SelectionUtils.getResultBasedOnRect(_rect, localPosition); + } + final Offset adjustedOffset = SelectionUtils.adjustDragOffset( + _rect, + localPosition, + direction: paragraph.textDirection, + ); + + final TextPosition position = paragraph.getPositionForOffset(adjustedOffset); + // Check if the original local position is within the rect, if it is not then + // we do not need to look up the text boundary for that position. This is to + // maintain a selectables selection collapsed at 0 when the local position is + // not located inside its rect. + _TextBoundaryRecord? textBoundary = _rect.contains(localPosition) ? getTextBoundary(position) : null; + if (textBoundary != null + && (textBoundary.boundaryStart.offset < range.start && textBoundary.boundaryEnd.offset <= range.start + || textBoundary.boundaryStart.offset >= range.end && textBoundary.boundaryEnd.offset > range.end)) { + // When the position is located at a placeholder inside of the text, then we may compute + // a text boundary that does not belong to the current selectable fragment. In this case + // we should invalidate the text boundary so that it is not taken into account when + // computing the target position. + textBoundary = null; + } + final TextPosition targetPosition = _clampTextPosition(isEnd ? _updateSelectionEndEdgeByTextBoundary(textBoundary, getTextBoundary, position, existingSelectionStart, existingSelectionEnd) : _updateSelectionStartEdgeByTextBoundary(textBoundary, getTextBoundary, position, existingSelectionStart, existingSelectionEnd)); + + _setSelectionPosition(targetPosition, isEnd: isEnd); + if (targetPosition.offset == range.end) { + return SelectionResult.next; + } + + if (targetPosition.offset == range.start) { + return SelectionResult.previous; + } + // TODO(chunhtai): The geometry information should not be used to determine + // selection result. This is a workaround to RenderParagraph, where it does + // not have a way to get accurate text length if its text is truncated due to + // layout constraint. + return SelectionUtils.getResultBasedOnRect(_rect, localPosition); + } + SelectionResult _updateSelectionEdge(Offset globalPosition, {required bool isEnd}) { _setSelectionPosition(null, isEnd: isEnd); final Matrix4 transform = paragraph.getTransformTo(null); @@ -1519,160 +1743,714 @@ class _SelectableFragment with Selectable, Diagnosticable, ChangeNotifier implem return SelectionUtils.getResultBasedOnRect(_rect, localPosition); } - TextPosition _closestWordBoundary( - _WordBoundaryRecord wordBoundary, - TextPosition position, - ) { - final int differenceA = (position.offset - wordBoundary.wordStart.offset).abs(); - final int differenceB = (position.offset - wordBoundary.wordEnd.offset).abs(); - return differenceA < differenceB ? wordBoundary.wordStart : wordBoundary.wordEnd; - } - - TextPosition _updateSelectionStartEdgeByWord( - _WordBoundaryRecord? wordBoundary, + // This method handles updating the start edge by a text boundary that may + // not be contained within this selectable fragment. It is possible + // that a boundary spans multiple selectable fragments when the text contains + // [WidgetSpan]s. + // + // This method differs from [_updateSelectionStartEdgeByTextBoundary] in that + // to pivot offset used to swap selection edges and maintain the origin + // text boundary selected may be located outside of this selectable fragment. + // + // See [_updateSelectionEndEdgeByMultiSelectableTextBoundary] for the method + // that handles updating the end edge. + SelectionResult? _updateSelectionStartEdgeByMultiSelectableTextBoundary( + _TextBoundaryAtPositionInText getTextBoundary, + bool paragraphContainsPosition, TextPosition position, TextPosition? existingSelectionStart, TextPosition? existingSelectionEnd, ) { - TextPosition? targetPosition; - if (wordBoundary != null) { - assert(wordBoundary.wordStart.offset >= range.start && wordBoundary.wordEnd.offset <= range.end); - if (_selectableContainsOriginWord && existingSelectionStart != null && existingSelectionEnd != null) { - final bool isSamePosition = position.offset == existingSelectionEnd.offset; - final bool isSelectionInverted = existingSelectionStart.offset > existingSelectionEnd.offset; - final bool shouldSwapEdges = !isSamePosition && (isSelectionInverted != (position.offset > existingSelectionEnd.offset)); - if (shouldSwapEdges) { - if (position.offset < existingSelectionEnd.offset) { - targetPosition = wordBoundary.wordStart; - } else { - targetPosition = wordBoundary.wordEnd; - } - // When the selection is inverted by the new position it is necessary to - // swap the start edge (moving edge) with the end edge (static edge) to - // maintain the origin word within the selection. - final _WordBoundaryRecord localWordBoundary = _getWordBoundaryAtPosition(existingSelectionEnd); - assert(localWordBoundary.wordStart.offset >= range.start && localWordBoundary.wordEnd.offset <= range.end); - _setSelectionPosition(existingSelectionEnd.offset == localWordBoundary.wordStart.offset ? localWordBoundary.wordEnd : localWordBoundary.wordStart, isEnd: true); + const bool isEnd = false; + if (_selectableContainsOriginTextBoundary && existingSelectionStart != null && existingSelectionEnd != null) { + // If this selectable contains the origin boundary, maintain the existing + // selection. + final bool forwardSelection = existingSelectionEnd.offset >= existingSelectionStart.offset; + if (paragraphContainsPosition) { + // When the position is within the root paragraph, swap the start and end + // edges when the selection is inverted. + final _TextBoundaryRecord boundaryAtPosition = getTextBoundary(position, fullText); + // To accurately retrieve the origin text boundary when the selection + // is forward, use existingSelectionEnd.offset - 1. This is necessary + // because in a forwards selection, existingSelectionEnd marks the end + // of the origin text boundary. Using the unmodified offset incorrectly + // targets the subsequent text boundary. + final _TextBoundaryRecord originTextBoundary = getTextBoundary( + forwardSelection + ? TextPosition( + offset: existingSelectionEnd.offset - 1, + affinity: existingSelectionEnd.affinity, + ) + : existingSelectionEnd, + fullText, + ); + final TextPosition targetPosition; + final int pivotOffset = forwardSelection ? originTextBoundary.boundaryEnd.offset : originTextBoundary.boundaryStart.offset; + final bool shouldSwapEdges = !forwardSelection != (position.offset > pivotOffset); + if (position.offset < pivotOffset) { + targetPosition = boundaryAtPosition.boundaryStart; + } else if (position.offset > pivotOffset) { + targetPosition = boundaryAtPosition.boundaryEnd; } else { - if (position.offset < existingSelectionEnd.offset) { - targetPosition = wordBoundary.wordStart; - } else if (position.offset > existingSelectionEnd.offset) { - targetPosition = wordBoundary.wordEnd; - } else { - // Keep the origin word in bounds when position is at the static edge. - targetPosition = existingSelectionStart; + // Keep the origin text boundary in bounds when position is at the static edge. + targetPosition = forwardSelection ? existingSelectionStart : existingSelectionEnd; + } + if (shouldSwapEdges) { + _setSelectionPosition( + _clampTextPosition(forwardSelection ? originTextBoundary.boundaryStart : originTextBoundary.boundaryEnd), + isEnd: true, + ); + } + _setSelectionPosition(_clampTextPosition(targetPosition), isEnd: isEnd); + final bool finalSelectionIsForward = _textSelectionEnd!.offset >= _textSelectionStart!.offset; + if (boundaryAtPosition.boundaryStart.offset > range.end && boundaryAtPosition.boundaryEnd.offset > range.end) { + return SelectionResult.next; + } + if (boundaryAtPosition.boundaryStart.offset < range.start && boundaryAtPosition.boundaryEnd.offset < range.start) { + return SelectionResult.previous; + } + if (finalSelectionIsForward) { + if (boundaryAtPosition.boundaryStart.offset >= originTextBoundary.boundaryStart.offset) { + return SelectionResult.end; + } + if (boundaryAtPosition.boundaryStart.offset < originTextBoundary.boundaryStart.offset) { + return SelectionResult.previous; + } + } else { + if (boundaryAtPosition.boundaryEnd.offset <= originTextBoundary.boundaryEnd.offset) { + return SelectionResult.end; + } + if (boundaryAtPosition.boundaryEnd.offset > originTextBoundary.boundaryEnd.offset) { + return SelectionResult.next; } } } else { - if (existingSelectionEnd != null) { - // If the end edge exists and the start edge is being moved, then the - // start edge is moved to encompass the entire word at the new position. - if (position.offset < existingSelectionEnd.offset) { - targetPosition = wordBoundary.wordStart; - } else { - targetPosition = wordBoundary.wordEnd; - } - } else { - // Move the start edge to the closest word boundary. - targetPosition = _closestWordBoundary(wordBoundary, position); + // When the drag position is not contained within the root paragraph, + // swap the edges when the selection changes direction. + final TextPosition clampedPosition = _clampTextPosition(position); + // To accurately retrieve the origin text boundary when the selection + // is forward, use existingSelectionEnd.offset - 1. This is necessary + // because in a forwards selection, existingSelectionEnd marks the end + // of the origin text boundary. Using the unmodified offset incorrectly + // targets the subsequent text boundary. + final _TextBoundaryRecord originTextBoundary = getTextBoundary( + forwardSelection + ? TextPosition( + offset: existingSelectionEnd.offset - 1, + affinity: existingSelectionEnd.affinity, + ) + : existingSelectionEnd, + fullText, + ); + if (forwardSelection && clampedPosition.offset == range.start) { + _setSelectionPosition(clampedPosition, isEnd: isEnd); + return SelectionResult.previous; + } + if (!forwardSelection && clampedPosition.offset == range.end) { + _setSelectionPosition(clampedPosition, isEnd: isEnd); + return SelectionResult.next; + } + if (forwardSelection && clampedPosition.offset == range.end) { + _setSelectionPosition(_clampTextPosition(originTextBoundary.boundaryStart), isEnd: true); + _setSelectionPosition(clampedPosition, isEnd: isEnd); + return SelectionResult.next; + } + if (!forwardSelection && clampedPosition.offset == range.start) { + _setSelectionPosition(_clampTextPosition(originTextBoundary.boundaryEnd), isEnd: true); + _setSelectionPosition(clampedPosition, isEnd: isEnd); + return SelectionResult.previous; } } } else { - // The position is not contained within the current rect. The targetPosition - // will either be at the end or beginning of the current rect. See [SelectionUtils.adjustDragOffset] - // for a more in depth explanation on this adjustment. - if (_selectableContainsOriginWord && existingSelectionStart != null && existingSelectionEnd != null) { + // A paragraph boundary may not be completely contained within this root + // selectable fragment. Keep searching until we find the end of the + // boundary. Do not search when the current drag position is on a placeholder + // to allow traversal to reach that placeholder. + final bool positionOnPlaceholder = paragraph.getWordBoundary(position).textInside(fullText) == _placeholderCharacter; + if (!paragraphContainsPosition || positionOnPlaceholder) { + return null; + } + if (existingSelectionEnd != null) { + final _TextBoundaryRecord boundaryAtPosition = getTextBoundary(position, fullText); + final bool backwardSelection = existingSelectionStart == null && existingSelectionEnd.offset == range.start + || existingSelectionStart == existingSelectionEnd && existingSelectionEnd.offset == range.start + || existingSelectionStart != null && existingSelectionStart.offset > existingSelectionEnd.offset; + if (boundaryAtPosition.boundaryStart.offset < range.start && boundaryAtPosition.boundaryEnd.offset < range.start) { + _setSelectionPosition(TextPosition(offset: range.start), isEnd: isEnd); + return SelectionResult.previous; + } + if (boundaryAtPosition.boundaryStart.offset > range.end && boundaryAtPosition.boundaryEnd.offset > range.end) { + _setSelectionPosition(TextPosition(offset: range.end), isEnd: isEnd); + return SelectionResult.next; + } + if (backwardSelection) { + if (boundaryAtPosition.boundaryEnd.offset <= range.end) { + _setSelectionPosition(_clampTextPosition(boundaryAtPosition.boundaryEnd), isEnd: isEnd); + return SelectionResult.end; + } + if (boundaryAtPosition.boundaryEnd.offset > range.end) { + _setSelectionPosition(TextPosition(offset: range.end), isEnd: isEnd); + return SelectionResult.next; + } + } else { + _setSelectionPosition(_clampTextPosition(boundaryAtPosition.boundaryStart), isEnd: isEnd); + if (boundaryAtPosition.boundaryStart.offset < range.start) { + return SelectionResult.previous; + } + if (boundaryAtPosition.boundaryStart.offset >= range.start) { + return SelectionResult.end; + } + } + } + } + return null; + } + + // This method handles updating the end edge by a text boundary that may + // not be contained within this selectable fragment. It is possible + // that a boundary spans multiple selectable fragments when the text contains + // [WidgetSpan]s. + // + // This method differs from [_updateSelectionEndEdgeByTextBoundary] in that + // to pivot offset used to swap selection edges and maintain the origin + // text boundary selected may be located outside of this selectable fragment. + // + // See [_updateSelectionStartEdgeByMultiSelectableTextBoundary] for the method + // that handles updating the end edge. + SelectionResult? _updateSelectionEndEdgeByMultiSelectableTextBoundary( + _TextBoundaryAtPositionInText getTextBoundary, + bool paragraphContainsPosition, + TextPosition position, + TextPosition? existingSelectionStart, + TextPosition? existingSelectionEnd, + ) { + const bool isEnd = true; + if (_selectableContainsOriginTextBoundary && existingSelectionStart != null && existingSelectionEnd != null) { + // If this selectable contains the origin boundary, maintain the existing + // selection. + final bool forwardSelection = existingSelectionEnd.offset >= existingSelectionStart.offset; + if (paragraphContainsPosition) { + // When the position is within the root paragraph, swap the start and end + // edges when the selection is inverted. + final _TextBoundaryRecord boundaryAtPosition = getTextBoundary(position, fullText); + // To accurately retrieve the origin text boundary when the selection + // is backwards, use existingSelectionStart.offset - 1. This is necessary + // because in a backwards selection, existingSelectionStart marks the end + // of the origin text boundary. Using the unmodified offset incorrectly + // targets the subsequent text boundary. + final _TextBoundaryRecord originTextBoundary = getTextBoundary( + forwardSelection + ? existingSelectionStart + : TextPosition( + offset: existingSelectionStart.offset - 1, + affinity: existingSelectionStart.affinity, + ), + fullText, + ); + final TextPosition targetPosition; + final int pivotOffset = forwardSelection ? originTextBoundary.boundaryStart.offset : originTextBoundary.boundaryEnd.offset; + final bool shouldSwapEdges = !forwardSelection != (position.offset < pivotOffset); + if (position.offset < pivotOffset) { + targetPosition = boundaryAtPosition.boundaryStart; + } else if (position.offset > pivotOffset) { + targetPosition = boundaryAtPosition.boundaryEnd; + } else { + // Keep the origin text boundary in bounds when position is at the static edge. + targetPosition = forwardSelection ? existingSelectionEnd : existingSelectionStart; + } + if (shouldSwapEdges) { + _setSelectionPosition( + _clampTextPosition(forwardSelection ? originTextBoundary.boundaryEnd : originTextBoundary.boundaryStart), + isEnd: false, + ); + } + _setSelectionPosition(_clampTextPosition(targetPosition), isEnd: isEnd); + final bool finalSelectionIsForward = _textSelectionEnd!.offset >= _textSelectionStart!.offset; + if (boundaryAtPosition.boundaryStart.offset > range.end && boundaryAtPosition.boundaryEnd.offset > range.end) { + return SelectionResult.next; + } + if (boundaryAtPosition.boundaryStart.offset < range.start && boundaryAtPosition.boundaryEnd.offset < range.start) { + return SelectionResult.previous; + } + if (finalSelectionIsForward) { + if (boundaryAtPosition.boundaryEnd.offset <= originTextBoundary.boundaryEnd.offset) { + return SelectionResult.end; + } + if (boundaryAtPosition.boundaryEnd.offset > originTextBoundary.boundaryEnd.offset) { + return SelectionResult.next; + } + } else { + if (boundaryAtPosition.boundaryStart.offset >= originTextBoundary.boundaryStart.offset) { + return SelectionResult.end; + } + if (boundaryAtPosition.boundaryStart.offset < originTextBoundary.boundaryStart.offset) { + return SelectionResult.previous; + } + } + } else { + // When the drag position is not contained within the root paragraph, + // swap the edges when the selection changes direction. + final TextPosition clampedPosition = _clampTextPosition(position); + // To accurately retrieve the origin text boundary when the selection + // is backwards, use existingSelectionStart.offset - 1. This is necessary + // because in a backwards selection, existingSelectionStart marks the end + // of the origin text boundary. Using the unmodified offset incorrectly + // targets the subsequent text boundary. + final _TextBoundaryRecord originTextBoundary = getTextBoundary( + forwardSelection + ? existingSelectionStart + : TextPosition( + offset: existingSelectionStart.offset - 1, + affinity: existingSelectionStart.affinity, + ), + fullText, + ); + if (forwardSelection && clampedPosition.offset == range.start) { + _setSelectionPosition(_clampTextPosition(originTextBoundary.boundaryEnd), isEnd: false); + _setSelectionPosition(clampedPosition, isEnd: isEnd); + return SelectionResult.previous; + } + if (!forwardSelection && clampedPosition.offset == range.end) { + _setSelectionPosition(_clampTextPosition(originTextBoundary.boundaryStart), isEnd: false); + _setSelectionPosition(clampedPosition, isEnd: isEnd); + return SelectionResult.next; + } + if (forwardSelection && clampedPosition.offset == range.end) { + _setSelectionPosition(clampedPosition, isEnd: isEnd); + return SelectionResult.next; + } + if (!forwardSelection && clampedPosition.offset == range.start) { + _setSelectionPosition(clampedPosition, isEnd: isEnd); + return SelectionResult.previous; + } + } + } else { + // A paragraph boundary may not be completely contained within this root + // selectable fragment. Keep searching until we find the end of the + // boundary. Do not search when the current drag position is on a placeholder + // to allow traversal to reach that placeholder. + final bool positionOnPlaceholder = paragraph.getWordBoundary(position).textInside(fullText) == _placeholderCharacter; + if (!paragraphContainsPosition || positionOnPlaceholder) { + return null; + } + if (existingSelectionStart != null) { + final _TextBoundaryRecord boundaryAtPosition = getTextBoundary(position, fullText); + final bool backwardSelection = existingSelectionEnd == null && existingSelectionStart.offset == range.end + || existingSelectionStart == existingSelectionEnd && existingSelectionStart.offset == range.end + || existingSelectionEnd != null && existingSelectionStart.offset > existingSelectionEnd.offset; + if (boundaryAtPosition.boundaryStart.offset < range.start && boundaryAtPosition.boundaryEnd.offset < range.start) { + _setSelectionPosition(TextPosition(offset: range.start), isEnd: isEnd); + return SelectionResult.previous; + } + if (boundaryAtPosition.boundaryStart.offset > range.end && boundaryAtPosition.boundaryEnd.offset > range.end) { + _setSelectionPosition(TextPosition(offset: range.end), isEnd: isEnd); + return SelectionResult.next; + } + if (backwardSelection) { + _setSelectionPosition(_clampTextPosition(boundaryAtPosition.boundaryStart), isEnd: isEnd); + if (boundaryAtPosition.boundaryStart.offset < range.start) { + return SelectionResult.previous; + } + if (boundaryAtPosition.boundaryStart.offset >= range.start) { + return SelectionResult.end; + } + } else { + if (boundaryAtPosition.boundaryEnd.offset <= range.end) { + _setSelectionPosition(_clampTextPosition(boundaryAtPosition.boundaryEnd), isEnd: isEnd); + return SelectionResult.end; + } + if (boundaryAtPosition.boundaryEnd.offset > range.end) { + _setSelectionPosition(TextPosition(offset: range.end), isEnd: isEnd); + return SelectionResult.next; + } + } + } + } + return null; + } + + // The placeholder character used by [RenderParagraph]. + static final String _placeholderCharacter = String.fromCharCode(PlaceholderSpan.placeholderCodeUnit); + static final int _placeholderLength = _placeholderCharacter.length; + // This method handles updating the start edge by a text boundary that may + // not be contained within this selectable fragment. It is possible + // that a boundary spans multiple selectable fragments when the text contains + // [WidgetSpan]s. + // + // This method differs from [_updateSelectionStartEdgeByMultiSelectableBoundary] + // in that to mantain the origin text boundary selected at a placeholder, + // this selectable fragment must be aware of the [RenderParagraph] that closely + // encompasses the complete origin text boundary. + // + // See [_updateSelectionEndEdgeAtPlaceholderByMultiSelectableTextBoundary] for the method + // that handles updating the end edge. + SelectionResult? _updateSelectionStartEdgeAtPlaceholderByMultiSelectableTextBoundary( + _TextBoundaryAtPositionInText getTextBoundary, + Offset globalPosition, + bool paragraphContainsPosition, + TextPosition position, + TextPosition? existingSelectionStart, + TextPosition? existingSelectionEnd, + ) { + const bool isEnd = false; + if (_selectableContainsOriginTextBoundary && existingSelectionStart != null && existingSelectionEnd != null) { + // If this selectable contains the origin boundary, maintain the existing + // selection. + final bool forwardSelection = existingSelectionEnd.offset >= existingSelectionStart.offset; + final RenderParagraph originParagraph = _getOriginParagraph(); + final bool fragmentBelongsToOriginParagraph = originParagraph == paragraph; + if (fragmentBelongsToOriginParagraph) { + return _updateSelectionStartEdgeByMultiSelectableTextBoundary( + getTextBoundary, + paragraphContainsPosition, + position, + existingSelectionStart, + existingSelectionEnd, + ); + } + final Matrix4 originTransform = originParagraph.getTransformTo(null); + originTransform.invert(); + final Offset originParagraphLocalPosition = MatrixUtils.transformPoint(originTransform, globalPosition); + final bool positionWithinOriginParagraph = originParagraph.paintBounds.contains(originParagraphLocalPosition); + final TextPosition positionRelativeToOriginParagraph = originParagraph.getPositionForOffset(originParagraphLocalPosition); + if (positionWithinOriginParagraph) { // When the selection is inverted by the new position it is necessary to // swap the start edge (moving edge) with the end edge (static edge) to - // maintain the origin word within the selection. - final bool isSamePosition = position.offset == existingSelectionEnd.offset; - final bool isSelectionInverted = existingSelectionStart.offset > existingSelectionEnd.offset; - final bool shouldSwapEdges = !isSamePosition && (isSelectionInverted != (position.offset > existingSelectionEnd.offset)); - + // maintain the origin text boundary within the selection. + final String originText = originParagraph.text.toPlainText(includeSemanticsLabels: false); + final _TextBoundaryRecord boundaryAtPosition = getTextBoundary(positionRelativeToOriginParagraph, originText); + final _TextBoundaryRecord originTextBoundary = getTextBoundary(_getPositionInParagraph(originParagraph), originText); + final TextPosition targetPosition; + final int pivotOffset = forwardSelection ? originTextBoundary.boundaryEnd.offset : originTextBoundary.boundaryStart.offset; + final bool shouldSwapEdges = !forwardSelection != (positionRelativeToOriginParagraph.offset > pivotOffset); + if (positionRelativeToOriginParagraph.offset < pivotOffset) { + targetPosition = boundaryAtPosition.boundaryStart; + } else if (positionRelativeToOriginParagraph.offset > pivotOffset) { + targetPosition = boundaryAtPosition.boundaryEnd; + } else { + // Keep the origin text boundary in bounds when position is at the static edge. + targetPosition = existingSelectionStart; + } if (shouldSwapEdges) { - final _WordBoundaryRecord localWordBoundary = _getWordBoundaryAtPosition(existingSelectionEnd); - assert(localWordBoundary.wordStart.offset >= range.start && localWordBoundary.wordEnd.offset <= range.end); - _setSelectionPosition(isSelectionInverted ? localWordBoundary.wordEnd : localWordBoundary.wordStart, isEnd: true); + _setSelectionPosition(existingSelectionStart, isEnd: true); + } + _setSelectionPosition(_clampTextPosition(targetPosition), isEnd: isEnd); + final bool finalSelectionIsForward = _textSelectionEnd!.offset >= _textSelectionStart!.offset; + final TextPosition originParagraphPlaceholderTextPosition = _getPositionInParagraph(originParagraph); + final TextRange originParagraphPlaceholderRange = TextRange(start: originParagraphPlaceholderTextPosition.offset, end: originParagraphPlaceholderTextPosition.offset + _placeholderLength); + if (boundaryAtPosition.boundaryStart.offset > originParagraphPlaceholderRange.end && boundaryAtPosition.boundaryEnd.offset > originParagraphPlaceholderRange.end) { + return SelectionResult.next; + } + if (boundaryAtPosition.boundaryStart.offset < originParagraphPlaceholderRange.start && boundaryAtPosition.boundaryEnd.offset < originParagraphPlaceholderRange.start) { + return SelectionResult.previous; + } + if (finalSelectionIsForward) { + if (boundaryAtPosition.boundaryEnd.offset <= originTextBoundary.boundaryEnd.offset) { + return SelectionResult.end; + } + if (boundaryAtPosition.boundaryEnd.offset > originTextBoundary.boundaryEnd.offset) { + return SelectionResult.next; + } + } else { + if (boundaryAtPosition.boundaryStart.offset >= originTextBoundary.boundaryStart.offset) { + return SelectionResult.end; + } + if (boundaryAtPosition.boundaryStart.offset < originTextBoundary.boundaryStart.offset) { + return SelectionResult.previous; + } + } + } else { + // When the drag position is not contained within the origin paragraph, + // swap the edges when the selection changes direction. + // + // [SelectionUtils.adjustDragOffset] will adjust the given [Offset] to the + // beginning or end of the provided [Rect] based on whether the [Offset] + // is located within the given [Rect]. + final Offset adjustedOffset = SelectionUtils.adjustDragOffset( + originParagraph.paintBounds, + originParagraphLocalPosition, + direction: paragraph.textDirection, + ); + final TextPosition adjustedPositionRelativeToOriginParagraph = originParagraph.getPositionForOffset(adjustedOffset); + final TextPosition originParagraphPlaceholderTextPosition = _getPositionInParagraph(originParagraph); + final TextRange originParagraphPlaceholderRange = TextRange(start: originParagraphPlaceholderTextPosition.offset, end: originParagraphPlaceholderTextPosition.offset + _placeholderLength); + if (forwardSelection && adjustedPositionRelativeToOriginParagraph.offset <= originParagraphPlaceholderRange.start) { + _setSelectionPosition(TextPosition(offset: range.start), isEnd: isEnd); + return SelectionResult.previous; + } + if (!forwardSelection && adjustedPositionRelativeToOriginParagraph.offset >= originParagraphPlaceholderRange.end) { + _setSelectionPosition(TextPosition(offset: range.end), isEnd: isEnd); + return SelectionResult.next; + } + if (forwardSelection && adjustedPositionRelativeToOriginParagraph.offset >= originParagraphPlaceholderRange.end) { + _setSelectionPosition(existingSelectionStart, isEnd: true); + _setSelectionPosition(TextPosition(offset: range.end), isEnd: isEnd); + return SelectionResult.next; + } + if (!forwardSelection && adjustedPositionRelativeToOriginParagraph.offset <= originParagraphPlaceholderRange.start) { + _setSelectionPosition(existingSelectionStart, isEnd: true); + _setSelectionPosition(TextPosition(offset: range.start), isEnd: isEnd); + return SelectionResult.previous; + } + } + } else { + // When the drag position is somewhere on the root text and not a placeholder, + // traverse the selectable fragments relative to the [RenderParagraph] that + // contains the drag position. + if (paragraphContainsPosition) { + return _updateSelectionStartEdgeByMultiSelectableTextBoundary( + getTextBoundary, + paragraphContainsPosition, + position, + existingSelectionStart, + existingSelectionEnd, + ); + } + if (existingSelectionEnd != null) { + final ({RenderParagraph paragraph, Offset localPosition})? targetDetails = _getParagraphContainingPosition(globalPosition); + if (targetDetails == null) { + return null; + } + final RenderParagraph targetParagraph = targetDetails.paragraph; + final TextPosition positionRelativeToTargetParagraph = targetParagraph.getPositionForOffset(targetDetails.localPosition); + final String targetText = targetParagraph.text.toPlainText(includeSemanticsLabels: false); + final bool positionOnPlaceholder = targetParagraph.getWordBoundary(positionRelativeToTargetParagraph).textInside(targetText) == _placeholderCharacter; + if (positionOnPlaceholder) { + return null; + } + final bool backwardSelection = existingSelectionStart == null && existingSelectionEnd.offset == range.start + || existingSelectionStart == existingSelectionEnd && existingSelectionEnd.offset == range.start + || existingSelectionStart != null && existingSelectionStart.offset > existingSelectionEnd.offset; + final _TextBoundaryRecord boundaryAtPositionRelativeToTargetParagraph = getTextBoundary(positionRelativeToTargetParagraph, targetText); + final TextPosition targetParagraphPlaceholderTextPosition = _getPositionInParagraph(targetParagraph); + final TextRange targetParagraphPlaceholderRange = TextRange(start: targetParagraphPlaceholderTextPosition.offset, end: targetParagraphPlaceholderTextPosition.offset + _placeholderLength); + if (boundaryAtPositionRelativeToTargetParagraph.boundaryStart.offset < targetParagraphPlaceholderRange.start && boundaryAtPositionRelativeToTargetParagraph.boundaryEnd.offset < targetParagraphPlaceholderRange.start) { + _setSelectionPosition(TextPosition(offset: range.start), isEnd: isEnd); + return SelectionResult.previous; + } + if (boundaryAtPositionRelativeToTargetParagraph.boundaryStart.offset > targetParagraphPlaceholderRange.end && boundaryAtPositionRelativeToTargetParagraph.boundaryEnd.offset > targetParagraphPlaceholderRange.end) { + _setSelectionPosition(TextPosition(offset: range.end), isEnd: isEnd); + return SelectionResult.next; + } + if (backwardSelection) { + if (boundaryAtPositionRelativeToTargetParagraph.boundaryEnd.offset <= targetParagraphPlaceholderRange.end) { + _setSelectionPosition(TextPosition(offset: range.end), isEnd: isEnd); + return SelectionResult.end; + } + if (boundaryAtPositionRelativeToTargetParagraph.boundaryEnd.offset > targetParagraphPlaceholderRange.end) { + _setSelectionPosition(TextPosition(offset: range.end), isEnd: isEnd); + return SelectionResult.next; + } + } else { + if (boundaryAtPositionRelativeToTargetParagraph.boundaryStart.offset >= targetParagraphPlaceholderRange.start) { + _setSelectionPosition(TextPosition(offset: range.start), isEnd: isEnd); + return SelectionResult.end; + } + if (boundaryAtPositionRelativeToTargetParagraph.boundaryStart.offset < targetParagraphPlaceholderRange.start) { + _setSelectionPosition(TextPosition(offset: range.start), isEnd: isEnd); + return SelectionResult.previous; + } } } } - return targetPosition ?? position; + return null; } - TextPosition _updateSelectionEndEdgeByWord( - _WordBoundaryRecord? wordBoundary, + // This method handles updating the end edge by a text boundary that may + // not be contained within this selectable fragment. It is possible + // that a boundary spans multiple selectable fragments when the text contains + // [WidgetSpan]s. + // + // This method differs from [_updateSelectionEndEdgeByMultiSelectableBoundary] + // in that to mantain the origin text boundary selected at a placeholder, this + // selectable fragment must be aware of the [RenderParagraph] that closely + // encompasses the complete origin text boundary. + // + // See [_updateSelectionStartEdgeAtPlaceholderByMultiSelectableTextBoundary] + // for the method that handles updating the start edge. + SelectionResult? _updateSelectionEndEdgeAtPlaceholderByMultiSelectableTextBoundary( + _TextBoundaryAtPositionInText getTextBoundary, + Offset globalPosition, + bool paragraphContainsPosition, TextPosition position, TextPosition? existingSelectionStart, TextPosition? existingSelectionEnd, ) { - TextPosition? targetPosition; - if (wordBoundary != null) { - assert(wordBoundary.wordStart.offset >= range.start && wordBoundary.wordEnd.offset <= range.end); - if (_selectableContainsOriginWord && existingSelectionStart != null && existingSelectionEnd != null) { - final bool isSamePosition = position.offset == existingSelectionStart.offset; - final bool isSelectionInverted = existingSelectionStart.offset > existingSelectionEnd.offset; - final bool shouldSwapEdges = !isSamePosition && (isSelectionInverted != (position.offset < existingSelectionStart.offset)); - if (shouldSwapEdges) { - if (position.offset < existingSelectionStart.offset) { - targetPosition = wordBoundary.wordStart; - } else { - targetPosition = wordBoundary.wordEnd; - } - // When the selection is inverted by the new position it is necessary to - // swap the end edge (moving edge) with the start edge (static edge) to - // maintain the origin word within the selection. - final _WordBoundaryRecord localWordBoundary = _getWordBoundaryAtPosition(existingSelectionStart); - assert(localWordBoundary.wordStart.offset >= range.start && localWordBoundary.wordEnd.offset <= range.end); - _setSelectionPosition(existingSelectionStart.offset == localWordBoundary.wordStart.offset ? localWordBoundary.wordEnd : localWordBoundary.wordStart, isEnd: false); + const bool isEnd = true; + if (_selectableContainsOriginTextBoundary && existingSelectionStart != null && existingSelectionEnd != null) { + // If this selectable contains the origin boundary, maintain the existing + // selection. + final bool forwardSelection = existingSelectionEnd.offset >= existingSelectionStart.offset; + final RenderParagraph originParagraph = _getOriginParagraph(); + final bool fragmentBelongsToOriginParagraph = originParagraph == paragraph; + if (fragmentBelongsToOriginParagraph) { + return _updateSelectionEndEdgeByMultiSelectableTextBoundary( + getTextBoundary, + paragraphContainsPosition, + position, + existingSelectionStart, + existingSelectionEnd, + ); + } + final Matrix4 originTransform = originParagraph.getTransformTo(null); + originTransform.invert(); + final Offset originParagraphLocalPosition = MatrixUtils.transformPoint(originTransform, globalPosition); + final bool positionWithinOriginParagraph = originParagraph.paintBounds.contains(originParagraphLocalPosition); + final TextPosition positionRelativeToOriginParagraph = originParagraph.getPositionForOffset(originParagraphLocalPosition); + if (positionWithinOriginParagraph) { + // When the selection is inverted by the new position it is necessary to + // swap the end edge (moving edge) with the start edge (static edge) to + // maintain the origin text boundary within the selection. + final String originText = originParagraph.text.toPlainText(includeSemanticsLabels: false); + final _TextBoundaryRecord boundaryAtPosition = getTextBoundary(positionRelativeToOriginParagraph, originText); + final _TextBoundaryRecord originTextBoundary = getTextBoundary(_getPositionInParagraph(originParagraph), originText); + final TextPosition targetPosition; + final int pivotOffset = forwardSelection ? originTextBoundary.boundaryStart.offset : originTextBoundary.boundaryEnd.offset; + final bool shouldSwapEdges = !forwardSelection != (positionRelativeToOriginParagraph.offset < pivotOffset); + if (positionRelativeToOriginParagraph.offset < pivotOffset) { + targetPosition = boundaryAtPosition.boundaryStart; + } else if (positionRelativeToOriginParagraph.offset > pivotOffset) { + targetPosition = boundaryAtPosition.boundaryEnd; } else { - if (position.offset < existingSelectionStart.offset) { - targetPosition = wordBoundary.wordStart; - } else if (position.offset > existingSelectionStart.offset) { - targetPosition = wordBoundary.wordEnd; - } else { - // Keep the origin word in bounds when position is at the static edge. - targetPosition = existingSelectionEnd; + // Keep the origin text boundary in bounds when position is at the static edge. + targetPosition = existingSelectionEnd; + } + if (shouldSwapEdges) { + _setSelectionPosition(existingSelectionEnd, isEnd: false); + } + _setSelectionPosition(_clampTextPosition(targetPosition), isEnd: isEnd); + final bool finalSelectionIsForward = _textSelectionEnd!.offset >= _textSelectionStart!.offset; + final TextPosition originParagraphPlaceholderTextPosition = _getPositionInParagraph(originParagraph); + final TextRange originParagraphPlaceholderRange = TextRange(start: originParagraphPlaceholderTextPosition.offset, end: originParagraphPlaceholderTextPosition.offset + _placeholderLength); + if (boundaryAtPosition.boundaryStart.offset > originParagraphPlaceholderRange.end && boundaryAtPosition.boundaryEnd.offset > originParagraphPlaceholderRange.end) { + return SelectionResult.next; + } + if (boundaryAtPosition.boundaryStart.offset < originParagraphPlaceholderRange.start && boundaryAtPosition.boundaryEnd.offset < originParagraphPlaceholderRange.start) { + return SelectionResult.previous; + } + if (finalSelectionIsForward) { + if (boundaryAtPosition.boundaryEnd.offset <= originTextBoundary.boundaryEnd.offset) { + return SelectionResult.end; + } + if (boundaryAtPosition.boundaryEnd.offset > originTextBoundary.boundaryEnd.offset) { + return SelectionResult.next; + } + } else { + if (boundaryAtPosition.boundaryStart.offset >= originTextBoundary.boundaryStart.offset) { + return SelectionResult.end; + } + if (boundaryAtPosition.boundaryStart.offset < originTextBoundary.boundaryStart.offset) { + return SelectionResult.previous; } } } else { - if (existingSelectionStart != null) { - // If the start edge exists and the end edge is being moved, then the - // end edge is moved to encompass the entire word at the new position. - if (position.offset < existingSelectionStart.offset) { - targetPosition = wordBoundary.wordStart; - } else { - targetPosition = wordBoundary.wordEnd; - } - } else { - // Move the end edge to the closest word boundary. - targetPosition = _closestWordBoundary(wordBoundary, position); + // When the drag position is not contained within the origin paragraph, + // swap the edges when the selection changes direction. + // + // [SelectionUtils.adjustDragOffset] will adjust the given [Offset] to the + // beginning or end of the provided [Rect] based on whether the [Offset] + // is located within the given [Rect]. + final Offset adjustedOffset = SelectionUtils.adjustDragOffset( + originParagraph.paintBounds, + originParagraphLocalPosition, + direction: paragraph.textDirection, + ); + final TextPosition adjustedPositionRelativeToOriginParagraph = originParagraph.getPositionForOffset(adjustedOffset); + final TextPosition originParagraphPlaceholderTextPosition = _getPositionInParagraph(originParagraph); + final TextRange originParagraphPlaceholderRange = TextRange(start: originParagraphPlaceholderTextPosition.offset, end: originParagraphPlaceholderTextPosition.offset + _placeholderLength); + if (forwardSelection && adjustedPositionRelativeToOriginParagraph.offset <= originParagraphPlaceholderRange.start) { + _setSelectionPosition(existingSelectionEnd, isEnd: false); + _setSelectionPosition(TextPosition(offset: range.start), isEnd: isEnd); + return SelectionResult.previous; + } + if (!forwardSelection && adjustedPositionRelativeToOriginParagraph.offset >= originParagraphPlaceholderRange.end) { + _setSelectionPosition(existingSelectionEnd, isEnd: false); + _setSelectionPosition(TextPosition(offset: range.end), isEnd: isEnd); + return SelectionResult.next; + } + if (forwardSelection && adjustedPositionRelativeToOriginParagraph.offset >= originParagraphPlaceholderRange.end) { + _setSelectionPosition(TextPosition(offset: range.end), isEnd: isEnd); + return SelectionResult.next; + } + if (!forwardSelection && adjustedPositionRelativeToOriginParagraph.offset <= originParagraphPlaceholderRange.start) { + _setSelectionPosition(TextPosition(offset: range.start), isEnd: isEnd); + return SelectionResult.previous; } } } else { - // The position is not contained within the current rect. The targetPosition - // will either be at the end or beginning of the current rect. See [SelectionUtils.adjustDragOffset] - // for a more in depth explanation on this adjustment. - if (_selectableContainsOriginWord && existingSelectionStart != null && existingSelectionEnd != null) { - // When the selection is inverted by the new position it is necessary to - // swap the end edge (moving edge) with the start edge (static edge) to - // maintain the origin word within the selection. - final bool isSamePosition = position.offset == existingSelectionStart.offset; - final bool isSelectionInverted = existingSelectionStart.offset > existingSelectionEnd.offset; - final bool shouldSwapEdges = isSelectionInverted != (position.offset < existingSelectionStart.offset) || isSamePosition; - if (shouldSwapEdges) { - final _WordBoundaryRecord localWordBoundary = _getWordBoundaryAtPosition(existingSelectionStart); - assert(localWordBoundary.wordStart.offset >= range.start && localWordBoundary.wordEnd.offset <= range.end); - _setSelectionPosition(isSelectionInverted ? localWordBoundary.wordStart : localWordBoundary.wordEnd, isEnd: false); + // When the drag position is somewhere on the root text and not a placeholder, + // traverse the selectable fragments relative to the [RenderParagraph] that + // contains the drag position. + if (paragraphContainsPosition) { + return _updateSelectionEndEdgeByMultiSelectableTextBoundary( + getTextBoundary, + paragraphContainsPosition, + position, + existingSelectionStart, + existingSelectionEnd, + ); + } + if (existingSelectionStart != null) { + final ({RenderParagraph paragraph, Offset localPosition})? targetDetails = _getParagraphContainingPosition(globalPosition); + if (targetDetails == null) { + return null; + } + final RenderParagraph targetParagraph = targetDetails.paragraph; + final TextPosition positionRelativeToTargetParagraph = targetParagraph.getPositionForOffset(targetDetails.localPosition); + final String targetText = targetParagraph.text.toPlainText(includeSemanticsLabels: false); + final bool positionOnPlaceholder = targetParagraph.getWordBoundary(positionRelativeToTargetParagraph).textInside(targetText) == _placeholderCharacter; + if (positionOnPlaceholder) { + return null; + } + final bool backwardSelection = existingSelectionEnd == null && existingSelectionStart.offset == range.end + || existingSelectionStart == existingSelectionEnd && existingSelectionStart.offset == range.end + || existingSelectionEnd != null && existingSelectionStart.offset > existingSelectionEnd.offset; + final _TextBoundaryRecord boundaryAtPositionRelativeToTargetParagraph = getTextBoundary(positionRelativeToTargetParagraph, targetText); + final TextPosition targetParagraphPlaceholderTextPosition = _getPositionInParagraph(targetParagraph); + final TextRange targetParagraphPlaceholderRange = TextRange(start: targetParagraphPlaceholderTextPosition.offset, end: targetParagraphPlaceholderTextPosition.offset + _placeholderLength); + if (boundaryAtPositionRelativeToTargetParagraph.boundaryStart.offset < targetParagraphPlaceholderRange.start && boundaryAtPositionRelativeToTargetParagraph.boundaryEnd.offset < targetParagraphPlaceholderRange.start) { + _setSelectionPosition(TextPosition(offset: range.start), isEnd: isEnd); + return SelectionResult.previous; + } + if (boundaryAtPositionRelativeToTargetParagraph.boundaryStart.offset > targetParagraphPlaceholderRange.end && boundaryAtPositionRelativeToTargetParagraph.boundaryEnd.offset > targetParagraphPlaceholderRange.end) { + _setSelectionPosition(TextPosition(offset: range.end), isEnd: isEnd); + return SelectionResult.next; + } + if (backwardSelection) { + if (boundaryAtPositionRelativeToTargetParagraph.boundaryStart.offset >= targetParagraphPlaceholderRange.start) { + _setSelectionPosition(TextPosition(offset: range.start), isEnd: isEnd); + return SelectionResult.end; + } + if (boundaryAtPositionRelativeToTargetParagraph.boundaryStart.offset < targetParagraphPlaceholderRange.start) { + _setSelectionPosition(TextPosition(offset: range.start), isEnd: isEnd); + return SelectionResult.previous; + } + } else { + if (boundaryAtPositionRelativeToTargetParagraph.boundaryEnd.offset <= targetParagraphPlaceholderRange.end) { + _setSelectionPosition(TextPosition(offset: range.end), isEnd: isEnd); + return SelectionResult.end; + } + if (boundaryAtPositionRelativeToTargetParagraph.boundaryEnd.offset > targetParagraphPlaceholderRange.end) { + _setSelectionPosition(TextPosition(offset: range.end), isEnd: isEnd); + return SelectionResult.next; + } } } } - return targetPosition ?? position; + return null; } - SelectionResult _updateSelectionEdgeByWord(Offset globalPosition, {required bool isEnd}) { + SelectionResult _updateSelectionEdgeByMultiSelectableTextBoundary( + Offset globalPosition, + { + required bool isEnd, + required _TextBoundaryAtPositionInText getTextBoundary, + required _TextBoundaryAtPosition getClampedTextBoundary, + } + ) { // When the start/end edges are swapped, i.e. the start is after the end, and // the scrollable synthesizes an event for the opposite edge, this will potentially - // move the opposite edge outside of the origin word boundary and we are unable to recover. + // move the opposite edge outside of the origin text boundary and we are unable to recover. final TextPosition? existingSelectionStart = _textSelectionStart; final TextPosition? existingSelectionEnd = _textSelectionEnd; @@ -1688,23 +2466,86 @@ class _SelectableFragment with Selectable, Diagnosticable, ChangeNotifier implem localPosition, direction: paragraph.textDirection, ); + final Offset adjustedOffsetRelativeToParagraph = SelectionUtils.adjustDragOffset( + paragraph.paintBounds, + localPosition, + direction: paragraph.textDirection, + ); final TextPosition position = paragraph.getPositionForOffset(adjustedOffset); + final TextPosition positionInFullText = paragraph.getPositionForOffset(adjustedOffsetRelativeToParagraph); + + final SelectionResult? result; + if (_isPlaceholder()) { + result = isEnd + ? _updateSelectionEndEdgeAtPlaceholderByMultiSelectableTextBoundary( + getTextBoundary, + globalPosition, + paragraph.paintBounds.contains(localPosition), + positionInFullText, + existingSelectionStart, + existingSelectionEnd, + ) + : _updateSelectionStartEdgeAtPlaceholderByMultiSelectableTextBoundary( + getTextBoundary, + globalPosition, + paragraph.paintBounds.contains(localPosition), + positionInFullText, + existingSelectionStart, + existingSelectionEnd, + ); + } else { + result = isEnd + ? _updateSelectionEndEdgeByMultiSelectableTextBoundary( + getTextBoundary, + paragraph.paintBounds.contains(localPosition), + positionInFullText, + existingSelectionStart, + existingSelectionEnd, + ) + : _updateSelectionStartEdgeByMultiSelectableTextBoundary( + getTextBoundary, + paragraph.paintBounds.contains(localPosition), + positionInFullText, + existingSelectionStart, + existingSelectionEnd, + ); + } + if (result != null) { + return result; + } + // Check if the original local position is within the rect, if it is not then - // we do not need to look up the word boundary for that position. This is to + // we do not need to look up the text boundary for that position. This is to // maintain a selectables selection collapsed at 0 when the local position is // not located inside its rect. - _WordBoundaryRecord? wordBoundary = _rect.contains(localPosition) ? _getWordBoundaryAtPosition(position) : null; - if (wordBoundary != null - && (wordBoundary.wordStart.offset < range.start && wordBoundary.wordEnd.offset <= range.start - || wordBoundary.wordStart.offset >= range.end && wordBoundary.wordEnd.offset > range.end)) { + _TextBoundaryRecord? textBoundary = _boundingBoxesContains(localPosition) ? getClampedTextBoundary(position) : null; + if (textBoundary != null + && (textBoundary.boundaryStart.offset < range.start && textBoundary.boundaryEnd.offset <= range.start + || textBoundary.boundaryStart.offset >= range.end && textBoundary.boundaryEnd.offset > range.end)) { // When the position is located at a placeholder inside of the text, then we may compute - // a word boundary that does not belong to the current selectable fragment. In this case - // we should invalidate the word boundary so that it is not taken into account when + // a text boundary that does not belong to the current selectable fragment. In this case + // we should invalidate the text boundary so that it is not taken into account when // computing the target position. - wordBoundary = null; + textBoundary = null; } - final TextPosition targetPosition = _clampTextPosition(isEnd ? _updateSelectionEndEdgeByWord(wordBoundary, position, existingSelectionStart, existingSelectionEnd) : _updateSelectionStartEdgeByWord(wordBoundary, position, existingSelectionStart, existingSelectionEnd)); + final TextPosition targetPosition = _clampTextPosition( + isEnd + ? _updateSelectionEndEdgeByTextBoundary( + textBoundary, + getClampedTextBoundary, + position, + existingSelectionStart, + existingSelectionEnd, + ) + : _updateSelectionStartEdgeByTextBoundary( + textBoundary, + getClampedTextBoundary, + position, + existingSelectionStart, + existingSelectionEnd, + ), + ); _setSelectionPosition(targetPosition, isEnd: isEnd); if (targetPosition.offset == range.end) { @@ -1721,6 +2562,87 @@ class _SelectableFragment with Selectable, Diagnosticable, ChangeNotifier implem return SelectionUtils.getResultBasedOnRect(_rect, localPosition); } + TextPosition _closestTextBoundary( + _TextBoundaryRecord textBoundary, + TextPosition position, + ) { + final int differenceA = (position.offset - textBoundary.boundaryStart.offset).abs(); + final int differenceB = (position.offset - textBoundary.boundaryEnd.offset).abs(); + return differenceA < differenceB ? textBoundary.boundaryStart : textBoundary.boundaryEnd; + } + + bool _isPlaceholder() { + // Determine whether this selectable fragment is a placeholder. + RenderObject? current = paragraph.parent; + while (current != null) { + if (current is RenderParagraph) { + return true; + } + current = current.parent; + } + return false; + } + + RenderParagraph _getOriginParagraph() { + // This method should only be called from a fragment that contains + // the origin boundary. By traversing up the RenderTree, determine the + // highest RenderParagraph that contains the origin text boundary. + assert(_selectableContainsOriginTextBoundary); + // Begin at the parent because it is guaranteed the paragraph containing + // this selectable fragment contains the origin boundary. + RenderObject? current = paragraph.parent; + RenderParagraph? originParagraph; + while (current != null) { + if (current is RenderParagraph) { + if (current._lastSelectableFragments != null) { + bool paragraphContainsOriginTextBoundary = false; + for (final _SelectableFragment fragment in current._lastSelectableFragments!) { + if (fragment._selectableContainsOriginTextBoundary) { + paragraphContainsOriginTextBoundary = true; + originParagraph = current; + break; + } + } + if (!paragraphContainsOriginTextBoundary) { + return originParagraph ?? paragraph; + } + } + } + current = current.parent; + } + return originParagraph ?? paragraph; + } + + ({RenderParagraph paragraph, Offset localPosition})? _getParagraphContainingPosition(Offset globalPosition) { + // This method will return the closest [RenderParagraph] whose rect + // contains the given `globalPosition` and the given `globalPosition` + // relative to that [RenderParagraph]. If no ancestor [RenderParagraph] + // contains the given `globalPosition` then this method will return null. + RenderObject? current = paragraph; + while (current != null) { + if (current is RenderParagraph) { + final Matrix4 currentTransform = current.getTransformTo(null); + currentTransform.invert(); + final Offset currentParagraphLocalPosition = MatrixUtils.transformPoint(currentTransform, globalPosition); + final bool positionWithinCurrentParagraph = current.paintBounds.contains(currentParagraphLocalPosition); + if (positionWithinCurrentParagraph) { + return (paragraph: current, localPosition: currentParagraphLocalPosition); + } + } + current = current.parent; + } + return null; + } + + bool _boundingBoxesContains(Offset position) { + for (final Rect rect in boundingBoxes) { + if (rect.contains(position)) { + return true; + } + } + return false; + } + TextPosition _clampTextPosition(TextPosition position) { // Affinity of range.end is upstream. if (position.offset > range.end || @@ -1744,7 +2666,7 @@ class _SelectableFragment with Selectable, Diagnosticable, ChangeNotifier implem SelectionResult _handleClearSelection() { _textSelectionStart = null; _textSelectionEnd = null; - _selectableContainsOriginWord = false; + _selectableContainsOriginTextBoundary = false; return SelectionResult.none; } @@ -1754,42 +2676,125 @@ class _SelectableFragment with Selectable, Diagnosticable, ChangeNotifier implem return SelectionResult.none; } + SelectionResult _handleSelectTextBoundary(_TextBoundaryRecord textBoundary) { + // This fragment may not contain the boundary, decide what direction the target + // fragment is located in. Because fragments are separated by placeholder + // spans, we also check if the beginning or end of the boundary is touching + // either edge of this fragment. + if (textBoundary.boundaryStart.offset < range.start && textBoundary.boundaryEnd.offset <= range.start) { + return SelectionResult.previous; + } else if (textBoundary.boundaryStart.offset >= range.end && textBoundary.boundaryEnd.offset > range.end) { + return SelectionResult.next; + } + // Fragments are separated by placeholder span, the text boundary shouldn't + // expand across fragments. + assert(textBoundary.boundaryStart.offset >= range.start && textBoundary.boundaryEnd.offset <= range.end); + _textSelectionStart = textBoundary.boundaryStart; + _textSelectionEnd = textBoundary.boundaryEnd; + _selectableContainsOriginTextBoundary = true; + return SelectionResult.end; + } + + TextRange? _intersect(TextRange a, TextRange b) { + assert(a.isNormalized); + assert(b.isNormalized); + final int startMax = math.max(a.start, b.start); + final int endMin = math.min(a.end, b.end); + if (startMax <= endMin) { + // Intersection. + return TextRange(start: startMax, end: endMin); + } + return null; + } + + SelectionResult _handleSelectMultiFragmentTextBoundary(_TextBoundaryRecord textBoundary) { + // This fragment may not contain the boundary, decide what direction the target + // fragment is located in. Because fragments are separated by placeholder + // spans, we also check if the beginning or end of the boundary is touching + // either edge of this fragment. + if (textBoundary.boundaryStart.offset < range.start && textBoundary.boundaryEnd.offset <= range.start) { + return SelectionResult.previous; + } else if (textBoundary.boundaryStart.offset >= range.end && textBoundary.boundaryEnd.offset > range.end) { + return SelectionResult.next; + } + final TextRange boundaryAsRange = TextRange(start: textBoundary.boundaryStart.offset, end: textBoundary.boundaryEnd.offset); + final TextRange? intersectRange = _intersect(range, boundaryAsRange); + if (intersectRange != null) { + _textSelectionStart = TextPosition(offset: intersectRange.start); + _textSelectionEnd = TextPosition(offset: intersectRange.end); + _selectableContainsOriginTextBoundary = true; + if (range.end < textBoundary.boundaryEnd.offset) { + return SelectionResult.next; + } + return SelectionResult.end; + } + return SelectionResult.none; + } + + _TextBoundaryRecord _adjustTextBoundaryAtPosition(TextRange textBoundary, TextPosition position) { + late final TextPosition start; + late final TextPosition end; + if (position.offset > textBoundary.end) { + start = end = TextPosition(offset: position.offset); + } else { + start = TextPosition(offset: textBoundary.start); + end = TextPosition(offset: textBoundary.end, affinity: TextAffinity.upstream); + } + return (boundaryStart: start, boundaryEnd: end); + } + SelectionResult _handleSelectWord(Offset globalPosition) { final TextPosition position = paragraph.getPositionForOffset(paragraph.globalToLocal(globalPosition)); if (_positionIsWithinCurrentSelection(position) && _textSelectionStart != _textSelectionEnd) { return SelectionResult.end; } - final _WordBoundaryRecord wordBoundary = _getWordBoundaryAtPosition(position); - // This fragment may not contain the word, decide what direction the target - // fragment is located in. Because fragments are separated by placeholder - // spans, we also check if the beginning or end of the word is touching - // either edge of this fragment. - if (wordBoundary.wordStart.offset < range.start && wordBoundary.wordEnd.offset <= range.start) { - return SelectionResult.previous; - } else if (wordBoundary.wordStart.offset >= range.end && wordBoundary.wordEnd.offset > range.end) { - return SelectionResult.next; - } - // Fragments are separated by placeholder span, the word boundary shouldn't - // expand across fragments. - assert(wordBoundary.wordStart.offset >= range.start && wordBoundary.wordEnd.offset <= range.end); - _textSelectionStart = wordBoundary.wordStart; - _textSelectionEnd = wordBoundary.wordEnd; - _selectableContainsOriginWord = true; - return SelectionResult.end; + final _TextBoundaryRecord wordBoundary = _getWordBoundaryAtPosition(position); + return _handleSelectTextBoundary(wordBoundary); } - _WordBoundaryRecord _getWordBoundaryAtPosition(TextPosition position) { + _TextBoundaryRecord _getWordBoundaryAtPosition(TextPosition position) { final TextRange word = paragraph.getWordBoundary(position); assert(word.isNormalized); - late TextPosition start; - late TextPosition end; - if (position.offset > word.end) { - start = end = TextPosition(offset: position.offset); - } else { - start = TextPosition(offset: word.start); - end = TextPosition(offset: word.end, affinity: TextAffinity.upstream); - } - return (wordStart: start, wordEnd: end); + return _adjustTextBoundaryAtPosition(word, position); + } + + SelectionResult _handleSelectParagraph(Offset globalPosition) { + final Offset localPosition = paragraph.globalToLocal(globalPosition); + final TextPosition position = paragraph.getPositionForOffset(localPosition); + final _TextBoundaryRecord paragraphBoundary = _getParagraphBoundaryAtPosition(position, fullText); + return _handleSelectMultiFragmentTextBoundary(paragraphBoundary); + } + + TextPosition _getPositionInParagraph(RenderParagraph targetParagraph) { + final Matrix4 transform = paragraph.getTransformTo(targetParagraph); + final Offset localCenter = paragraph.paintBounds.centerLeft; + final Offset localPos = MatrixUtils.transformPoint(transform, localCenter); + final TextPosition position = targetParagraph.getPositionForOffset(localPos); + return position; + } + + _TextBoundaryRecord _getParagraphBoundaryAtPosition(TextPosition position, String text) { + final ParagraphBoundary paragraphBoundary = ParagraphBoundary(text); + // Use position.offset - 1 when `position` is at the end of the selectable to retrieve + // the previous text boundary's location. + final int paragraphStart = paragraphBoundary.getLeadingTextBoundaryAt(position.offset == text.length || position.affinity == TextAffinity.upstream ? position.offset - 1 : position.offset) ?? 0; + final int paragraphEnd = paragraphBoundary.getTrailingTextBoundaryAt(position.offset) ?? text.length; + final TextRange paragraphRange = TextRange(start: paragraphStart, end: paragraphEnd); + assert(paragraphRange.isNormalized); + return _adjustTextBoundaryAtPosition(paragraphRange, position); + } + + _TextBoundaryRecord _getClampedParagraphBoundaryAtPosition(TextPosition position) { + final ParagraphBoundary paragraphBoundary = ParagraphBoundary(fullText); + // Use position.offset - 1 when `position` is at the end of the selectable to retrieve + // the previous text boundary's location. + int paragraphStart = paragraphBoundary.getLeadingTextBoundaryAt(position.offset == fullText.length || position.affinity == TextAffinity.upstream ? position.offset - 1 : position.offset) ?? 0; + int paragraphEnd = paragraphBoundary.getTrailingTextBoundaryAt(position.offset) ?? fullText.length; + paragraphStart = paragraphStart < range.start ? range.start : paragraphStart > range.end ? range.end : paragraphStart; + paragraphEnd = paragraphEnd > range.end ? range.end : paragraphEnd < range.start ? range.start : paragraphEnd; + final TextRange paragraphRange = TextRange(start: paragraphStart, end: paragraphEnd); + assert(paragraphRange.isNormalized); + return _adjustTextBoundaryAtPosition(paragraphRange, position); } SelectionResult _handleDirectionallyExtendSelection(double horizontalBaseline, bool isExtent, SelectionExtendDirection movement) { @@ -1867,6 +2872,10 @@ class _SelectableFragment with Selectable, Diagnosticable, ChangeNotifier implem final TextBoundary textBoundary = paragraph._textPainter.wordBoundaries.moveByWordBoundary; newPosition = _moveBeyondTextBoundaryAtDirection(targetedEdge, forward, textBoundary); result = SelectionResult.end; + case TextGranularity.paragraph: + final String text = range.textInside(fullText); + newPosition = _moveBeyondTextBoundaryAtDirection(targetedEdge, forward, ParagraphBoundary(text)); + result = SelectionResult.end; case TextGranularity.line: newPosition = _moveToTextBoundaryAtDirection(targetedEdge, forward, LineBoundary(this)); result = SelectionResult.end; diff --git a/packages/flutter/lib/src/rendering/selection.dart b/packages/flutter/lib/src/rendering/selection.dart index 610432c370..521caa400a 100644 --- a/packages/flutter/lib/src/rendering/selection.dart +++ b/packages/flutter/lib/src/rendering/selection.dart @@ -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 diff --git a/packages/flutter/lib/src/widgets/scrollable.dart b/packages/flutter/lib/src/widgets/scrollable.dart index f79ac2d931..780c3ed0a4 100644 --- a/packages/flutter/lib/src/widgets/scrollable.dart +++ b/packages/flutter/lib/src/widgets/scrollable.dart @@ -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; } diff --git a/packages/flutter/lib/src/widgets/selectable_region.dart b/packages/flutter/lib/src/widgets/selectable_region.dart index 519489c66b..538267f142 100644 --- a/packages/flutter/lib/src/widgets/selectable_region.dart +++ b/packages/flutter/lib/src/widgets/selectable_region.dart @@ -486,7 +486,7 @@ class SelectableRegionState extends State 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 with TextSelectionDe } case 2: _selectWordAt(offset: details.globalPosition); + case 3: + _selectParagraphAt(offset: details.globalPosition); } _updateSelectedContentIfNeeded(); } @@ -573,6 +575,8 @@ class SelectableRegionState extends State 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 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 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 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 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 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 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 selectables = []; /// 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 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 selections = []; @@ -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); diff --git a/packages/flutter/lib/src/widgets/text.dart b/packages/flutter/lib/src/widgets/text.dart index 7d342cb649..48068aaaa1 100644 --- a/packages/flutter/lib/src/widgets/text.dart +++ b/packages/flutter/lib/src/widgets/text.dart @@ -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 ? [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 ? [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 ? [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 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 _hasReceivedStartEvent = {}; + final Set _hasReceivedEndEvent = {}; + + 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 selectableSet = selectables.toSet(); + _hasReceivedEndEvent.removeWhere((Selectable selectable) => !selectableSet.contains(selectable)); + _hasReceivedStartEvent.removeWhere((Selectable selectable) => !selectableSet.contains(selectable)); + super.didChangeSelectables(); + } +} diff --git a/packages/flutter/test/widgets/scrollable_selection_test.dart b/packages/flutter/test/widgets/scrollable_selection_test.dart index 5f50551ed4..db962a5c57 100644 --- a/packages/flutter/test/widgets/scrollable_selection_test.dart +++ b/packages/flutter/test/widgets/scrollable_selection_test.dart @@ -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(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(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(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(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(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(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(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); diff --git a/packages/flutter/test/widgets/selectable_region_test.dart b/packages/flutter/test/widgets/selectable_region_test.dart index bc8908c173..461493227e 100644 --- a/packages/flutter/test/widgets/selectable_region_test.dart +++ b/packages/flutter/test/widgets/selectable_region_test.dart @@ -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(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: [ + 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(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(find.descendant(of: find.textContaining('Text widget E.'), matching: find.byType(RichText))); + final RenderParagraph paragraphD = tester.renderObject(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: [ + 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(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(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(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: [ + 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(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(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(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: [ + 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(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(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(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); diff --git a/packages/flutter/test/widgets/selection_container_test.dart b/packages/flutter/test/widgets/selection_container_test.dart index ae7b70a480..e98bf6c18c 100644 --- a/packages/flutter/test/widgets/selection_container_test.dart +++ b/packages/flutter/test/widgets/selection_container_test.dart @@ -9,15 +9,7 @@ import 'package:flutter_test/flutter_test.dart'; void main() { Future 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();