Support keyboard selection in SelectabledRegion (#112584)

* Support keyboard selection in selectable region

* fix some comments

* addressing comments
This commit is contained in:
chunhtai 2022-11-04 10:55:28 -07:00 committed by GitHub
parent cfb2f158d6
commit 80bf355192
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 1893 additions and 117 deletions

View file

@ -194,6 +194,80 @@ class _RenderSelectableAdapter extends RenderProxyBox with Selectable, Selection
_start = Offset.zero;
_end = Offset.infinite;
break;
case SelectionEventType.granularlyExtendSelection:
result = SelectionResult.end;
final GranularlyExtendSelectionEvent extendSelectionEvent = event as GranularlyExtendSelectionEvent;
// Initialize the offset it there is no ongoing selection.
if (_start == null || _end == null) {
if (extendSelectionEvent.forward) {
_start = _end = Offset.zero;
} else {
_start = _end = Offset.infinite;
}
}
// Move the corresponding selection edge.
final Offset newOffset = extendSelectionEvent.forward ? Offset.infinite : Offset.zero;
if (extendSelectionEvent.isEnd) {
if (newOffset == _end) {
result = extendSelectionEvent.forward ? SelectionResult.next : SelectionResult.previous;
}
_end = newOffset;
} else {
if (newOffset == _start) {
result = extendSelectionEvent.forward ? SelectionResult.next : SelectionResult.previous;
}
_start = newOffset;
}
break;
case SelectionEventType.directionallyExtendSelection:
result = SelectionResult.end;
final DirectionallyExtendSelectionEvent extendSelectionEvent = event as DirectionallyExtendSelectionEvent;
// Convert to local coordinates.
final double horizontalBaseLine = globalToLocal(Offset(event.dx, 0)).dx;
final Offset newOffset;
final bool forward;
switch(extendSelectionEvent.direction) {
case SelectionExtendDirection.backward:
case SelectionExtendDirection.previousLine:
forward = false;
// Initialize the offset it there is no ongoing selection.
if (_start == null || _end == null) {
_start = _end = Offset.infinite;
}
// Move the corresponding selection edge.
if (extendSelectionEvent.direction == SelectionExtendDirection.previousLine || horizontalBaseLine < 0) {
newOffset = Offset.zero;
} else {
newOffset = Offset.infinite;
}
break;
case SelectionExtendDirection.nextLine:
case SelectionExtendDirection.forward:
forward = true;
// Initialize the offset it there is no ongoing selection.
if (_start == null || _end == null) {
_start = _end = Offset.zero;
}
// Move the corresponding selection edge.
if (extendSelectionEvent.direction == SelectionExtendDirection.nextLine || horizontalBaseLine > size.width) {
newOffset = Offset.infinite;
} else {
newOffset = Offset.zero;
}
break;
}
if (extendSelectionEvent.isEnd) {
if (newOffset == _end) {
result = forward ? SelectionResult.next : SelectionResult.previous;
}
_end = newOffset;
} else {
if (newOffset == _start) {
result = forward ? SelectionResult.next : SelectionResult.previous;
}
_start = newOffset;
}
break;
}
_updateGeometry();
return result;

View file

@ -4,15 +4,17 @@
import 'dart:collection';
import 'dart:math' as math;
import 'dart:ui' as ui show BoxHeightStyle, BoxWidthStyle, Gradient, PlaceholderAlignment, Shader, TextBox, TextHeightBehavior;
import 'dart:ui' as ui show BoxHeightStyle, BoxWidthStyle, Gradient, LineMetrics, PlaceholderAlignment, Shader, TextBox, TextHeightBehavior;
import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/scheduler.dart';
import 'package:flutter/semantics.dart';
import 'package:flutter/services.dart';
import 'box.dart';
import 'debug.dart';
import 'editable.dart';
import 'layer.dart';
import 'object.dart';
import 'selection.dart';
@ -151,11 +153,11 @@ class RenderParagraph extends RenderBox
_cachedCombinedSemanticsInfos = null;
_extractPlaceholderSpans(value);
markNeedsLayout();
break;
}
_removeSelectionRegistrarSubscription();
_disposeSelectableFragments();
_updateSelectionRegistrarSubscription();
break;
}
}
/// The ongoing selections in this paragraph.
@ -226,7 +228,7 @@ class RenderParagraph extends RenderBox
if (end == -1) {
end = plainText.length;
}
result.add(_SelectableFragment(paragraph: this, range: TextRange(start: start, end: end)));
result.add(_SelectableFragment(paragraph: this, range: TextRange(start: start, end: end), fullText: plainText));
start = end;
}
start += 1;
@ -439,6 +441,10 @@ class RenderParagraph extends RenderBox
return getOffsetForCaret(position, Rect.zero) + Offset(0, getFullHeightForCaret(position) ?? 0.0);
}
List<ui.LineMetrics> _computeLineMetrics() {
return _textPainter.computeLineMetrics();
}
@override
double computeMinIntrinsicWidth(double height) {
if (!_canComputeIntrinsics()) {
@ -1027,6 +1033,28 @@ class RenderParagraph extends RenderBox
return _textPainter.getWordBoundary(position);
}
TextRange _getLineAtOffset(TextPosition position) => _textPainter.getLineBoundary(position);
TextPosition _getTextPositionAbove(TextPosition position) {
// -0.5 of preferredLineHeight points to the middle of the line above.
final double preferredLineHeight = _textPainter.preferredLineHeight;
final double verticalOffset = -0.5 * preferredLineHeight;
return _getTextPositionVertical(position, verticalOffset);
}
TextPosition _getTextPositionBelow(TextPosition position) {
// 1.5 of preferredLineHeight points to the middle of the line below.
final double preferredLineHeight = _textPainter.preferredLineHeight;
final double verticalOffset = 1.5 * preferredLineHeight;
return _getTextPositionVertical(position, verticalOffset);
}
TextPosition _getTextPositionVertical(TextPosition position, double verticalOffset) {
final Offset caretOffset = _textPainter.getOffsetForCaret(position, Rect.zero);
final Offset caretOffsetTranslated = caretOffset.translate(0.0, verticalOffset);
return _textPainter.getPositionForOffset(caretOffsetTranslated);
}
/// Returns the size of the text as laid out.
///
/// This can differ from [size] if the text overflowed or if the [constraints]
@ -1271,9 +1299,10 @@ class RenderParagraph extends RenderBox
/// [PlaceHolderSpan]. The [RenderParagraph] splits itself on [PlaceHolderSpan]
/// to create multiple `_SelectableFragment`s so that they can be selected
/// separately.
class _SelectableFragment with Selectable, ChangeNotifier {
class _SelectableFragment with Selectable, ChangeNotifier implements TextLayoutMetrics {
_SelectableFragment({
required this.paragraph,
required this.fullText,
required this.range,
}) : assert(range.isValid && !range.isCollapsed && range.isNormalized) {
_selectionGeometry = _getSelectionGeometry();
@ -1281,6 +1310,7 @@ class _SelectableFragment with Selectable, ChangeNotifier {
final TextRange range;
final RenderParagraph paragraph;
final String fullText;
TextPosition? _textSelectionStart;
TextPosition? _textSelectionEnd;
@ -1356,6 +1386,22 @@ class _SelectableFragment with Selectable, ChangeNotifier {
final SelectWordSelectionEvent selectWord = event as SelectWordSelectionEvent;
result = _handleSelectWord(selectWord.globalPosition);
break;
case SelectionEventType.granularlyExtendSelection:
final GranularlyExtendSelectionEvent granularlyExtendSelection = event as GranularlyExtendSelectionEvent;
result = _handleGranularlyExtendSelection(
granularlyExtendSelection.forward,
granularlyExtendSelection.isEnd,
granularlyExtendSelection.granularity,
);
break;
case SelectionEventType.directionallyExtendSelection:
final DirectionallyExtendSelectionEvent directionallyExtendSelection = event as DirectionallyExtendSelectionEvent;
result = _handleDirectionallyExtendSelection(
directionallyExtendSelection.dx,
directionallyExtendSelection.isEnd,
directionallyExtendSelection.direction,
);
break;
}
if (existingSelectionStart != _textSelectionStart ||
@ -1373,7 +1419,7 @@ class _SelectableFragment with Selectable, ChangeNotifier {
final int start = math.min(_textSelectionStart!.offset, _textSelectionEnd!.offset);
final int end = math.max(_textSelectionStart!.offset, _textSelectionEnd!.offset);
return SelectedContent(
plainText: paragraph.text.toPlainText(includeSemanticsLabels: false).substring(start, end),
plainText: fullText.substring(start, end),
);
}
@ -1466,6 +1512,155 @@ class _SelectableFragment with Selectable, ChangeNotifier {
return SelectionResult.end;
}
SelectionResult _handleDirectionallyExtendSelection(double horizontalBaseline, bool isExtent, SelectionExtendDirection movement) {
final Matrix4 transform = paragraph.getTransformTo(null);
if (transform.invert() == 0.0) {
switch(movement) {
case SelectionExtendDirection.previousLine:
case SelectionExtendDirection.backward:
return SelectionResult.previous;
case SelectionExtendDirection.nextLine:
case SelectionExtendDirection.forward:
return SelectionResult.next;
}
}
final double baselineInParagraphCoordinates = MatrixUtils.transformPoint(transform, Offset(horizontalBaseline, 0)).dx;
assert(!baselineInParagraphCoordinates.isNaN);
final TextPosition newPosition;
final SelectionResult result;
switch(movement) {
case SelectionExtendDirection.previousLine:
case SelectionExtendDirection.nextLine:
assert(_textSelectionEnd != null && _textSelectionStart != null);
final TextPosition targetedEdge = isExtent ? _textSelectionEnd! : _textSelectionStart!;
final MapEntry<TextPosition, SelectionResult> moveResult = _handleVerticalMovement(
targetedEdge,
horizontalBaselineInParagraphCoordinates: baselineInParagraphCoordinates,
below: movement == SelectionExtendDirection.nextLine,
);
newPosition = moveResult.key;
result = moveResult.value;
break;
case SelectionExtendDirection.forward:
case SelectionExtendDirection.backward:
_textSelectionEnd ??= movement == SelectionExtendDirection.forward
? TextPosition(offset: range.start)
: TextPosition(offset: range.end, affinity: TextAffinity.upstream);
_textSelectionStart ??= _textSelectionEnd;
final TextPosition targetedEdge = isExtent ? _textSelectionEnd! : _textSelectionStart!;
final Offset edgeOffsetInParagraphCoordinates = paragraph._getOffsetForPosition(targetedEdge);
final Offset baselineOffsetInParagraphCoordinates = Offset(
baselineInParagraphCoordinates,
// Use half of line height to point to the middle of the line.
edgeOffsetInParagraphCoordinates.dy - paragraph._textPainter.preferredLineHeight / 2,
);
newPosition = paragraph.getPositionForOffset(baselineOffsetInParagraphCoordinates);
result = SelectionResult.end;
break;
}
if (isExtent) {
_textSelectionEnd = newPosition;
} else {
_textSelectionStart = newPosition;
}
return result;
}
SelectionResult _handleGranularlyExtendSelection(bool forward, bool isExtent, TextGranularity granularity) {
_textSelectionEnd ??= forward
? TextPosition(offset: range.start)
: TextPosition(offset: range.end, affinity: TextAffinity.upstream);
_textSelectionStart ??= _textSelectionEnd;
final TextPosition targetedEdge = isExtent ? _textSelectionEnd! : _textSelectionStart!;
if (forward && (targetedEdge.offset == range.end)) {
return SelectionResult.next;
}
if (!forward && (targetedEdge.offset == range.start)) {
return SelectionResult.previous;
}
final SelectionResult result;
final TextPosition newPosition;
switch (granularity) {
case TextGranularity.character:
final String text = range.textInside(fullText);
newPosition = _getNextPosition(CharacterBoundary(text), targetedEdge, forward);
result = SelectionResult.end;
break;
case TextGranularity.word:
final String text = range.textInside(fullText);
newPosition = _getNextPosition(WhitespaceBoundary(text) + WordBoundary(this), targetedEdge, forward);
result = SelectionResult.end;
break;
case TextGranularity.line:
newPosition = _getNextPosition(LineBreak(this), targetedEdge, forward);
result = SelectionResult.end;
break;
case TextGranularity.document:
final String text = range.textInside(fullText);
newPosition = _getNextPosition(DocumentBoundary(text), targetedEdge, forward);
if (forward && newPosition.offset == range.end) {
result = SelectionResult.next;
} else if (!forward && newPosition.offset == range.start) {
result = SelectionResult.previous;
} else {
result = SelectionResult.end;
}
break;
}
if (isExtent) {
_textSelectionEnd = newPosition;
} else {
_textSelectionStart = newPosition;
}
return result;
}
TextPosition _getNextPosition(TextBoundary boundary, TextPosition position, bool forward) {
if (forward) {
return _clampTextPosition(
(PushTextPosition.forward + boundary).getTrailingTextBoundaryAt(position)
);
}
return _clampTextPosition(
(PushTextPosition.backward + boundary).getLeadingTextBoundaryAt(position),
);
}
MapEntry<TextPosition, SelectionResult> _handleVerticalMovement(TextPosition position, {required double horizontalBaselineInParagraphCoordinates, required bool below}) {
final List<ui.LineMetrics> lines = paragraph._computeLineMetrics();
final Offset offset = paragraph.getOffsetForCaret(position, Rect.zero);
int currentLine = lines.length - 1;
for (final ui.LineMetrics lineMetrics in lines) {
if (lineMetrics.baseline > offset.dy) {
currentLine = lineMetrics.lineNumber;
break;
}
}
final TextPosition newPosition;
if (below && currentLine == lines.length - 1) {
newPosition = TextPosition(offset: range.end, affinity: TextAffinity.upstream);
} else if (!below && currentLine == 0) {
newPosition = TextPosition(offset: range.start);
} else {
final int newLine = below ? currentLine + 1 : currentLine - 1;
newPosition = _clampTextPosition(
paragraph.getPositionForOffset(Offset(horizontalBaselineInParagraphCoordinates, lines[newLine].baseline))
);
}
final SelectionResult result;
if (newPosition.offset == range.start) {
result = SelectionResult.previous;
} else if (newPosition.offset == range.end) {
result = SelectionResult.next;
} else {
result = SelectionResult.end;
}
assert(result != SelectionResult.next || below);
assert(result != SelectionResult.previous || !below);
return MapEntry<TextPosition, SelectionResult>(newPosition, result);
}
/// Whether the given text position is contained in current selection
/// range.
///
@ -1596,4 +1791,25 @@ class _SelectableFragment with Selectable, ChangeNotifier {
);
}
}
@override
TextSelection getLineAtOffset(TextPosition position) {
final TextRange line = paragraph._getLineAtOffset(position);
final int start = line.start.clamp(range.start, range.end); // ignore_clamp_double_lint
final int end = line.end.clamp(range.start, range.end); // ignore_clamp_double_lint
return TextSelection(baseOffset: start, extentOffset: end);
}
@override
TextPosition getTextPositionAbove(TextPosition position) {
return _clampTextPosition(paragraph._getTextPositionAbove(position));
}
@override
TextPosition getTextPositionBelow(TextPosition position) {
return _clampTextPosition(paragraph._getTextPositionBelow(position));
}
@override
TextRange getWordBoundary(TextPosition position) => paragraph.getWordBoundary(position);
}

View file

@ -296,6 +296,30 @@ enum SelectionEventType {
///
/// Used by [SelectWordSelectionEvent].
selectWord,
/// An event that extends the selection by a specific [TextGranularity].
granularlyExtendSelection,
/// An event that extends the selection in a specific direction.
directionallyExtendSelection,
}
/// The unit of how selection handles move in text.
///
/// The [GranularlyExtendSelectionEvent] uses this enum to describe how
/// [Selectable] should extend its selection.
enum TextGranularity {
/// Treats each character as an atomic unit when moving the selection handles.
character,
/// Treats word as an atomic unit when moving the selection handles.
word,
/// Treats each line break as an atomic unit when moving the selection handles.
line,
/// Treats the entire document as an atomic unit when moving the selection handles.
document,
}
/// An abstract base class for selection events.
@ -375,6 +399,127 @@ class SelectionEdgeUpdateEvent extends SelectionEvent {
final Offset globalPosition;
}
/// Extends the start or end of the selection by a given [TextGranularity].
///
/// To handle this event, move the associated selection edge, as dictated by
/// [isEnd], according to the [granularity].
class GranularlyExtendSelectionEvent extends SelectionEvent {
/// Creates a [GranularlyExtendSelectionEvent].
///
/// All parameters are required and must not be null.
const GranularlyExtendSelectionEvent({
required this.forward,
required this.isEnd,
required this.granularity,
}) : super._(SelectionEventType.granularlyExtendSelection);
/// Whether to extend the selection forward.
final bool forward;
/// Whether this event is updating the end selection edge.
final bool isEnd;
/// The granularity for which the selection extend.
final TextGranularity granularity;
}
/// The direction to extend a selection.
///
/// The [DirectionallyExtendSelectionEvent] uses this enum to describe how
/// [Selectable] should extend their selection.
enum SelectionExtendDirection {
/// Move one edge of the selection vertically to the previous adjacent line.
///
/// For text selection, it should consider both soft and hard linebreak.
///
/// See [DirectionallyExtendSelectionEvent.dx] on how to
/// calculate the horizontal offset.
previousLine,
/// Move one edge of the selection vertically to the next adjacent line.
///
/// For text selection, it should consider both soft and hard linebreak.
///
/// See [DirectionallyExtendSelectionEvent.dx] on how to
/// calculate the horizontal offset.
nextLine,
/// Move the selection edges forward to a certain horizontal offset in the
/// same line.
///
/// If there is no on-going selection, the selection must start with the first
/// line (or equivalence of first line in a non-text selectable) and select
/// toward the horizontal offset in the same line.
///
/// The selectable that receives [DirectionallyExtendSelectionEvent] with this
/// enum must return [SelectionResult.end].
///
/// See [DirectionallyExtendSelectionEvent.dx] on how to
/// calculate the horizontal offset.
forward,
/// Move the selection edges backward to a certain horizontal offset in the
/// same line.
///
/// If there is no on-going selection, the selection must start with the last
/// line (or equivalence of last line in a non-text selectable) and select
/// backward the horizontal offset in the same line.
///
/// The selectable that receives [DirectionallyExtendSelectionEvent] with this
/// enum must return [SelectionResult.end].
///
/// See [DirectionallyExtendSelectionEvent.dx] on how to
/// calculate the horizontal offset.
backward,
}
/// Extends the current selection with respect to a [direction].
///
/// To handle this event, move the associated selection edge, as dictated by
/// [isEnd], according to the [direction].
///
/// The movements are always based on [dx]. The value is in
/// global coordinates and is the horizontal offset the selection edge should
/// move to when moving to across lines.
class DirectionallyExtendSelectionEvent extends SelectionEvent {
/// Creates a [DirectionallyExtendSelectionEvent].
///
/// All parameters are required and must not be null.
const DirectionallyExtendSelectionEvent({
required this.dx,
required this.isEnd,
required this.direction,
}) : super._(SelectionEventType.directionallyExtendSelection);
/// The horizontal offset the selection should move to.
///
/// The offset is in global coordinates.
final double dx;
/// Whether this event is updating the end selection edge.
final bool isEnd;
/// The directional movement of this event.
///
/// See also:
/// * [SelectionExtendDirection], which explains how to handle each enum.
final SelectionExtendDirection direction;
/// Makes a copy of this object with its property replaced with the new
/// values.
DirectionallyExtendSelectionEvent copyWith({
double? dx,
bool? isEnd,
SelectionExtendDirection? direction,
}) {
return DirectionallyExtendSelectionEvent(
dx: dx ?? this.dx,
isEnd: isEnd ?? this.isEnd,
direction: direction ?? this.direction,
);
}
}
/// A registrar that keeps track of [Selectable]s in the subtree.
///
/// A [Selectable] is only included in the [SelectableRegion] if they are

View file

@ -295,8 +295,8 @@ class DefaultTextEditingShortcuts extends StatelessWidget {
const SingleActivator(LogicalKeyboardKey.arrowLeft, shift: true, alt: true): const ExtendSelectionToNextWordBoundaryOrCaretLocationIntent(forward: false),
const SingleActivator(LogicalKeyboardKey.arrowRight, shift: true, alt: true): const ExtendSelectionToNextWordBoundaryOrCaretLocationIntent(forward: true),
const SingleActivator(LogicalKeyboardKey.arrowUp, shift: true, alt: true): const ExtendSelectionToLineBreakIntent(forward: false, collapseSelection: false, collapseAtReversal: true),
const SingleActivator(LogicalKeyboardKey.arrowDown, shift: true, alt: true): const ExtendSelectionToLineBreakIntent(forward: true, collapseSelection: false, collapseAtReversal: true),
const SingleActivator(LogicalKeyboardKey.arrowUp, shift: true, alt: true): const ExtendSelectionVerticallyToAdjacentLineIntent(forward: false, collapseSelection: false),
const SingleActivator(LogicalKeyboardKey.arrowDown, shift: true, alt: true): const ExtendSelectionVerticallyToAdjacentLineIntent(forward: true, collapseSelection: false),
const SingleActivator(LogicalKeyboardKey.arrowLeft, meta: true): const ExtendSelectionToLineBreakIntent(forward: false, collapseSelection: true),
const SingleActivator(LogicalKeyboardKey.arrowRight, meta: true): const ExtendSelectionToLineBreakIntent(forward: true, collapseSelection: true),

View file

@ -1248,11 +1248,11 @@ class _ScrollableSelectionContainerDelegate extends MultiSelectableSelectionCont
/// selection is triggered by none drag events. The
/// [_currentDragStartRelatedToOrigin] and [_currentDragEndRelatedToOrigin]
/// are essential to handle future [SelectionEdgeUpdateEvent]s.
void _updateDragLocationsFromGeometries() {
void _updateDragLocationsFromGeometries({bool forceUpdateStart = true, bool forceUpdateEnd = true}) {
final Offset deltaToOrigin = _getDeltaToScrollOrigin(state);
final RenderBox box = state.context.findRenderObject()! as RenderBox;
final Matrix4 transform = box.getTransformTo(null);
if (currentSelectionStartIndex != -1) {
if (currentSelectionStartIndex != -1 && (_currentDragStartRelatedToOrigin == null || forceUpdateStart)) {
final SelectionGeometry geometry = selectables[currentSelectionStartIndex].value;
assert(geometry.hasSelection);
final SelectionPoint start = geometry.startSelectionPoint!;
@ -1263,7 +1263,7 @@ class _ScrollableSelectionContainerDelegate extends MultiSelectableSelectionCont
);
_currentDragStartRelatedToOrigin = MatrixUtils.transformPoint(transform, localDragStart + deltaToOrigin);
}
if (currentSelectionEndIndex != -1) {
if (currentSelectionEndIndex != -1 && (_currentDragEndRelatedToOrigin == null || forceUpdateEnd)) {
final SelectionGeometry geometry = selectables[currentSelectionEndIndex].value;
assert(geometry.hasSelection);
final SelectionPoint end = geometry.endSelectionPoint!;
@ -1295,6 +1295,116 @@ class _ScrollableSelectionContainerDelegate extends MultiSelectableSelectionCont
return result;
}
@override
SelectionResult handleGranularlyExtendSelection(GranularlyExtendSelectionEvent event) {
final SelectionResult result = super.handleGranularlyExtendSelection(event);
// The selection geometry may not have the accurate offset for the edges
// that are outside of the viewport whose transform may not be valid. Only
// the edge this event is updating is sure to be accurate.
_updateDragLocationsFromGeometries(
forceUpdateStart: !event.isEnd,
forceUpdateEnd: event.isEnd,
);
if (_selectionStartsInScrollable) {
_jumpToEdge(event.isEnd);
}
return result;
}
@override
SelectionResult handleDirectionallyExtendSelection(DirectionallyExtendSelectionEvent event) {
final SelectionResult result = super.handleDirectionallyExtendSelection(event);
// The selection geometry may not have the accurate offset for the edges
// that are outside of the viewport whose transform may not be valid. Only
// the edge this event is updating is sure to be accurate.
_updateDragLocationsFromGeometries(
forceUpdateStart: !event.isEnd,
forceUpdateEnd: event.isEnd,
);
if (_selectionStartsInScrollable) {
_jumpToEdge(event.isEnd);
}
return result;
}
void _jumpToEdge(bool isExtent) {
final Selectable selectable;
final double? lineHeight;
final SelectionPoint? edge;
if (isExtent) {
selectable = selectables[currentSelectionEndIndex];
edge = selectable.value.endSelectionPoint;
lineHeight = selectable.value.endSelectionPoint!.lineHeight;
} else {
selectable = selectables[currentSelectionStartIndex];
edge = selectable.value.startSelectionPoint;
lineHeight = selectable.value.startSelectionPoint?.lineHeight;
}
if (lineHeight == null || edge == null) {
return;
}
final RenderBox scrollableBox = state.context.findRenderObject()! as RenderBox;
final Matrix4 transform = selectable.getTransformTo(scrollableBox);
final Offset edgeOffsetInScrollableCoordinates = MatrixUtils.transformPoint(transform, edge.localPosition);
final Rect scrollableRect = Rect.fromLTRB(0, 0, scrollableBox.size.width, scrollableBox.size.height);
switch (state.axisDirection) {
case AxisDirection.up:
final double edgeBottom = edgeOffsetInScrollableCoordinates.dy;
final double edgeTop = edgeOffsetInScrollableCoordinates.dy - lineHeight;
if (edgeBottom >= scrollableRect.bottom && edgeTop <= scrollableRect.top) {
return;
}
if (edgeBottom > scrollableRect.bottom) {
position.jumpTo(position.pixels + scrollableRect.bottom - edgeBottom);
return;
}
if (edgeTop < scrollableRect.top) {
position.jumpTo(position.pixels + scrollableRect.top - edgeTop);
}
return;
case AxisDirection.right:
final double edge = edgeOffsetInScrollableCoordinates.dx;
if (edge >= scrollableRect.right && edge <= scrollableRect.left) {
return;
}
if (edge > scrollableRect.right) {
position.jumpTo(position.pixels + edge - scrollableRect.right);
return;
}
if (edge < scrollableRect.left) {
position.jumpTo(position.pixels + edge - scrollableRect.left);
}
return;
case AxisDirection.down:
final double edgeBottom = edgeOffsetInScrollableCoordinates.dy;
final double edgeTop = edgeOffsetInScrollableCoordinates.dy - lineHeight;
if (edgeBottom >= scrollableRect.bottom && edgeTop <= scrollableRect.top) {
return;
}
if (edgeBottom > scrollableRect.bottom) {
position.jumpTo(position.pixels + edgeBottom - scrollableRect.bottom);
return;
}
if (edgeTop < scrollableRect.top) {
position.jumpTo(position.pixels + edgeTop - scrollableRect.top);
}
return;
case AxisDirection.left:
final double edge = edgeOffsetInScrollableCoordinates.dx;
if (edge >= scrollableRect.right && edge <= scrollableRect.left) {
return;
}
if (edge > scrollableRect.right) {
position.jumpTo(position.pixels + scrollableRect.right - edge);
return;
}
if (edge < scrollableRect.left) {
position.jumpTo(position.pixels + scrollableRect.left - edge);
}
return;
}
}
bool _globalPositionInScrollable(Offset globalPosition) {
final RenderBox box = state.context.findRenderObject()! as RenderBox;
final Offset localPosition = box.globalToLocal(globalPosition);
@ -1317,6 +1427,12 @@ class _ScrollableSelectionContainerDelegate extends MultiSelectableSelectionCont
_selectableEndEdgeUpdateRecords[selectable] = state.position.pixels;
ensureChildUpdated(selectable);
break;
case SelectionEventType.granularlyExtendSelection:
case SelectionEventType.directionallyExtendSelection:
ensureChildUpdated(selectable);
_selectableStartEdgeUpdateRecords[selectable] = state.position.pixels;
_selectableEndEdgeUpdateRecords[selectable] = state.position.pixels;
break;
case SelectionEventType.clear:
_selectableEndEdgeUpdateRecords.remove(selectable);
_selectableStartEdgeUpdateRecords.remove(selectable);

View file

@ -291,7 +291,16 @@ class SelectableRegionState extends State<SelectableRegion> with TextSelectionDe
late final Map<Type, Action<Intent>> _actions = <Type, Action<Intent>>{
SelectAllTextIntent: _makeOverridable(_SelectAllAction(this)),
CopySelectionTextIntent: _makeOverridable(_CopySelectionAction(this)),
ExtendSelectionToNextWordBoundaryOrCaretLocationIntent: _makeOverridable(_GranularlyExtendSelectionAction<ExtendSelectionToNextWordBoundaryOrCaretLocationIntent>(this, granularity: TextGranularity.word)),
ExpandSelectionToDocumentBoundaryIntent: _makeOverridable(_GranularlyExtendSelectionAction<ExpandSelectionToDocumentBoundaryIntent>(this, granularity: TextGranularity.document)),
ExpandSelectionToLineBreakIntent: _makeOverridable(_GranularlyExtendSelectionAction<ExpandSelectionToLineBreakIntent>(this, granularity: TextGranularity.line)),
ExtendSelectionByCharacterIntent: _makeOverridable(_GranularlyExtendCaretSelectionAction<ExtendSelectionByCharacterIntent>(this, granularity: TextGranularity.character)),
ExtendSelectionToNextWordBoundaryIntent: _makeOverridable(_GranularlyExtendCaretSelectionAction<ExtendSelectionToNextWordBoundaryIntent>(this, granularity: TextGranularity.word)),
ExtendSelectionToLineBreakIntent: _makeOverridable(_GranularlyExtendCaretSelectionAction<ExtendSelectionToLineBreakIntent>(this, granularity: TextGranularity.line)),
ExtendSelectionVerticallyToAdjacentLineIntent: _makeOverridable(_DirectionallyExtendCaretSelectionAction<ExtendSelectionVerticallyToAdjacentLineIntent>(this)),
ExtendSelectionToDocumentBoundaryIntent: _makeOverridable(_GranularlyExtendCaretSelectionAction<ExtendSelectionToDocumentBoundaryIntent>(this, granularity: TextGranularity.document)),
};
final Map<Type, GestureRecognizerFactory> _gestureRecognizers = <Type, GestureRecognizerFactory>{};
SelectionOverlay? _selectionOverlay;
final LayerLink _startHandleLayerLink = LayerLink();
@ -329,7 +338,6 @@ class SelectableRegionState extends State<SelectableRegion> with TextSelectionDe
@override
void didChangeDependencies() {
super.didChangeDependencies();
switch (defaultTargetPlatform) {
case TargetPlatform.android:
case TargetPlatform.iOS:
@ -864,6 +872,8 @@ class SelectableRegionState extends State<SelectableRegion> with TextSelectionDe
/// Removes the ongoing selection.
void _clearSelection() {
_finalizeSelection();
_directionalHorizontalBaseline = null;
_adjustingSelectionEnd = null;
_selectable?.dispatchSelectionEvent(const ClearSelectionEvent());
_updateSelectedContentIfNeeded();
}
@ -897,6 +907,63 @@ class SelectableRegionState extends State<SelectableRegion> with TextSelectionDe
);
}
bool? _adjustingSelectionEnd;
bool _determineIsAdjustingSelectionEnd(bool forward) {
if (_adjustingSelectionEnd != null) {
return _adjustingSelectionEnd!;
}
final bool isReversed;
final SelectionPoint start = _selectionDelegate.value
.startSelectionPoint!;
final SelectionPoint end = _selectionDelegate.value.endSelectionPoint!;
if (start.localPosition.dy > end.localPosition.dy) {
isReversed = true;
} else if (start.localPosition.dy < end.localPosition.dy) {
isReversed = false;
} else {
isReversed = start.localPosition.dx > end.localPosition.dx;
}
// Always move the selection edge that increases the selection range.
return _adjustingSelectionEnd = forward != isReversed;
}
void _granularlyExtendSelection(TextGranularity granularity, bool forward) {
_directionalHorizontalBaseline = null;
if (!_selectionDelegate.value.hasSelection) {
return;
}
_selectable?.dispatchSelectionEvent(
GranularlyExtendSelectionEvent(
forward: forward,
isEnd: _determineIsAdjustingSelectionEnd(forward),
granularity: granularity,
),
);
}
double? _directionalHorizontalBaseline;
void _directionallyExtendSelection(bool forward) {
if (!_selectionDelegate.value.hasSelection) {
return;
}
final bool adjustingSelectionExtend = _determineIsAdjustingSelectionEnd(forward);
final SelectionPoint baseLinePoint = adjustingSelectionExtend
? _selectionDelegate.value.endSelectionPoint!
: _selectionDelegate.value.startSelectionPoint!;
_directionalHorizontalBaseline ??= baseLinePoint.localPosition.dx;
final Offset globalSelectionPointOffset = MatrixUtils.transformPoint(context.findRenderObject()!.getTransformTo(null), Offset(_directionalHorizontalBaseline!, 0));
_selectable?.dispatchSelectionEvent(
DirectionallyExtendSelectionEvent(
isEnd: _adjustingSelectionEnd!,
direction: forward ? SelectionExtendDirection.nextLine : SelectionExtendDirection.previousLine,
dx: globalSelectionPointOffset.dx,
),
);
}
// [TextSelectionDelegate] overrides.
/// Returns the [ContextMenuButtonItem]s representing the buttons in this
/// platform's default selection menu.
///
@ -1147,6 +1214,49 @@ class _CopySelectionAction extends _NonOverrideAction<CopySelectionTextIntent> {
}
}
class _GranularlyExtendSelectionAction<T extends DirectionalTextEditingIntent> extends _NonOverrideAction<T> {
_GranularlyExtendSelectionAction(this.state, {required this.granularity});
final SelectableRegionState state;
final TextGranularity granularity;
@override
void invokeAction(T intent, [BuildContext? context]) {
state._granularlyExtendSelection(granularity, intent.forward);
}
}
class _GranularlyExtendCaretSelectionAction<T extends DirectionalCaretMovementIntent> extends _NonOverrideAction<T> {
_GranularlyExtendCaretSelectionAction(this.state, {required this.granularity});
final SelectableRegionState state;
final TextGranularity granularity;
@override
void invokeAction(T intent, [BuildContext? context]) {
if (intent.collapseSelection) {
// Selectable region never collapses selection.
return;
}
state._granularlyExtendSelection(granularity, intent.forward);
}
}
class _DirectionallyExtendCaretSelectionAction<T extends DirectionalCaretMovementIntent> extends _NonOverrideAction<T> {
_DirectionallyExtendCaretSelectionAction(this.state);
final SelectableRegionState state;
@override
void invokeAction(T intent, [BuildContext? context]) {
if (intent.collapseSelection) {
// Selectable region never collapses selection.
return;
}
state._directionallyExtendSelection(intent.forward);
}
}
class _SelectableRegionContainerDelegate extends MultiSelectableSelectionContainerDelegate {
final Set<Selectable> _hasReceivedStartEvent = <Selectable>{};
final Set<Selectable> _hasReceivedEndEvent = <Selectable>{};
@ -1248,6 +1358,12 @@ class _SelectableRegionContainerDelegate extends MultiSelectableSelectionContain
case SelectionEventType.selectAll:
case SelectionEventType.selectWord:
break;
case SelectionEventType.granularlyExtendSelection:
case SelectionEventType.directionallyExtendSelection:
_hasReceivedStartEvent.add(selectable);
_hasReceivedEndEvent.add(selectable);
ensureChildUpdated(selectable);
break;
}
return super.dispatchSelectionEventToChild(selectable, event);
}
@ -1340,6 +1456,8 @@ abstract class MultiSelectableSelectionContainerDelegate extends SelectionContai
bool _selectionInProgress = false;
Set<Selectable> _additions = <Selectable>{};
bool _extendSelectionInProgress = false;
@override
void add(Selectable selectable) {
assert(!selectables.contains(selectable));
@ -1548,6 +1666,7 @@ abstract class MultiSelectableSelectionContainerDelegate extends SelectionContai
);
}
if (!_extendSelectionInProgress) {
currentSelectionStartIndex = _adjustSelectionIndexBasedOnSelectionGeometry(
currentSelectionStartIndex,
currentSelectionEndIndex,
@ -1556,6 +1675,7 @@ abstract class MultiSelectableSelectionContainerDelegate extends SelectionContai
currentSelectionEndIndex,
currentSelectionStartIndex,
);
}
// Need to find the non-null start selection point.
SelectionGeometry startGeometry = selectables[currentSelectionStartIndex].value;
@ -1760,6 +1880,100 @@ abstract class MultiSelectableSelectionContainerDelegate extends SelectionContai
return SelectionResult.none;
}
/// Extend current selection in a certain text granularity.
@protected
SelectionResult handleGranularlyExtendSelection(GranularlyExtendSelectionEvent event) {
assert((currentSelectionStartIndex == -1) == (currentSelectionEndIndex == -1));
if (currentSelectionStartIndex == -1) {
if (event.forward) {
currentSelectionStartIndex = currentSelectionEndIndex = 0;
} else {
currentSelectionStartIndex = currentSelectionEndIndex = selectables.length;
}
}
int targetIndex = event.isEnd ? currentSelectionEndIndex : currentSelectionStartIndex;
SelectionResult result = dispatchSelectionEventToChild(selectables[targetIndex], event);
if (event.forward) {
assert(result != SelectionResult.previous);
while (targetIndex < selectables.length - 1 && result == SelectionResult.next) {
targetIndex += 1;
result = dispatchSelectionEventToChild(selectables[targetIndex], event);
assert(result != SelectionResult.previous);
}
} else {
assert(result != SelectionResult.next);
while (targetIndex > 0 && result == SelectionResult.previous) {
targetIndex -= 1;
result = dispatchSelectionEventToChild(selectables[targetIndex], event);
assert(result != SelectionResult.next);
}
}
if (event.isEnd) {
currentSelectionEndIndex = targetIndex;
} else {
currentSelectionStartIndex = targetIndex;
}
return result;
}
/// Extend current selection in a certain text granularity.
@protected
SelectionResult handleDirectionallyExtendSelection(DirectionallyExtendSelectionEvent event) {
assert((currentSelectionStartIndex == -1) == (currentSelectionEndIndex == -1));
if (currentSelectionStartIndex == -1) {
switch(event.direction) {
case SelectionExtendDirection.previousLine:
case SelectionExtendDirection.backward:
currentSelectionStartIndex = currentSelectionEndIndex = selectables.length;
break;
case SelectionExtendDirection.nextLine:
case SelectionExtendDirection.forward:
currentSelectionStartIndex = currentSelectionEndIndex = 0;
break;
}
}
int targetIndex = event.isEnd ? currentSelectionEndIndex : currentSelectionStartIndex;
SelectionResult result = dispatchSelectionEventToChild(selectables[targetIndex], event);
switch (event.direction) {
case SelectionExtendDirection.previousLine:
assert(result == SelectionResult.end || result == SelectionResult.previous);
if (result == SelectionResult.previous) {
if (targetIndex > 0) {
targetIndex -= 1;
result = dispatchSelectionEventToChild(
selectables[targetIndex],
event.copyWith(direction: SelectionExtendDirection.backward),
);
assert(result == SelectionResult.end);
}
}
break;
case SelectionExtendDirection.nextLine:
assert(result == SelectionResult.end || result == SelectionResult.next);
if (result == SelectionResult.next) {
if (targetIndex < selectables.length - 1) {
targetIndex += 1;
result = dispatchSelectionEventToChild(
selectables[targetIndex],
event.copyWith(direction: SelectionExtendDirection.forward),
);
assert(result == SelectionResult.end);
}
}
break;
case SelectionExtendDirection.forward:
case SelectionExtendDirection.backward:
assert(result == SelectionResult.end);
break;
}
if (event.isEnd) {
currentSelectionEndIndex = targetIndex;
} else {
currentSelectionStartIndex = targetIndex;
}
return result;
}
/// Updates the selection edges.
@protected
SelectionResult handleSelectionEdgeUpdate(SelectionEdgeUpdateEvent event) {
@ -1782,17 +1996,29 @@ abstract class MultiSelectableSelectionContainerDelegate extends SelectionContai
switch (event.type) {
case SelectionEventType.startEdgeUpdate:
case SelectionEventType.endEdgeUpdate:
_extendSelectionInProgress = false;
result = handleSelectionEdgeUpdate(event as SelectionEdgeUpdateEvent);
break;
case SelectionEventType.clear:
_extendSelectionInProgress = false;
result = handleClearSelection(event as ClearSelectionEvent);
break;
case SelectionEventType.selectAll:
_extendSelectionInProgress = false;
result = handleSelectAll(event as SelectAllSelectionEvent);
break;
case SelectionEventType.selectWord:
_extendSelectionInProgress = false;
result = handleSelectWord(event as SelectWordSelectionEvent);
break;
case SelectionEventType.granularlyExtendSelection:
_extendSelectionInProgress = true;
result = handleGranularlyExtendSelection(event as GranularlyExtendSelectionEvent);
break;
case SelectionEventType.directionallyExtendSelection:
_extendSelectionInProgress = true;
result = handleDirectionallyExtendSelection(event as DirectionallyExtendSelectionEvent);
break;
}
_isHandlingSelectionEvent = false;
_updateSelectionGeometry();

View file

@ -12,6 +12,7 @@ import 'package:flutter_test/flutter_test.dart';
import 'rendering_tester.dart';
const String _kText = "I polished up that handle so carefullee\nThat now I am the Ruler of the Queen's Navee!";
const bool isCanvasKit = bool.fromEnvironment('FLUTTER_WEB_USE_SKIA');
// A subclass of RenderParagraph that returns an empty list in getBoxesForSelection
// for a given TextSelection.
@ -821,12 +822,12 @@ void main() {
for (final Selectable selectable in (paragraph.registrar! as TestSelectionRegistrar).selectables) {
selectable.dispatchSelectionEvent(
SelectionEdgeUpdateEvent.forStart(
globalPosition: paragraph.getOffsetForCaret(start, Rect.zero),
globalPosition: paragraph.getOffsetForCaret(start, Rect.zero) + const Offset(0, 5),
),
);
selectable.dispatchSelectionEvent(
SelectionEdgeUpdateEvent.forEnd(
globalPosition: paragraph.getOffsetForCaret(end, Rect.zero),
globalPosition: paragraph.getOffsetForCaret(end, Rect.zero) + const Offset(0, 5),
),
);
}
@ -911,6 +912,392 @@ void main() {
expect(geometry2.hasContent, true);
expect(geometry2.status, SelectionStatus.uncollapsed);
});
test('can granularly extend selection - character', () async {
final TestSelectionRegistrar registrar = TestSelectionRegistrar();
final List<RenderBox> renderBoxes = <RenderBox>[];
final RenderParagraph paragraph = RenderParagraph(
const TextSpan(
children: <InlineSpan>[
TextSpan(text: 'how are you\nI am fine\nThank you'),
]
),
textDirection: TextDirection.ltr,
registrar: registrar,
children: renderBoxes,
);
layout(paragraph);
expect(registrar.selectables.length, 1);
selectionParagraph(paragraph, const TextPosition(offset: 4), const TextPosition(offset: 5));
expect(paragraph.selections.length, 1);
TextSelection selection = paragraph.selections[0];
expect(selection.start, 4); // how [a]re you
expect(selection.end, 5);
// Equivalent to sending shift + arrow-right
registrar.selectables[0].dispatchSelectionEvent(
const GranularlyExtendSelectionEvent(
forward: true,
isEnd: true,
granularity: TextGranularity.character,
),
);
selection = paragraph.selections[0];
expect(selection.start, 4); // how [ar]e you
expect(selection.end, 6);
// Equivalent to sending shift + arrow-left
registrar.selectables[0].dispatchSelectionEvent(
const GranularlyExtendSelectionEvent(
forward: false,
isEnd: true,
granularity: TextGranularity.character,
),
);
selection = paragraph.selections[0];
expect(selection.start, 4); // how [a]re you
expect(selection.end, 5);
});
test('can granularly extend selection - word', () async {
final TestSelectionRegistrar registrar = TestSelectionRegistrar();
final List<RenderBox> renderBoxes = <RenderBox>[];
final RenderParagraph paragraph = RenderParagraph(
const TextSpan(
children: <InlineSpan>[
TextSpan(text: 'how are you\nI am fine\nThank you'),
]
),
textDirection: TextDirection.ltr,
registrar: registrar,
children: renderBoxes,
);
layout(paragraph);
expect(registrar.selectables.length, 1);
selectionParagraph(paragraph, const TextPosition(offset: 4), const TextPosition(offset: 5));
expect(paragraph.selections.length, 1);
TextSelection selection = paragraph.selections[0];
expect(selection.start, 4); // how [a]re you
expect(selection.end, 5);
// Equivalent to sending shift + alt + arrow-right.
registrar.selectables[0].dispatchSelectionEvent(
const GranularlyExtendSelectionEvent(
forward: true,
isEnd: true,
granularity: TextGranularity.word,
),
);
selection = paragraph.selections[0];
expect(selection.start, 4); // how [are] you
expect(selection.end, 7);
// Equivalent to sending shift + alt + arrow-left.
registrar.selectables[0].dispatchSelectionEvent(
const GranularlyExtendSelectionEvent(
forward: false,
isEnd: true,
granularity: TextGranularity.word,
),
);
expect(paragraph.selections.length, 0); // how []are you
// Equivalent to sending shift + alt + arrow-left.
registrar.selectables[0].dispatchSelectionEvent(
const GranularlyExtendSelectionEvent(
forward: false,
isEnd: true,
granularity: TextGranularity.word,
),
);
selection = paragraph.selections[0];
expect(selection.start, 0); // [how ]are you
expect(selection.end, 4);
});
test('can granularly extend selection - line', () async {
final TestSelectionRegistrar registrar = TestSelectionRegistrar();
final List<RenderBox> renderBoxes = <RenderBox>[];
final RenderParagraph paragraph = RenderParagraph(
const TextSpan(
children: <InlineSpan>[
TextSpan(text: 'how are you\nI am fine\nThank you'),
]
),
textDirection: TextDirection.ltr,
registrar: registrar,
children: renderBoxes,
);
layout(paragraph);
expect(registrar.selectables.length, 1);
selectionParagraph(paragraph, const TextPosition(offset: 4), const TextPosition(offset: 5));
expect(paragraph.selections.length, 1);
TextSelection selection = paragraph.selections[0];
expect(selection.start, 4); // how [a]re you
expect(selection.end, 5);
// Equivalent to sending shift + meta + arrow-right.
registrar.selectables[0].dispatchSelectionEvent(
const GranularlyExtendSelectionEvent(
forward: true,
isEnd: true,
granularity: TextGranularity.line,
),
);
selection = paragraph.selections[0];
expect(selection.start, 4); // how [are you]
if (isBrowser && !isCanvasKit) {
expect(selection.end, 12);
} else {
expect(selection.end, 11);
}
// Equivalent to sending shift + meta + arrow-left.
registrar.selectables[0].dispatchSelectionEvent(
const GranularlyExtendSelectionEvent(
forward: false,
isEnd: true,
granularity: TextGranularity.line,
),
);
selection = paragraph.selections[0];
expect(selection.start, 0); // [how ]are you
expect(selection.end, 4);
});
test('can granularly extend selection - document', () async {
final TestSelectionRegistrar registrar = TestSelectionRegistrar();
final List<RenderBox> renderBoxes = <RenderBox>[];
final RenderParagraph paragraph = RenderParagraph(
const TextSpan(
children: <InlineSpan>[
TextSpan(text: 'how are you\nI am fine\nThank you'),
]
),
textDirection: TextDirection.ltr,
registrar: registrar,
children: renderBoxes,
);
layout(paragraph);
expect(registrar.selectables.length, 1);
selectionParagraph(paragraph, const TextPosition(offset: 14), const TextPosition(offset: 15));
expect(paragraph.selections.length, 1);
TextSelection selection = paragraph.selections[0];
// how are you
// I [a]m fine
expect(selection.start, 14);
expect(selection.end, 15);
// Equivalent to sending shift + meta + arrow-down.
registrar.selectables[0].dispatchSelectionEvent(
const GranularlyExtendSelectionEvent(
forward: true,
isEnd: true,
granularity: TextGranularity.document,
),
);
selection = paragraph.selections[0];
// how are you
// I [am fine
// Thank you]
expect(selection.start, 14);
expect(selection.end, 31);
// Equivalent to sending shift + meta + arrow-up.
registrar.selectables[0].dispatchSelectionEvent(
const GranularlyExtendSelectionEvent(
forward: false,
isEnd: true,
granularity: TextGranularity.document,
),
);
selection = paragraph.selections[0];
// [how are you
// I ]am fine
// Thank you
expect(selection.start, 0);
expect(selection.end, 14);
});
test('can granularly extend selection when no active selection', () async {
final TestSelectionRegistrar registrar = TestSelectionRegistrar();
final List<RenderBox> renderBoxes = <RenderBox>[];
final RenderParagraph paragraph = RenderParagraph(
const TextSpan(
children: <InlineSpan>[
TextSpan(text: 'how are you\nI am fine\nThank you'),
]
),
textDirection: TextDirection.ltr,
registrar: registrar,
children: renderBoxes,
);
layout(paragraph);
expect(registrar.selectables.length, 1);
expect(paragraph.selections.length, 0);
// Equivalent to sending shift + alt + right.
registrar.selectables[0].dispatchSelectionEvent(
const GranularlyExtendSelectionEvent(
forward: true,
isEnd: true,
granularity: TextGranularity.word,
),
);
TextSelection selection = paragraph.selections[0];
// [how] are you
// I am fine
// Thank you
expect(selection.start, 0);
expect(selection.end, 3);
// Remove selection
registrar.selectables[0].dispatchSelectionEvent(
const ClearSelectionEvent(),
);
expect(paragraph.selections.length, 0);
// Equivalent to sending shift + alt + left.
registrar.selectables[0].dispatchSelectionEvent(
const GranularlyExtendSelectionEvent(
forward: false,
isEnd: true,
granularity: TextGranularity.word,
),
);
selection = paragraph.selections[0];
// how are you
// I am fine
// Thank [you]
expect(selection.start, 28);
expect(selection.end, 31);
});
test('can directionally extend selection', () async {
final TestSelectionRegistrar registrar = TestSelectionRegistrar();
final List<RenderBox> renderBoxes = <RenderBox>[];
final RenderParagraph paragraph = RenderParagraph(
const TextSpan(
children: <InlineSpan>[
TextSpan(text: 'how are you\nI am fine\nThank you'),
]
),
textDirection: TextDirection.ltr,
registrar: registrar,
children: renderBoxes,
);
layout(paragraph);
expect(registrar.selectables.length, 1);
selectionParagraph(paragraph, const TextPosition(offset: 14), const TextPosition(offset: 15));
expect(paragraph.selections.length, 1);
TextSelection selection = paragraph.selections[0];
// how are you
// I [a]m fine
expect(selection.start, 14);
expect(selection.end, 15);
final Matrix4 transform = registrar.selectables[0].getTransformTo(null);
final double baseline = MatrixUtils.transformPoint(
transform,
registrar.selectables[0].value.endSelectionPoint!.localPosition,
).dx;
// Equivalent to sending shift + arrow-down.
registrar.selectables[0].dispatchSelectionEvent(
DirectionallyExtendSelectionEvent(
isEnd: true,
dx: baseline,
direction: SelectionExtendDirection.nextLine,
),
);
selection = paragraph.selections[0];
// how are you
// I [am fine
// Tha]nk you
expect(selection.start, 14);
expect(selection.end, 25);
// Equivalent to sending shift + arrow-up.
registrar.selectables[0].dispatchSelectionEvent(
DirectionallyExtendSelectionEvent(
isEnd: true,
dx: baseline,
direction: SelectionExtendDirection.previousLine,
),
);
selection = paragraph.selections[0];
// how are you
// I [a]m fine
// Thank you
expect(selection.start, 14);
expect(selection.end, 15);
});
test('can directionally extend selection when no selection', () async {
final TestSelectionRegistrar registrar = TestSelectionRegistrar();
final List<RenderBox> renderBoxes = <RenderBox>[];
final RenderParagraph paragraph = RenderParagraph(
const TextSpan(
children: <InlineSpan>[
TextSpan(text: 'how are you\nI am fine\nThank you'),
]
),
textDirection: TextDirection.ltr,
registrar: registrar,
children: renderBoxes,
);
layout(paragraph);
expect(registrar.selectables.length, 1);
expect(paragraph.selections.length, 0);
final Matrix4 transform = registrar.selectables[0].getTransformTo(null);
final double baseline = MatrixUtils.transformPoint(
transform,
Offset(registrar.selectables[0].size.width / 2, 0),
).dx;
// Equivalent to sending shift + arrow-down.
registrar.selectables[0].dispatchSelectionEvent(
DirectionallyExtendSelectionEvent(
isEnd: true,
dx: baseline,
direction: SelectionExtendDirection.forward,
),
);
TextSelection selection = paragraph.selections[0];
// [how ar]e you
// I am fine
// Thank you
expect(selection.start, 0);
expect(selection.end, 6);
registrar.selectables[0].dispatchSelectionEvent(
const ClearSelectionEvent(),
);
expect(paragraph.selections.length, 0);
// Equivalent to sending shift + arrow-up.
registrar.selectables[0].dispatchSelectionEvent(
DirectionallyExtendSelectionEvent(
isEnd: true,
dx: baseline,
direction: SelectionExtendDirection.backward,
),
);
selection = paragraph.selections[0];
// how are you
// I am fine
// Thank [you]
expect(selection.start, 28);
expect(selection.end, 31);
});
});
}

View file

@ -7,26 +7,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
Future<void> sendKeyCombination(
WidgetTester tester,
SingleActivator activator,
) async {
final List<LogicalKeyboardKey> modifiers = <LogicalKeyboardKey>[
if (activator.control) LogicalKeyboardKey.control,
if (activator.shift) LogicalKeyboardKey.shift,
if (activator.alt) LogicalKeyboardKey.alt,
if (activator.meta) LogicalKeyboardKey.meta,
];
for (final LogicalKeyboardKey modifier in modifiers) {
await tester.sendKeyDownEvent(modifier);
}
await tester.sendKeyDownEvent(activator.trigger);
await tester.sendKeyUpEvent(activator.trigger);
await tester.pump();
for (final LogicalKeyboardKey modifier in modifiers.reversed) {
await tester.sendKeyUpEvent(modifier);
}
}
import 'keyboard_utils.dart';
void main() {
Widget buildSpyAboveEditableText({

View file

@ -8,27 +8,7 @@ import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'clipboard_utils.dart';
Future<void> sendKeyCombination(
WidgetTester tester,
SingleActivator activator,
) async {
final List<LogicalKeyboardKey> modifiers = <LogicalKeyboardKey>[
if (activator.control) LogicalKeyboardKey.control,
if (activator.shift) LogicalKeyboardKey.shift,
if (activator.alt) LogicalKeyboardKey.alt,
if (activator.meta) LogicalKeyboardKey.meta,
];
for (final LogicalKeyboardKey modifier in modifiers) {
await tester.sendKeyDownEvent(modifier);
}
await tester.sendKeyDownEvent(activator.trigger);
await tester.sendKeyUpEvent(activator.trigger);
await tester.pump();
for (final LogicalKeyboardKey modifier in modifiers.reversed) {
await tester.sendKeyUpEvent(modifier);
}
}
import 'keyboard_utils.dart';
Iterable<SingleActivator> allModifierVariants(LogicalKeyboardKey trigger) {
const Iterable<bool> trueFalse = <bool>[false, true];

View file

@ -0,0 +1,28 @@
// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
Future<void> sendKeyCombination(
WidgetTester tester,
SingleActivator activator,
) async {
final List<LogicalKeyboardKey> modifiers = <LogicalKeyboardKey>[
if (activator.control) LogicalKeyboardKey.control,
if (activator.shift) LogicalKeyboardKey.shift,
if (activator.alt) LogicalKeyboardKey.alt,
if (activator.meta) LogicalKeyboardKey.meta,
];
for (final LogicalKeyboardKey modifier in modifiers) {
await tester.sendKeyDownEvent(modifier);
}
await tester.sendKeyDownEvent(activator.trigger);
await tester.sendKeyUpEvent(activator.trigger);
await tester.pump();
for (final LogicalKeyboardKey modifier in modifiers.reversed) {
await tester.sendKeyUpEvent(modifier);
}
}

View file

@ -10,6 +10,7 @@ import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'clipboard_utils.dart';
import 'keyboard_utils.dart';
Offset textOffsetToPosition(RenderParagraph paragraph, int offset) {
const Rect caret = Rect.fromLTWH(0.0, 0.0, 2.0, 20.0);
@ -400,10 +401,7 @@ void main() {
));
await tester.pumpAndSettle();
node.requestFocus();
await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft);
await tester.sendKeyDownEvent(LogicalKeyboardKey.keyA);
await tester.sendKeyUpEvent(LogicalKeyboardKey.keyA);
await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft);
await sendKeyCombination(tester, const SingleActivator(LogicalKeyboardKey.keyA, control: true));
await tester.pump();
for (int i = 0; i < 13; i += 1) {
@ -429,10 +427,7 @@ void main() {
));
await tester.pumpAndSettle();
node.requestFocus();
await tester.sendKeyDownEvent(LogicalKeyboardKey.metaLeft);
await tester.sendKeyDownEvent(LogicalKeyboardKey.keyA);
await tester.sendKeyUpEvent(LogicalKeyboardKey.keyA);
await tester.sendKeyUpEvent(LogicalKeyboardKey.metaLeft);
await sendKeyCombination(tester, const SingleActivator(LogicalKeyboardKey.keyA, meta: true));
await tester.pump();
for (int i = 0; i < 13; i += 1) {
@ -498,6 +493,234 @@ void main() {
await gesture.up();
});
testWidgets('keyboard selection should auto scroll - vertical', (WidgetTester tester) async {
final FocusNode node = FocusNode();
final ScrollController controller = ScrollController();
await tester.pumpWidget(MaterialApp(
home: SelectionArea(
focusNode: node,
selectionControls: materialTextSelectionControls,
child: ListView.builder(
controller: controller,
itemCount: 100,
itemBuilder: (BuildContext context, int index) {
return Text('Item $index');
},
),
),
));
await tester.pumpAndSettle();
final RenderParagraph paragraph9 = tester.renderObject<RenderParagraph>(find.descendant(of: find.text('Item 9'), matching: find.byType(RichText)));
final TestGesture gesture = await tester.startGesture(textOffsetToPosition(paragraph9, 2), kind: ui.PointerDeviceKind.mouse);
addTearDown(gesture.removePointer);
await gesture.moveTo(textOffsetToPosition(paragraph9, 4) + const Offset(0, 5));
await tester.pumpAndSettle();
await gesture.up();
await tester.pump();
expect(paragraph9.selections.length, 1);
expect(paragraph9.selections[0].start, 2);
expect(paragraph9.selections[0].end, 4);
expect(controller.offset, 0.0);
await sendKeyCombination(tester, const SingleActivator(LogicalKeyboardKey.arrowDown, shift: true));
await tester.pump();
final RenderParagraph paragraph10 = tester.renderObject<RenderParagraph>(find.descendant(of: find.text('Item 10'), matching: find.byType(RichText)));
expect(paragraph10.selections.length, 1);
expect(paragraph10.selections[0].start, 0);
expect(paragraph10.selections[0].end, 4);
expect(controller.offset, 0.0);
await sendKeyCombination(tester, const SingleActivator(LogicalKeyboardKey.arrowDown, shift: true));
await tester.pump();
final RenderParagraph paragraph11 = tester.renderObject<RenderParagraph>(find.descendant(of: find.text('Item 11'), matching: find.byType(RichText)));
expect(paragraph11.selections.length, 1);
expect(paragraph11.selections[0].start, 0);
expect(paragraph11.selections[0].end, 4);
expect(controller.offset, 0.0);
// Should start scrolling.
await sendKeyCombination(tester, const SingleActivator(LogicalKeyboardKey.arrowDown, shift: true));
await tester.pump();
final RenderParagraph paragraph12 = tester.renderObject<RenderParagraph>(find.descendant(of: find.text('Item 12'), matching: find.byType(RichText)));
expect(paragraph12.selections.length, 1);
expect(paragraph12.selections[0].start, 0);
expect(paragraph12.selections[0].end, 4);
expect(controller.offset, 24.0);
await sendKeyCombination(tester, const SingleActivator(LogicalKeyboardKey.arrowDown, shift: true));
await tester.pump();
final RenderParagraph paragraph13 = tester.renderObject<RenderParagraph>(find.descendant(of: find.text('Item 13'), matching: find.byType(RichText)));
expect(paragraph13.selections.length, 1);
expect(paragraph13.selections[0].start, 0);
expect(paragraph13.selections[0].end, 4);
expect(controller.offset, 72.0);
}, variant: TargetPlatformVariant.all());
testWidgets('keyboard selection should auto scroll - vertical reversed', (WidgetTester tester) async {
final FocusNode node = FocusNode();
final ScrollController controller = ScrollController();
await tester.pumpWidget(MaterialApp(
home: SelectionArea(
focusNode: node,
selectionControls: materialTextSelectionControls,
child: ListView.builder(
controller: controller,
reverse: true,
itemCount: 100,
itemBuilder: (BuildContext context, int index) {
return Text('Item $index');
},
),
),
));
await tester.pumpAndSettle();
final RenderParagraph paragraph9 = tester.renderObject<RenderParagraph>(find.descendant(of: find.text('Item 9'), matching: find.byType(RichText)));
final TestGesture gesture = await tester.startGesture(textOffsetToPosition(paragraph9, 2), kind: ui.PointerDeviceKind.mouse);
addTearDown(gesture.removePointer);
await gesture.moveTo(textOffsetToPosition(paragraph9, 4) + const Offset(0, 5));
await tester.pumpAndSettle();
await gesture.up();
await tester.pump();
expect(paragraph9.selections.length, 1);
expect(paragraph9.selections[0].start, 2);
expect(paragraph9.selections[0].end, 4);
expect(controller.offset, 0.0);
await sendKeyCombination(tester, const SingleActivator(LogicalKeyboardKey.arrowUp, shift: true));
await tester.pump();
final RenderParagraph paragraph10 = tester.renderObject<RenderParagraph>(find.descendant(of: find.text('Item 10'), matching: find.byType(RichText)));
expect(paragraph10.selections.length, 1);
expect(paragraph10.selections[0].start, 2);
expect(paragraph10.selections[0].end, 7);
expect(controller.offset, 0.0);
await sendKeyCombination(tester, const SingleActivator(LogicalKeyboardKey.arrowUp, shift: true));
await tester.pump();
final RenderParagraph paragraph11 = tester.renderObject<RenderParagraph>(find.descendant(of: find.text('Item 11'), matching: find.byType(RichText)));
expect(paragraph11.selections.length, 1);
expect(paragraph11.selections[0].start, 2);
expect(paragraph11.selections[0].end, 7);
expect(controller.offset, 0.0);
// Should start scrolling.
await sendKeyCombination(tester, const SingleActivator(LogicalKeyboardKey.arrowUp, shift: true));
await tester.pump();
final RenderParagraph paragraph12 = tester.renderObject<RenderParagraph>(find.descendant(of: find.text('Item 12'), matching: find.byType(RichText)));
expect(paragraph12.selections.length, 1);
expect(paragraph12.selections[0].start, 2);
expect(paragraph12.selections[0].end, 7);
expect(controller.offset, 24.0);
await sendKeyCombination(tester, const SingleActivator(LogicalKeyboardKey.arrowUp, shift: true));
await tester.pump();
final RenderParagraph paragraph13 = tester.renderObject<RenderParagraph>(find.descendant(of: find.text('Item 13'), matching: find.byType(RichText)));
expect(paragraph13.selections.length, 1);
expect(paragraph13.selections[0].start, 2);
expect(paragraph13.selections[0].end, 7);
expect(controller.offset, 72.0);
}, variant: TargetPlatformVariant.all());
testWidgets('keyboard selection should auto scroll - horizontal', (WidgetTester tester) async {
final FocusNode node = FocusNode();
final ScrollController controller = ScrollController();
await tester.pumpWidget(MaterialApp(
home: SelectionArea(
focusNode: node,
selectionControls: materialTextSelectionControls,
child: ListView.builder(
controller: controller,
scrollDirection: Axis.horizontal,
itemCount: 100,
itemBuilder: (BuildContext context, int index) {
return Text('Item $index');
},
),
),
));
await tester.pumpAndSettle();
final RenderParagraph paragraph2 = tester.renderObject<RenderParagraph>(find.descendant(of: find.text('Item 2'), matching: find.byType(RichText)));
final TestGesture gesture = await tester.startGesture(textOffsetToPosition(paragraph2, 0), kind: ui.PointerDeviceKind.mouse);
addTearDown(gesture.removePointer);
await gesture.moveTo(textOffsetToPosition(paragraph2, 1) + const Offset(0, 5));
await tester.pumpAndSettle();
await gesture.up();
await tester.pump();
expect(paragraph2.selections.length, 1);
expect(paragraph2.selections[0].start, 0);
expect(paragraph2.selections[0].end, 1);
expect(controller.offset, 0.0);
await sendKeyCombination(tester, const SingleActivator(LogicalKeyboardKey.arrowDown, shift: true));
await tester.pump();
expect(paragraph2.selections.length, 1);
expect(paragraph2.selections[0].start, 0);
expect(paragraph2.selections[0].end, 6);
expect(controller.offset, 64.0);
await sendKeyCombination(tester, const SingleActivator(LogicalKeyboardKey.arrowDown, shift: true));
await tester.pump();
final RenderParagraph paragraph3 = tester.renderObject<RenderParagraph>(find.descendant(of: find.text('Item 3'), matching: find.byType(RichText)));
expect(paragraph3.selections.length, 1);
expect(paragraph3.selections[0].start, 0);
expect(paragraph3.selections[0].end, 6);
expect(controller.offset, 352.0);
}, variant: TargetPlatformVariant.all());
testWidgets('keyboard selection should auto scroll - horizontal reversed', (WidgetTester tester) async {
final FocusNode node = FocusNode();
final ScrollController controller = ScrollController();
await tester.pumpWidget(MaterialApp(
home: SelectionArea(
focusNode: node,
selectionControls: materialTextSelectionControls,
child: ListView.builder(
controller: controller,
scrollDirection: Axis.horizontal,
reverse: true,
itemCount: 100,
itemBuilder: (BuildContext context, int index) {
return Text('Item $index');
},
),
),
));
await tester.pumpAndSettle();
final RenderParagraph paragraph1 = tester.renderObject<RenderParagraph>(find.descendant(of: find.text('Item 1'), matching: find.byType(RichText)));
final TestGesture gesture = await tester.startGesture(textOffsetToPosition(paragraph1, 5) + const Offset(0, 5), kind: ui.PointerDeviceKind.mouse);
addTearDown(gesture.removePointer);
await gesture.moveTo(textOffsetToPosition(paragraph1, 4) + const Offset(0, 5));
await tester.pumpAndSettle();
await gesture.up();
await tester.pumpAndSettle();
expect(paragraph1.selections.length, 1);
expect(paragraph1.selections[0].start, 4);
expect(paragraph1.selections[0].end, 5);
expect(controller.offset, 0.0);
await sendKeyCombination(tester, const SingleActivator(LogicalKeyboardKey.arrowUp, shift: true));
await tester.pump();
expect(paragraph1.selections.length, 1);
expect(paragraph1.selections[0].start, 0);
expect(paragraph1.selections[0].end, 5);
expect(controller.offset, 0.0);
await sendKeyCombination(tester, const SingleActivator(LogicalKeyboardKey.arrowUp, shift: true));
await tester.pump();
final RenderParagraph paragraph2 = tester.renderObject<RenderParagraph>(find.descendant(of: find.text('Item 2'), matching: find.byType(RichText)));
expect(paragraph2.selections.length, 1);
expect(paragraph2.selections[0].start, 0);
expect(paragraph2.selections[0].end, 6);
expect(controller.offset, 64.0);
await sendKeyCombination(tester, const SingleActivator(LogicalKeyboardKey.arrowUp, shift: true));
await tester.pump();
final RenderParagraph paragraph3 = tester.renderObject<RenderParagraph>(find.descendant(of: find.text('Item 3'), matching: find.byType(RichText)));
expect(paragraph3.selections.length, 1);
expect(paragraph3.selections[0].start, 0);
expect(paragraph3.selections[0].end, 6);
expect(controller.offset, 352.0);
}, variant: TargetPlatformVariant.all());
group('Complex cases', () {
testWidgets('selection starts outside of the scrollable', (WidgetTester tester) async {
final ScrollController controller = ScrollController();
@ -642,10 +865,7 @@ void main() {
expect(find.descendant(of: find.text('Item 1'), matching: find.byType(RichText)), findsNothing);
// Start copying.
await tester.sendKeyDownEvent(LogicalKeyboardKey.metaLeft);
await tester.sendKeyDownEvent(LogicalKeyboardKey.keyC);
await tester.sendKeyUpEvent(LogicalKeyboardKey.keyC);
await tester.sendKeyUpEvent(LogicalKeyboardKey.metaLeft);
await sendKeyCombination(tester, const SingleActivator(LogicalKeyboardKey.keyC, meta: true));
final Map<String, dynamic> clipboardData = mockClipboard.clipboardData as Map<String, dynamic>;
expect(clipboardData['text'], 'em 0It');
@ -686,10 +906,7 @@ void main() {
expect(find.descendant(of: find.text('Item 1'), matching: find.byType(RichText)), findsNothing);
// Start copying.
await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft);
await tester.sendKeyDownEvent(LogicalKeyboardKey.keyC);
await tester.sendKeyUpEvent(LogicalKeyboardKey.keyC);
await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft);
await sendKeyCombination(tester, const SingleActivator(LogicalKeyboardKey.keyC, control: true));
final Map<String, dynamic> clipboardData = mockClipboard.clipboardData as Map<String, dynamic>;
expect(clipboardData['text'], 'em 0It');

View file

@ -9,7 +9,8 @@ import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import '../widgets/clipboard_utils.dart';
import 'clipboard_utils.dart';
import 'keyboard_utils.dart';
import 'semantics_tester.dart';
Offset textOffsetToPosition(RenderParagraph paragraph, int offset) {
@ -488,10 +489,7 @@ void main() {
await gesture.up();
// keyboard copy.
await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft);
await tester.sendKeyDownEvent(LogicalKeyboardKey.keyC);
await tester.sendKeyUpEvent(LogicalKeyboardKey.keyC);
await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft);
await sendKeyCombination(tester, const SingleActivator(LogicalKeyboardKey.keyC, control: true));
final Map<String, dynamic> clipboardData = mockClipboard.clipboardData as Map<String, dynamic>;
expect(clipboardData['text'], 'w are you?Good, and you?Fine, ');
@ -524,10 +522,7 @@ void main() {
await tester.pump();
// Make sure keyboard select all works on TextField.
await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft);
await tester.sendKeyDownEvent(LogicalKeyboardKey.keyA);
await tester.sendKeyUpEvent(LogicalKeyboardKey.keyA);
await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft);
await sendKeyCombination(tester, const SingleActivator(LogicalKeyboardKey.keyA, control: true));
expect(controller.selection, const TextSelection(baseOffset: 0, extentOffset: 21));
// Make sure no selection in SelectableRegion.
@ -542,10 +537,7 @@ void main() {
await tester.pump();
// Make sure keyboard select all will be handled by selectable region now.
await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft);
await tester.sendKeyDownEvent(LogicalKeyboardKey.keyA);
await tester.sendKeyUpEvent(LogicalKeyboardKey.keyA);
await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft);
await sendKeyCombination(tester, const SingleActivator(LogicalKeyboardKey.keyA, control: true));
expect(controller.selection, const TextSelection.collapsed(offset: -1));
expect(paragraph2.selections[0], const TextSelection(baseOffset: 0, extentOffset: 14));
expect(paragraph1.selections[0], const TextSelection(baseOffset: 0, extentOffset: 12));
@ -581,10 +573,7 @@ void main() {
await tester.pump();
// Make sure keyboard select all works on TextField.
await tester.sendKeyDownEvent(LogicalKeyboardKey.metaLeft);
await tester.sendKeyDownEvent(LogicalKeyboardKey.keyA);
await tester.sendKeyUpEvent(LogicalKeyboardKey.keyA);
await tester.sendKeyUpEvent(LogicalKeyboardKey.metaLeft);
await sendKeyCombination(tester, const SingleActivator(LogicalKeyboardKey.keyA, meta: true));
expect(controller.selection, const TextSelection(baseOffset: 0, extentOffset: 21));
// Make sure no selection in SelectableRegion.
@ -599,10 +588,7 @@ void main() {
await tester.pump();
// Make sure keyboard select all will be handled by selectable region now.
await tester.sendKeyDownEvent(LogicalKeyboardKey.metaLeft);
await tester.sendKeyDownEvent(LogicalKeyboardKey.keyA);
await tester.sendKeyUpEvent(LogicalKeyboardKey.keyA);
await tester.sendKeyUpEvent(LogicalKeyboardKey.metaLeft);
await sendKeyCombination(tester, const SingleActivator(LogicalKeyboardKey.keyA, meta: true));
expect(controller.selection, const TextSelection.collapsed(offset: -1));
expect(paragraph2.selections[0], const TextSelection(baseOffset: 0, extentOffset: 14));
expect(paragraph1.selections[0], const TextSelection(baseOffset: 0, extentOffset: 12));
@ -631,10 +617,7 @@ void main() {
focusNode.requestFocus();
// keyboard select all.
await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft);
await tester.sendKeyDownEvent(LogicalKeyboardKey.keyA);
await tester.sendKeyUpEvent(LogicalKeyboardKey.keyA);
await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft);
await sendKeyCombination(tester, const SingleActivator(LogicalKeyboardKey.keyA, control: true));
final RenderParagraph paragraph3 = tester.renderObject<RenderParagraph>(find.descendant(of: find.text('Fine, thank you.'), matching: find.byType(RichText)));
final RenderParagraph paragraph2 = tester.renderObject<RenderParagraph>(find.descendant(of: find.text('Good, and you?'), matching: find.byType(RichText)));
@ -675,10 +658,7 @@ void main() {
await gesture.up();
// keyboard copy.
await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft);
await tester.sendKeyDownEvent(LogicalKeyboardKey.keyC);
await tester.sendKeyUpEvent(LogicalKeyboardKey.keyC);
await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft);
await sendKeyCombination(tester, const SingleActivator(LogicalKeyboardKey.keyC, control: true));
final Map<String, dynamic> clipboardData = mockClipboard.clipboardData as Map<String, dynamic>;
expect(clipboardData['text'], 'w are you?Good, and you?Fine');
},
@ -718,10 +698,7 @@ void main() {
await gesture.up();
// keyboard copy.
await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft);
await tester.sendKeyDownEvent(LogicalKeyboardKey.keyC);
await tester.sendKeyUpEvent(LogicalKeyboardKey.keyC);
await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft);
await sendKeyCombination(tester, const SingleActivator(LogicalKeyboardKey.keyC, control: true));
final Map<String, dynamic> clipboardData = mockClipboard.clipboardData as Map<String, dynamic>;
expect(clipboardData['text'], 'w are you?Fine');
},
@ -761,10 +738,7 @@ void main() {
await gesture.up();
// keyboard copy.
await tester.sendKeyDownEvent(LogicalKeyboardKey.metaLeft);
await tester.sendKeyDownEvent(LogicalKeyboardKey.keyC);
await tester.sendKeyUpEvent(LogicalKeyboardKey.keyC);
await tester.sendKeyUpEvent(LogicalKeyboardKey.metaLeft);
await sendKeyCombination(tester, const SingleActivator(LogicalKeyboardKey.keyC, meta: true));
final Map<String, dynamic> clipboardData = mockClipboard.clipboardData as Map<String, dynamic>;
expect(clipboardData['text'], 'w are you?Fine');
},
@ -1100,6 +1074,438 @@ void main() {
expect(clipboardData['text'], 'thank');
}, skip: kIsWeb); // [intended] Web uses its native context menu.
testWidgets('can use keyboard to granularly extend selection - character', (WidgetTester tester) async {
await tester.pumpWidget(
MaterialApp(
home: SelectableRegion(
focusNode: FocusNode(),
selectionControls: materialTextSelectionControls,
child: Column(
children: const <Widget>[
Text('How are you?'),
Text('Good, and you?'),
Text('Fine, thank you.'),
],
),
),
),
);
// Select from offset 2 of paragraph1 to offset 6 of paragraph1.
final RenderParagraph paragraph1 = tester.renderObject<RenderParagraph>(find.descendant(of: find.text('How are you?'), matching: find.byType(RichText)));
final TestGesture gesture = await tester.startGesture(textOffsetToPosition(paragraph1, 2), kind: PointerDeviceKind.mouse);
addTearDown(gesture.removePointer);
await tester.pump();
await gesture.moveTo(textOffsetToPosition(paragraph1, 6));
await gesture.up();
await tester.pump();
// Ho[w ar]e you?
// Good, and you?
// Fine, thank you.
expect(paragraph1.selections.length, 1);
expect(paragraph1.selections[0].start, 2);
expect(paragraph1.selections[0].end, 6);
await sendKeyCombination(tester, const SingleActivator(LogicalKeyboardKey.arrowRight, shift: true));
await tester.pump();
// Ho[w are] you?
// Good, and you?
// Fine, thank you.
expect(paragraph1.selections.length, 1);
expect(paragraph1.selections[0].start, 2);
expect(paragraph1.selections[0].end, 7);
for (int i = 0; i < 5; i += 1) {
await sendKeyCombination(tester,
const SingleActivator(LogicalKeyboardKey.arrowRight, shift: true));
await tester.pump();
expect(paragraph1.selections.length, 1);
expect(paragraph1.selections[0].start, 2);
expect(paragraph1.selections[0].end, 8 + i);
}
for (int i = 0; i < 5; i += 1) {
await sendKeyCombination(tester,
const SingleActivator(LogicalKeyboardKey.arrowLeft, shift: true));
await tester.pump();
expect(paragraph1.selections.length, 1);
expect(paragraph1.selections[0].start, 2);
expect(paragraph1.selections[0].end, 11 - i);
}
}, variant: TargetPlatformVariant.all());
testWidgets('can use keyboard to granularly extend selection - word', (WidgetTester tester) async {
await tester.pumpWidget(
MaterialApp(
home: SelectableRegion(
focusNode: FocusNode(),
selectionControls: materialTextSelectionControls,
child: Column(
children: const <Widget>[
Text('How are you?'),
Text('Good, and you?'),
Text('Fine, thank you.'),
],
),
),
),
);
// Select from offset 2 of paragraph1 to offset 6 of paragraph1.
final RenderParagraph paragraph1 = tester.renderObject<RenderParagraph>(find.descendant(of: find.text('How are you?'), matching: find.byType(RichText)));
final TestGesture gesture = await tester.startGesture(textOffsetToPosition(paragraph1, 2), kind: PointerDeviceKind.mouse);
addTearDown(gesture.removePointer);
await tester.pump();
await gesture.moveTo(textOffsetToPosition(paragraph1, 6));
await gesture.up();
await tester.pump();
final bool alt;
final bool control;
switch(defaultTargetPlatform) {
case TargetPlatform.android:
case TargetPlatform.fuchsia:
case TargetPlatform.linux:
case TargetPlatform.windows:
alt = false;
control = true;
break;
case TargetPlatform.iOS:
case TargetPlatform.macOS:
alt = true;
control = false;
break;
}
// Ho[w ar]e you?
// Good, and you?
// Fine, thank you.
expect(paragraph1.selections.length, 1);
expect(paragraph1.selections[0].start, 2);
expect(paragraph1.selections[0].end, 6);
await sendKeyCombination(tester, SingleActivator(LogicalKeyboardKey.arrowRight, shift: true, alt: alt, control: control));
await tester.pump();
// Ho[w are] you?
// Good, and you?
// Fine, thank you.
expect(paragraph1.selections.length, 1);
expect(paragraph1.selections[0].start, 2);
expect(paragraph1.selections[0].end, 7);
await sendKeyCombination(tester, SingleActivator(LogicalKeyboardKey.arrowRight, shift: true, alt: alt, control: control));
await tester.pump();
// Ho[w are you]?
// Good, and you?
// Fine, thank you.
expect(paragraph1.selections.length, 1);
expect(paragraph1.selections[0].start, 2);
expect(paragraph1.selections[0].end, 11);
await sendKeyCombination(tester, SingleActivator(LogicalKeyboardKey.arrowRight, shift: true, alt: alt, control: control));
await tester.pump();
// Ho[w are you?]
// Good, and you?
// Fine, thank you.
expect(paragraph1.selections.length, 1);
expect(paragraph1.selections[0].start, 2);
expect(paragraph1.selections[0].end, 12);
await sendKeyCombination(tester, SingleActivator(LogicalKeyboardKey.arrowRight, shift: true, alt: alt, control: control));
await tester.pump();
// Ho[w are you?
// Good], and you?
// Fine, thank you.
final RenderParagraph paragraph2 = tester.renderObject<RenderParagraph>(find.descendant(of: find.text('Good, and you?'), matching: find.byType(RichText)));
expect(paragraph1.selections.length, 1);
expect(paragraph1.selections[0].start, 2);
expect(paragraph1.selections[0].end, 12);
expect(paragraph2.selections.length, 1);
expect(paragraph2.selections[0].start, 0);
expect(paragraph2.selections[0].end, 4);
await sendKeyCombination(tester, SingleActivator(LogicalKeyboardKey.arrowLeft, shift: true, alt: alt, control: control));
await tester.pump();
// Ho[w are you?
// ]Good, and you?
// Fine, thank you.
expect(paragraph1.selections.length, 1);
expect(paragraph1.selections[0].start, 2);
expect(paragraph1.selections[0].end, 12);
expect(paragraph2.selections.length, 0);
await sendKeyCombination(tester, SingleActivator(LogicalKeyboardKey.arrowLeft, shift: true, alt: alt, control: control));
await tester.pump();
// Ho[w are you]?
// Good, and you?
// Fine, thank you.
expect(paragraph1.selections.length, 1);
expect(paragraph1.selections[0].start, 2);
expect(paragraph1.selections[0].end, 11);
expect(paragraph2.selections.length, 0);
}, variant: TargetPlatformVariant.all());
testWidgets('can use keyboard to granularly extend selection - line', (WidgetTester tester) async {
await tester.pumpWidget(
MaterialApp(
home: SelectableRegion(
focusNode: FocusNode(),
selectionControls: materialTextSelectionControls,
child: Column(
children: const <Widget>[
Text('How are you?'),
Text('Good, and you?'),
Text('Fine, thank you.'),
],
),
),
),
);
// Select from offset 2 of paragraph1 to offset 6 of paragraph1.
final RenderParagraph paragraph1 = tester.renderObject<RenderParagraph>(find.descendant(of: find.text('How are you?'), matching: find.byType(RichText)));
final TestGesture gesture = await tester.startGesture(textOffsetToPosition(paragraph1, 2), kind: PointerDeviceKind.mouse);
addTearDown(gesture.removePointer);
await tester.pump();
await gesture.moveTo(textOffsetToPosition(paragraph1, 6));
await gesture.up();
await tester.pump();
final bool alt;
final bool meta;
switch(defaultTargetPlatform) {
case TargetPlatform.android:
case TargetPlatform.fuchsia:
case TargetPlatform.linux:
case TargetPlatform.windows:
meta = false;
alt = true;
break;
case TargetPlatform.iOS:
case TargetPlatform.macOS:
meta = true;
alt = false;
break;
}
// Ho[w ar]e you?
// Good, and you?
// Fine, thank you.
expect(paragraph1.selections.length, 1);
expect(paragraph1.selections[0].start, 2);
expect(paragraph1.selections[0].end, 6);
await sendKeyCombination(tester, SingleActivator(LogicalKeyboardKey.arrowRight, shift: true, alt: alt, meta: meta));
await tester.pump();
// Ho[w are you?]
// Good, and you?
// Fine, thank you.
expect(paragraph1.selections.length, 1);
expect(paragraph1.selections[0].start, 2);
expect(paragraph1.selections[0].end, 12);
await sendKeyCombination(tester, SingleActivator(LogicalKeyboardKey.arrowRight, shift: true, alt: alt, meta: meta));
await tester.pump();
// Ho[w are you?
// Good, and you?]
// Fine, thank you.
final RenderParagraph paragraph2 = tester.renderObject<RenderParagraph>(find.descendant(of: find.text('Good, and you?'), matching: find.byType(RichText)));
expect(paragraph1.selections.length, 1);
expect(paragraph1.selections[0].start, 2);
expect(paragraph1.selections[0].end, 12);
expect(paragraph2.selections.length, 1);
expect(paragraph2.selections[0].start, 0);
expect(paragraph2.selections[0].end, 14);
await sendKeyCombination(tester, SingleActivator(LogicalKeyboardKey.arrowLeft, shift: true, alt: alt, meta: meta));
await tester.pump();
// Ho[w are you?]
// Good, and you?
// Fine, thank you.
expect(paragraph1.selections.length, 1);
expect(paragraph1.selections[0].start, 2);
expect(paragraph1.selections[0].end, 12);
expect(paragraph2.selections.length, 0);
await sendKeyCombination(tester, SingleActivator(LogicalKeyboardKey.arrowLeft, shift: true, alt: alt, meta: meta));
await tester.pump();
// [Ho]w are you?
// Good, and you?
// Fine, thank you.
expect(paragraph1.selections.length, 1);
expect(paragraph1.selections[0].start, 0);
expect(paragraph1.selections[0].end, 2);
}, variant: TargetPlatformVariant.all());
testWidgets('can use keyboard to granularly extend selection - document', (WidgetTester tester) async {
await tester.pumpWidget(
MaterialApp(
home: SelectableRegion(
focusNode: FocusNode(),
selectionControls: materialTextSelectionControls,
child: Column(
children: const <Widget>[
Text('How are you?'),
Text('Good, and you?'),
Text('Fine, thank you.'),
],
),
),
),
);
// Select from offset 2 of paragraph1 to offset 6 of paragraph1.
final RenderParagraph paragraph1 = tester.renderObject<RenderParagraph>(find.descendant(of: find.text('How are you?'), matching: find.byType(RichText)));
final TestGesture gesture = await tester.startGesture(textOffsetToPosition(paragraph1, 2), kind: PointerDeviceKind.mouse);
addTearDown(gesture.removePointer);
await tester.pump();
await gesture.moveTo(textOffsetToPosition(paragraph1, 6));
await gesture.up();
await tester.pump();
final bool alt;
final bool meta;
switch(defaultTargetPlatform) {
case TargetPlatform.android:
case TargetPlatform.fuchsia:
case TargetPlatform.linux:
case TargetPlatform.windows:
meta = false;
alt = true;
break;
case TargetPlatform.iOS:
case TargetPlatform.macOS:
meta = true;
alt = false;
break;
}
// Ho[w ar]e you?
// Good, and you?
// Fine, thank you.
expect(paragraph1.selections.length, 1);
expect(paragraph1.selections[0].start, 2);
expect(paragraph1.selections[0].end, 6);
await sendKeyCombination(tester, SingleActivator(LogicalKeyboardKey.arrowDown, shift: true, meta: meta, alt: alt));
await tester.pump();
// Ho[w are you?
// Good, and you?
// Fine, thank you.]
final RenderParagraph paragraph2 = tester.renderObject<RenderParagraph>(find.descendant(of: find.text('Good, and you?'), matching: find.byType(RichText)));
final RenderParagraph paragraph3 = tester.renderObject<RenderParagraph>(find.descendant(of: find.text('Fine, thank you.'), matching: find.byType(RichText)));
expect(paragraph1.selections.length, 1);
expect(paragraph1.selections[0].start, 2);
expect(paragraph1.selections[0].end, 12);
expect(paragraph2.selections.length, 1);
expect(paragraph2.selections[0].start, 0);
expect(paragraph2.selections[0].end, 14);
expect(paragraph3.selections.length, 1);
expect(paragraph3.selections[0].start, 0);
expect(paragraph3.selections[0].end, 16);
await sendKeyCombination(tester, SingleActivator(LogicalKeyboardKey.arrowUp, shift: true, meta: meta, alt: alt));
await tester.pump();
// [Ho]w are you?
// Good, and you?
// Fine, thank you.
expect(paragraph1.selections.length, 1);
expect(paragraph1.selections[0].start, 0);
expect(paragraph1.selections[0].end, 2);
expect(paragraph2.selections.length, 0);
expect(paragraph3.selections.length, 0);
}, variant: TargetPlatformVariant.all());
testWidgets('can use keyboard to directionally extend selection', (WidgetTester tester) async {
await tester.pumpWidget(
MaterialApp(
home: SelectableRegion(
focusNode: FocusNode(),
selectionControls: materialTextSelectionControls,
child: Column(
children: const <Widget>[
Text('How are you?'),
Text('Good, and you?'),
Text('Fine, thank you.'),
],
),
),
),
);
// Select from offset 2 of paragraph2 to offset 6 of paragraph2.
final RenderParagraph paragraph2 = tester.renderObject<RenderParagraph>(find.descendant(of: find.text('Good, and you?'), matching: find.byType(RichText)));
final TestGesture gesture = await tester.startGesture(textOffsetToPosition(paragraph2, 2), kind: PointerDeviceKind.mouse);
addTearDown(gesture.removePointer);
await tester.pump();
await gesture.moveTo(textOffsetToPosition(paragraph2, 6));
await gesture.up();
await tester.pump();
// How are you?
// Go[od, ]and you?
// Fine, thank you.
expect(paragraph2.selections.length, 1);
expect(paragraph2.selections[0].start, 2);
expect(paragraph2.selections[0].end, 6);
await sendKeyCombination(tester, const SingleActivator(LogicalKeyboardKey.arrowDown, shift: true));
await tester.pump();
// How are you?
// Go[od, and you?
// Fine, t]hank you.
final RenderParagraph paragraph3 = tester.renderObject<RenderParagraph>(find.descendant(of: find.text('Fine, thank you.'), matching: find.byType(RichText)));
expect(paragraph2.selections.length, 1);
expect(paragraph2.selections[0].start, 2);
expect(paragraph2.selections[0].end, 14);
expect(paragraph3.selections.length, 1);
expect(paragraph3.selections[0].start, 0);
expect(paragraph3.selections[0].end, 7);
await sendKeyCombination(tester, const SingleActivator(LogicalKeyboardKey.arrowDown, shift: true));
await tester.pump();
// How are you?
// Go[od, and you?
// Fine, thank you.]
expect(paragraph2.selections.length, 1);
expect(paragraph2.selections[0].start, 2);
expect(paragraph2.selections[0].end, 14);
expect(paragraph3.selections.length, 1);
expect(paragraph3.selections[0].start, 0);
expect(paragraph3.selections[0].end, 16);
await sendKeyCombination(tester, const SingleActivator(LogicalKeyboardKey.arrowUp, shift: true));
await tester.pump();
// How are you?
// Go[od, ]and you?
// Fine, thank you.
expect(paragraph2.selections.length, 1);
expect(paragraph2.selections[0].start, 2);
expect(paragraph2.selections[0].end, 6);
expect(paragraph3.selections.length, 0);
await sendKeyCombination(tester, const SingleActivator(LogicalKeyboardKey.arrowUp, shift: true));
await tester.pump();
// How a[re you?
// Go]od, and you?
// Fine, thank you.
final RenderParagraph paragraph1 = tester.renderObject<RenderParagraph>(find.descendant(of: find.text('How are you?'), matching: find.byType(RichText)));
expect(paragraph1.selections.length, 1);
expect(paragraph1.selections[0].start, 5);
expect(paragraph1.selections[0].end, 12);
expect(paragraph2.selections.length, 1);
expect(paragraph2.selections[0].start, 0);
expect(paragraph2.selections[0].end, 2);
await sendKeyCombination(tester, const SingleActivator(LogicalKeyboardKey.arrowUp, shift: true));
await tester.pump();
// [How are you?
// Go]od, and you?
// Fine, thank you.
expect(paragraph1.selections.length, 1);
expect(paragraph1.selections[0].start, 0);
expect(paragraph1.selections[0].end, 12);
expect(paragraph2.selections.length, 1);
expect(paragraph2.selections[0].start, 0);
expect(paragraph2.selections[0].end, 2);
}, variant: TargetPlatformVariant.all());
group('magnifier', () {
late ValueNotifier<MagnifierInfo> magnifierInfo;
final Widget fakeMagnifier = Container(key: UniqueKey());