Unverified Commit 0a2e0a47 authored by LongCatIsLooong's avatar LongCatIsLooong Committed by GitHub

Avoid using `TextAffinity` in `TextBoundary` (#117446)

* Avoid affinity like the plague

* ignore lint

* clean up

* fix test

* review

* Move wordboundary to text painter

* docs

* fix tests
parent 5a87a829
......@@ -144,6 +144,133 @@ enum TextWidthBasis {
longestLine,
}
/// A [TextBoundary] subclass for locating word breaks.
///
/// The underlying implementation uses [UAX #29](https://unicode.org/reports/tr29/)
/// defined default word boundaries.
///
/// The default word break rules can be tailored to meet the requirements of
/// different use cases. For instance, the default rule set keeps horizontal
/// whitespaces together as a single word, which may not make sense in a
/// word-counting context -- "hello world" counts as 3 words instead of 2.
/// An example is the [moveByWordBoundary] variant, which is a tailored
/// word-break locator that more closely matches the default behavior of most
/// platforms and editors when it comes to handling text editing keyboard
/// shortcuts that move or delete word by word.
class WordBoundary extends TextBoundary {
/// Creates a [WordBoundary] with the text and layout information.
WordBoundary._(this._text, this._paragraph);
final InlineSpan _text;
final ui.Paragraph _paragraph;
@override
TextRange getTextBoundaryAt(int position) => _paragraph.getWordBoundary(TextPosition(offset: max(position, 0)));
// Combines two UTF-16 code units (high surrogate + low surrogate) into a
// single code point that represents a supplementary character.
static int _codePointFromSurrogates(int highSurrogate, int lowSurrogate) {
assert(
TextPainter._isHighSurrogate(highSurrogate),
'U+${highSurrogate.toRadixString(16).toUpperCase().padLeft(4, "0")}) is not a high surrogate.',
);
assert(
TextPainter._isLowSurrogate(lowSurrogate),
'U+${lowSurrogate.toRadixString(16).toUpperCase().padLeft(4, "0")}) is not a low surrogate.',
);
const int base = 0x010000 - (0xD800 << 10) - 0xDC00;
return (highSurrogate << 10) + lowSurrogate + base;
}
// The Runes class does not provide random access with a code unit offset.
int? _codePointAt(int index) {
final int? codeUnitAtIndex = _text.codeUnitAt(index);
if (codeUnitAtIndex == null) {
return null;
}
switch (codeUnitAtIndex & 0xFC00) {
case 0xD800:
return _codePointFromSurrogates(codeUnitAtIndex, _text.codeUnitAt(index + 1)!);
case 0xDC00:
return _codePointFromSurrogates(_text.codeUnitAt(index - 1)!, codeUnitAtIndex);
default:
return codeUnitAtIndex;
}
}
static bool _isNewline(int codePoint) {
switch (codePoint) {
case 0x000A:
case 0x0085:
case 0x000B:
case 0x000C:
case 0x2028:
case 0x2029:
return true;
default:
return false;
}
}
bool _skipSpacesAndPunctuations(int offset, bool forward) {
// Use code point since some punctuations are supplementary characters.
// "inner" here refers to the code unit that's before the break in the
// search direction (`forward`).
final int? innerCodePoint = _codePointAt(forward ? offset - 1 : offset);
final int? outerCodeUnit = _text.codeUnitAt(forward ? offset : offset - 1);
// Make sure the hard break rules in UAX#29 take precedence over the ones we
// add below. Luckily there're only 4 hard break rules for word breaks, and
// dictionary based breaking does not introduce new hard breaks:
// https://unicode-org.github.io/icu/userguide/boundaryanalysis/break-rules.html#word-dictionaries
//
// WB1 & WB2: always break at the start or the end of the text.
final bool hardBreakRulesApply = innerCodePoint == null || outerCodeUnit == null
// WB3a & WB3b: always break before and after newlines.
|| _isNewline(innerCodePoint) || _isNewline(outerCodeUnit);
return hardBreakRulesApply || !RegExp(r'[\p{Space_Separator}\p{Punctuation}]', unicode: true).hasMatch(String.fromCharCode(innerCodePoint));
}
/// Returns a [TextBoundary] suitable for handling keyboard navigation
/// commands that change the current selection word by word.
///
/// This [TextBoundary] is used by text widgets in the flutter framework to
/// provide default implementation for text editing shortcuts, for example,
/// "delete to the previous word".
///
/// The implementation applies the same set of rules [WordBoundary] uses,
/// except that word breaks end on a space separator or a punctuation will be
/// skipped, to match the behavior of most platforms. Additional rules may be
/// added in the future to better match platform behaviors.
late final TextBoundary moveByWordBoundary = _UntilTextBoundary(this, _skipSpacesAndPunctuations);
}
class _UntilTextBoundary extends TextBoundary {
const _UntilTextBoundary(this._textBoundary, this._predicate);
final UntilPredicate _predicate;
final TextBoundary _textBoundary;
@override
int? getLeadingTextBoundaryAt(int position) {
if (position < 0) {
return null;
}
final int? offset = _textBoundary.getLeadingTextBoundaryAt(position);
return offset == null || _predicate(offset, false)
? offset
: getLeadingTextBoundaryAt(offset - 1);
}
@override
int? getTrailingTextBoundaryAt(int position) {
final int? offset = _textBoundary.getTrailingTextBoundaryAt(max(position, 0));
return offset == null || _predicate(offset, true)
? offset
: getTrailingTextBoundaryAt(offset);
}
}
/// This is used to cache and pass the computed metrics regarding the
/// caret's size and position. This is preferred due to the expensive
/// nature of the calculation.
......@@ -750,7 +877,7 @@ class TextPainter {
// Creates a ui.Paragraph using the current configurations in this class and
// assign it to _paragraph.
void _createParagraph() {
ui.Paragraph _createParagraph() {
assert(_paragraph == null || _rebuildParagraphForPaint);
final InlineSpan? text = this.text;
if (text == null) {
......@@ -763,8 +890,9 @@ class TextPainter {
_debugMarkNeedsLayoutCallStack = null;
return true;
}());
_paragraph = builder.build();
final ui.Paragraph paragraph = _paragraph = builder.build();
_rebuildParagraphForPaint = false;
return paragraph;
}
void _layoutParagraph(double minWidth, double maxWidth) {
......@@ -861,13 +989,18 @@ class TextPainter {
canvas.drawParagraph(_paragraph!, offset);
}
// Returns true iff the given value is a valid UTF-16 surrogate. The value
// Returns true iff the given value is a valid UTF-16 high surrogate. The value
// must be a UTF-16 code unit, meaning it must be in the range 0x0000-0xFFFF.
//
// See also:
// * https://en.wikipedia.org/wiki/UTF-16#Code_points_from_U+010000_to_U+10FFFF
static bool _isUtf16Surrogate(int value) {
return value & 0xF800 == 0xD800;
static bool _isHighSurrogate(int value) {
return value & 0xFC00 == 0xD800;
}
// Whether the given UTF-16 code unit is a low (second) surrogate.
static bool _isLowSurrogate(int value) {
return value & 0xFC00 == 0xDC00;
}
// Checks if the glyph is either [Unicode.RLM] or [Unicode.LRM]. These values take
......@@ -886,7 +1019,7 @@ class TextPainter {
return null;
}
// TODO(goderbauer): doesn't handle extended grapheme clusters with more than one Unicode scalar value (https://github.com/flutter/flutter/issues/13404).
return _isUtf16Surrogate(nextCodeUnit) ? offset + 2 : offset + 1;
return _isHighSurrogate(nextCodeUnit) ? offset + 2 : offset + 1;
}
/// Returns the closest offset before `offset` at which the input cursor can
......@@ -897,7 +1030,7 @@ class TextPainter {
return null;
}
// TODO(goderbauer): doesn't handle extended grapheme clusters with more than one Unicode scalar value (https://github.com/flutter/flutter/issues/13404).
return _isUtf16Surrogate(prevCodeUnit) ? offset - 2 : offset - 1;
return _isLowSurrogate(prevCodeUnit) ? offset - 2 : offset - 1;
}
// Unicode value for a zero width joiner character.
......@@ -916,7 +1049,7 @@ class TextPainter {
const int NEWLINE_CODE_UNIT = 10;
// Check for multi-code-unit glyphs such as emojis or zero width joiner.
final bool needsSearch = _isUtf16Surrogate(prevCodeUnit) || _text!.codeUnitAt(offset) == _zwjUtf16 || _isUnicodeDirectionality(prevCodeUnit);
final bool needsSearch = _isHighSurrogate(prevCodeUnit) || _isLowSurrogate(prevCodeUnit) || _text!.codeUnitAt(offset) == _zwjUtf16 || _isUnicodeDirectionality(prevCodeUnit);
int graphemeClusterLength = needsSearch ? 2 : 1;
List<TextBox> boxes = <TextBox>[];
while (boxes.isEmpty) {
......@@ -966,7 +1099,7 @@ class TextPainter {
final int nextCodeUnit = plainText.codeUnitAt(min(offset, plainTextLength - 1));
// Check for multi-code-unit glyphs such as emojis or zero width joiner
final bool needsSearch = _isUtf16Surrogate(nextCodeUnit) || nextCodeUnit == _zwjUtf16 || _isUnicodeDirectionality(nextCodeUnit);
final bool needsSearch = _isHighSurrogate(nextCodeUnit) || _isLowSurrogate(nextCodeUnit) || nextCodeUnit == _zwjUtf16 || _isUnicodeDirectionality(nextCodeUnit);
int graphemeClusterLength = needsSearch ? 2 : 1;
List<TextBox> boxes = <TextBox>[];
while (boxes.isEmpty) {
......@@ -1141,6 +1274,18 @@ class TextPainter {
return _paragraph!.getWordBoundary(position);
}
/// {@template flutter.painting.TextPainter.wordBoundaries}
/// Returns a [TextBoundary] that can be used to perform word boundary analysis
/// on the current [text].
///
/// This [TextBoundary] uses word boundary rules defined in [Unicode Standard
/// Annex #29](http://www.unicode.org/reports/tr29/#Word_Boundaries).
/// {@endtemplate}
///
/// Currently word boundary analysis can only be performed after [layout]
/// has been called.
WordBoundary get wordBoundaries => WordBoundary._(text!, _paragraph!);
/// Returns the text range of the line at the given offset.
///
/// The newline (if any) is not returned as part of the range.
......
......@@ -2098,6 +2098,9 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin,
_setSelection(newSelection, cause);
}
/// {@macro flutter.painting.TextPainter.wordBoundaries}
WordBoundary get wordBoundaries => _textPainter.wordBoundaries;
/// Select a word around the location of the last tap down.
///
/// {@macro flutter.rendering.RenderEditable.selectPosition}
......
......@@ -1559,21 +1559,21 @@ class _SelectableFragment with Selectable, ChangeNotifier implements TextLayoutM
switch (granularity) {
case TextGranularity.character:
final String text = range.textInside(fullText);
newPosition = _getNextPosition(CharacterBoundary(text), targetedEdge, forward);
newPosition = _moveBeyondTextBoundaryAtDirection(targetedEdge, forward, CharacterBoundary(text));
result = SelectionResult.end;
break;
case TextGranularity.word:
final String text = range.textInside(fullText);
newPosition = _getNextPosition(WhitespaceBoundary(text) + WordBoundary(this), targetedEdge, forward);
final TextBoundary textBoundary = paragraph._textPainter.wordBoundaries.moveByWordBoundary;
newPosition = _moveBeyondTextBoundaryAtDirection(targetedEdge, forward, textBoundary);
result = SelectionResult.end;
break;
case TextGranularity.line:
newPosition = _getNextPosition(LineBreak(this), targetedEdge, forward);
newPosition = _moveToTextBoundaryAtDirection(targetedEdge, forward, LineBoundary(this));
result = SelectionResult.end;
break;
case TextGranularity.document:
final String text = range.textInside(fullText);
newPosition = _getNextPosition(DocumentBoundary(text), targetedEdge, forward);
newPosition = _moveBeyondTextBoundaryAtDirection(targetedEdge, forward, DocumentBoundary(text));
if (forward && newPosition.offset == range.end) {
result = SelectionResult.next;
} else if (!forward && newPosition.offset == range.start) {
......@@ -1592,15 +1592,43 @@ class _SelectableFragment with Selectable, ChangeNotifier implements TextLayoutM
return result;
}
TextPosition _getNextPosition(TextBoundary boundary, TextPosition position, bool forward) {
if (forward) {
return _clampTextPosition(
(PushTextPosition.forward + boundary).getTrailingTextBoundaryAt(position)
);
// Move **beyond** the local boundary of the given type (unless range.start or
// range.end is reached). Used for most TextGranularity types except for
// TextGranularity.line, to ensure the selection movement doesn't get stuck at
// a local fixed point.
TextPosition _moveBeyondTextBoundaryAtDirection(TextPosition end, bool forward, TextBoundary textBoundary) {
final int newOffset = forward
? textBoundary.getTrailingTextBoundaryAt(end.offset) ?? range.end
: textBoundary.getLeadingTextBoundaryAt(end.offset - 1) ?? range.start;
return TextPosition(offset: newOffset);
}
// Move **to** the local boundary of the given type. Typically used for line
// boundaries, such that performing "move to line start" more than once never
// moves the selection to the previous line.
TextPosition _moveToTextBoundaryAtDirection(TextPosition end, bool forward, TextBoundary textBoundary) {
assert(end.offset >= 0);
final int caretOffset;
switch (end.affinity) {
case TextAffinity.upstream:
if (end.offset < 1 && !forward) {
assert (end.offset == 0);
return const TextPosition(offset: 0);
}
final CharacterBoundary characterBoundary = CharacterBoundary(fullText);
caretOffset = math.max(
0,
characterBoundary.getLeadingTextBoundaryAt(range.start + end.offset) ?? range.start,
) - 1;
break;
case TextAffinity.downstream:
caretOffset = end.offset;
break;
}
return _clampTextPosition(
(PushTextPosition.backward + boundary).getLeadingTextBoundaryAt(position),
);
final int offset = forward
? textBoundary.getTrailingTextBoundaryAt(caretOffset) ?? range.end
: textBoundary.getLeadingTextBoundaryAt(caretOffset) ?? range.start;
return TextPosition(offset: offset);
}
MapEntry<TextPosition, SelectionResult> _handleVerticalMovement(TextPosition position, {required double horizontalBaselineInParagraphCoordinates, required bool below}) {
......
......@@ -2,60 +2,68 @@
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'dart:math' as math;
import 'dart:ui';
import 'dart:math';
import 'package:characters/characters.dart' show CharacterRange;
import 'text_layout_metrics.dart';
/// An interface for retrieving the logical text boundary (left-closed-right-open)
/// at a given location in a document.
// Examples can assume:
// late TextLayoutMetrics textLayout;
// late TextSpan text;
// bool isWhitespace(int? codeUnit) => true;
/// Signature for a predicate that takes an offset into a UTF-16 string, and a
/// boolean that indicates the search direction.
typedef UntilPredicate = bool Function(int offset, bool forward);
/// An interface for retrieving the logical text boundary (as opposed to the
/// visual boundary) at a given code unit offset in a document.
///
/// The input [TextPosition] points to a position between 2 code units (which
/// can be visually represented by the caret if the selection were to collapse
/// to that position). The [TextPosition.affinity] is used to determine which
/// code unit it points. For example, `TextPosition(i, upstream)` points to
/// code unit `i - 1` and `TextPosition(i, downstream)` points to code unit `i`.
/// Either the [getTextBoundaryAt] method, or both the
/// [getLeadingTextBoundaryAt] method and the [getTrailingTextBoundaryAt] method
/// must be implemented.
abstract class TextBoundary {
/// A constant constructor to enable subclass override.
const TextBoundary();
/// Returns the leading text boundary at the given location.
/// Returns the offset of the closest text boundary before or at the given
/// `position`, or null if no boundaries can be found.
///
/// The return value must be less or equal to the input position.
TextPosition getLeadingTextBoundaryAt(TextPosition position);
/// The return value, if not null, is usually less than or equal to `position`.
int? getLeadingTextBoundaryAt(int position) {
if (position < 0) {
return null;
}
final int start = getTextBoundaryAt(position).start;
return start >= 0 ? start : null;
}
/// Returns the trailing text boundary at the given location, exclusive.
/// Returns the offset of the closest text boundaries after the given `position`,
/// or null if there is no boundaries can be found after `position`.
///
/// The return value must be greater or equal to the input position.
TextPosition getTrailingTextBoundaryAt(TextPosition position);
/// Gets the text boundary range that encloses the input position.
TextRange getTextBoundaryAt(TextPosition position) {
return TextRange(
start: getLeadingTextBoundaryAt(position).offset,
end: getTrailingTextBoundaryAt(position).offset,
);
/// The return value, if not null, is usually greater than `position`.
int? getTrailingTextBoundaryAt(int position) {
final int end = getTextBoundaryAt(max(0, position)).end;
return end >= 0 ? end : null;
}
/// Gets the boundary by calling the left-hand side and pipe the result to
/// right-hand side.
/// Returns the text boundary range that encloses the input position.
///
/// Combining two text boundaries can be useful if one wants to ignore certain
/// text before finding the text boundary. For example, use
/// [WhitespaceBoundary] + [WordBoundary] to ignores any white space before
/// finding word boundary if the input position happens to be a whitespace
/// character.
TextBoundary operator +(TextBoundary other) {
return _ExpandedTextBoundary(inner: other, outer: this);
/// The returned [TextRange] may contain `-1`, which indicates no boundaries
/// can be found in that direction.
TextRange getTextBoundaryAt(int position) {
final int start = getLeadingTextBoundaryAt(position) ?? -1;
final int end = getTrailingTextBoundaryAt(position) ?? -1;
return TextRange(start: start, end: end);
}
}
/// A text boundary that uses characters as logical boundaries.
/// A [TextBoundary] subclass for retriving the range of the grapheme the given
/// `position` is in.
///
/// This class takes grapheme clusters into account and avoid creating
/// boundaries that generate malformed utf-16 characters.
/// The class is implemented using the
/// [characters](https://pub.dev/packages/characters) package.
class CharacterBoundary extends TextBoundary {
/// Creates a [CharacterBoundary] with the text.
const CharacterBoundary(this._text);
......@@ -63,127 +71,59 @@ class CharacterBoundary extends TextBoundary {
final String _text;
@override
TextPosition getLeadingTextBoundaryAt(TextPosition position) {
if (position.offset <= 0) {
return const TextPosition(offset: 0);
int? getLeadingTextBoundaryAt(int position) {
if (position < 0) {
return null;
}
if (position.offset > _text.length ||
(position.offset == _text.length && position.affinity == TextAffinity.downstream)) {
return TextPosition(offset: _text.length, affinity: TextAffinity.upstream);
}
final int endOffset;
final int startOffset;
switch (position.affinity) {
case TextAffinity.upstream:
startOffset = math.min(position.offset - 1, _text.length);
endOffset = math.min(position.offset, _text.length);
break;
case TextAffinity.downstream:
startOffset = math.min(position.offset, _text.length);
endOffset = math.min(position.offset + 1, _text.length);
break;
}
return TextPosition(
offset: CharacterRange.at(_text, startOffset, endOffset).stringBeforeLength,
);
final int graphemeStart = CharacterRange.at(_text, min(position, _text.length)).stringBeforeLength;
assert(CharacterRange.at(_text, graphemeStart).isEmpty);
return graphemeStart;
}
@override
TextPosition getTrailingTextBoundaryAt(TextPosition position) {
if (position.offset < 0 ||
(position.offset == 0 && position.affinity == TextAffinity.upstream)) {
return const TextPosition(offset: 0);
}
if (position.offset >= _text.length) {
return TextPosition(offset: _text.length, affinity: TextAffinity.upstream);
}
final int endOffset;
final int startOffset;
switch (position.affinity) {
case TextAffinity.upstream:
startOffset = math.min(position.offset - 1, _text.length);
endOffset = math.min(position.offset, _text.length);
break;
case TextAffinity.downstream:
startOffset = math.min(position.offset, _text.length);
endOffset = math.min(position.offset + 1, _text.length);
break;
}
final CharacterRange range = CharacterRange.at(_text, startOffset, endOffset);
return TextPosition(
offset: _text.length - range.stringAfterLength,
affinity: TextAffinity.upstream,
);
int? getTrailingTextBoundaryAt(int position) {
if (position >= _text.length) {
return null;
}
}
/// A text boundary that uses words as logical boundaries.
///
/// This class uses [UAX #29](https://unicode.org/reports/tr29/) defined word
/// boundaries to calculate its logical boundaries.
class WordBoundary extends TextBoundary {
/// Creates a [WordBoundary] with the text and layout information.
const WordBoundary(this._textLayout);
final TextLayoutMetrics _textLayout;
@override
TextPosition getLeadingTextBoundaryAt(TextPosition position) {
return TextPosition(
offset: _textLayout.getWordBoundary(position).start,
affinity: TextAffinity.downstream, // ignore: avoid_redundant_argument_values
);
final CharacterRange rangeAtPosition = CharacterRange.at(_text, max(0, position + 1));
final int nextBoundary = rangeAtPosition.stringBeforeLength + rangeAtPosition.current.length;
assert(nextBoundary == _text.length || CharacterRange.at(_text, nextBoundary).isEmpty);
return nextBoundary;
}
@override
TextPosition getTrailingTextBoundaryAt(TextPosition position) {
return TextPosition(
offset: _textLayout.getWordBoundary(position).end,
affinity: TextAffinity.upstream,
);
TextRange getTextBoundaryAt(int position) {
if (position < 0) {
return TextRange(start: -1, end: getTrailingTextBoundaryAt(position) ?? -1);
} else if (position >= _text.length) {
return TextRange(start: getLeadingTextBoundaryAt(position) ?? -1, end: -1);
}
@override
TextRange getTextBoundaryAt(TextPosition position) {
return _textLayout.getWordBoundary(position);
final CharacterRange rangeAtPosition = CharacterRange.at(_text, position);
return rangeAtPosition.isNotEmpty
? TextRange(start: rangeAtPosition.stringBeforeLength, end: rangeAtPosition.stringBeforeLength + rangeAtPosition.current.length)
// rangeAtPosition is empty means `position` is a grapheme boundary.
: TextRange(start: rangeAtPosition.stringBeforeLength, end: getTrailingTextBoundaryAt(position) ?? -1);
}
}
/// A text boundary that uses line breaks as logical boundaries.
/// A [TextBoundary] subclass for locating closest line breaks to a given
/// `position`.
///
/// The input [TextPosition]s will be interpreted as caret locations if
/// [TextLayoutMetrics.getLineAtOffset] is text-affinity-aware.
class LineBreak extends TextBoundary {
/// Creates a [LineBreak] with the text and layout information.
const LineBreak(this._textLayout);
/// When the given `position` points to a hard line break, the returned range
/// is the line's content range before the hard line break, and does not contain
/// the given `position`. For instance, the line breaks at `position = 1` for
/// "a\nb" is `[0, 1)`, which does not contain the position `1`.
class LineBoundary extends TextBoundary {
/// Creates a [LineBoundary] with the text and layout information.
const LineBoundary(this._textLayout);
final TextLayoutMetrics _textLayout;
@override
TextPosition getLeadingTextBoundaryAt(TextPosition position) {
return TextPosition(
offset: _textLayout.getLineAtOffset(position).start,
);
}
@override
TextPosition getTrailingTextBoundaryAt(TextPosition position) {
return TextPosition(
offset: _textLayout.getLineAtOffset(position).end,
affinity: TextAffinity.upstream,
);
}
@override
TextRange getTextBoundaryAt(TextPosition position) {
return _textLayout.getLineAtOffset(position);
}
TextRange getTextBoundaryAt(int position) => _textLayout.getLineAtOffset(TextPosition(offset: max(position, 0)));
}
/// A text boundary that uses the entire document as logical boundary.
///
/// The document boundary is unique and is a constant function of the input
/// position.
class DocumentBoundary extends TextBoundary {
/// Creates a [DocumentBoundary] with the text
const DocumentBoundary(this._text);
......@@ -191,158 +131,7 @@ class DocumentBoundary extends TextBoundary {
final String _text;
@override
TextPosition getLeadingTextBoundaryAt(TextPosition position) => const TextPosition(offset: 0);
@override
TextPosition getTrailingTextBoundaryAt(TextPosition position) {
return TextPosition(
offset: _text.length,
affinity: TextAffinity.upstream,
);
}
}
/// A text boundary that uses the first non-whitespace character as the logical
/// boundary.
///
/// This text boundary uses [TextLayoutMetrics.isWhitespace] to identify white
/// spaces, this includes newline characters from ASCII and separators from the
/// [unicode separator category](https://en.wikipedia.org/wiki/Whitespace_character).
class WhitespaceBoundary extends TextBoundary {
/// Creates a [WhitespaceBoundary] with the text.
const WhitespaceBoundary(this._text);
final String _text;
@override
TextPosition getLeadingTextBoundaryAt(TextPosition position) {
// Handles outside of string end.
if (position.offset > _text.length || (position.offset == _text.length && position.affinity == TextAffinity.downstream)) {
position = TextPosition(offset: _text.length, affinity: TextAffinity.upstream);
}
// Handles outside of string start.
if (position.offset <= 0) {
return const TextPosition(offset: 0);
}
int index = position.offset;
if (position.affinity == TextAffinity.downstream && !TextLayoutMetrics.isWhitespace(_text.codeUnitAt(index))) {
return position;
}
while ((index -= 1) >= 0) {
if (!TextLayoutMetrics.isWhitespace(_text.codeUnitAt(index))) {
return TextPosition(offset: index + 1, affinity: TextAffinity.upstream);
}
}
return const TextPosition(offset: 0);
}
@override
TextPosition getTrailingTextBoundaryAt(TextPosition position) {
// Handles outside of right bound.
if (position.offset >= _text.length) {
return TextPosition(offset: _text.length, affinity: TextAffinity.upstream);
}
// Handles outside of left bound.
if (position.offset < 0 || (position.offset == 0 && position.affinity == TextAffinity.upstream)) {
position = const TextPosition(offset: 0);
}
int index = position.offset;
if (position.affinity == TextAffinity.upstream && !TextLayoutMetrics.isWhitespace(_text.codeUnitAt(index - 1))) {
return position;
}
for (; index < _text.length; index += 1) {
if (!TextLayoutMetrics.isWhitespace(_text.codeUnitAt(index))) {
return TextPosition(offset: index);
}
}
return TextPosition(offset: _text.length, affinity: TextAffinity.upstream);
}
}
/// Gets the boundary by calling the [outer] and pipe the result to
/// [inner].
class _ExpandedTextBoundary extends TextBoundary {
/// Creates a [_ExpandedTextBoundary] with inner and outter boundaries
const _ExpandedTextBoundary({required this.inner, required this.outer});
/// The inner boundary to call with the result from [outer].
final TextBoundary inner;
/// The outer boundary to call with the input position.
///
/// The result is piped to the [inner] before returning to the caller.
final TextBoundary outer;
@override
TextPosition getLeadingTextBoundaryAt(TextPosition position) {
return inner.getLeadingTextBoundaryAt(
outer.getLeadingTextBoundaryAt(position),
);
}
@override
TextPosition getTrailingTextBoundaryAt(TextPosition position) {
return inner.getTrailingTextBoundaryAt(
outer.getTrailingTextBoundaryAt(position),
);
}
}
/// A text boundary that will push input text position forward or backward
/// one affinity
///
/// To push a text position forward one affinity unit, this proxy converts
/// affinity to downstream if it is upstream; otherwise it increase the offset
/// by one with its affinity sets to upstream. For example,
/// `TextPosition(1, upstream)` becomes `TextPosition(1, downstream)`,
/// `TextPosition(4, downstream)` becomes `TextPosition(5, upstream)`.
///
/// See also:
/// * [PushTextPosition.forward], a text boundary to push the input position
/// forward.
/// * [PushTextPosition.backward], a text boundary to push the input position
/// backward.
class PushTextPosition extends TextBoundary {
const PushTextPosition._(this._forward);
/// A text boundary that pushes the input position forward.
static const TextBoundary forward = PushTextPosition._(true);
/// A text boundary that pushes the input position backward.
static const TextBoundary backward = PushTextPosition._(false);
/// Whether to push the input position forward or backward.
final bool _forward;
TextPosition _calculateTargetPosition(TextPosition position) {
if (_forward) {
switch(position.affinity) {
case TextAffinity.upstream:
return TextPosition(offset: position.offset);
case TextAffinity.downstream:
return position = TextPosition(
offset: position.offset + 1,
affinity: TextAffinity.upstream,
);
}
} else {
switch(position.affinity) {
case TextAffinity.upstream:
return position = TextPosition(offset: position.offset - 1);
case TextAffinity.downstream:
return TextPosition(
offset: position.offset,
affinity: TextAffinity.upstream,
);
}
}
}
@override
TextPosition getLeadingTextBoundaryAt(TextPosition position) => _calculateTargetPosition(position);
int? getLeadingTextBoundaryAt(int position) => position < 0 ? null : 0;
@override
TextPosition getTrailingTextBoundaryAt(TextPosition position) => _calculateTargetPosition(position);
int? getTrailingTextBoundaryAt(int position) => position >= _text.length ? null : _text.length;
}
......@@ -71,6 +71,10 @@ typedef EditableTextContextMenuBuilder = Widget Function(
EditableTextState editableTextState,
);
// Signature for a function that determines the target location of the given
// [TextPosition] after applying the given [TextBoundary].
typedef _ApplyTextBoundary = TextPosition Function(TextPosition, bool, TextBoundary);
// The time it takes for the cursor to fade from fully opaque to fully
// transparent and vice versa. A full cursor blink, from transparent to opaque
// to transparent, is twice this duration.
......@@ -3947,61 +3951,65 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
: null;
}
// --------------------------- Text Editing Actions ---------------------------
TextBoundary _characterBoundary(DirectionalTextEditingIntent intent) {
final TextBoundary atomicTextBoundary = widget.obscureText ? _CodeUnitBoundary(_value.text) : CharacterBoundary(_value.text);
return intent.forward ? PushTextPosition.forward + atomicTextBoundary : PushTextPosition.backward + atomicTextBoundary;
// Returns the closest boundary location to `extent` but not including `extent`
// itself (unless already at the start/end of the text), in the direction
// specified by `forward`.
TextPosition _moveBeyondTextBoundary(TextPosition extent, bool forward, TextBoundary textBoundary) {
assert(extent.offset >= 0);
final int newOffset = forward
? textBoundary.getTrailingTextBoundaryAt(extent.offset) ?? _value.text.length
// if x is a boundary defined by `textBoundary`, most textBoundaries (except
// LineBreaker) guarantees `x == textBoundary.getLeadingTextBoundaryAt(x)`.
// Use x - 1 here to make sure we don't get stuck at the fixed point x.
: textBoundary.getLeadingTextBoundaryAt(extent.offset - 1) ?? 0;
return TextPosition(offset: newOffset);
}
TextBoundary _nextWordBoundary(DirectionalTextEditingIntent intent) {
final TextBoundary atomicTextBoundary;
final TextBoundary boundary;
if (widget.obscureText) {
atomicTextBoundary = _CodeUnitBoundary(_value.text);
boundary = DocumentBoundary(_value.text);
} else {
final TextEditingValue textEditingValue = _textEditingValueforTextLayoutMetrics;
atomicTextBoundary = CharacterBoundary(textEditingValue.text);
// This isn't enough. Newline characters.
boundary = WhitespaceBoundary(textEditingValue.text) + WordBoundary(renderEditable);
// Returns the closest boundary location to `extent`, including `extent`
// itself, in the direction specified by `forward`.
//
// This method returns a fixed point of itself: applying `_toTextBoundary`
// again on the returned TextPosition gives the same TextPosition. It's used
// exclusively for handling line boundaries, since performing "move to line
// start" more than once usually doesn't move you to the previous line.
TextPosition _moveToTextBoundary(TextPosition extent, bool forward, TextBoundary textBoundary) {
assert(extent.offset >= 0);
final int caretOffset;
switch (extent.affinity) {
case TextAffinity.upstream:
if (extent.offset < 1 && !forward) {
assert (extent.offset == 0);
return const TextPosition(offset: 0);
}
final _MixedBoundary mixedBoundary = intent.forward
? _MixedBoundary(atomicTextBoundary, boundary)
: _MixedBoundary(boundary, atomicTextBoundary);
// Use a _MixedBoundary to make sure we don't leave invalid codepoints in
// the field after deletion.
return intent.forward ? PushTextPosition.forward + mixedBoundary : PushTextPosition.backward + mixedBoundary;
// When the text affinity is upstream, the caret is associated with the
// grapheme before the code unit at `extent.offset`.
// TODO(LongCatIsLooong): don't assume extent.offset is at a grapheme
// boundary, and do this instead:
// final int graphemeStart = CharacterRange.at(string, extent.offset).stringBeforeLength - 1;
caretOffset = math.max(0, extent.offset - 1);
break;
case TextAffinity.downstream:
caretOffset = extent.offset;
break;
}
TextBoundary _linebreak(DirectionalTextEditingIntent intent) {
final TextBoundary atomicTextBoundary;
final TextBoundary boundary;
if (widget.obscureText) {
atomicTextBoundary = _CodeUnitBoundary(_value.text);
boundary = DocumentBoundary(_value.text);
} else {
final TextEditingValue textEditingValue = _textEditingValueforTextLayoutMetrics;
atomicTextBoundary = CharacterBoundary(textEditingValue.text);
boundary = LineBreak(renderEditable);
// The line boundary range does not include some control characters
// (most notably, Line Feed), in which case there's
// `x ∉ getTextBoundaryAt(x)`. In case `caretOffset` points to one such
// control character, we define that these control characters themselves are
// still part of the previous line, but also exclude them from the
// the line boundary range since they're non-printing. IOW, no additional
// processing needed since the LineBoundary class does exactly that.
return forward
? TextPosition(offset: textBoundary.getTrailingTextBoundaryAt(caretOffset) ?? _value.text.length, affinity: TextAffinity.upstream)
: TextPosition(offset: textBoundary.getLeadingTextBoundaryAt(caretOffset) ?? 0);
}
// The _MixedBoundary is to make sure we don't leave invalid code units in
// the field after deletion.
// `boundary` doesn't need to be wrapped in a _CollapsedSelectionBoundary,
// since the document boundary is unique and the linebreak boundary is
// already caret-location based.
final TextBoundary pushed = intent.forward
? PushTextPosition.forward + atomicTextBoundary
: PushTextPosition.backward + atomicTextBoundary;
return intent.forward ? _MixedBoundary(pushed, boundary) : _MixedBoundary(boundary, pushed);
}
// --------------------------- Text Editing Actions ---------------------------
TextBoundary _documentBoundary(DirectionalTextEditingIntent intent) => DocumentBoundary(_value.text);
TextBoundary _characterBoundary() => widget.obscureText ? _CodeUnitBoundary(_value.text) : CharacterBoundary(_value.text);
TextBoundary _nextWordBoundary() => widget.obscureText ? _documentBoundary() : renderEditable.wordBoundaries.moveByWordBoundary;
TextBoundary _linebreak() => widget.obscureText ? _documentBoundary() : LineBoundary(renderEditable);
TextBoundary _documentBoundary() => DocumentBoundary(_value.text);
Action<T> _makeOverridable<T extends Intent>(Action<T> defaultAction) {
return Action<T>.overridable(context: context, defaultAction: defaultAction);
......@@ -4178,40 +4186,6 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
late final _UpdateTextSelectionVerticallyAction<DirectionalCaretMovementIntent> _verticalSelectionUpdateAction =
_UpdateTextSelectionVerticallyAction<DirectionalCaretMovementIntent>(this);
void _expandSelectionToDocumentBoundary(ExpandSelectionToDocumentBoundaryIntent intent) {
final TextBoundary textBoundary = _documentBoundary(intent);
_expandSelection(intent.forward, textBoundary, true);
}
void _expandSelectionToLinebreak(ExpandSelectionToLineBreakIntent intent) {
final TextBoundary textBoundary = _linebreak(intent);
_expandSelection(intent.forward, textBoundary);
}
void _expandSelection(bool forward, TextBoundary textBoundary, [bool extentAtIndex = false]) {
final TextSelection textBoundarySelection = _value.selection;
if (!textBoundarySelection.isValid) {
return;
}
final bool inOrder = textBoundarySelection.baseOffset <= textBoundarySelection.extentOffset;
final bool towardsExtent = forward == inOrder;
final TextPosition position = towardsExtent
? textBoundarySelection.extent
: textBoundarySelection.base;
final TextPosition newExtent = forward
? textBoundary.getTrailingTextBoundaryAt(position)
: textBoundary.getLeadingTextBoundaryAt(position);
final TextSelection newSelection = textBoundarySelection.expandTo(newExtent, textBoundarySelection.isCollapsed || extentAtIndex);
userUpdateTextEditingValue(
_value.copyWith(selection: newSelection),
SelectionChangedCause.keyboard,
);
bringIntoView(newSelection.extent);
}
Object? _hideToolbarIfVisible(DismissIntent intent) {
if (_selectionOverlay?.toolbarIsVisible ?? false) {
hideToolbar(false);
......@@ -4265,24 +4239,26 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
DismissIntent: CallbackAction<DismissIntent>(onInvoke: _hideToolbarIfVisible),
// Delete
DeleteCharacterIntent: _makeOverridable(_DeleteTextAction<DeleteCharacterIntent>(this, _characterBoundary)),
DeleteToNextWordBoundaryIntent: _makeOverridable(_DeleteTextAction<DeleteToNextWordBoundaryIntent>(this, _nextWordBoundary)),
DeleteToLineBreakIntent: _makeOverridable(_DeleteTextAction<DeleteToLineBreakIntent>(this, _linebreak)),
DeleteCharacterIntent: _makeOverridable(_DeleteTextAction<DeleteCharacterIntent>(this, _characterBoundary, _moveBeyondTextBoundary)),
DeleteToNextWordBoundaryIntent: _makeOverridable(_DeleteTextAction<DeleteToNextWordBoundaryIntent>(this, _nextWordBoundary, _moveBeyondTextBoundary)),
DeleteToLineBreakIntent: _makeOverridable(_DeleteTextAction<DeleteToLineBreakIntent>(this, _linebreak, _moveToTextBoundary)),
// Extend/Move Selection
ExtendSelectionByCharacterIntent: _makeOverridable(_UpdateTextSelectionAction<ExtendSelectionByCharacterIntent>(this, false, _characterBoundary)),
ExtendSelectionByCharacterIntent: _makeOverridable(_UpdateTextSelectionAction<ExtendSelectionByCharacterIntent>(this, _characterBoundary, _moveBeyondTextBoundary, ignoreNonCollapsedSelection: false)),
ExtendSelectionByPageIntent: _makeOverridable(CallbackAction<ExtendSelectionByPageIntent>(onInvoke: _extendSelectionByPage)),
ExtendSelectionToNextWordBoundaryIntent: _makeOverridable(_UpdateTextSelectionAction<ExtendSelectionToNextWordBoundaryIntent>(this, true, _nextWordBoundary)),
ExtendSelectionToLineBreakIntent: _makeOverridable(_UpdateTextSelectionAction<ExtendSelectionToLineBreakIntent>(this, true, _linebreak)),
ExpandSelectionToLineBreakIntent: _makeOverridable(CallbackAction<ExpandSelectionToLineBreakIntent>(onInvoke: _expandSelectionToLinebreak)),
ExpandSelectionToDocumentBoundaryIntent: _makeOverridable(CallbackAction<ExpandSelectionToDocumentBoundaryIntent>(onInvoke: _expandSelectionToDocumentBoundary)),
ExtendSelectionToNextWordBoundaryIntent: _makeOverridable(_UpdateTextSelectionAction<ExtendSelectionToNextWordBoundaryIntent>(this, _nextWordBoundary, _moveBeyondTextBoundary, ignoreNonCollapsedSelection: true)),
ExtendSelectionToLineBreakIntent: _makeOverridable(_UpdateTextSelectionAction<ExtendSelectionToLineBreakIntent>(this, _linebreak, _moveToTextBoundary, ignoreNonCollapsedSelection: true)),
ExtendSelectionVerticallyToAdjacentLineIntent: _makeOverridable(_verticalSelectionUpdateAction),
ExtendSelectionVerticallyToAdjacentPageIntent: _makeOverridable(_verticalSelectionUpdateAction),
ExtendSelectionToDocumentBoundaryIntent: _makeOverridable(_UpdateTextSelectionAction<ExtendSelectionToDocumentBoundaryIntent>(this, true, _documentBoundary)),
ExtendSelectionToNextWordBoundaryOrCaretLocationIntent: _makeOverridable(_ExtendSelectionOrCaretPositionAction(this, _nextWordBoundary)),
ExtendSelectionToDocumentBoundaryIntent: _makeOverridable(_UpdateTextSelectionAction<ExtendSelectionToDocumentBoundaryIntent>(this, _documentBoundary, _moveBeyondTextBoundary, ignoreNonCollapsedSelection: true)),
ExtendSelectionToNextWordBoundaryOrCaretLocationIntent: _makeOverridable(_UpdateTextSelectionAction<ExtendSelectionToNextWordBoundaryOrCaretLocationIntent>(this, _nextWordBoundary, _moveBeyondTextBoundary, ignoreNonCollapsedSelection: true)),
ScrollToDocumentBoundaryIntent: _makeOverridable(CallbackAction<ScrollToDocumentBoundaryIntent>(onInvoke: _scrollToDocumentBoundary)),
ScrollIntent: CallbackAction<ScrollIntent>(onInvoke: _scroll),
// Expand Selection
ExpandSelectionToLineBreakIntent: _makeOverridable(_UpdateTextSelectionAction<ExpandSelectionToLineBreakIntent>(this, _linebreak, _moveToTextBoundary, ignoreNonCollapsedSelection: true, isExpand: true)),
ExpandSelectionToDocumentBoundaryIntent: _makeOverridable(_UpdateTextSelectionAction<ExpandSelectionToDocumentBoundaryIntent>(this, _documentBoundary, _moveToTextBoundary, ignoreNonCollapsedSelection: true, isExpand: true, extentAtIndex: true)),
// Copy Paste
SelectAllTextIntent: _makeOverridable(_SelectAllAction(this)),
CopySelectionTextIntent: _makeOverridable(_CopySelectionAction(this)),
......@@ -4832,7 +4808,7 @@ class _ScribblePlaceholder extends WidgetSpan {
///
/// This text boundary treats every character in input string as an utf-16 code
/// unit. This can be useful when handling text without any grapheme cluster,
/// e.g. the obscure string in [EditableText]. If you are handling text that may
/// e.g. password input in [EditableText]. If you are handling text that may
/// include grapheme clusters, consider using [CharacterBoundary].
class _CodeUnitBoundary extends TextBoundary {
const _CodeUnitBoundary(this._text);
......@@ -4840,113 +4816,51 @@ class _CodeUnitBoundary extends TextBoundary {
final String _text;
@override
TextPosition getLeadingTextBoundaryAt(TextPosition position) {
if (position.offset <= 0) {
return const TextPosition(offset: 0);
}
if (position.offset > _text.length ||
(position.offset == _text.length && position.affinity == TextAffinity.downstream)) {
return TextPosition(offset: _text.length, affinity: TextAffinity.upstream);
}
switch (position.affinity) {
case TextAffinity.upstream:
return TextPosition(offset: math.min(position.offset - 1, _text.length));
case TextAffinity.downstream:
return TextPosition(offset: math.min(position.offset, _text.length));
}
}
int getLeadingTextBoundaryAt(int position) => position.clamp(0, _text.length); // ignore_clamp_double_lint
@override
TextPosition getTrailingTextBoundaryAt(TextPosition position) {
if (position.offset < 0 ||
(position.offset == 0 && position.affinity == TextAffinity.upstream)) {
return const TextPosition(offset: 0);
}
if (position.offset >= _text.length) {
return TextPosition(offset: _text.length, affinity: TextAffinity.upstream);
}
switch (position.affinity) {
case TextAffinity.upstream:
return TextPosition(offset: math.min(position.offset, _text.length), affinity: TextAffinity.upstream);
case TextAffinity.downstream:
return TextPosition(offset: math.min(position.offset + 1, _text.length), affinity: TextAffinity.upstream);
}
}
}
// ------------------------ Text Boundary Combinators ------------------------
// A _TextBoundary that creates a [TextRange] where its start is from the
// specified leading text boundary and its end is from the specified trailing
// text boundary.
class _MixedBoundary extends TextBoundary {
_MixedBoundary(
this.leadingTextBoundary,
this.trailingTextBoundary
);
final TextBoundary leadingTextBoundary;
final TextBoundary trailingTextBoundary;
@override
TextPosition getLeadingTextBoundaryAt(TextPosition position) => leadingTextBoundary.getLeadingTextBoundaryAt(position);
@override
TextPosition getTrailingTextBoundaryAt(TextPosition position) => trailingTextBoundary.getTrailingTextBoundaryAt(position);
int getTrailingTextBoundaryAt(int position) => (position + 1).clamp(0, _text.length); // ignore_clamp_double_lint
}
// ------------------------------- Text Actions -------------------------------
class _DeleteTextAction<T extends DirectionalTextEditingIntent> extends ContextAction<T> {
_DeleteTextAction(this.state, this.getTextBoundariesForIntent);
_DeleteTextAction(this.state, this.getTextBoundary, this._applyTextBoundary);
final EditableTextState state;
final TextBoundary Function(T intent) getTextBoundariesForIntent;
TextRange _expandNonCollapsedRange(TextEditingValue value) {
final TextRange selection = value.selection;
assert(selection.isValid);
assert(!selection.isCollapsed);
final TextBoundary atomicBoundary = state.widget.obscureText
? _CodeUnitBoundary(value.text)
: CharacterBoundary(value.text);
return TextRange(
start: atomicBoundary.getLeadingTextBoundaryAt(TextPosition(offset: selection.start)).offset,
end: atomicBoundary.getTrailingTextBoundaryAt(TextPosition(offset: selection.end - 1)).offset,
);
}
final TextBoundary Function() getTextBoundary;
final _ApplyTextBoundary _applyTextBoundary;
@override
Object? invoke(T intent, [BuildContext? context]) {
final TextSelection selection = state._value.selection;
if (!selection.isValid) {
return null;
}
assert(selection.isValid);
// Expands the selection to ensure the range covers full graphemes.
final TextBoundary atomicBoundary = state._characterBoundary();
if (!selection.isCollapsed) {
return Actions.invoke(
context!,
ReplaceTextIntent(state._value, '', _expandNonCollapsedRange(state._value), SelectionChangedCause.keyboard),
// Expands the selection to ensure the range covers full graphemes.
final TextRange range = TextRange(
start: atomicBoundary.getLeadingTextBoundaryAt(selection.start) ?? state._value.text.length,
end: atomicBoundary.getTrailingTextBoundaryAt(selection.end - 1) ?? 0,
);
}
final TextBoundary textBoundary = getTextBoundariesForIntent(intent);
if (!state._value.selection.isValid) {
return null;
}
if (!state._value.selection.isCollapsed) {
return Actions.invoke(
context!,
ReplaceTextIntent(state._value, '', _expandNonCollapsedRange(state._value), SelectionChangedCause.keyboard),
ReplaceTextIntent(state._value, '', range, SelectionChangedCause.keyboard),
);
}
final int target = _applyTextBoundary(selection.base, intent.forward, getTextBoundary()).offset;
final TextRange rangeToDelete = TextSelection(
baseOffset: intent.forward
? atomicBoundary.getLeadingTextBoundaryAt(selection.baseOffset) ?? state._value.text.length
: atomicBoundary.getTrailingTextBoundaryAt(selection.baseOffset - 1) ?? 0,
extentOffset: target,
);
return Actions.invoke(
context!,
ReplaceTextIntent(
state._value,
'',
textBoundary.getTextBoundaryAt(state._value.selection.base),
SelectionChangedCause.keyboard,
),
ReplaceTextIntent(state._value, '', rangeToDelete, SelectionChangedCause.keyboard),
);
}
......@@ -4957,13 +4871,19 @@ class _DeleteTextAction<T extends DirectionalTextEditingIntent> extends ContextA
class _UpdateTextSelectionAction<T extends DirectionalCaretMovementIntent> extends ContextAction<T> {
_UpdateTextSelectionAction(
this.state,
this.ignoreNonCollapsedSelection,
this.getTextBoundariesForIntent,
);
this.getTextBoundary,
this.applyTextBoundary, {
required this.ignoreNonCollapsedSelection,
this.isExpand = false,
this.extentAtIndex = false,
});
final EditableTextState state;
final bool ignoreNonCollapsedSelection;
final TextBoundary Function(T intent) getTextBoundariesForIntent;
final bool isExpand;
final bool extentAtIndex;
final TextBoundary Function() getTextBoundary;
final _ApplyTextBoundary applyTextBoundary;
static const int NEWLINE_CODE_UNIT = 10;
......@@ -4994,25 +4914,14 @@ class _UpdateTextSelectionAction<T extends DirectionalCaretMovementIntent> exten
assert(selection.isValid);
final bool collapseSelection = intent.collapseSelection || !state.widget.selectionEnabled;
// Collapse to the logical start/end.
TextSelection collapse(TextSelection selection) {
assert(selection.isValid);
assert(!selection.isCollapsed);
return selection.copyWith(
baseOffset: intent.forward ? selection.end : selection.start,
extentOffset: intent.forward ? selection.end : selection.start,
);
}
if (!selection.isCollapsed && !ignoreNonCollapsedSelection && collapseSelection) {
return Actions.invoke(
context!,
UpdateSelectionIntent(state._value, collapse(selection), SelectionChangedCause.keyboard),
);
return Actions.invoke(context!, UpdateSelectionIntent(
state._value,
TextSelection.collapsed(offset: intent.forward ? selection.end : selection.start),
SelectionChangedCause.keyboard,
));
}
final TextBoundary textBoundary = getTextBoundariesForIntent(intent);
TextPosition extent = selection.extent;
// If continuesAtWrap is true extent and is at the relevant wordwrap, then
// move it just to the other side of the wordwrap.
......@@ -5029,76 +4938,22 @@ class _UpdateTextSelectionAction<T extends DirectionalCaretMovementIntent> exten
}
}
final TextPosition newExtent = intent.forward
? textBoundary.getTrailingTextBoundaryAt(extent)
: textBoundary.getLeadingTextBoundaryAt(extent);
final TextSelection newSelection = collapseSelection
final bool shouldTargetBase = isExpand && (intent.forward ? selection.baseOffset > selection.extentOffset : selection.baseOffset < selection.extentOffset);
final TextPosition newExtent = applyTextBoundary(shouldTargetBase ? selection.base : extent, intent.forward, getTextBoundary());
final TextSelection newSelection = collapseSelection || (!isExpand && newExtent.offset == selection.baseOffset)
? TextSelection.fromPosition(newExtent)
: selection.extendTo(newExtent);
// If collapseAtReversal is true and would have an effect, collapse it.
if (!selection.isCollapsed && intent.collapseAtReversal
&& (selection.baseOffset < selection.extentOffset !=
newSelection.baseOffset < newSelection.extentOffset)) {
return Actions.invoke(
context!,
UpdateSelectionIntent(
state._value,
TextSelection.fromPosition(selection.base),
SelectionChangedCause.keyboard,
),
);
}
: isExpand ? selection.expandTo(newExtent, extentAtIndex || selection.isCollapsed) : selection.extendTo(newExtent);
return Actions.invoke(
context!,
UpdateSelectionIntent(state._value, newSelection, SelectionChangedCause.keyboard),
);
final bool shouldCollapseToBase = intent.collapseAtReversal
&& (selection.baseOffset - selection.extentOffset) * (selection.baseOffset - newSelection.extentOffset) < 0;
final TextSelection newRange = shouldCollapseToBase ? TextSelection.fromPosition(selection.base) : newSelection;
return Actions.invoke(context!, UpdateSelectionIntent(state._value, newRange, SelectionChangedCause.keyboard));
}
@override
bool get isActionEnabled => state._value.selection.isValid;
}
class _ExtendSelectionOrCaretPositionAction extends ContextAction<ExtendSelectionToNextWordBoundaryOrCaretLocationIntent> {
_ExtendSelectionOrCaretPositionAction(this.state, this.getTextBoundariesForIntent);
final EditableTextState state;
final TextBoundary Function(ExtendSelectionToNextWordBoundaryOrCaretLocationIntent intent) getTextBoundariesForIntent;
@override
Object? invoke(ExtendSelectionToNextWordBoundaryOrCaretLocationIntent intent, [BuildContext? context]) {
final TextSelection selection = state._value.selection;
assert(selection.isValid);
final TextBoundary textBoundary = getTextBoundariesForIntent(intent);
final TextSelection textBoundarySelection = state._value.selection;
if (!textBoundarySelection.isValid) {
return null;
}
final TextPosition extent = textBoundarySelection.extent;
final TextPosition newExtent = intent.forward
? textBoundary.getTrailingTextBoundaryAt(extent)
: textBoundary.getLeadingTextBoundaryAt(extent);
final TextSelection newSelection = (newExtent.offset - textBoundarySelection.baseOffset) * (textBoundarySelection.extentOffset - textBoundarySelection.baseOffset) < 0
? textBoundarySelection.copyWith(
extentOffset: textBoundarySelection.baseOffset,
affinity: textBoundarySelection.extentOffset > textBoundarySelection.baseOffset ? TextAffinity.downstream : TextAffinity.upstream,
)
: textBoundarySelection.extendTo(newExtent);
return Actions.invoke(
context!,
UpdateSelectionIntent(state._value, newSelection, SelectionChangedCause.keyboard),
);
}
@override
bool get isActionEnabled => state.widget.selectionEnabled && state._value.selection.isValid;
}
class _UpdateTextSelectionVerticallyAction<T extends DirectionalCaretMovementIntent> extends ContextAction<T> {
_UpdateTextSelectionVerticallyAction(this.state);
......
......@@ -137,11 +137,11 @@ class ExtendSelectionToNextWordBoundaryIntent extends DirectionalCaretMovementIn
/// reverse.
///
/// This is typically only used on MacOS.
class ExtendSelectionToNextWordBoundaryOrCaretLocationIntent extends DirectionalTextEditingIntent {
class ExtendSelectionToNextWordBoundaryOrCaretLocationIntent extends DirectionalCaretMovementIntent {
/// Creates an [ExtendSelectionToNextWordBoundaryOrCaretLocationIntent].
const ExtendSelectionToNextWordBoundaryOrCaretLocationIntent({
required bool forward,
}) : super(forward);
}) : super(forward, false, true);
}
/// Expands the current selection to the document boundary in the direction
......@@ -154,11 +154,11 @@ class ExtendSelectionToNextWordBoundaryOrCaretLocationIntent extends Directional
///
/// [ExtendSelectionToDocumentBoundaryIntent], which is similar but always
/// moves the extent.
class ExpandSelectionToDocumentBoundaryIntent extends DirectionalTextEditingIntent {
class ExpandSelectionToDocumentBoundaryIntent extends DirectionalCaretMovementIntent {
/// Creates an [ExpandSelectionToDocumentBoundaryIntent].
const ExpandSelectionToDocumentBoundaryIntent({
required bool forward,
}) : super(forward);
}) : super(forward, false);
}
/// Expands the current selection to the closest line break in the direction
......@@ -173,11 +173,11 @@ class ExpandSelectionToDocumentBoundaryIntent extends DirectionalTextEditingInte
///
/// [ExtendSelectionToLineBreakIntent], which is similar but always moves the
/// extent.
class ExpandSelectionToLineBreakIntent extends DirectionalTextEditingIntent {
class ExpandSelectionToLineBreakIntent extends DirectionalCaretMovementIntent {
/// Creates an [ExpandSelectionToLineBreakIntent].
const ExpandSelectionToLineBreakIntent({
required bool forward,
}) : super(forward);
}) : super(forward, false);
}
/// Extends, or moves the current selection from the current
......
......@@ -9785,7 +9785,8 @@ void main() {
await tester.pumpAndSettle();
expect(
controller.selection,
const TextSelection.collapsed(offset: 56, affinity: TextAffinity.upstream),
// arrowRight always sets the affinity to downstream.
const TextSelection.collapsed(offset: 56),
);
// Keep moving out.
......@@ -9795,7 +9796,7 @@ void main() {
await tester.pumpAndSettle();
expect(
controller.selection,
const TextSelection.collapsed(offset: 62, affinity: TextAffinity.upstream),
const TextSelection.collapsed(offset: 62),
);
for (int i = 0; i < (66 - 62); i += 1) {
await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight);
......@@ -9803,7 +9804,7 @@ void main() {
await tester.pumpAndSettle();
expect(
controller.selection,
const TextSelection.collapsed(offset: 66, affinity: TextAffinity.upstream),
const TextSelection.collapsed(offset: 66),
); // We're at the edge now.
await tester.pumpAndSettle();
......
......@@ -1075,8 +1075,13 @@ void main() {
),
);
selection = paragraph.selections[0];
expect(selection.start, 0); // [how ]are you
expect(selection.end, 4);
if (isBrowser && !isCanvasKit) {
// how [are you\n]
expect(selection, const TextRange(start: 4, end: 12));
} else {
// [how ]are you
expect(selection, const TextRange(start: 0, end: 4));
}
});
test('can granularly extend selection - document', () async {
......
......@@ -2,132 +2,169 @@
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
class _ConsistentTextRangeImplementationMatcher extends Matcher {
_ConsistentTextRangeImplementationMatcher(int length)
: range = TextRange(start: -1, end: length + 1),
assert(length >= 0);
final TextRange range;
@override
Description describe(Description description) {
return description.add('The implementation of TextBoundary.getTextBoundaryAt is consistent with its other methods.');
}
@override
Description describeMismatch(dynamic item, Description mismatchDescription, Map<dynamic, dynamic> matchState, bool verbose) {
final TextBoundary boundary = matchState['textBoundary'] as TextBoundary;
final int position = matchState['position'] as int;
final int leading = boundary.getLeadingTextBoundaryAt(position) ?? -1;
final int trailing = boundary.getTrailingTextBoundaryAt(position) ?? -1;
return mismatchDescription.add(
'at position $position, expected ${TextRange(start: leading, end: trailing)} but got ${boundary.getTextBoundaryAt(position)}',
);
}
@override
bool matches(dynamic item, Map<dynamic, dynamic> matchState) {
for (int i = range.start; i <= range.end; i++) {
final int? leading = (item as TextBoundary).getLeadingTextBoundaryAt(i);
final int? trailing = item.getTrailingTextBoundaryAt(i);
final TextRange boundary = item.getTextBoundaryAt(i);
final bool consistent = boundary.start == (leading ?? -1) && boundary.end == (trailing ?? -1);
if (!consistent) {
matchState['textBoundary'] = item;
matchState['position'] = i;
return false;
}
}
return true;
}
}
Matcher _hasConsistentTextRangeImplementationWithinRange(int length) => _ConsistentTextRangeImplementationMatcher(length);
void main() {
test('Character boundary works', () {
const CharacterBoundary boundary = CharacterBoundary('abc');
const TextPosition midPosition = TextPosition(offset: 1);
expect(boundary.getLeadingTextBoundaryAt(midPosition), const TextPosition(offset: 1));
expect(boundary.getTrailingTextBoundaryAt(midPosition), const TextPosition(offset: 2, affinity: TextAffinity.upstream));
expect(boundary, _hasConsistentTextRangeImplementationWithinRange(3));
expect(boundary.getLeadingTextBoundaryAt(-1), null);
expect(boundary.getTrailingTextBoundaryAt(-1), 0);
expect(boundary.getLeadingTextBoundaryAt(0), 0);
expect(boundary.getTrailingTextBoundaryAt(0), 1);
expect(boundary.getLeadingTextBoundaryAt(1), 1);
expect(boundary.getTrailingTextBoundaryAt(1), 2);
expect(boundary.getLeadingTextBoundaryAt(2), 2);
expect(boundary.getTrailingTextBoundaryAt(2), 3);
const TextPosition startPosition = TextPosition(offset: 0);
expect(boundary.getLeadingTextBoundaryAt(startPosition), const TextPosition(offset: 0));
expect(boundary.getTrailingTextBoundaryAt(startPosition), const TextPosition(offset: 1, affinity: TextAffinity.upstream));
expect(boundary.getLeadingTextBoundaryAt(3), 3);
expect(boundary.getTrailingTextBoundaryAt(3), null);
const TextPosition endPosition = TextPosition(offset: 3);
expect(boundary.getLeadingTextBoundaryAt(endPosition), const TextPosition(offset: 3, affinity: TextAffinity.upstream));
expect(boundary.getTrailingTextBoundaryAt(endPosition), const TextPosition(offset: 3, affinity: TextAffinity.upstream));
expect(boundary.getLeadingTextBoundaryAt(4), 3);
expect(boundary.getTrailingTextBoundaryAt(4), null);
});
test('Character boundary works with grapheme', () {
const String text = 'a❄︎c';
const CharacterBoundary boundary = CharacterBoundary(text);
TextPosition position = const TextPosition(offset: 1);
expect(boundary.getLeadingTextBoundaryAt(position), const TextPosition(offset: 1));
expect(boundary, _hasConsistentTextRangeImplementationWithinRange(text.length));
expect(boundary.getLeadingTextBoundaryAt(-1), null);
expect(boundary.getTrailingTextBoundaryAt(-1), 0);
expect(boundary.getLeadingTextBoundaryAt(0), 0);
expect(boundary.getTrailingTextBoundaryAt(0), 1);
// The `❄` takes two character length.
expect(boundary.getTrailingTextBoundaryAt(position), const TextPosition(offset: 3, affinity: TextAffinity.upstream));
expect(boundary.getLeadingTextBoundaryAt(1), 1);
expect(boundary.getTrailingTextBoundaryAt(1), 3);
position = const TextPosition(offset: 2);
expect(boundary.getLeadingTextBoundaryAt(position), const TextPosition(offset: 1));
expect(boundary.getTrailingTextBoundaryAt(position), const TextPosition(offset: 3, affinity: TextAffinity.upstream));
expect(boundary.getLeadingTextBoundaryAt(2), 1);
expect(boundary.getTrailingTextBoundaryAt(2), 3);
position = const TextPosition(offset: 0);
expect(boundary.getLeadingTextBoundaryAt(position), const TextPosition(offset: 0));
expect(boundary.getTrailingTextBoundaryAt(position), const TextPosition(offset: 1, affinity: TextAffinity.upstream));
expect(boundary.getLeadingTextBoundaryAt(3), 3);
expect(boundary.getTrailingTextBoundaryAt(3), 4);
position = const TextPosition(offset: text.length);
expect(boundary.getLeadingTextBoundaryAt(position), const TextPosition(offset: text.length, affinity: TextAffinity.upstream));
expect(boundary.getTrailingTextBoundaryAt(position), const TextPosition(offset: text.length, affinity: TextAffinity.upstream));
expect(boundary.getLeadingTextBoundaryAt(text.length), text.length);
expect(boundary.getTrailingTextBoundaryAt(text.length), null);
});
test('word boundary works', () {
final WordBoundary boundary = WordBoundary(TestTextLayoutMetrics());
const TextPosition position = TextPosition(offset: 3);
expect(boundary.getLeadingTextBoundaryAt(position).offset, TestTextLayoutMetrics.wordBoundaryAt3.start);
expect(boundary.getTrailingTextBoundaryAt(position).offset, TestTextLayoutMetrics.wordBoundaryAt3.end);
test('wordBoundary.moveByWordBoundary', () {
const String text = 'ABC ABC\n' // [0, 10)
'AÁ Á\n' // [10, 20)
' \n' // [20, 30)
'ABC!!!ABC\n' // [30, 40)
' !ABC !!\n' // [40, 50)
'A 𑗋𑗋 A\n'; // [50, 60)
final TextPainter textPainter = TextPainter()
..textDirection = TextDirection.ltr
..text = const TextSpan(text: text)
..layout();
final TextBoundary boundary = textPainter.wordBoundaries.moveByWordBoundary;
// 4 points to the 2nd whitespace in the first line.
// Don't break between horizontal spaces and letters/numbers.
expect(boundary.getLeadingTextBoundaryAt(4), 0);
expect(boundary.getTrailingTextBoundaryAt(4), 9);
// Works when words are starting/ending with a combining diacritical mark.
expect(boundary.getLeadingTextBoundaryAt(14), 10);
expect(boundary.getTrailingTextBoundaryAt(14), 19);
// Do break before and after newlines.
expect(boundary.getLeadingTextBoundaryAt(24), 20);
expect(boundary.getTrailingTextBoundaryAt(24), 29);
// Do not break on punctuations.
expect(boundary.getLeadingTextBoundaryAt(34), 30);
expect(boundary.getTrailingTextBoundaryAt(34), 39);
// Ok to break if next to punctuations or separating spaces.
expect(boundary.getLeadingTextBoundaryAt(44), 43);
expect(boundary.getTrailingTextBoundaryAt(44), 46);
// 44 points to a low surrogate of a punctuation.
expect(boundary.getLeadingTextBoundaryAt(54), 50);
expect(boundary.getTrailingTextBoundaryAt(54), 59);
});
test('line boundary works', () {
final LineBreak boundary = LineBreak(TestTextLayoutMetrics());
const TextPosition position = TextPosition(offset: 3);
expect(boundary.getLeadingTextBoundaryAt(position).offset, TestTextLayoutMetrics.lineAt3.start);
expect(boundary.getTrailingTextBoundaryAt(position).offset, TestTextLayoutMetrics.lineAt3.end);
final LineBoundary boundary = LineBoundary(TestTextLayoutMetrics());
expect(boundary.getLeadingTextBoundaryAt(3), TestTextLayoutMetrics.lineAt3.start);
expect(boundary.getTrailingTextBoundaryAt(3), TestTextLayoutMetrics.lineAt3.end);
expect(boundary.getTextBoundaryAt(3), TestTextLayoutMetrics.lineAt3);
});
test('document boundary works', () {
const String text = 'abcd efg hi\njklmno\npqrstuv';
const DocumentBoundary boundary = DocumentBoundary(text);
const TextPosition position = TextPosition(offset: 10);
expect(boundary.getLeadingTextBoundaryAt(position), const TextPosition(offset: 0));
expect(boundary.getTrailingTextBoundaryAt(position), const TextPosition(offset: text.length, affinity: TextAffinity.upstream));
});
expect(boundary, _hasConsistentTextRangeImplementationWithinRange(text.length));
test('white space boundary works', () {
const String text = 'abcd efg';
const WhitespaceBoundary boundary = WhitespaceBoundary(text);
TextPosition position = const TextPosition(offset: 1);
// Should return the same position if the position points to a non white space.
expect(boundary.getLeadingTextBoundaryAt(position), position);
expect(boundary.getTrailingTextBoundaryAt(position), position);
position = const TextPosition(offset: 1, affinity: TextAffinity.upstream);
expect(boundary.getLeadingTextBoundaryAt(position), position);
expect(boundary.getTrailingTextBoundaryAt(position), position);
position = const TextPosition(offset: 4, affinity: TextAffinity.upstream);
expect(boundary.getLeadingTextBoundaryAt(position), position);
expect(boundary.getTrailingTextBoundaryAt(position), position);
// white space
position = const TextPosition(offset: 4);
expect(boundary.getLeadingTextBoundaryAt(position), const TextPosition(offset: 4, affinity: TextAffinity.upstream));
expect(boundary.getTrailingTextBoundaryAt(position), const TextPosition(offset: 8));
// white space
position = const TextPosition(offset: 6);
expect(boundary.getLeadingTextBoundaryAt(position), const TextPosition(offset: 4, affinity: TextAffinity.upstream));
expect(boundary.getTrailingTextBoundaryAt(position), const TextPosition(offset: 8));
position = const TextPosition(offset: 8);
expect(boundary.getLeadingTextBoundaryAt(position), position);
expect(boundary.getTrailingTextBoundaryAt(position), position);
});
expect(boundary.getLeadingTextBoundaryAt(-1), null);
expect(boundary.getTrailingTextBoundaryAt(-1), text.length);
test('extended boundary should work', () {
const String text = 'abcd efg';
const WhitespaceBoundary outer = WhitespaceBoundary(text);
const CharacterBoundary inner = CharacterBoundary(text);
final TextBoundary expanded = outer + inner;
expect(boundary.getLeadingTextBoundaryAt(0), 0);
expect(boundary.getTrailingTextBoundaryAt(0), text.length);
TextPosition position = const TextPosition(offset: 1);
expect(expanded.getLeadingTextBoundaryAt(position), position);
expect(expanded.getTrailingTextBoundaryAt(position), const TextPosition(offset: 2, affinity: TextAffinity.upstream));
expect(boundary.getLeadingTextBoundaryAt(10), 0);
expect(boundary.getTrailingTextBoundaryAt(10), text.length);
position = const TextPosition(offset: 5);
// should skip white space
expect(expanded.getLeadingTextBoundaryAt(position), const TextPosition(offset: 3));
expect(expanded.getTrailingTextBoundaryAt(position), const TextPosition(offset: 9, affinity: TextAffinity.upstream));
});
expect(boundary.getLeadingTextBoundaryAt(text.length), 0);
expect(boundary.getTrailingTextBoundaryAt(text.length), null);
test('push text position works', () {
const String text = 'abcd efg';
const CharacterBoundary inner = CharacterBoundary(text);
final TextBoundary forward = PushTextPosition.forward + inner;
final TextBoundary backward = PushTextPosition.backward + inner;
TextPosition position = const TextPosition(offset: 1, affinity: TextAffinity.upstream);
const TextPosition pushedForward = TextPosition(offset: 1);
// the forward should push position one affinity
expect(forward.getLeadingTextBoundaryAt(position), inner.getLeadingTextBoundaryAt(pushedForward));
expect(forward.getTrailingTextBoundaryAt(position), inner.getTrailingTextBoundaryAt(pushedForward));
position = const TextPosition(offset: 5);
const TextPosition pushedBackward = TextPosition(offset: 5, affinity: TextAffinity.upstream);
// should skip white space
expect(backward.getLeadingTextBoundaryAt(position), inner.getLeadingTextBoundaryAt(pushedBackward));
expect(backward.getTrailingTextBoundaryAt(position), inner.getTrailingTextBoundaryAt(pushedBackward));
expect(boundary.getLeadingTextBoundaryAt(text.length + 1), 0);
expect(boundary.getTrailingTextBoundaryAt(text.length + 1), null);
});
}
......
......@@ -45,7 +45,7 @@ void main() {
);
}
group('iOS: do not delete/backspace events', () {
group('iOS: do not handle delete/backspace events', () {
final TargetPlatformVariant iOS = TargetPlatformVariant.only(TargetPlatform.iOS);
final FocusNode editable = FocusNode();
final FocusNode spy = FocusNode();
......
......@@ -576,12 +576,12 @@ void main() {
'Now is the time for\n'
'all good people\n'
'to come to the aid\n'
'of their country',
'of their ',
);
expect(
controller.selection,
const TextSelection.collapsed(offset: 71),
const TextSelection.collapsed(offset: 64),
);
}, variant: TargetPlatformVariant.all(excluding: <TargetPlatform>{ TargetPlatform.iOS }));
......@@ -795,7 +795,7 @@ void main() {
testWidgets('softwrap line boundary, upstream', (WidgetTester tester) async {
controller.text = testSoftwrapText;
// Place the caret at the beginning of the 3rd line.
// Place the caret at the end of the 2nd line.
controller.selection = const TextSelection.collapsed(
offset: 40,
affinity: TextAffinity.upstream,
......@@ -827,12 +827,11 @@ void main() {
await tester.pumpWidget(buildEditableText());
await sendKeyCombination(tester, lineModifierBackspace());
expect(controller.text, testSoftwrapText);
expect(
controller.selection,
const TextSelection.collapsed(offset: 40),
);
expect(controller.text, testSoftwrapText);
}, variant: TargetPlatformVariant.all());
testWidgets('readonly', (WidgetTester tester) async {
......@@ -976,7 +975,7 @@ void main() {
testWidgets('softwrap line boundary, upstream', (WidgetTester tester) async {
controller.text = testSoftwrapText;
// Place the caret at the beginning of the 3rd line.
// Place the caret at the end of the 2nd line.
controller.selection = const TextSelection.collapsed(
offset: 40,
affinity: TextAffinity.upstream,
......@@ -1215,7 +1214,6 @@ void main() {
expect(controller.selection, const TextSelection.collapsed(
offset: 21,
affinity: TextAffinity.upstream,
));
}, variant: TargetPlatformVariant.all());
......@@ -1230,7 +1228,6 @@ void main() {
expect(controller.selection, const TextSelection.collapsed(
offset: 10,
affinity: TextAffinity.upstream,
));
}, variant: allExceptApple);
......@@ -1341,7 +1338,6 @@ void main() {
await tester.pump();
expect(controller.selection, const TextSelection.collapsed(
offset: 46, // After "to".
affinity: TextAffinity.upstream,
));
// "good" to "come" is selected.
......@@ -1354,7 +1350,6 @@ void main() {
await tester.pump();
expect(controller.selection, const TextSelection.collapsed(
offset: 28, // After "good".
affinity: TextAffinity.upstream,
));
}, variant: allExceptApple);
......@@ -1718,8 +1713,7 @@ void main() {
await tester.pump();
expect(controller.selection, const TextSelection.collapsed(
offset: 10,
affinity: TextAffinity.upstream,
offset: 10, // after the first "the"
));
}, variant: macOSOnly);
......@@ -1790,7 +1784,6 @@ void main() {
await tester.pump();
expect(controller.selection, const TextSelection.collapsed(
offset: 46, // After "to".
affinity: TextAffinity.upstream,
));
// "good" to "come" is selected.
......@@ -1803,7 +1796,6 @@ void main() {
await tester.pump();
expect(controller.selection, const TextSelection.collapsed(
offset: 28, // After "good".
affinity: TextAffinity.upstream,
));
}, variant: macOSOnly);
......@@ -2351,9 +2343,7 @@ void main() {
expect(controller.text, 'testing');
expect(
controller.selection,
const TextSelection.collapsed(
offset: 6,
affinity: TextAffinity.upstream), // should not expand selection
const TextSelection.collapsed(offset: 6), // should not expand selection
reason: selectRight.toString(),
);
}, variant: TargetPlatformVariant.desktop());
......
......@@ -5695,7 +5695,6 @@ void main() {
const TextSelection(
baseOffset: 0,
extentOffset: 6,
affinity: TextAffinity.upstream,
),
),
reason: 'on $platform',
......@@ -5957,7 +5956,6 @@ void main() {
equals(
const TextSelection.collapsed(
offset: testText.length,
affinity: TextAffinity.upstream,
),
),
reason: 'on $platform',
......@@ -6001,7 +5999,6 @@ void main() {
equals(
const TextSelection.collapsed(
offset: 3,
affinity: TextAffinity.upstream,
),
),
reason: 'on $platform',
......@@ -6193,7 +6190,6 @@ void main() {
const TextSelection(
baseOffset: 10,
extentOffset: 10,
affinity: TextAffinity.upstream,
),
),
reason: 'on $platform',
......@@ -6653,7 +6649,6 @@ void main() {
equals(
const TextSelection.collapsed(
offset: 23,
affinity: TextAffinity.upstream,
),
),
reason: 'on $platform',
......@@ -6678,7 +6673,6 @@ void main() {
equals(
const TextSelection.collapsed(
offset: 23,
affinity: TextAffinity.upstream,
),
),
reason: 'on $platform',
......@@ -6721,7 +6715,6 @@ void main() {
equals(
const TextSelection.collapsed(
offset: 23,
affinity: TextAffinity.upstream,
),
),
reason: 'on $platform',
......@@ -6807,7 +6800,6 @@ void main() {
equals(
const TextSelection.collapsed(
offset: 32,
affinity: TextAffinity.upstream,
),
),
reason: 'on $platform',
......@@ -6832,7 +6824,6 @@ void main() {
equals(
const TextSelection.collapsed(
offset: 32,
affinity: TextAffinity.upstream,
),
),
reason: 'on $platform',
......@@ -6875,7 +6866,6 @@ void main() {
equals(
const TextSelection.collapsed(
offset: 32,
affinity: TextAffinity.upstream,
),
),
reason: 'on $platform',
......@@ -6971,7 +6961,6 @@ void main() {
equals(
const TextSelection.collapsed(
offset: 32,
affinity: TextAffinity.upstream,
),
),
reason: 'on $platform',
......@@ -6996,7 +6985,6 @@ void main() {
equals(
const TextSelection.collapsed(
offset: 32,
affinity: TextAffinity.upstream,
),
),
reason: 'on $platform',
......@@ -7039,7 +7027,6 @@ void main() {
equals(
const TextSelection.collapsed(
offset: 32,
affinity: TextAffinity.upstream,
),
),
reason: 'on $platform',
......@@ -7136,7 +7123,6 @@ void main() {
equals(
const TextSelection.collapsed(
offset: 23,
affinity: TextAffinity.upstream,
),
),
reason: 'on $platform',
......@@ -7182,7 +7168,6 @@ void main() {
const TextSelection(
baseOffset: 23,
extentOffset: 23,
affinity: TextAffinity.upstream,
),
),
reason: 'on $platform',
......@@ -7193,7 +7178,6 @@ void main() {
const TextSelection(
baseOffset: 23,
extentOffset: 23,
affinity: TextAffinity.upstream,
),
),
reason: 'on $platform',
......@@ -7327,7 +7311,6 @@ void main() {
controller.selection,
equals(const TextSelection.collapsed(
offset: 4,
affinity: TextAffinity.upstream,
)),
);
......@@ -7511,7 +7494,6 @@ void main() {
equals(
const TextSelection.collapsed(
offset: 32,
affinity: TextAffinity.upstream,
),
),
reason: 'on $platform',
......@@ -7535,7 +7517,6 @@ void main() {
equals(
const TextSelection.collapsed(
offset: 32,
affinity: TextAffinity.upstream,
),
),
reason: 'on $platform',
......@@ -7593,7 +7574,6 @@ void main() {
equals(
const TextSelection.collapsed(
offset: 32,
affinity: TextAffinity.upstream,
),
),
reason: 'on $platform',
......@@ -7706,7 +7686,6 @@ void main() {
equals(
const TextSelection.collapsed(
offset: 32,
affinity: TextAffinity.upstream,
),
),
reason: 'on $platform',
......@@ -7730,7 +7709,6 @@ void main() {
equals(
const TextSelection.collapsed(
offset: 32,
affinity: TextAffinity.upstream,
),
),
reason: 'on $platform',
......@@ -7788,7 +7766,6 @@ void main() {
equals(
const TextSelection.collapsed(
offset: 32,
affinity: TextAffinity.upstream,
),
),
reason: 'on $platform',
......@@ -7900,7 +7877,6 @@ void main() {
equals(
const TextSelection.collapsed(
offset: 32,
affinity: TextAffinity.upstream,
),
),
);
......@@ -7994,7 +7970,6 @@ void main() {
controller.selection,
equals(const TextSelection.collapsed(
offset: testText.length,
affinity: TextAffinity.upstream,
)),
);
......@@ -8078,7 +8053,6 @@ void main() {
controller.selection,
equals(const TextSelection.collapsed(
offset: testText.length,
affinity: TextAffinity.upstream,
)),
);
......@@ -11036,7 +11010,6 @@ void main() {
controller.selection = const TextSelection(
baseOffset: 15,
extentOffset: 15,
affinity: TextAffinity.upstream,
);
await tester.pump();
expect(controller.selection.isCollapsed, true);
......@@ -11275,7 +11248,6 @@ void main() {
const TextSelection(
baseOffset: 29,
extentOffset: 0,
affinity: TextAffinity.upstream,
),
),
);
......
......@@ -1296,12 +1296,12 @@ void main() {
await sendKeyCombination(tester, SingleActivator(LogicalKeyboardKey.arrowLeft, shift: true, alt: alt, control: control));
await tester.pump();
// Ho[w are you]?
// 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(paragraph1.selections[0].end, 8);
expect(paragraph2.selections.length, 0);
}, variant: TargetPlatformVariant.all());
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment