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 { ...@@ -144,6 +144,133 @@ enum TextWidthBasis {
longestLine, 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 /// This is used to cache and pass the computed metrics regarding the
/// caret's size and position. This is preferred due to the expensive /// caret's size and position. This is preferred due to the expensive
/// nature of the calculation. /// nature of the calculation.
...@@ -750,7 +877,7 @@ class TextPainter { ...@@ -750,7 +877,7 @@ class TextPainter {
// Creates a ui.Paragraph using the current configurations in this class and // Creates a ui.Paragraph using the current configurations in this class and
// assign it to _paragraph. // assign it to _paragraph.
void _createParagraph() { ui.Paragraph _createParagraph() {
assert(_paragraph == null || _rebuildParagraphForPaint); assert(_paragraph == null || _rebuildParagraphForPaint);
final InlineSpan? text = this.text; final InlineSpan? text = this.text;
if (text == null) { if (text == null) {
...@@ -763,8 +890,9 @@ class TextPainter { ...@@ -763,8 +890,9 @@ class TextPainter {
_debugMarkNeedsLayoutCallStack = null; _debugMarkNeedsLayoutCallStack = null;
return true; return true;
}()); }());
_paragraph = builder.build(); final ui.Paragraph paragraph = _paragraph = builder.build();
_rebuildParagraphForPaint = false; _rebuildParagraphForPaint = false;
return paragraph;
} }
void _layoutParagraph(double minWidth, double maxWidth) { void _layoutParagraph(double minWidth, double maxWidth) {
...@@ -861,13 +989,18 @@ class TextPainter { ...@@ -861,13 +989,18 @@ class TextPainter {
canvas.drawParagraph(_paragraph!, offset); 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. // must be a UTF-16 code unit, meaning it must be in the range 0x0000-0xFFFF.
// //
// See also: // See also:
// * https://en.wikipedia.org/wiki/UTF-16#Code_points_from_U+010000_to_U+10FFFF // * https://en.wikipedia.org/wiki/UTF-16#Code_points_from_U+010000_to_U+10FFFF
static bool _isUtf16Surrogate(int value) { static bool _isHighSurrogate(int value) {
return value & 0xF800 == 0xD800; 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 // Checks if the glyph is either [Unicode.RLM] or [Unicode.LRM]. These values take
...@@ -886,7 +1019,7 @@ class TextPainter { ...@@ -886,7 +1019,7 @@ class TextPainter {
return null; return null;
} }
// TODO(goderbauer): doesn't handle extended grapheme clusters with more than one Unicode scalar value (https://github.com/flutter/flutter/issues/13404). // 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 /// Returns the closest offset before `offset` at which the input cursor can
...@@ -897,7 +1030,7 @@ class TextPainter { ...@@ -897,7 +1030,7 @@ class TextPainter {
return null; return null;
} }
// TODO(goderbauer): doesn't handle extended grapheme clusters with more than one Unicode scalar value (https://github.com/flutter/flutter/issues/13404). // 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. // Unicode value for a zero width joiner character.
...@@ -916,7 +1049,7 @@ class TextPainter { ...@@ -916,7 +1049,7 @@ class TextPainter {
const int NEWLINE_CODE_UNIT = 10; const int NEWLINE_CODE_UNIT = 10;
// Check for multi-code-unit glyphs such as emojis or zero width joiner. // 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; int graphemeClusterLength = needsSearch ? 2 : 1;
List<TextBox> boxes = <TextBox>[]; List<TextBox> boxes = <TextBox>[];
while (boxes.isEmpty) { while (boxes.isEmpty) {
...@@ -966,7 +1099,7 @@ class TextPainter { ...@@ -966,7 +1099,7 @@ class TextPainter {
final int nextCodeUnit = plainText.codeUnitAt(min(offset, plainTextLength - 1)); final int nextCodeUnit = plainText.codeUnitAt(min(offset, plainTextLength - 1));
// Check for multi-code-unit glyphs such as emojis or zero width joiner // 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; int graphemeClusterLength = needsSearch ? 2 : 1;
List<TextBox> boxes = <TextBox>[]; List<TextBox> boxes = <TextBox>[];
while (boxes.isEmpty) { while (boxes.isEmpty) {
...@@ -1141,6 +1274,18 @@ class TextPainter { ...@@ -1141,6 +1274,18 @@ class TextPainter {
return _paragraph!.getWordBoundary(position); 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. /// Returns the text range of the line at the given offset.
/// ///
/// The newline (if any) is not returned as part of the range. /// The newline (if any) is not returned as part of the range.
......
...@@ -2098,6 +2098,9 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin, ...@@ -2098,6 +2098,9 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin,
_setSelection(newSelection, cause); _setSelection(newSelection, cause);
} }
/// {@macro flutter.painting.TextPainter.wordBoundaries}
WordBoundary get wordBoundaries => _textPainter.wordBoundaries;
/// Select a word around the location of the last tap down. /// Select a word around the location of the last tap down.
/// ///
/// {@macro flutter.rendering.RenderEditable.selectPosition} /// {@macro flutter.rendering.RenderEditable.selectPosition}
......
...@@ -1559,21 +1559,21 @@ class _SelectableFragment with Selectable, ChangeNotifier implements TextLayoutM ...@@ -1559,21 +1559,21 @@ class _SelectableFragment with Selectable, ChangeNotifier implements TextLayoutM
switch (granularity) { switch (granularity) {
case TextGranularity.character: case TextGranularity.character:
final String text = range.textInside(fullText); final String text = range.textInside(fullText);
newPosition = _getNextPosition(CharacterBoundary(text), targetedEdge, forward); newPosition = _moveBeyondTextBoundaryAtDirection(targetedEdge, forward, CharacterBoundary(text));
result = SelectionResult.end; result = SelectionResult.end;
break; break;
case TextGranularity.word: case TextGranularity.word:
final String text = range.textInside(fullText); final TextBoundary textBoundary = paragraph._textPainter.wordBoundaries.moveByWordBoundary;
newPosition = _getNextPosition(WhitespaceBoundary(text) + WordBoundary(this), targetedEdge, forward); newPosition = _moveBeyondTextBoundaryAtDirection(targetedEdge, forward, textBoundary);
result = SelectionResult.end; result = SelectionResult.end;
break; break;
case TextGranularity.line: case TextGranularity.line:
newPosition = _getNextPosition(LineBreak(this), targetedEdge, forward); newPosition = _moveToTextBoundaryAtDirection(targetedEdge, forward, LineBoundary(this));
result = SelectionResult.end; result = SelectionResult.end;
break; break;
case TextGranularity.document: case TextGranularity.document:
final String text = range.textInside(fullText); final String text = range.textInside(fullText);
newPosition = _getNextPosition(DocumentBoundary(text), targetedEdge, forward); newPosition = _moveBeyondTextBoundaryAtDirection(targetedEdge, forward, DocumentBoundary(text));
if (forward && newPosition.offset == range.end) { if (forward && newPosition.offset == range.end) {
result = SelectionResult.next; result = SelectionResult.next;
} else if (!forward && newPosition.offset == range.start) { } else if (!forward && newPosition.offset == range.start) {
...@@ -1592,15 +1592,43 @@ class _SelectableFragment with Selectable, ChangeNotifier implements TextLayoutM ...@@ -1592,15 +1592,43 @@ class _SelectableFragment with Selectable, ChangeNotifier implements TextLayoutM
return result; return result;
} }
TextPosition _getNextPosition(TextBoundary boundary, TextPosition position, bool forward) { // Move **beyond** the local boundary of the given type (unless range.start or
if (forward) { // range.end is reached). Used for most TextGranularity types except for
return _clampTextPosition( // TextGranularity.line, to ensure the selection movement doesn't get stuck at
(PushTextPosition.forward + boundary).getTrailingTextBoundaryAt(position) // 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( final int offset = forward
(PushTextPosition.backward + boundary).getLeadingTextBoundaryAt(position), ? 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}) { MapEntry<TextPosition, SelectionResult> _handleVerticalMovement(TextPosition position, {required double horizontalBaselineInParagraphCoordinates, required bool below}) {
......
...@@ -2,60 +2,68 @@ ...@@ -2,60 +2,68 @@
// Use of this source code is governed by a BSD-style license that can be // Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file. // found in the LICENSE file.
import 'dart:math' as math; import 'dart:math';
import 'dart:ui';
import 'package:characters/characters.dart' show CharacterRange; import 'package:characters/characters.dart' show CharacterRange;
import 'text_layout_metrics.dart'; import 'text_layout_metrics.dart';
/// An interface for retrieving the logical text boundary (left-closed-right-open) // Examples can assume:
/// at a given location in a document. // 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 /// Either the [getTextBoundaryAt] method, or both the
/// can be visually represented by the caret if the selection were to collapse /// [getLeadingTextBoundaryAt] method and the [getTrailingTextBoundaryAt] method
/// to that position). The [TextPosition.affinity] is used to determine which /// must be implemented.
/// code unit it points. For example, `TextPosition(i, upstream)` points to
/// code unit `i - 1` and `TextPosition(i, downstream)` points to code unit `i`.
abstract class TextBoundary { abstract class TextBoundary {
/// A constant constructor to enable subclass override. /// A constant constructor to enable subclass override.
const TextBoundary(); 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. /// The return value, if not null, is usually less than or equal to `position`.
TextPosition getLeadingTextBoundaryAt(TextPosition 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. /// The return value, if not null, is usually greater than `position`.
TextPosition getTrailingTextBoundaryAt(TextPosition position); int? getTrailingTextBoundaryAt(int position) {
final int end = getTextBoundaryAt(max(0, position)).end;
/// Gets the text boundary range that encloses the input position. return end >= 0 ? end : null;
TextRange getTextBoundaryAt(TextPosition position) {
return TextRange(
start: getLeadingTextBoundaryAt(position).offset,
end: getTrailingTextBoundaryAt(position).offset,
);
} }
/// Gets the boundary by calling the left-hand side and pipe the result to /// Returns the text boundary range that encloses the input position.
/// right-hand side.
/// ///
/// Combining two text boundaries can be useful if one wants to ignore certain /// The returned [TextRange] may contain `-1`, which indicates no boundaries
/// text before finding the text boundary. For example, use /// can be found in that direction.
/// [WhitespaceBoundary] + [WordBoundary] to ignores any white space before TextRange getTextBoundaryAt(int position) {
/// finding word boundary if the input position happens to be a whitespace final int start = getLeadingTextBoundaryAt(position) ?? -1;
/// character. final int end = getTrailingTextBoundaryAt(position) ?? -1;
TextBoundary operator +(TextBoundary other) { return TextRange(start: start, end: end);
return _ExpandedTextBoundary(inner: other, outer: this);
} }
} }
/// 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 /// The class is implemented using the
/// boundaries that generate malformed utf-16 characters. /// [characters](https://pub.dev/packages/characters) package.
class CharacterBoundary extends TextBoundary { class CharacterBoundary extends TextBoundary {
/// Creates a [CharacterBoundary] with the text. /// Creates a [CharacterBoundary] with the text.
const CharacterBoundary(this._text); const CharacterBoundary(this._text);
...@@ -63,127 +71,59 @@ class CharacterBoundary extends TextBoundary { ...@@ -63,127 +71,59 @@ class CharacterBoundary extends TextBoundary {
final String _text; final String _text;
@override @override
TextPosition getLeadingTextBoundaryAt(TextPosition position) { int? getLeadingTextBoundaryAt(int position) {
if (position.offset <= 0) { if (position < 0) {
return const TextPosition(offset: 0); return null;
} }
if (position.offset > _text.length || final int graphemeStart = CharacterRange.at(_text, min(position, _text.length)).stringBeforeLength;
(position.offset == _text.length && position.affinity == TextAffinity.downstream)) { assert(CharacterRange.at(_text, graphemeStart).isEmpty);
return TextPosition(offset: _text.length, affinity: TextAffinity.upstream); return graphemeStart;
}
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,
);
} }
@override @override
TextPosition getTrailingTextBoundaryAt(TextPosition position) { int? getTrailingTextBoundaryAt(int position) {
if (position.offset < 0 || if (position >= _text.length) {
(position.offset == 0 && position.affinity == TextAffinity.upstream)) { return null;
return const TextPosition(offset: 0);
}
if (position.offset >= _text.length) {
return TextPosition(offset: _text.length, affinity: TextAffinity.upstream);
} }
final int endOffset; final CharacterRange rangeAtPosition = CharacterRange.at(_text, max(0, position + 1));
final int startOffset; final int nextBoundary = rangeAtPosition.stringBeforeLength + rangeAtPosition.current.length;
switch (position.affinity) { assert(nextBoundary == _text.length || CharacterRange.at(_text, nextBoundary).isEmpty);
case TextAffinity.upstream: return nextBoundary;
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,
);
}
}
/// 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
);
}
@override
TextPosition getTrailingTextBoundaryAt(TextPosition position) {
return TextPosition(
offset: _textLayout.getWordBoundary(position).end,
affinity: TextAffinity.upstream,
);
} }
@override @override
TextRange getTextBoundaryAt(TextPosition position) { TextRange getTextBoundaryAt(int position) {
return _textLayout.getWordBoundary(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);
}
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 /// When the given `position` points to a hard line break, the returned range
/// [TextLayoutMetrics.getLineAtOffset] is text-affinity-aware. /// is the line's content range before the hard line break, and does not contain
class LineBreak extends TextBoundary { /// the given `position`. For instance, the line breaks at `position = 1` for
/// Creates a [LineBreak] with the text and layout information. /// "a\nb" is `[0, 1)`, which does not contain the position `1`.
const LineBreak(this._textLayout); class LineBoundary extends TextBoundary {
/// Creates a [LineBoundary] with the text and layout information.
const LineBoundary(this._textLayout);
final TextLayoutMetrics _textLayout; final TextLayoutMetrics _textLayout;
@override @override
TextPosition getLeadingTextBoundaryAt(TextPosition position) { TextRange getTextBoundaryAt(int position) => _textLayout.getLineAtOffset(TextPosition(offset: max(position, 0)));
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);
}
} }
/// A text boundary that uses the entire document as logical boundary. /// 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 { class DocumentBoundary extends TextBoundary {
/// Creates a [DocumentBoundary] with the text /// Creates a [DocumentBoundary] with the text
const DocumentBoundary(this._text); const DocumentBoundary(this._text);
...@@ -191,158 +131,7 @@ class DocumentBoundary extends TextBoundary { ...@@ -191,158 +131,7 @@ class DocumentBoundary extends TextBoundary {
final String _text; final String _text;
@override @override
TextPosition getLeadingTextBoundaryAt(TextPosition position) => const TextPosition(offset: 0); int? getLeadingTextBoundaryAt(int position) => position < 0 ? null : 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);
@override @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( ...@@ -71,6 +71,10 @@ typedef EditableTextContextMenuBuilder = Widget Function(
EditableTextState editableTextState, 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 // 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 // transparent and vice versa. A full cursor blink, from transparent to opaque
// to transparent, is twice this duration. // to transparent, is twice this duration.
...@@ -3947,61 +3951,65 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien ...@@ -3947,61 +3951,65 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
: null; : null;
} }
// Returns the closest boundary location to `extent` but not including `extent`
// --------------------------- Text Editing Actions --------------------------- // itself (unless already at the start/end of the text), in the direction
// specified by `forward`.
TextBoundary _characterBoundary(DirectionalTextEditingIntent intent) { TextPosition _moveBeyondTextBoundary(TextPosition extent, bool forward, TextBoundary textBoundary) {
final TextBoundary atomicTextBoundary = widget.obscureText ? _CodeUnitBoundary(_value.text) : CharacterBoundary(_value.text); assert(extent.offset >= 0);
return intent.forward ? PushTextPosition.forward + atomicTextBoundary : PushTextPosition.backward + atomicTextBoundary; 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) { // Returns the closest boundary location to `extent`, including `extent`
final TextBoundary atomicTextBoundary; // itself, in the direction specified by `forward`.
final TextBoundary boundary; //
// This method returns a fixed point of itself: applying `_toTextBoundary`
if (widget.obscureText) { // again on the returned TextPosition gives the same TextPosition. It's used
atomicTextBoundary = _CodeUnitBoundary(_value.text); // exclusively for handling line boundaries, since performing "move to line
boundary = DocumentBoundary(_value.text); // start" more than once usually doesn't move you to the previous line.
} else { TextPosition _moveToTextBoundary(TextPosition extent, bool forward, TextBoundary textBoundary) {
final TextEditingValue textEditingValue = _textEditingValueforTextLayoutMetrics; assert(extent.offset >= 0);
atomicTextBoundary = CharacterBoundary(textEditingValue.text); final int caretOffset;
// This isn't enough. Newline characters. switch (extent.affinity) {
boundary = WhitespaceBoundary(textEditingValue.text) + WordBoundary(renderEditable); case TextAffinity.upstream:
if (extent.offset < 1 && !forward) {
assert (extent.offset == 0);
return const TextPosition(offset: 0);
}
// 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;
} }
// The line boundary range does not include some control characters
final _MixedBoundary mixedBoundary = intent.forward // (most notably, Line Feed), in which case there's
? _MixedBoundary(atomicTextBoundary, boundary) // `x ∉ getTextBoundaryAt(x)`. In case `caretOffset` points to one such
: _MixedBoundary(boundary, atomicTextBoundary); // control character, we define that these control characters themselves are
// Use a _MixedBoundary to make sure we don't leave invalid codepoints in // still part of the previous line, but also exclude them from the
// the field after deletion. // the line boundary range since they're non-printing. IOW, no additional
return intent.forward ? PushTextPosition.forward + mixedBoundary : PushTextPosition.backward + mixedBoundary; // 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);
} }
TextBoundary _linebreak(DirectionalTextEditingIntent intent) { // --------------------------- Text Editing Actions ---------------------------
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 _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);
}
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) { Action<T> _makeOverridable<T extends Intent>(Action<T> defaultAction) {
return Action<T>.overridable(context: context, defaultAction: defaultAction); return Action<T>.overridable(context: context, defaultAction: defaultAction);
...@@ -4178,40 +4186,6 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien ...@@ -4178,40 +4186,6 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
late final _UpdateTextSelectionVerticallyAction<DirectionalCaretMovementIntent> _verticalSelectionUpdateAction = late final _UpdateTextSelectionVerticallyAction<DirectionalCaretMovementIntent> _verticalSelectionUpdateAction =
_UpdateTextSelectionVerticallyAction<DirectionalCaretMovementIntent>(this); _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) { Object? _hideToolbarIfVisible(DismissIntent intent) {
if (_selectionOverlay?.toolbarIsVisible ?? false) { if (_selectionOverlay?.toolbarIsVisible ?? false) {
hideToolbar(false); hideToolbar(false);
...@@ -4265,24 +4239,26 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien ...@@ -4265,24 +4239,26 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
DismissIntent: CallbackAction<DismissIntent>(onInvoke: _hideToolbarIfVisible), DismissIntent: CallbackAction<DismissIntent>(onInvoke: _hideToolbarIfVisible),
// Delete // Delete
DeleteCharacterIntent: _makeOverridable(_DeleteTextAction<DeleteCharacterIntent>(this, _characterBoundary)), DeleteCharacterIntent: _makeOverridable(_DeleteTextAction<DeleteCharacterIntent>(this, _characterBoundary, _moveBeyondTextBoundary)),
DeleteToNextWordBoundaryIntent: _makeOverridable(_DeleteTextAction<DeleteToNextWordBoundaryIntent>(this, _nextWordBoundary)), DeleteToNextWordBoundaryIntent: _makeOverridable(_DeleteTextAction<DeleteToNextWordBoundaryIntent>(this, _nextWordBoundary, _moveBeyondTextBoundary)),
DeleteToLineBreakIntent: _makeOverridable(_DeleteTextAction<DeleteToLineBreakIntent>(this, _linebreak)), DeleteToLineBreakIntent: _makeOverridable(_DeleteTextAction<DeleteToLineBreakIntent>(this, _linebreak, _moveToTextBoundary)),
// Extend/Move Selection // 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)), ExtendSelectionByPageIntent: _makeOverridable(CallbackAction<ExtendSelectionByPageIntent>(onInvoke: _extendSelectionByPage)),
ExtendSelectionToNextWordBoundaryIntent: _makeOverridable(_UpdateTextSelectionAction<ExtendSelectionToNextWordBoundaryIntent>(this, true, _nextWordBoundary)), ExtendSelectionToNextWordBoundaryIntent: _makeOverridable(_UpdateTextSelectionAction<ExtendSelectionToNextWordBoundaryIntent>(this, _nextWordBoundary, _moveBeyondTextBoundary, ignoreNonCollapsedSelection: true)),
ExtendSelectionToLineBreakIntent: _makeOverridable(_UpdateTextSelectionAction<ExtendSelectionToLineBreakIntent>(this, true, _linebreak)), ExtendSelectionToLineBreakIntent: _makeOverridable(_UpdateTextSelectionAction<ExtendSelectionToLineBreakIntent>(this, _linebreak, _moveToTextBoundary, ignoreNonCollapsedSelection: true)),
ExpandSelectionToLineBreakIntent: _makeOverridable(CallbackAction<ExpandSelectionToLineBreakIntent>(onInvoke: _expandSelectionToLinebreak)),
ExpandSelectionToDocumentBoundaryIntent: _makeOverridable(CallbackAction<ExpandSelectionToDocumentBoundaryIntent>(onInvoke: _expandSelectionToDocumentBoundary)),
ExtendSelectionVerticallyToAdjacentLineIntent: _makeOverridable(_verticalSelectionUpdateAction), ExtendSelectionVerticallyToAdjacentLineIntent: _makeOverridable(_verticalSelectionUpdateAction),
ExtendSelectionVerticallyToAdjacentPageIntent: _makeOverridable(_verticalSelectionUpdateAction), ExtendSelectionVerticallyToAdjacentPageIntent: _makeOverridable(_verticalSelectionUpdateAction),
ExtendSelectionToDocumentBoundaryIntent: _makeOverridable(_UpdateTextSelectionAction<ExtendSelectionToDocumentBoundaryIntent>(this, true, _documentBoundary)), ExtendSelectionToDocumentBoundaryIntent: _makeOverridable(_UpdateTextSelectionAction<ExtendSelectionToDocumentBoundaryIntent>(this, _documentBoundary, _moveBeyondTextBoundary, ignoreNonCollapsedSelection: true)),
ExtendSelectionToNextWordBoundaryOrCaretLocationIntent: _makeOverridable(_ExtendSelectionOrCaretPositionAction(this, _nextWordBoundary)), ExtendSelectionToNextWordBoundaryOrCaretLocationIntent: _makeOverridable(_UpdateTextSelectionAction<ExtendSelectionToNextWordBoundaryOrCaretLocationIntent>(this, _nextWordBoundary, _moveBeyondTextBoundary, ignoreNonCollapsedSelection: true)),
ScrollToDocumentBoundaryIntent: _makeOverridable(CallbackAction<ScrollToDocumentBoundaryIntent>(onInvoke: _scrollToDocumentBoundary)), ScrollToDocumentBoundaryIntent: _makeOverridable(CallbackAction<ScrollToDocumentBoundaryIntent>(onInvoke: _scrollToDocumentBoundary)),
ScrollIntent: CallbackAction<ScrollIntent>(onInvoke: _scroll), 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 // Copy Paste
SelectAllTextIntent: _makeOverridable(_SelectAllAction(this)), SelectAllTextIntent: _makeOverridable(_SelectAllAction(this)),
CopySelectionTextIntent: _makeOverridable(_CopySelectionAction(this)), CopySelectionTextIntent: _makeOverridable(_CopySelectionAction(this)),
...@@ -4832,7 +4808,7 @@ class _ScribblePlaceholder extends WidgetSpan { ...@@ -4832,7 +4808,7 @@ class _ScribblePlaceholder extends WidgetSpan {
/// ///
/// This text boundary treats every character in input string as an utf-16 code /// 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, /// 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]. /// include grapheme clusters, consider using [CharacterBoundary].
class _CodeUnitBoundary extends TextBoundary { class _CodeUnitBoundary extends TextBoundary {
const _CodeUnitBoundary(this._text); const _CodeUnitBoundary(this._text);
...@@ -4840,113 +4816,51 @@ class _CodeUnitBoundary extends TextBoundary { ...@@ -4840,113 +4816,51 @@ class _CodeUnitBoundary extends TextBoundary {
final String _text; final String _text;
@override @override
TextPosition getLeadingTextBoundaryAt(TextPosition position) { int getLeadingTextBoundaryAt(int position) => position.clamp(0, _text.length); // ignore_clamp_double_lint
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));
}
}
@override @override
TextPosition getTrailingTextBoundaryAt(TextPosition position) { int getTrailingTextBoundaryAt(int position) => (position + 1).clamp(0, _text.length); // ignore_clamp_double_lint
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);
} }
// ------------------------------- Text Actions ------------------------------- // ------------------------------- Text Actions -------------------------------
class _DeleteTextAction<T extends DirectionalTextEditingIntent> extends ContextAction<T> { class _DeleteTextAction<T extends DirectionalTextEditingIntent> extends ContextAction<T> {
_DeleteTextAction(this.state, this.getTextBoundariesForIntent); _DeleteTextAction(this.state, this.getTextBoundary, this._applyTextBoundary);
final EditableTextState state; final EditableTextState state;
final TextBoundary Function(T intent) getTextBoundariesForIntent; final TextBoundary Function() getTextBoundary;
final _ApplyTextBoundary _applyTextBoundary;
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,
);
}
@override @override
Object? invoke(T intent, [BuildContext? context]) { Object? invoke(T intent, [BuildContext? context]) {
final TextSelection selection = state._value.selection; final TextSelection selection = state._value.selection;
if (!selection.isValid) {
return null;
}
assert(selection.isValid); assert(selection.isValid);
// Expands the selection to ensure the range covers full graphemes.
final TextBoundary atomicBoundary = state._characterBoundary();
if (!selection.isCollapsed) { if (!selection.isCollapsed) {
return Actions.invoke( // Expands the selection to ensure the range covers full graphemes.
context!, final TextRange range = TextRange(
ReplaceTextIntent(state._value, '', _expandNonCollapsedRange(state._value), SelectionChangedCause.keyboard), 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( return Actions.invoke(
context!, 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( return Actions.invoke(
context!, context!,
ReplaceTextIntent( ReplaceTextIntent(state._value, '', rangeToDelete, SelectionChangedCause.keyboard),
state._value,
'',
textBoundary.getTextBoundaryAt(state._value.selection.base),
SelectionChangedCause.keyboard,
),
); );
} }
...@@ -4957,13 +4871,19 @@ class _DeleteTextAction<T extends DirectionalTextEditingIntent> extends ContextA ...@@ -4957,13 +4871,19 @@ class _DeleteTextAction<T extends DirectionalTextEditingIntent> extends ContextA
class _UpdateTextSelectionAction<T extends DirectionalCaretMovementIntent> extends ContextAction<T> { class _UpdateTextSelectionAction<T extends DirectionalCaretMovementIntent> extends ContextAction<T> {
_UpdateTextSelectionAction( _UpdateTextSelectionAction(
this.state, this.state,
this.ignoreNonCollapsedSelection, this.getTextBoundary,
this.getTextBoundariesForIntent, this.applyTextBoundary, {
); required this.ignoreNonCollapsedSelection,
this.isExpand = false,
this.extentAtIndex = false,
});
final EditableTextState state; final EditableTextState state;
final bool ignoreNonCollapsedSelection; 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; static const int NEWLINE_CODE_UNIT = 10;
...@@ -4994,25 +4914,14 @@ class _UpdateTextSelectionAction<T extends DirectionalCaretMovementIntent> exten ...@@ -4994,25 +4914,14 @@ class _UpdateTextSelectionAction<T extends DirectionalCaretMovementIntent> exten
assert(selection.isValid); assert(selection.isValid);
final bool collapseSelection = intent.collapseSelection || !state.widget.selectionEnabled; 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) { if (!selection.isCollapsed && !ignoreNonCollapsedSelection && collapseSelection) {
return Actions.invoke( return Actions.invoke(context!, UpdateSelectionIntent(
context!, state._value,
UpdateSelectionIntent(state._value, collapse(selection), SelectionChangedCause.keyboard), TextSelection.collapsed(offset: intent.forward ? selection.end : selection.start),
); SelectionChangedCause.keyboard,
));
} }
final TextBoundary textBoundary = getTextBoundariesForIntent(intent);
TextPosition extent = selection.extent; TextPosition extent = selection.extent;
// If continuesAtWrap is true extent and is at the relevant wordwrap, then // If continuesAtWrap is true extent and is at the relevant wordwrap, then
// move it just to the other side of the wordwrap. // move it just to the other side of the wordwrap.
...@@ -5029,76 +4938,22 @@ class _UpdateTextSelectionAction<T extends DirectionalCaretMovementIntent> exten ...@@ -5029,76 +4938,22 @@ class _UpdateTextSelectionAction<T extends DirectionalCaretMovementIntent> exten
} }
} }
final TextPosition newExtent = intent.forward final bool shouldTargetBase = isExpand && (intent.forward ? selection.baseOffset > selection.extentOffset : selection.baseOffset < selection.extentOffset);
? textBoundary.getTrailingTextBoundaryAt(extent) final TextPosition newExtent = applyTextBoundary(shouldTargetBase ? selection.base : extent, intent.forward, getTextBoundary());
: textBoundary.getLeadingTextBoundaryAt(extent); final TextSelection newSelection = collapseSelection || (!isExpand && newExtent.offset == selection.baseOffset)
final TextSelection newSelection = collapseSelection
? TextSelection.fromPosition(newExtent) ? TextSelection.fromPosition(newExtent)
: selection.extendTo(newExtent); : isExpand ? selection.expandTo(newExtent, extentAtIndex || selection.isCollapsed) : selection.extendTo(newExtent);
// If collapseAtReversal is true and would have an effect, collapse it. final bool shouldCollapseToBase = intent.collapseAtReversal
if (!selection.isCollapsed && intent.collapseAtReversal && (selection.baseOffset - selection.extentOffset) * (selection.baseOffset - newSelection.extentOffset) < 0;
&& (selection.baseOffset < selection.extentOffset != final TextSelection newRange = shouldCollapseToBase ? TextSelection.fromPosition(selection.base) : newSelection;
newSelection.baseOffset < newSelection.extentOffset)) { return Actions.invoke(context!, UpdateSelectionIntent(state._value, newRange, SelectionChangedCause.keyboard));
return Actions.invoke(
context!,
UpdateSelectionIntent(
state._value,
TextSelection.fromPosition(selection.base),
SelectionChangedCause.keyboard,
),
);
}
return Actions.invoke(
context!,
UpdateSelectionIntent(state._value, newSelection, SelectionChangedCause.keyboard),
);
} }
@override @override
bool get isActionEnabled => state._value.selection.isValid; 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> { class _UpdateTextSelectionVerticallyAction<T extends DirectionalCaretMovementIntent> extends ContextAction<T> {
_UpdateTextSelectionVerticallyAction(this.state); _UpdateTextSelectionVerticallyAction(this.state);
......
...@@ -137,11 +137,11 @@ class ExtendSelectionToNextWordBoundaryIntent extends DirectionalCaretMovementIn ...@@ -137,11 +137,11 @@ class ExtendSelectionToNextWordBoundaryIntent extends DirectionalCaretMovementIn
/// reverse. /// reverse.
/// ///
/// This is typically only used on MacOS. /// This is typically only used on MacOS.
class ExtendSelectionToNextWordBoundaryOrCaretLocationIntent extends DirectionalTextEditingIntent { class ExtendSelectionToNextWordBoundaryOrCaretLocationIntent extends DirectionalCaretMovementIntent {
/// Creates an [ExtendSelectionToNextWordBoundaryOrCaretLocationIntent]. /// Creates an [ExtendSelectionToNextWordBoundaryOrCaretLocationIntent].
const ExtendSelectionToNextWordBoundaryOrCaretLocationIntent({ const ExtendSelectionToNextWordBoundaryOrCaretLocationIntent({
required bool forward, required bool forward,
}) : super(forward); }) : super(forward, false, true);
} }
/// Expands the current selection to the document boundary in the direction /// Expands the current selection to the document boundary in the direction
...@@ -154,11 +154,11 @@ class ExtendSelectionToNextWordBoundaryOrCaretLocationIntent extends Directional ...@@ -154,11 +154,11 @@ class ExtendSelectionToNextWordBoundaryOrCaretLocationIntent extends Directional
/// ///
/// [ExtendSelectionToDocumentBoundaryIntent], which is similar but always /// [ExtendSelectionToDocumentBoundaryIntent], which is similar but always
/// moves the extent. /// moves the extent.
class ExpandSelectionToDocumentBoundaryIntent extends DirectionalTextEditingIntent { class ExpandSelectionToDocumentBoundaryIntent extends DirectionalCaretMovementIntent {
/// Creates an [ExpandSelectionToDocumentBoundaryIntent]. /// Creates an [ExpandSelectionToDocumentBoundaryIntent].
const ExpandSelectionToDocumentBoundaryIntent({ const ExpandSelectionToDocumentBoundaryIntent({
required bool forward, required bool forward,
}) : super(forward); }) : super(forward, false);
} }
/// Expands the current selection to the closest line break in the direction /// Expands the current selection to the closest line break in the direction
...@@ -173,11 +173,11 @@ class ExpandSelectionToDocumentBoundaryIntent extends DirectionalTextEditingInte ...@@ -173,11 +173,11 @@ class ExpandSelectionToDocumentBoundaryIntent extends DirectionalTextEditingInte
/// ///
/// [ExtendSelectionToLineBreakIntent], which is similar but always moves the /// [ExtendSelectionToLineBreakIntent], which is similar but always moves the
/// extent. /// extent.
class ExpandSelectionToLineBreakIntent extends DirectionalTextEditingIntent { class ExpandSelectionToLineBreakIntent extends DirectionalCaretMovementIntent {
/// Creates an [ExpandSelectionToLineBreakIntent]. /// Creates an [ExpandSelectionToLineBreakIntent].
const ExpandSelectionToLineBreakIntent({ const ExpandSelectionToLineBreakIntent({
required bool forward, required bool forward,
}) : super(forward); }) : super(forward, false);
} }
/// Extends, or moves the current selection from the current /// Extends, or moves the current selection from the current
......
...@@ -9785,7 +9785,8 @@ void main() { ...@@ -9785,7 +9785,8 @@ void main() {
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect( expect(
controller.selection, 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. // Keep moving out.
...@@ -9795,7 +9796,7 @@ void main() { ...@@ -9795,7 +9796,7 @@ void main() {
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect( expect(
controller.selection, controller.selection,
const TextSelection.collapsed(offset: 62, affinity: TextAffinity.upstream), const TextSelection.collapsed(offset: 62),
); );
for (int i = 0; i < (66 - 62); i += 1) { for (int i = 0; i < (66 - 62); i += 1) {
await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight);
...@@ -9803,7 +9804,7 @@ void main() { ...@@ -9803,7 +9804,7 @@ void main() {
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect( expect(
controller.selection, controller.selection,
const TextSelection.collapsed(offset: 66, affinity: TextAffinity.upstream), const TextSelection.collapsed(offset: 66),
); // We're at the edge now. ); // We're at the edge now.
await tester.pumpAndSettle(); await tester.pumpAndSettle();
......
...@@ -1075,8 +1075,13 @@ void main() { ...@@ -1075,8 +1075,13 @@ void main() {
), ),
); );
selection = paragraph.selections[0]; selection = paragraph.selections[0];
expect(selection.start, 0); // [how ]are you if (isBrowser && !isCanvasKit) {
expect(selection.end, 4); // 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 { test('can granularly extend selection - document', () async {
......
...@@ -2,132 +2,169 @@ ...@@ -2,132 +2,169 @@
// Use of this source code is governed by a BSD-style license that can be // Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file. // found in the LICENSE file.
import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.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() { void main() {
test('Character boundary works', () { test('Character boundary works', () {
const CharacterBoundary boundary = CharacterBoundary('abc'); const CharacterBoundary boundary = CharacterBoundary('abc');
const TextPosition midPosition = TextPosition(offset: 1); expect(boundary, _hasConsistentTextRangeImplementationWithinRange(3));
expect(boundary.getLeadingTextBoundaryAt(midPosition), const TextPosition(offset: 1));
expect(boundary.getTrailingTextBoundaryAt(midPosition), const TextPosition(offset: 2, affinity: TextAffinity.upstream)); 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(3), 3);
expect(boundary.getLeadingTextBoundaryAt(startPosition), const TextPosition(offset: 0)); expect(boundary.getTrailingTextBoundaryAt(3), null);
expect(boundary.getTrailingTextBoundaryAt(startPosition), const TextPosition(offset: 1, affinity: TextAffinity.upstream));
const TextPosition endPosition = TextPosition(offset: 3); expect(boundary.getLeadingTextBoundaryAt(4), 3);
expect(boundary.getLeadingTextBoundaryAt(endPosition), const TextPosition(offset: 3, affinity: TextAffinity.upstream)); expect(boundary.getTrailingTextBoundaryAt(4), null);
expect(boundary.getTrailingTextBoundaryAt(endPosition), const TextPosition(offset: 3, affinity: TextAffinity.upstream));
}); });
test('Character boundary works with grapheme', () { test('Character boundary works with grapheme', () {
const String text = 'a❄︎c'; const String text = 'a❄︎c';
const CharacterBoundary boundary = CharacterBoundary(text); const CharacterBoundary boundary = CharacterBoundary(text);
TextPosition position = const TextPosition(offset: 1); expect(boundary, _hasConsistentTextRangeImplementationWithinRange(text.length));
expect(boundary.getLeadingTextBoundaryAt(position), const TextPosition(offset: 1));
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. // 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(2), 1);
expect(boundary.getLeadingTextBoundaryAt(position), const TextPosition(offset: 1)); expect(boundary.getTrailingTextBoundaryAt(2), 3);
expect(boundary.getTrailingTextBoundaryAt(position), const TextPosition(offset: 3, affinity: TextAffinity.upstream));
position = const TextPosition(offset: 0); expect(boundary.getLeadingTextBoundaryAt(3), 3);
expect(boundary.getLeadingTextBoundaryAt(position), const TextPosition(offset: 0)); expect(boundary.getTrailingTextBoundaryAt(3), 4);
expect(boundary.getTrailingTextBoundaryAt(position), const TextPosition(offset: 1, affinity: TextAffinity.upstream));
position = const TextPosition(offset: text.length); expect(boundary.getLeadingTextBoundaryAt(text.length), text.length);
expect(boundary.getLeadingTextBoundaryAt(position), const TextPosition(offset: text.length, affinity: TextAffinity.upstream)); expect(boundary.getTrailingTextBoundaryAt(text.length), null);
expect(boundary.getTrailingTextBoundaryAt(position), const TextPosition(offset: text.length, affinity: TextAffinity.upstream));
}); });
test('word boundary works', () { test('wordBoundary.moveByWordBoundary', () {
final WordBoundary boundary = WordBoundary(TestTextLayoutMetrics()); const String text = 'ABC ABC\n' // [0, 10)
const TextPosition position = TextPosition(offset: 3); 'AÁ Á\n' // [10, 20)
expect(boundary.getLeadingTextBoundaryAt(position).offset, TestTextLayoutMetrics.wordBoundaryAt3.start); ' \n' // [20, 30)
expect(boundary.getTrailingTextBoundaryAt(position).offset, TestTextLayoutMetrics.wordBoundaryAt3.end); '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', () { test('line boundary works', () {
final LineBreak boundary = LineBreak(TestTextLayoutMetrics()); final LineBoundary boundary = LineBoundary(TestTextLayoutMetrics());
const TextPosition position = TextPosition(offset: 3); expect(boundary.getLeadingTextBoundaryAt(3), TestTextLayoutMetrics.lineAt3.start);
expect(boundary.getLeadingTextBoundaryAt(position).offset, TestTextLayoutMetrics.lineAt3.start); expect(boundary.getTrailingTextBoundaryAt(3), TestTextLayoutMetrics.lineAt3.end);
expect(boundary.getTrailingTextBoundaryAt(position).offset, TestTextLayoutMetrics.lineAt3.end); expect(boundary.getTextBoundaryAt(3), TestTextLayoutMetrics.lineAt3);
}); });
test('document boundary works', () { test('document boundary works', () {
const String text = 'abcd efg hi\njklmno\npqrstuv'; const String text = 'abcd efg hi\njklmno\npqrstuv';
const DocumentBoundary boundary = DocumentBoundary(text); const DocumentBoundary boundary = DocumentBoundary(text);
const TextPosition position = TextPosition(offset: 10); expect(boundary, _hasConsistentTextRangeImplementationWithinRange(text.length));
expect(boundary.getLeadingTextBoundaryAt(position), const TextPosition(offset: 0));
expect(boundary.getTrailingTextBoundaryAt(position), const TextPosition(offset: text.length, affinity: TextAffinity.upstream));
});
test('white space boundary works', () { expect(boundary.getLeadingTextBoundaryAt(-1), null);
const String text = 'abcd efg'; expect(boundary.getTrailingTextBoundaryAt(-1), text.length);
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);
});
test('extended boundary should work', () { expect(boundary.getLeadingTextBoundaryAt(0), 0);
const String text = 'abcd efg'; expect(boundary.getTrailingTextBoundaryAt(0), text.length);
const WhitespaceBoundary outer = WhitespaceBoundary(text);
const CharacterBoundary inner = CharacterBoundary(text);
final TextBoundary expanded = outer + inner;
TextPosition position = const TextPosition(offset: 1); expect(boundary.getLeadingTextBoundaryAt(10), 0);
expect(expanded.getLeadingTextBoundaryAt(position), position); expect(boundary.getTrailingTextBoundaryAt(10), text.length);
expect(expanded.getTrailingTextBoundaryAt(position), const TextPosition(offset: 2, affinity: TextAffinity.upstream));
position = const TextPosition(offset: 5); expect(boundary.getLeadingTextBoundaryAt(text.length), 0);
// should skip white space expect(boundary.getTrailingTextBoundaryAt(text.length), null);
expect(expanded.getLeadingTextBoundaryAt(position), const TextPosition(offset: 3));
expect(expanded.getTrailingTextBoundaryAt(position), const TextPosition(offset: 9, affinity: TextAffinity.upstream));
});
test('push text position works', () { expect(boundary.getLeadingTextBoundaryAt(text.length + 1), 0);
const String text = 'abcd efg'; expect(boundary.getTrailingTextBoundaryAt(text.length + 1), null);
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));
}); });
} }
......
...@@ -45,7 +45,7 @@ void main() { ...@@ -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 TargetPlatformVariant iOS = TargetPlatformVariant.only(TargetPlatform.iOS);
final FocusNode editable = FocusNode(); final FocusNode editable = FocusNode();
final FocusNode spy = FocusNode(); final FocusNode spy = FocusNode();
......
...@@ -576,12 +576,12 @@ void main() { ...@@ -576,12 +576,12 @@ void main() {
'Now is the time for\n' 'Now is the time for\n'
'all good people\n' 'all good people\n'
'to come to the aid\n' 'to come to the aid\n'
'of their country', 'of their ',
); );
expect( expect(
controller.selection, controller.selection,
const TextSelection.collapsed(offset: 71), const TextSelection.collapsed(offset: 64),
); );
}, variant: TargetPlatformVariant.all(excluding: <TargetPlatform>{ TargetPlatform.iOS })); }, variant: TargetPlatformVariant.all(excluding: <TargetPlatform>{ TargetPlatform.iOS }));
...@@ -795,7 +795,7 @@ void main() { ...@@ -795,7 +795,7 @@ void main() {
testWidgets('softwrap line boundary, upstream', (WidgetTester tester) async { testWidgets('softwrap line boundary, upstream', (WidgetTester tester) async {
controller.text = testSoftwrapText; 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( controller.selection = const TextSelection.collapsed(
offset: 40, offset: 40,
affinity: TextAffinity.upstream, affinity: TextAffinity.upstream,
...@@ -827,12 +827,11 @@ void main() { ...@@ -827,12 +827,11 @@ void main() {
await tester.pumpWidget(buildEditableText()); await tester.pumpWidget(buildEditableText());
await sendKeyCombination(tester, lineModifierBackspace()); await sendKeyCombination(tester, lineModifierBackspace());
expect(controller.text, testSoftwrapText);
expect( expect(
controller.selection, controller.selection,
const TextSelection.collapsed(offset: 40), const TextSelection.collapsed(offset: 40),
); );
expect(controller.text, testSoftwrapText);
}, variant: TargetPlatformVariant.all()); }, variant: TargetPlatformVariant.all());
testWidgets('readonly', (WidgetTester tester) async { testWidgets('readonly', (WidgetTester tester) async {
...@@ -976,7 +975,7 @@ void main() { ...@@ -976,7 +975,7 @@ void main() {
testWidgets('softwrap line boundary, upstream', (WidgetTester tester) async { testWidgets('softwrap line boundary, upstream', (WidgetTester tester) async {
controller.text = testSoftwrapText; 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( controller.selection = const TextSelection.collapsed(
offset: 40, offset: 40,
affinity: TextAffinity.upstream, affinity: TextAffinity.upstream,
...@@ -1215,7 +1214,6 @@ void main() { ...@@ -1215,7 +1214,6 @@ void main() {
expect(controller.selection, const TextSelection.collapsed( expect(controller.selection, const TextSelection.collapsed(
offset: 21, offset: 21,
affinity: TextAffinity.upstream,
)); ));
}, variant: TargetPlatformVariant.all()); }, variant: TargetPlatformVariant.all());
...@@ -1230,7 +1228,6 @@ void main() { ...@@ -1230,7 +1228,6 @@ void main() {
expect(controller.selection, const TextSelection.collapsed( expect(controller.selection, const TextSelection.collapsed(
offset: 10, offset: 10,
affinity: TextAffinity.upstream,
)); ));
}, variant: allExceptApple); }, variant: allExceptApple);
...@@ -1341,7 +1338,6 @@ void main() { ...@@ -1341,7 +1338,6 @@ void main() {
await tester.pump(); await tester.pump();
expect(controller.selection, const TextSelection.collapsed( expect(controller.selection, const TextSelection.collapsed(
offset: 46, // After "to". offset: 46, // After "to".
affinity: TextAffinity.upstream,
)); ));
// "good" to "come" is selected. // "good" to "come" is selected.
...@@ -1354,7 +1350,6 @@ void main() { ...@@ -1354,7 +1350,6 @@ void main() {
await tester.pump(); await tester.pump();
expect(controller.selection, const TextSelection.collapsed( expect(controller.selection, const TextSelection.collapsed(
offset: 28, // After "good". offset: 28, // After "good".
affinity: TextAffinity.upstream,
)); ));
}, variant: allExceptApple); }, variant: allExceptApple);
...@@ -1718,8 +1713,7 @@ void main() { ...@@ -1718,8 +1713,7 @@ void main() {
await tester.pump(); await tester.pump();
expect(controller.selection, const TextSelection.collapsed( expect(controller.selection, const TextSelection.collapsed(
offset: 10, offset: 10, // after the first "the"
affinity: TextAffinity.upstream,
)); ));
}, variant: macOSOnly); }, variant: macOSOnly);
...@@ -1790,7 +1784,6 @@ void main() { ...@@ -1790,7 +1784,6 @@ void main() {
await tester.pump(); await tester.pump();
expect(controller.selection, const TextSelection.collapsed( expect(controller.selection, const TextSelection.collapsed(
offset: 46, // After "to". offset: 46, // After "to".
affinity: TextAffinity.upstream,
)); ));
// "good" to "come" is selected. // "good" to "come" is selected.
...@@ -1803,7 +1796,6 @@ void main() { ...@@ -1803,7 +1796,6 @@ void main() {
await tester.pump(); await tester.pump();
expect(controller.selection, const TextSelection.collapsed( expect(controller.selection, const TextSelection.collapsed(
offset: 28, // After "good". offset: 28, // After "good".
affinity: TextAffinity.upstream,
)); ));
}, variant: macOSOnly); }, variant: macOSOnly);
...@@ -2351,9 +2343,7 @@ void main() { ...@@ -2351,9 +2343,7 @@ void main() {
expect(controller.text, 'testing'); expect(controller.text, 'testing');
expect( expect(
controller.selection, controller.selection,
const TextSelection.collapsed( const TextSelection.collapsed(offset: 6), // should not expand selection
offset: 6,
affinity: TextAffinity.upstream), // should not expand selection
reason: selectRight.toString(), reason: selectRight.toString(),
); );
}, variant: TargetPlatformVariant.desktop()); }, variant: TargetPlatformVariant.desktop());
......
...@@ -5695,7 +5695,6 @@ void main() { ...@@ -5695,7 +5695,6 @@ void main() {
const TextSelection( const TextSelection(
baseOffset: 0, baseOffset: 0,
extentOffset: 6, extentOffset: 6,
affinity: TextAffinity.upstream,
), ),
), ),
reason: 'on $platform', reason: 'on $platform',
...@@ -5957,7 +5956,6 @@ void main() { ...@@ -5957,7 +5956,6 @@ void main() {
equals( equals(
const TextSelection.collapsed( const TextSelection.collapsed(
offset: testText.length, offset: testText.length,
affinity: TextAffinity.upstream,
), ),
), ),
reason: 'on $platform', reason: 'on $platform',
...@@ -6001,7 +5999,6 @@ void main() { ...@@ -6001,7 +5999,6 @@ void main() {
equals( equals(
const TextSelection.collapsed( const TextSelection.collapsed(
offset: 3, offset: 3,
affinity: TextAffinity.upstream,
), ),
), ),
reason: 'on $platform', reason: 'on $platform',
...@@ -6193,7 +6190,6 @@ void main() { ...@@ -6193,7 +6190,6 @@ void main() {
const TextSelection( const TextSelection(
baseOffset: 10, baseOffset: 10,
extentOffset: 10, extentOffset: 10,
affinity: TextAffinity.upstream,
), ),
), ),
reason: 'on $platform', reason: 'on $platform',
...@@ -6653,7 +6649,6 @@ void main() { ...@@ -6653,7 +6649,6 @@ void main() {
equals( equals(
const TextSelection.collapsed( const TextSelection.collapsed(
offset: 23, offset: 23,
affinity: TextAffinity.upstream,
), ),
), ),
reason: 'on $platform', reason: 'on $platform',
...@@ -6678,7 +6673,6 @@ void main() { ...@@ -6678,7 +6673,6 @@ void main() {
equals( equals(
const TextSelection.collapsed( const TextSelection.collapsed(
offset: 23, offset: 23,
affinity: TextAffinity.upstream,
), ),
), ),
reason: 'on $platform', reason: 'on $platform',
...@@ -6721,7 +6715,6 @@ void main() { ...@@ -6721,7 +6715,6 @@ void main() {
equals( equals(
const TextSelection.collapsed( const TextSelection.collapsed(
offset: 23, offset: 23,
affinity: TextAffinity.upstream,
), ),
), ),
reason: 'on $platform', reason: 'on $platform',
...@@ -6807,7 +6800,6 @@ void main() { ...@@ -6807,7 +6800,6 @@ void main() {
equals( equals(
const TextSelection.collapsed( const TextSelection.collapsed(
offset: 32, offset: 32,
affinity: TextAffinity.upstream,
), ),
), ),
reason: 'on $platform', reason: 'on $platform',
...@@ -6832,7 +6824,6 @@ void main() { ...@@ -6832,7 +6824,6 @@ void main() {
equals( equals(
const TextSelection.collapsed( const TextSelection.collapsed(
offset: 32, offset: 32,
affinity: TextAffinity.upstream,
), ),
), ),
reason: 'on $platform', reason: 'on $platform',
...@@ -6875,7 +6866,6 @@ void main() { ...@@ -6875,7 +6866,6 @@ void main() {
equals( equals(
const TextSelection.collapsed( const TextSelection.collapsed(
offset: 32, offset: 32,
affinity: TextAffinity.upstream,
), ),
), ),
reason: 'on $platform', reason: 'on $platform',
...@@ -6971,7 +6961,6 @@ void main() { ...@@ -6971,7 +6961,6 @@ void main() {
equals( equals(
const TextSelection.collapsed( const TextSelection.collapsed(
offset: 32, offset: 32,
affinity: TextAffinity.upstream,
), ),
), ),
reason: 'on $platform', reason: 'on $platform',
...@@ -6996,7 +6985,6 @@ void main() { ...@@ -6996,7 +6985,6 @@ void main() {
equals( equals(
const TextSelection.collapsed( const TextSelection.collapsed(
offset: 32, offset: 32,
affinity: TextAffinity.upstream,
), ),
), ),
reason: 'on $platform', reason: 'on $platform',
...@@ -7039,7 +7027,6 @@ void main() { ...@@ -7039,7 +7027,6 @@ void main() {
equals( equals(
const TextSelection.collapsed( const TextSelection.collapsed(
offset: 32, offset: 32,
affinity: TextAffinity.upstream,
), ),
), ),
reason: 'on $platform', reason: 'on $platform',
...@@ -7136,7 +7123,6 @@ void main() { ...@@ -7136,7 +7123,6 @@ void main() {
equals( equals(
const TextSelection.collapsed( const TextSelection.collapsed(
offset: 23, offset: 23,
affinity: TextAffinity.upstream,
), ),
), ),
reason: 'on $platform', reason: 'on $platform',
...@@ -7182,7 +7168,6 @@ void main() { ...@@ -7182,7 +7168,6 @@ void main() {
const TextSelection( const TextSelection(
baseOffset: 23, baseOffset: 23,
extentOffset: 23, extentOffset: 23,
affinity: TextAffinity.upstream,
), ),
), ),
reason: 'on $platform', reason: 'on $platform',
...@@ -7193,7 +7178,6 @@ void main() { ...@@ -7193,7 +7178,6 @@ void main() {
const TextSelection( const TextSelection(
baseOffset: 23, baseOffset: 23,
extentOffset: 23, extentOffset: 23,
affinity: TextAffinity.upstream,
), ),
), ),
reason: 'on $platform', reason: 'on $platform',
...@@ -7327,7 +7311,6 @@ void main() { ...@@ -7327,7 +7311,6 @@ void main() {
controller.selection, controller.selection,
equals(const TextSelection.collapsed( equals(const TextSelection.collapsed(
offset: 4, offset: 4,
affinity: TextAffinity.upstream,
)), )),
); );
...@@ -7511,7 +7494,6 @@ void main() { ...@@ -7511,7 +7494,6 @@ void main() {
equals( equals(
const TextSelection.collapsed( const TextSelection.collapsed(
offset: 32, offset: 32,
affinity: TextAffinity.upstream,
), ),
), ),
reason: 'on $platform', reason: 'on $platform',
...@@ -7535,7 +7517,6 @@ void main() { ...@@ -7535,7 +7517,6 @@ void main() {
equals( equals(
const TextSelection.collapsed( const TextSelection.collapsed(
offset: 32, offset: 32,
affinity: TextAffinity.upstream,
), ),
), ),
reason: 'on $platform', reason: 'on $platform',
...@@ -7593,7 +7574,6 @@ void main() { ...@@ -7593,7 +7574,6 @@ void main() {
equals( equals(
const TextSelection.collapsed( const TextSelection.collapsed(
offset: 32, offset: 32,
affinity: TextAffinity.upstream,
), ),
), ),
reason: 'on $platform', reason: 'on $platform',
...@@ -7706,7 +7686,6 @@ void main() { ...@@ -7706,7 +7686,6 @@ void main() {
equals( equals(
const TextSelection.collapsed( const TextSelection.collapsed(
offset: 32, offset: 32,
affinity: TextAffinity.upstream,
), ),
), ),
reason: 'on $platform', reason: 'on $platform',
...@@ -7730,7 +7709,6 @@ void main() { ...@@ -7730,7 +7709,6 @@ void main() {
equals( equals(
const TextSelection.collapsed( const TextSelection.collapsed(
offset: 32, offset: 32,
affinity: TextAffinity.upstream,
), ),
), ),
reason: 'on $platform', reason: 'on $platform',
...@@ -7788,7 +7766,6 @@ void main() { ...@@ -7788,7 +7766,6 @@ void main() {
equals( equals(
const TextSelection.collapsed( const TextSelection.collapsed(
offset: 32, offset: 32,
affinity: TextAffinity.upstream,
), ),
), ),
reason: 'on $platform', reason: 'on $platform',
...@@ -7900,7 +7877,6 @@ void main() { ...@@ -7900,7 +7877,6 @@ void main() {
equals( equals(
const TextSelection.collapsed( const TextSelection.collapsed(
offset: 32, offset: 32,
affinity: TextAffinity.upstream,
), ),
), ),
); );
...@@ -7994,7 +7970,6 @@ void main() { ...@@ -7994,7 +7970,6 @@ void main() {
controller.selection, controller.selection,
equals(const TextSelection.collapsed( equals(const TextSelection.collapsed(
offset: testText.length, offset: testText.length,
affinity: TextAffinity.upstream,
)), )),
); );
...@@ -8078,7 +8053,6 @@ void main() { ...@@ -8078,7 +8053,6 @@ void main() {
controller.selection, controller.selection,
equals(const TextSelection.collapsed( equals(const TextSelection.collapsed(
offset: testText.length, offset: testText.length,
affinity: TextAffinity.upstream,
)), )),
); );
...@@ -11036,7 +11010,6 @@ void main() { ...@@ -11036,7 +11010,6 @@ void main() {
controller.selection = const TextSelection( controller.selection = const TextSelection(
baseOffset: 15, baseOffset: 15,
extentOffset: 15, extentOffset: 15,
affinity: TextAffinity.upstream,
); );
await tester.pump(); await tester.pump();
expect(controller.selection.isCollapsed, true); expect(controller.selection.isCollapsed, true);
...@@ -11275,7 +11248,6 @@ void main() { ...@@ -11275,7 +11248,6 @@ void main() {
const TextSelection( const TextSelection(
baseOffset: 29, baseOffset: 29,
extentOffset: 0, extentOffset: 0,
affinity: TextAffinity.upstream,
), ),
), ),
); );
......
...@@ -1296,12 +1296,12 @@ void main() { ...@@ -1296,12 +1296,12 @@ void main() {
await sendKeyCombination(tester, SingleActivator(LogicalKeyboardKey.arrowLeft, shift: true, alt: alt, control: control)); await sendKeyCombination(tester, SingleActivator(LogicalKeyboardKey.arrowLeft, shift: true, alt: alt, control: control));
await tester.pump(); await tester.pump();
// Ho[w are you]? // Ho[w are ]you?
// Good, and you? // Good, and you?
// Fine, thank you. // Fine, thank you.
expect(paragraph1.selections.length, 1); expect(paragraph1.selections.length, 1);
expect(paragraph1.selections[0].start, 2); expect(paragraph1.selections[0].start, 2);
expect(paragraph1.selections[0].end, 11); expect(paragraph1.selections[0].end, 8);
expect(paragraph2.selections.length, 0); expect(paragraph2.selections.length, 0);
}, variant: TargetPlatformVariant.all()); }, 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