Makes TextBoundary and its subclasses public (#110367)
diff --git a/packages/flutter/lib/services.dart b/packages/flutter/lib/services.dart
index 9c77b1c..a0ff42f 100644
--- a/packages/flutter/lib/services.dart
+++ b/packages/flutter/lib/services.dart
@@ -43,6 +43,7 @@
export 'src/services/system_chrome.dart';
export 'src/services/system_navigator.dart';
export 'src/services/system_sound.dart';
+export 'src/services/text_boundary.dart';
export 'src/services/text_editing.dart';
export 'src/services/text_editing_delta.dart';
export 'src/services/text_formatter.dart';
diff --git a/packages/flutter/lib/src/painting/text_painter.dart b/packages/flutter/lib/src/painting/text_painter.dart
index 0c893ec..7cb35e2 100644
--- a/packages/flutter/lib/src/painting/text_painter.dart
+++ b/packages/flutter/lib/src/painting/text_painter.dart
@@ -1129,6 +1129,12 @@
/// {@endtemplate}
TextRange getWordBoundary(TextPosition position) {
assert(_debugAssertTextLayoutIsValid);
+ // TODO(chunhtai): remove this workaround once ui.Paragraph.getWordBoundary
+ // can handle caret position.
+ // https://github.com/flutter/flutter/issues/111751.
+ if (position.affinity == TextAffinity.upstream) {
+ position = TextPosition(offset: position.offset - 1);
+ }
return _paragraph!.getWordBoundary(position);
}
diff --git a/packages/flutter/lib/src/rendering/editable.dart b/packages/flutter/lib/src/rendering/editable.dart
index 91c9eff..0283f9e 100644
--- a/packages/flutter/lib/src/rendering/editable.dart
+++ b/packages/flutter/lib/src/rendering/editable.dart
@@ -2040,7 +2040,6 @@
final TextSelection firstWord = _getWordAtOffset(firstPosition);
final TextSelection lastWord = to == null ?
firstWord : _getWordAtOffset(_textPainter.getPositionForOffset(globalToLocal(to - _paintOffset)));
-
_setSelection(
TextSelection(
baseOffset: firstWord.base.offset,
@@ -2071,14 +2070,28 @@
TextSelection _getWordAtOffset(TextPosition position) {
debugAssertLayoutUpToDate();
- final TextRange word = _textPainter.getWordBoundary(position);
// When long-pressing past the end of the text, we want a collapsed cursor.
- if (position.offset >= word.end) {
- return TextSelection.fromPosition(position);
+ if (position.offset >= _plainText.length) {
+ return TextSelection.fromPosition(
+ TextPosition(offset: _plainText.length, affinity: TextAffinity.upstream)
+ );
}
// If text is obscured, the entire sentence should be treated as one word.
if (obscureText) {
return TextSelection(baseOffset: 0, extentOffset: _plainText.length);
+ }
+ final TextRange word = _textPainter.getWordBoundary(position);
+ final int effectiveOffset;
+ switch (position.affinity) {
+ case TextAffinity.upstream:
+ // upstream affinity is effectively -1 in text position.
+ effectiveOffset = position.offset - 1;
+ break;
+ case TextAffinity.downstream:
+ effectiveOffset = position.offset;
+ break;
+ }
+
// On iOS, select the previous word if there is a previous word, or select
// to the end of the next word if there is a next word. Select nothing if
// there is neither a previous word nor a next word.
@@ -2086,8 +2099,8 @@
// If the platform is Android and the text is read only, try to select the
// previous word if there is one; otherwise, select the single whitespace at
// the position.
- } else if (TextLayoutMetrics.isWhitespace(_plainText.codeUnitAt(position.offset))
- && position.offset > 0) {
+ if (TextLayoutMetrics.isWhitespace(_plainText.codeUnitAt(effectiveOffset))
+ && effectiveOffset > 0) {
assert(defaultTargetPlatform != null);
final TextRange? previousWord = _getPreviousWord(word.start);
switch (defaultTargetPlatform) {
diff --git a/packages/flutter/lib/src/services/text_boundary.dart b/packages/flutter/lib/src/services/text_boundary.dart
new file mode 100644
index 0000000..1c94847
--- /dev/null
+++ b/packages/flutter/lib/src/services/text_boundary.dart
@@ -0,0 +1,179 @@
+// Copyright 2014 The Flutter Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import 'dart:math' as math;
+import 'dart:ui';
+
+import 'package:characters/characters.dart' show CharacterRange;
+
+import 'text_layout_metrics.dart';
+
+/// An interface for retrieving the logical text boundary (left-closed-right-open)
+/// at a given location in a document.
+///
+/// The input [TextPosition] points to a position between 2 code units (which
+/// can be visually represented by the caret if the selection were to collapse
+/// to that position). The [TextPosition.affinity] is used to determine which
+/// code unit it points. For example, `TextPosition(i, upstream)` points to
+/// code unit `i - 1` and `TextPosition(i, downstream)` points to code unit `i`.
+abstract class TextBoundary {
+ /// A constant constructor to enable subclass override.
+ const TextBoundary();
+
+ /// Returns the leading text boundary at the given location.
+ ///
+ /// The return value must be less or equal to the input position.
+ TextPosition getLeadingTextBoundaryAt(TextPosition position);
+
+ /// Returns the trailing text boundary at the given location, exclusive.
+ ///
+ /// The return value must be greater or equal to the input position.
+ TextPosition getTrailingTextBoundaryAt(TextPosition position);
+
+ /// Gets the text boundary range that encloses the input position.
+ TextRange getTextBoundaryAt(TextPosition position) {
+ return TextRange(
+ start: getLeadingTextBoundaryAt(position).offset,
+ end: getTrailingTextBoundaryAt(position).offset,
+ );
+ }
+}
+
+/// A text boundary that uses characters as logical boundaries.
+///
+/// This class takes grapheme clusters into account and avoid creating
+/// boundaries that generate malformed utf-16 characters.
+class CharacterBoundary extends TextBoundary {
+ /// Creates a [CharacterBoundary] with the text.
+ const CharacterBoundary(this._text);
+
+ final String _text;
+
+ @override
+ TextPosition getLeadingTextBoundaryAt(TextPosition position) {
+ if (position.offset <= 0) {
+ return const TextPosition(offset: 0);
+ }
+ if (position.offset > _text.length ||
+ (position.offset == _text.length && position.affinity == TextAffinity.downstream)) {
+ return TextPosition(offset: _text.length, affinity: TextAffinity.upstream);
+ }
+ 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
+ TextPosition getTrailingTextBoundaryAt(TextPosition position) {
+ if (position.offset < 0 ||
+ (position.offset == 0 && position.affinity == TextAffinity.upstream)) {
+ return const TextPosition(offset: 0);
+ }
+ if (position.offset >= _text.length) {
+ return TextPosition(offset: _text.length, affinity: TextAffinity.upstream);
+ }
+ final int endOffset;
+ final int startOffset;
+ switch (position.affinity) {
+ case TextAffinity.upstream:
+ startOffset = math.min(position.offset - 1, _text.length);
+ endOffset = math.min(position.offset, _text.length);
+ break;
+ case TextAffinity.downstream:
+ startOffset = math.min(position.offset, _text.length);
+ endOffset = math.min(position.offset + 1, _text.length);
+ break;
+ }
+ final CharacterRange range = CharacterRange.at(_text, startOffset, endOffset);
+ return TextPosition(
+ offset: _text.length - range.stringAfterLength,
+ affinity: TextAffinity.upstream,
+ );
+ }
+}
+
+/// 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 [CharacterBoundary] 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,
+ );
+ }
+}
+
+/// A text boundary that uses line breaks as logical boundaries.
+///
+/// The input [TextPosition]s will be interpreted as caret locations if
+/// [TextLayoutMetrics.getLineAtOffset] is text-affinity-aware.
+class LineBreak extends TextBoundary {
+ /// Creates a [CharacterBoundary] with the text and layout information.
+ const LineBreak(this._textLayout);
+
+ final TextLayoutMetrics _textLayout;
+
+ @override
+ TextPosition getLeadingTextBoundaryAt(TextPosition position) {
+ return TextPosition(
+ offset: _textLayout.getLineAtOffset(position).start,
+ );
+ }
+
+ @override
+ TextPosition getTrailingTextBoundaryAt(TextPosition position) {
+ return TextPosition(
+ offset: _textLayout.getLineAtOffset(position).end,
+ affinity: TextAffinity.upstream,
+ );
+ }
+}
+
+/// A text boundary that uses the entire document as logical boundary.
+///
+/// The document boundary is unique and is a constant function of the input
+/// position.
+class DocumentBoundary extends TextBoundary {
+ /// Creates a [CharacterBoundary] with the text
+ const DocumentBoundary(this._text);
+
+ final String _text;
+
+ @override
+ TextPosition getLeadingTextBoundaryAt(TextPosition position) => const TextPosition(offset: 0);
+ @override
+ TextPosition getTrailingTextBoundaryAt(TextPosition position) {
+ return TextPosition(
+ offset: _text.length,
+ affinity: TextAffinity.upstream,
+ );
+ }
+}
diff --git a/packages/flutter/lib/src/widgets/editable_text.dart b/packages/flutter/lib/src/widgets/editable_text.dart
index a96148a..c0c7c73 100644
--- a/packages/flutter/lib/src/widgets/editable_text.dart
+++ b/packages/flutter/lib/src/widgets/editable_text.dart
@@ -3478,23 +3478,23 @@
// --------------------------- Text Editing Actions ---------------------------
- _TextBoundary _characterBoundary(DirectionalTextEditingIntent intent) {
- final _TextBoundary atomicTextBoundary = widget.obscureText ? _CodeUnitBoundary(_value) : _CharacterBoundary(_value);
- return _CollapsedSelectionBoundary(atomicTextBoundary, intent.forward);
+ TextBoundary _characterBoundary(DirectionalTextEditingIntent intent) {
+ final TextBoundary atomicTextBoundary = widget.obscureText ? _CodeUnitBoundary(_value.text) : CharacterBoundary(_value.text);
+ return _PushTextPosition(atomicTextBoundary, intent.forward);
}
- _TextBoundary _nextWordBoundary(DirectionalTextEditingIntent intent) {
- final _TextBoundary atomicTextBoundary;
- final _TextBoundary boundary;
+ TextBoundary _nextWordBoundary(DirectionalTextEditingIntent intent) {
+ final TextBoundary atomicTextBoundary;
+ final TextBoundary boundary;
if (widget.obscureText) {
- atomicTextBoundary = _CodeUnitBoundary(_value);
- boundary = _DocumentBoundary(_value);
+ atomicTextBoundary = _CodeUnitBoundary(_value.text);
+ boundary = DocumentBoundary(_value.text);
} else {
final TextEditingValue textEditingValue = _textEditingValueforTextLayoutMetrics;
- atomicTextBoundary = _CharacterBoundary(textEditingValue);
+ atomicTextBoundary = CharacterBoundary(textEditingValue.text);
// This isn't enough. Newline characters.
- boundary = _ExpandedTextBoundary(_WhitespaceBoundary(textEditingValue), _WordBoundary(renderEditable, textEditingValue));
+ boundary = _ExpandedTextBoundary(_WhitespaceBoundary(textEditingValue.text), WordBoundary(renderEditable));
}
final _MixedBoundary mixedBoundary = intent.forward
@@ -3502,20 +3502,20 @@
: _MixedBoundary(boundary, atomicTextBoundary);
// Use a _MixedBoundary to make sure we don't leave invalid codepoints in
// the field after deletion.
- return _CollapsedSelectionBoundary(mixedBoundary, intent.forward);
+ return _PushTextPosition(mixedBoundary, intent.forward);
}
- _TextBoundary _linebreak(DirectionalTextEditingIntent intent) {
- final _TextBoundary atomicTextBoundary;
- final _TextBoundary boundary;
+ TextBoundary _linebreak(DirectionalTextEditingIntent intent) {
+ final TextBoundary atomicTextBoundary;
+ final TextBoundary boundary;
if (widget.obscureText) {
- atomicTextBoundary = _CodeUnitBoundary(_value);
- boundary = _DocumentBoundary(_value);
+ atomicTextBoundary = _CodeUnitBoundary(_value.text);
+ boundary = DocumentBoundary(_value.text);
} else {
final TextEditingValue textEditingValue = _textEditingValueforTextLayoutMetrics;
- atomicTextBoundary = _CharacterBoundary(textEditingValue);
- boundary = _LineBreak(renderEditable, textEditingValue);
+ atomicTextBoundary = CharacterBoundary(textEditingValue.text);
+ boundary = LineBreak(renderEditable);
}
// The _MixedBoundary is to make sure we don't leave invalid code units in
@@ -3524,11 +3524,11 @@
// since the document boundary is unique and the linebreak boundary is
// already caret-location based.
return intent.forward
- ? _MixedBoundary(_CollapsedSelectionBoundary(atomicTextBoundary, true), boundary)
- : _MixedBoundary(boundary, _CollapsedSelectionBoundary(atomicTextBoundary, false));
+ ? _MixedBoundary(_PushTextPosition(atomicTextBoundary, true), boundary)
+ : _MixedBoundary(boundary, _PushTextPosition(atomicTextBoundary, false));
}
- _TextBoundary _documentBoundary(DirectionalTextEditingIntent intent) => _DocumentBoundary(_value);
+ TextBoundary _documentBoundary(DirectionalTextEditingIntent intent) => DocumentBoundary(_value.text);
Action<T> _makeOverridable<T extends Intent>(Action<T> defaultAction) {
return Action<T>.overridable(context: context, defaultAction: defaultAction);
@@ -3615,17 +3615,17 @@
late final _UpdateTextSelectionToAdjacentLineAction<ExtendSelectionVerticallyToAdjacentLineIntent> _adjacentLineAction = _UpdateTextSelectionToAdjacentLineAction<ExtendSelectionVerticallyToAdjacentLineIntent>(this);
void _expandSelectionToDocumentBoundary(ExpandSelectionToDocumentBoundaryIntent intent) {
- final _TextBoundary textBoundary = _documentBoundary(intent);
+ final TextBoundary textBoundary = _documentBoundary(intent);
_expandSelection(intent.forward, textBoundary, true);
}
void _expandSelectionToLinebreak(ExpandSelectionToLineBreakIntent intent) {
- final _TextBoundary textBoundary = _linebreak(intent);
+ final TextBoundary textBoundary = _linebreak(intent);
_expandSelection(intent.forward, textBoundary);
}
- void _expandSelection(bool forward, _TextBoundary textBoundary, [bool extentAtIndex = false]) {
- final TextSelection textBoundarySelection = textBoundary.textEditingValue.selection;
+ void _expandSelection(bool forward, TextBoundary textBoundary, [bool extentAtIndex = false]) {
+ final TextSelection textBoundarySelection = _value.selection;
if (!textBoundarySelection.isValid) {
return;
}
@@ -4220,71 +4220,84 @@
}
}
-/// An interface for retrieving the logical text boundary (left-closed-right-open)
-/// at a given location in a document.
+/// A text boundary that uses code units as logical boundaries.
///
-/// Depending on the implementation of the [_TextBoundary], the input
-/// [TextPosition] can either point to a code unit, or a position between 2 code
-/// units (which can be visually represented by the caret if the selection were
-/// to collapse to that position).
-///
-/// For example, [_LineBreak] interprets the input [TextPosition] as a caret
-/// location, since in Flutter the caret is generally painted between the
-/// character the [TextPosition] points to and its previous character, and
-/// [_LineBreak] cares about the affinity of the input [TextPosition]. Most
-/// other text boundaries however, interpret the input [TextPosition] as the
-/// location of a code unit in the document, since it's easier to reason about
-/// the text boundary given a code unit in the text.
-///
-/// To convert a "code-unit-based" [_TextBoundary] to "caret-location-based",
-/// use the [_CollapsedSelectionBoundary] combinator.
-abstract class _TextBoundary {
- const _TextBoundary();
+/// This text boundary treats every character in input string as an utf-16 code
+/// unit. This can be useful when handling text without any grapheme cluster,
+/// e.g. the obscure string in [EditableText]. If you are handling text that may
+/// include grapheme clusters, consider using [CharacterBoundary].
+class _CodeUnitBoundary extends TextBoundary {
+ const _CodeUnitBoundary(this._text);
- TextEditingValue get textEditingValue;
-
- /// Returns the leading text boundary at the given location, inclusive.
- TextPosition getLeadingTextBoundaryAt(TextPosition position);
-
- /// Returns the trailing text boundary at the given location, exclusive.
- TextPosition getTrailingTextBoundaryAt(TextPosition position);
-
- TextRange getTextBoundaryAt(TextPosition position) {
- return TextRange(
- start: getLeadingTextBoundaryAt(position).offset,
- end: getTrailingTextBoundaryAt(position).offset,
- );
- }
-}
-
-// ----------------------------- Text Boundaries -----------------------------
-
-class _CodeUnitBoundary extends _TextBoundary {
- const _CodeUnitBoundary(this.textEditingValue);
-
- @override
- final TextEditingValue textEditingValue;
-
- @override
- TextPosition getLeadingTextBoundaryAt(TextPosition position) => TextPosition(offset: position.offset);
- @override
- TextPosition getTrailingTextBoundaryAt(TextPosition position) => TextPosition(offset: math.min(position.offset + 1, textEditingValue.text.length));
-}
-
-// The word modifier generally removes the word boundaries around white spaces
-// (and newlines), IOW white spaces and some other punctuations are considered
-// a part of the next word in the search direction.
-class _WhitespaceBoundary extends _TextBoundary {
- const _WhitespaceBoundary(this.textEditingValue);
-
- @override
- final TextEditingValue textEditingValue;
+ final String _text;
@override
TextPosition getLeadingTextBoundaryAt(TextPosition position) {
- for (int index = position.offset; index >= 0; index -= 1) {
- if (!TextLayoutMetrics.isWhitespace(textEditingValue.text.codeUnitAt(index))) {
- return TextPosition(offset: index);
+ 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
+ TextPosition getTrailingTextBoundaryAt(TextPosition position) {
+ if (position.offset < 0 ||
+ (position.offset == 0 && position.affinity == TextAffinity.upstream)) {
+ return const TextPosition(offset: 0);
+ }
+ if (position.offset >= _text.length) {
+ return TextPosition(offset: _text.length, affinity: TextAffinity.upstream);
+ }
+ switch (position.affinity) {
+ case TextAffinity.upstream:
+ return TextPosition(offset: math.min(position.offset, _text.length), affinity: TextAffinity.upstream);
+ case TextAffinity.downstream:
+ return TextPosition(offset: math.min(position.offset + 1, _text.length), affinity: TextAffinity.upstream);
+ }
+ }
+}
+
+// ------------------------ Text Boundary Combinators ------------------------
+
+/// A text boundary that use the first non-whitespace character as the logical
+/// boundary.
+///
+/// This text boundary uses [TextLayoutMetrics.isWhitespace] to identify white
+/// spaces, this include newline characters from ASCII and separators from the
+/// [unicode separator category](https://www.compart.com/en/unicode/category/Zs).
+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 right bound.
+ if (position.offset > _text.length || (position.offset == _text.length && position.affinity == TextAffinity.downstream)) {
+ position = TextPosition(offset: _text.length, affinity: TextAffinity.upstream);
+ }
+ // Handles outside of left bound.
+ if (position.offset <= 0) {
+ return const TextPosition(offset: 0);
+ }
+ int index = position.offset;
+ if (!TextLayoutMetrics.isWhitespace(_text.codeUnitAt(index)) && position.affinity == TextAffinity.downstream) {
+ return position;
+ }
+
+ for (index -= 1; index >= 0; index -= 1) {
+ if (!TextLayoutMetrics.isWhitespace(_text.codeUnitAt(index))) {
+ return TextPosition(offset: index + 1, affinity: TextAffinity.upstream);
}
}
return const TextPosition(offset: 0);
@@ -4292,142 +4305,35 @@
@override
TextPosition getTrailingTextBoundaryAt(TextPosition position) {
- for (int index = position.offset; index < textEditingValue.text.length; index += 1) {
- if (!TextLayoutMetrics.isWhitespace(textEditingValue.text.codeUnitAt(index))) {
- return TextPosition(offset: index + 1);
+ // 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 (!TextLayoutMetrics.isWhitespace(_text.codeUnitAt(index)) && position.affinity == TextAffinity.downstream) {
+ return position;
+ }
+
+ for (index += 1; index < _text.length; index += 1) {
+ if (!TextLayoutMetrics.isWhitespace(_text.codeUnitAt(index))) {
+ return TextPosition(offset: index);
}
}
- return TextPosition(offset: textEditingValue.text.length);
+ return TextPosition(offset: _text.length, affinity: TextAffinity.upstream);
}
}
-// Most apps delete the entire grapheme when the backspace key is pressed.
-// Also always put the new caret location to character boundaries to avoid
-// sending malformed UTF-16 code units to the paragraph builder.
-class _CharacterBoundary extends _TextBoundary {
- const _CharacterBoundary(this.textEditingValue);
-
- @override
- final TextEditingValue textEditingValue;
-
- @override
- TextPosition getLeadingTextBoundaryAt(TextPosition position) {
- final int endOffset = math.min(position.offset + 1, textEditingValue.text.length);
- return TextPosition(
- offset: CharacterRange.at(textEditingValue.text, position.offset, endOffset).stringBeforeLength,
- );
- }
-
- @override
- TextPosition getTrailingTextBoundaryAt(TextPosition position) {
- final int endOffset = math.min(position.offset + 1, textEditingValue.text.length);
- final CharacterRange range = CharacterRange.at(textEditingValue.text, position.offset, endOffset);
- return TextPosition(
- offset: textEditingValue.text.length - range.stringAfterLength,
- );
- }
-
- @override
- TextRange getTextBoundaryAt(TextPosition position) {
- final int endOffset = math.min(position.offset + 1, textEditingValue.text.length);
- final CharacterRange range = CharacterRange.at(textEditingValue.text, position.offset, endOffset);
- return TextRange(
- start: range.stringBeforeLength,
- end: textEditingValue.text.length - range.stringAfterLength,
- );
- }
-}
-
-// [UAX #29](https://unicode.org/reports/tr29/) defined word boundaries.
-class _WordBoundary extends _TextBoundary {
- const _WordBoundary(this.textLayout, this.textEditingValue);
-
- final TextLayoutMetrics textLayout;
-
- @override
- final TextEditingValue textEditingValue;
-
- @override
- TextPosition getLeadingTextBoundaryAt(TextPosition position) {
- return TextPosition(
- offset: textLayout.getWordBoundary(position).start,
- // Word boundary seems to always report downstream on many platforms.
- affinity: TextAffinity.downstream, // ignore: avoid_redundant_argument_values
- );
- }
- @override
- TextPosition getTrailingTextBoundaryAt(TextPosition position) {
- return TextPosition(
- offset: textLayout.getWordBoundary(position).end,
- // Word boundary seems to always report downstream on many platforms.
- affinity: TextAffinity.downstream, // ignore: avoid_redundant_argument_values
- );
- }
-}
-
-// The linebreaks of the current text layout. The input [TextPosition]s are
-// interpreted as caret locations because [TextPainter.getLineAtOffset] is
-// text-affinity-aware.
-class _LineBreak extends _TextBoundary {
- const _LineBreak(
- this.textLayout,
- this.textEditingValue,
- );
-
- final TextLayoutMetrics textLayout;
-
- @override
- final TextEditingValue textEditingValue;
-
- @override
- TextPosition getLeadingTextBoundaryAt(TextPosition position) {
- return TextPosition(
- offset: textLayout.getLineAtOffset(position).start,
- );
- }
-
- @override
- TextPosition getTrailingTextBoundaryAt(TextPosition position) {
- return TextPosition(
- offset: textLayout.getLineAtOffset(position).end,
- affinity: TextAffinity.upstream,
- );
- }
-}
-
-// The document boundary is unique and is a constant function of the input
-// position.
-class _DocumentBoundary extends _TextBoundary {
- const _DocumentBoundary(this.textEditingValue);
-
- @override
- final TextEditingValue textEditingValue;
-
- @override
- TextPosition getLeadingTextBoundaryAt(TextPosition position) => const TextPosition(offset: 0);
- @override
- TextPosition getTrailingTextBoundaryAt(TextPosition position) {
- return TextPosition(
- offset: textEditingValue.text.length,
- affinity: TextAffinity.upstream,
- );
- }
-}
-
-// ------------------------ Text Boundary Combinators ------------------------
-
// Expands the innerTextBoundary with outerTextBoundary.
-class _ExpandedTextBoundary extends _TextBoundary {
+class _ExpandedTextBoundary extends TextBoundary {
_ExpandedTextBoundary(this.innerTextBoundary, this.outerTextBoundary);
- final _TextBoundary innerTextBoundary;
- final _TextBoundary outerTextBoundary;
-
- @override
- TextEditingValue get textEditingValue {
- assert(innerTextBoundary.textEditingValue == outerTextBoundary.textEditingValue);
- return innerTextBoundary.textEditingValue;
- }
+ final TextBoundary innerTextBoundary;
+ final TextBoundary outerTextBoundary;
@override
TextPosition getLeadingTextBoundaryAt(TextPosition position) {
@@ -4444,49 +4350,70 @@
}
}
-// Force the innerTextBoundary to interpret the input [TextPosition]s as caret
-// locations instead of code unit positions.
-//
-// The innerTextBoundary must be a [_TextBoundary] that interprets the input
-// [TextPosition]s as code unit positions.
-class _CollapsedSelectionBoundary extends _TextBoundary {
- _CollapsedSelectionBoundary(this.innerTextBoundary, this.isForward);
+/// A proxy text boundary that will push input text position forward or backward
+/// one affinity unit before sending it to the [innerTextBoundary].
+///
+/// If the [isForward] is true, this proxy text boundary push the position
+/// forward; otherwise, backward.
+///
+/// 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)`.
+///
+/// This class is used to kick-start the text position to find the next boundary
+/// determined by [innerTextBoundary] so that it won't be trapped if the input
+/// text position is right at the edge.
+class _PushTextPosition extends TextBoundary {
+ _PushTextPosition(this.innerTextBoundary, this.isForward);
- final _TextBoundary innerTextBoundary;
+ final TextBoundary innerTextBoundary;
final bool isForward;
- @override
- TextEditingValue get textEditingValue => innerTextBoundary.textEditingValue;
+ TextPosition _calculateTargetPosition(TextPosition position) {
+ if (isForward) {
+ 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) {
- return isForward
- ? innerTextBoundary.getLeadingTextBoundaryAt(position)
- : position.offset <= 0 ? const TextPosition(offset: 0) : innerTextBoundary.getLeadingTextBoundaryAt(TextPosition(offset: position.offset - 1));
+ return innerTextBoundary.getLeadingTextBoundaryAt(_calculateTargetPosition(position));
}
@override
TextPosition getTrailingTextBoundaryAt(TextPosition position) {
- return isForward
- ? innerTextBoundary.getTrailingTextBoundaryAt(position)
- : position.offset <= 0 ? const TextPosition(offset: 0) : innerTextBoundary.getTrailingTextBoundaryAt(TextPosition(offset: position.offset - 1));
+ return innerTextBoundary.getTrailingTextBoundaryAt(_calculateTargetPosition(position));
}
}
// 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 {
+class _MixedBoundary extends TextBoundary {
_MixedBoundary(this.leadingTextBoundary, this.trailingTextBoundary);
- final _TextBoundary leadingTextBoundary;
- final _TextBoundary trailingTextBoundary;
-
- @override
- TextEditingValue get textEditingValue {
- assert(leadingTextBoundary.textEditingValue == trailingTextBoundary.textEditingValue);
- return leadingTextBoundary.textEditingValue;
- }
+ final TextBoundary leadingTextBoundary;
+ final TextBoundary trailingTextBoundary;
@override
TextPosition getLeadingTextBoundaryAt(TextPosition position) => leadingTextBoundary.getLeadingTextBoundaryAt(position);
@@ -4500,15 +4427,15 @@
_DeleteTextAction(this.state, this.getTextBoundariesForIntent);
final EditableTextState state;
- final _TextBoundary Function(T intent) getTextBoundariesForIntent;
+ final TextBoundary Function(T intent) getTextBoundariesForIntent;
TextRange _expandNonCollapsedRange(TextEditingValue value) {
final TextRange selection = value.selection;
assert(selection.isValid);
assert(!selection.isCollapsed);
- final _TextBoundary atomicBoundary = state.widget.obscureText
- ? _CodeUnitBoundary(value)
- : _CharacterBoundary(value);
+ final TextBoundary atomicBoundary = state.widget.obscureText
+ ? _CodeUnitBoundary(value.text)
+ : CharacterBoundary(value.text);
return TextRange(
start: atomicBoundary.getLeadingTextBoundaryAt(TextPosition(offset: selection.start)).offset,
@@ -4528,23 +4455,23 @@
);
}
- final _TextBoundary textBoundary = getTextBoundariesForIntent(intent);
- if (!textBoundary.textEditingValue.selection.isValid) {
+ final TextBoundary textBoundary = getTextBoundariesForIntent(intent);
+ if (!state._value.selection.isValid) {
return null;
}
- if (!textBoundary.textEditingValue.selection.isCollapsed) {
+ if (!state._value.selection.isCollapsed) {
return Actions.invoke(
context!,
- ReplaceTextIntent(state._value, '', _expandNonCollapsedRange(textBoundary.textEditingValue), SelectionChangedCause.keyboard),
+ ReplaceTextIntent(state._value, '', _expandNonCollapsedRange(state._value), SelectionChangedCause.keyboard),
);
}
return Actions.invoke(
context!,
ReplaceTextIntent(
- textBoundary.textEditingValue,
+ state._value,
'',
- textBoundary.getTextBoundaryAt(textBoundary.textEditingValue.selection.base),
+ textBoundary.getTextBoundaryAt(state._value.selection.base),
SelectionChangedCause.keyboard,
),
);
@@ -4563,7 +4490,7 @@
final EditableTextState state;
final bool ignoreNonCollapsedSelection;
- final _TextBoundary Function(T intent) getTextBoundariesForIntent;
+ final TextBoundary Function(T intent) getTextBoundariesForIntent;
static const int NEWLINE_CODE_UNIT = 10;
@@ -4578,7 +4505,7 @@
&& state.textEditingValue.text.codeUnitAt(position.offset) != NEWLINE_CODE_UNIT;
}
- // Returns true iff the given position at a wordwrap boundary in the
+ // Returns true if the given position at a wordwrap boundary in the
// downstream position.
bool _isAtWordwrapDownstream(TextPosition position) {
final TextPosition start = TextPosition(
@@ -4611,29 +4538,9 @@
);
}
- final _TextBoundary textBoundary = getTextBoundariesForIntent(intent);
+ final TextBoundary textBoundary = getTextBoundariesForIntent(intent);
- // "textBoundary's selection is only updated after rebuild; if the text
- // is the same, use the selection from state, which is more recent.
- // This is necessary on macOS where alt+up sends the moveBackward:
- // and moveToBeginningOfParagraph: selectors at the same time.
- final TextSelection textBoundarySelection =
- textBoundary.textEditingValue.text == state._value.text
- ? state._value.selection
- : textBoundary.textEditingValue.selection;
-
- if (!textBoundarySelection.isValid) {
- return null;
- }
- if (!textBoundarySelection.isCollapsed && !ignoreNonCollapsedSelection && collapseSelection) {
- return Actions.invoke(
- context!,
- UpdateSelectionIntent(state._value, collapse(textBoundarySelection), SelectionChangedCause.keyboard),
- );
- }
-
- TextPosition extent = textBoundarySelection.extent;
-
+ TextPosition extent = selection.extent;
// If continuesAtWrap is true extent and is at the relevant wordwrap, then
// move it just to the other side of the wordwrap.
if (intent.continuesAtWrap) {
@@ -4652,10 +4559,9 @@
final TextPosition newExtent = intent.forward
? textBoundary.getTrailingTextBoundaryAt(extent)
: textBoundary.getLeadingTextBoundaryAt(extent);
-
final TextSelection newSelection = collapseSelection
? TextSelection.fromPosition(newExtent)
- : textBoundarySelection.extendTo(newExtent);
+ : selection.extendTo(newExtent);
// If collapseAtReversal is true and would have an effect, collapse it.
if (!selection.isCollapsed && intent.collapseAtReversal
@@ -4673,7 +4579,7 @@
return Actions.invoke(
context!,
- UpdateSelectionIntent(textBoundary.textEditingValue, newSelection, SelectionChangedCause.keyboard),
+ UpdateSelectionIntent(state._value, newSelection, SelectionChangedCause.keyboard),
);
}
@@ -4685,15 +4591,15 @@
_ExtendSelectionOrCaretPositionAction(this.state, this.getTextBoundariesForIntent);
final EditableTextState state;
- final _TextBoundary Function(ExtendSelectionToNextWordBoundaryOrCaretLocationIntent intent) getTextBoundariesForIntent;
+ 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 = textBoundary.textEditingValue.selection;
+ final TextBoundary textBoundary = getTextBoundariesForIntent(intent);
+ final TextSelection textBoundarySelection = state._value.selection;
if (!textBoundarySelection.isValid) {
return null;
}
@@ -4712,7 +4618,7 @@
return Actions.invoke(
context!,
- UpdateSelectionIntent(textBoundary.textEditingValue, newSelection, SelectionChangedCause.keyboard),
+ UpdateSelectionIntent(state._value, newSelection, SelectionChangedCause.keyboard),
);
}
diff --git a/packages/flutter/test/cupertino/text_field_test.dart b/packages/flutter/test/cupertino/text_field_test.dart
index e1d685b..d8ff496 100644
--- a/packages/flutter/test/cupertino/text_field_test.dart
+++ b/packages/flutter/test/cupertino/text_field_test.dart
@@ -199,7 +199,8 @@
return endpoints[0].point;
}
- Offset textOffsetToPosition(WidgetTester tester, int offset) => textOffsetToBottomLeftPosition(tester, offset) + const Offset(0, -2);
+ // Web has a less threshold for downstream/upstream text position.
+ Offset textOffsetToPosition(WidgetTester tester, int offset) => textOffsetToBottomLeftPosition(tester, offset) + const Offset(kIsWeb ? 1 : 0, -2);
setUp(() async {
EditableText.debugDeterministicCursor = false;
@@ -2087,6 +2088,7 @@
await tester.pump(const Duration(milliseconds: 50));
await tester.tapAt(textOffsetToPosition(tester, 5));
await tester.pumpAndSettle();
+
expect(controller.value.selection, isNotNull);
expect(controller.value.selection.baseOffset, 5);
expect(controller.value.selection.extentOffset, 6);
@@ -3053,7 +3055,7 @@
expect(controller.selection.extentOffset, 8);
// Tiny movement shouldn't cause text selection to change.
- await gesture.moveTo(gPos + const Offset(4.0, 0.0));
+ await gesture.moveTo(gPos + const Offset(2.0, 0.0));
await tester.pumpAndSettle();
expect(selectionChangedCount, 0);
diff --git a/packages/flutter/test/material/text_field_test.dart b/packages/flutter/test/material/text_field_test.dart
index 1172d9e..89a3f01 100644
--- a/packages/flutter/test/material/text_field_test.dart
+++ b/packages/flutter/test/material/text_field_test.dart
@@ -2172,7 +2172,7 @@
expect(controller.selection.extentOffset, 8);
// Tiny movement shouldn't cause text selection to change.
- await gesture.moveTo(gPos + const Offset(4.0, 0.0));
+ await gesture.moveTo(gPos + const Offset(2.0, 0.0));
await tester.pumpAndSettle();
expect(selectionChangedCount, 0);
@@ -3372,10 +3372,7 @@
final Offset secondPos = textOffsetToPosition(tester, testValue.indexOf('Second'));
final Offset thirdPos = textOffsetToPosition(tester, testValue.indexOf('Third'));
final Offset middleStringPos = textOffsetToPosition(tester, testValue.indexOf('irst'));
- expect(firstPos.dx, 0);
- expect(secondPos.dx, 0);
- expect(thirdPos.dx, 0);
- expect(middleStringPos.dx, 34);
+ expect(firstPos.dx, lessThan(middleStringPos.dx));
expect(firstPos.dx, secondPos.dx);
expect(firstPos.dx, thirdPos.dx);
expect(firstPos.dy, lessThan(secondPos.dy));
@@ -3457,8 +3454,6 @@
// Check that the last line of text is not displayed.
final Offset firstPos = textOffsetToPosition(tester, kMoreThanFourLines.indexOf('First'));
final Offset fourthPos = textOffsetToPosition(tester, kMoreThanFourLines.indexOf('Fourth'));
- expect(firstPos.dx, 0);
- expect(fourthPos.dx, 0);
expect(firstPos.dx, fourthPos.dx);
expect(firstPos.dy, lessThan(fourthPos.dy));
expect(inputBox.hitTest(BoxHitTestResult(), position: inputBox.globalToLocal(firstPos)), isTrue);
@@ -8397,10 +8392,10 @@
),
),
);
-
+ final Size screenSize = MediaQuery.of(tester.element(find.byType(TextField))).size;
// Just testing the test and making sure that the last character is off
// the right side of the screen.
- expect(textOffsetToPosition(tester, 66).dx, 1056);
+ expect(textOffsetToPosition(tester, 66).dx, greaterThan(screenSize.width));
final TestGesture gesture =
await tester.startGesture(
@@ -8448,7 +8443,7 @@
);
// The first character is now offscreen to the left.
- expect(textOffsetToPosition(tester, 0).dx, moreOrLessEquals(-257.0, epsilon: 1));
+ expect(textOffsetToPosition(tester, 0).dx, lessThan(-100.0));
}, variant: TargetPlatformVariant.all());
testWidgets('keyboard selection change scrolls the field', (WidgetTester tester) async {
@@ -8485,7 +8480,7 @@
await tester.pumpAndSettle();
expect(
controller.selection,
- const TextSelection.collapsed(offset: 56),
+ const TextSelection.collapsed(offset: 56, affinity: TextAffinity.upstream),
);
// Keep moving out.
@@ -8495,7 +8490,7 @@
await tester.pumpAndSettle();
expect(
controller.selection,
- const TextSelection.collapsed(offset: 62),
+ const TextSelection.collapsed(offset: 62, affinity: TextAffinity.upstream),
);
for (int i = 0; i < (66 - 62); i += 1) {
await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight);
@@ -8503,7 +8498,7 @@
await tester.pumpAndSettle();
expect(
controller.selection,
- const TextSelection.collapsed(offset: 66),
+ const TextSelection.collapsed(offset: 66, affinity: TextAffinity.upstream),
); // We're at the edge now.
await tester.pumpAndSettle();
diff --git a/packages/flutter/test/services/text_boundary_test.dart b/packages/flutter/test/services/text_boundary_test.dart
new file mode 100644
index 0000000..6ec9873
--- /dev/null
+++ b/packages/flutter/test/services/text_boundary_test.dart
@@ -0,0 +1,97 @@
+// Copyright 2014 The Flutter Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import 'package:flutter/services.dart';
+import 'package:flutter_test/flutter_test.dart';
+
+void main() {
+ test('Character boundary works', () {
+ const CharacterBoundary boundary = CharacterBoundary('abc');
+ const TextPosition midPosition = TextPosition(offset: 1);
+ expect(boundary.getLeadingTextBoundaryAt(midPosition), const TextPosition(offset: 1));
+ expect(boundary.getTrailingTextBoundaryAt(midPosition), const TextPosition(offset: 2, affinity: TextAffinity.upstream));
+
+ const TextPosition startPosition = TextPosition(offset: 0);
+ expect(boundary.getLeadingTextBoundaryAt(startPosition), const TextPosition(offset: 0));
+ expect(boundary.getTrailingTextBoundaryAt(startPosition), const TextPosition(offset: 1, affinity: TextAffinity.upstream));
+
+ const TextPosition endPosition = TextPosition(offset: 3);
+ expect(boundary.getLeadingTextBoundaryAt(endPosition), const TextPosition(offset: 3, affinity: TextAffinity.upstream));
+ expect(boundary.getTrailingTextBoundaryAt(endPosition), const TextPosition(offset: 3, affinity: TextAffinity.upstream));
+ });
+
+ test('Character boundary works with grapheme', () {
+ const String text = 'a❄︎c';
+ const CharacterBoundary boundary = CharacterBoundary(text);
+ TextPosition position = const TextPosition(offset: 1);
+ expect(boundary.getLeadingTextBoundaryAt(position), const TextPosition(offset: 1));
+ // The `❄` takes two character length.
+ expect(boundary.getTrailingTextBoundaryAt(position), const TextPosition(offset: 3, affinity: TextAffinity.upstream));
+
+ position = const TextPosition(offset: 2);
+ expect(boundary.getLeadingTextBoundaryAt(position), const TextPosition(offset: 1));
+ expect(boundary.getTrailingTextBoundaryAt(position), const TextPosition(offset: 3, affinity: TextAffinity.upstream));
+
+ position = const TextPosition(offset: 0);
+ expect(boundary.getLeadingTextBoundaryAt(position), const TextPosition(offset: 0));
+ expect(boundary.getTrailingTextBoundaryAt(position), const TextPosition(offset: 1, affinity: TextAffinity.upstream));
+
+ position = const TextPosition(offset: text.length);
+ expect(boundary.getLeadingTextBoundaryAt(position), const TextPosition(offset: text.length, affinity: TextAffinity.upstream));
+ expect(boundary.getTrailingTextBoundaryAt(position), const TextPosition(offset: text.length, affinity: TextAffinity.upstream));
+ });
+
+ test('word boundary works', () {
+ final WordBoundary boundary = WordBoundary(TestTextLayoutMetrics());
+ const TextPosition position = TextPosition(offset: 3);
+ expect(boundary.getLeadingTextBoundaryAt(position).offset, TestTextLayoutMetrics.wordBoundaryAt3.start);
+ expect(boundary.getTrailingTextBoundaryAt(position).offset, TestTextLayoutMetrics.wordBoundaryAt3.end);
+ });
+
+ test('line boundary works', () {
+ final LineBreak boundary = LineBreak(TestTextLayoutMetrics());
+ const TextPosition position = TextPosition(offset: 3);
+ expect(boundary.getLeadingTextBoundaryAt(position).offset, TestTextLayoutMetrics.lineAt3.start);
+ expect(boundary.getTrailingTextBoundaryAt(position).offset, TestTextLayoutMetrics.lineAt3.end);
+ });
+
+ test('document boundary works', () {
+ const String text = 'abcd efg hi\njklmno\npqrstuv';
+ const DocumentBoundary boundary = DocumentBoundary(text);
+ const TextPosition position = TextPosition(offset: 10);
+ expect(boundary.getLeadingTextBoundaryAt(position), const TextPosition(offset: 0));
+ expect(boundary.getTrailingTextBoundaryAt(position), const TextPosition(offset: text.length, affinity: TextAffinity.upstream));
+ });
+}
+
+class TestTextLayoutMetrics extends TextLayoutMetrics {
+ static const TextSelection lineAt3 = TextSelection(baseOffset: 0, extentOffset: 10);
+ static const TextRange wordBoundaryAt3 = TextRange(start: 4, end: 7);
+
+ @override
+ TextSelection getLineAtOffset(TextPosition position) {
+ if (position.offset == 3) {
+ return lineAt3;
+ }
+ throw UnimplementedError();
+ }
+
+ @override
+ TextPosition getTextPositionAbove(TextPosition position) {
+ throw UnimplementedError();
+ }
+
+ @override
+ TextPosition getTextPositionBelow(TextPosition position) {
+ throw UnimplementedError();
+ }
+
+ @override
+ TextRange getWordBoundary(TextPosition position) {
+ if (position.offset == 3) {
+ return wordBoundaryAt3;
+ }
+ throw UnimplementedError();
+ }
+}
diff --git a/packages/flutter/test/widgets/editable_text_shortcuts_test.dart b/packages/flutter/test/widgets/editable_text_shortcuts_test.dart
index 2c2b486..a0d8ee4 100644
--- a/packages/flutter/test/widgets/editable_text_shortcuts_test.dart
+++ b/packages/flutter/test/widgets/editable_text_shortcuts_test.dart
@@ -1229,6 +1229,7 @@
expect(controller.selection, const TextSelection.collapsed(
offset: 21,
+ affinity: TextAffinity.upstream,
));
}, variant: TargetPlatformVariant.all());
@@ -1243,6 +1244,7 @@
expect(controller.selection, const TextSelection.collapsed(
offset: 10,
+ affinity: TextAffinity.upstream,
));
}, variant: allExceptApple);
@@ -1353,6 +1355,7 @@
await tester.pump();
expect(controller.selection, const TextSelection.collapsed(
offset: 46, // After "to".
+ affinity: TextAffinity.upstream,
));
// "good" to "come" is selected.
@@ -1365,6 +1368,7 @@
await tester.pump();
expect(controller.selection, const TextSelection.collapsed(
offset: 28, // After "good".
+ affinity: TextAffinity.upstream,
));
}, variant: allExceptApple);
@@ -1673,6 +1677,7 @@
expect(controller.selection, const TextSelection.collapsed(
offset: 10,
+ affinity: TextAffinity.upstream,
));
}, variant: macOSOnly);
@@ -1743,6 +1748,7 @@
await tester.pump();
expect(controller.selection, const TextSelection.collapsed(
offset: 46, // After "to".
+ affinity: TextAffinity.upstream,
));
// "good" to "come" is selected.
@@ -1755,6 +1761,7 @@
await tester.pump();
expect(controller.selection, const TextSelection.collapsed(
offset: 28, // After "good".
+ affinity: TextAffinity.upstream,
));
}, variant: macOSOnly);
diff --git a/packages/flutter/test/widgets/editable_text_test.dart b/packages/flutter/test/widgets/editable_text_test.dart
index 98cbbf0..c49091d 100644
--- a/packages/flutter/test/widgets/editable_text_test.dart
+++ b/packages/flutter/test/widgets/editable_text_test.dart
@@ -5832,6 +5832,7 @@
equals(
const TextSelection.collapsed(
offset: 3,
+ affinity: TextAffinity.upstream,
),
),
reason: 'on $platform',
@@ -5941,6 +5942,7 @@
const TextSelection(
baseOffset: 10,
extentOffset: 10,
+ affinity: TextAffinity.upstream,
),
),
reason: 'on $platform',
@@ -6398,6 +6400,7 @@
equals(
const TextSelection.collapsed(
offset: 23,
+ affinity: TextAffinity.upstream,
),
),
reason: 'on $platform',
@@ -6422,6 +6425,7 @@
equals(
const TextSelection.collapsed(
offset: 23,
+ affinity: TextAffinity.upstream,
),
),
reason: 'on $platform',
@@ -6464,6 +6468,7 @@
equals(
const TextSelection.collapsed(
offset: 23,
+ affinity: TextAffinity.upstream,
),
),
reason: 'on $platform',
@@ -6549,6 +6554,7 @@
equals(
const TextSelection.collapsed(
offset: 32,
+ affinity: TextAffinity.upstream,
),
),
reason: 'on $platform',
@@ -6573,6 +6579,7 @@
equals(
const TextSelection.collapsed(
offset: 32,
+ affinity: TextAffinity.upstream,
),
),
reason: 'on $platform',
@@ -6615,6 +6622,7 @@
equals(
const TextSelection.collapsed(
offset: 32,
+ affinity: TextAffinity.upstream,
),
),
reason: 'on $platform',
@@ -6710,6 +6718,7 @@
equals(
const TextSelection.collapsed(
offset: 32,
+ affinity: TextAffinity.upstream,
),
),
reason: 'on $platform',
@@ -6734,6 +6743,7 @@
equals(
const TextSelection.collapsed(
offset: 32,
+ affinity: TextAffinity.upstream,
),
),
reason: 'on $platform',
@@ -6776,6 +6786,7 @@
equals(
const TextSelection.collapsed(
offset: 32,
+ affinity: TextAffinity.upstream,
),
),
reason: 'on $platform',
@@ -6872,6 +6883,7 @@
equals(
const TextSelection.collapsed(
offset: 23,
+ affinity: TextAffinity.upstream,
),
),
reason: 'on $platform',
@@ -6917,6 +6929,7 @@
const TextSelection(
baseOffset: 23,
extentOffset: 23,
+ affinity: TextAffinity.upstream,
),
),
reason: 'on $platform',
@@ -6927,6 +6940,7 @@
const TextSelection(
baseOffset: 23,
extentOffset: 23,
+ affinity: TextAffinity.upstream,
),
),
reason: 'on $platform',
@@ -7060,6 +7074,7 @@
controller.selection,
equals(const TextSelection.collapsed(
offset: 4,
+ affinity: TextAffinity.upstream,
)),
);
@@ -7243,6 +7258,7 @@
equals(
const TextSelection.collapsed(
offset: 32,
+ affinity: TextAffinity.upstream,
),
),
reason: 'on $platform',
@@ -7266,6 +7282,7 @@
equals(
const TextSelection.collapsed(
offset: 32,
+ affinity: TextAffinity.upstream,
),
),
reason: 'on $platform',
@@ -7323,6 +7340,7 @@
equals(
const TextSelection.collapsed(
offset: 32,
+ affinity: TextAffinity.upstream,
),
),
reason: 'on $platform',
@@ -7435,6 +7453,7 @@
equals(
const TextSelection.collapsed(
offset: 32,
+ affinity: TextAffinity.upstream,
),
),
reason: 'on $platform',
@@ -7458,6 +7477,7 @@
equals(
const TextSelection.collapsed(
offset: 32,
+ affinity: TextAffinity.upstream,
),
),
reason: 'on $platform',
@@ -7515,6 +7535,7 @@
equals(
const TextSelection.collapsed(
offset: 32,
+ affinity: TextAffinity.upstream,
),
),
reason: 'on $platform',
@@ -7626,6 +7647,7 @@
equals(
const TextSelection.collapsed(
offset: 32,
+ affinity: TextAffinity.upstream,
),
),
);
@@ -10383,7 +10405,6 @@
expect(controller.selection.isCollapsed, false);
expect(controller.selection.baseOffset, 7);
expect(controller.selection.extentOffset, 10);
-
await sendKeys(
tester,
<LogicalKeyboardKey>[LogicalKeyboardKey.arrowLeft],
diff --git a/packages/flutter/test/widgets/editable_text_utils.dart b/packages/flutter/test/widgets/editable_text_utils.dart
index 7a65bff..505e104 100644
--- a/packages/flutter/test/widgets/editable_text_utils.dart
+++ b/packages/flutter/test/widgets/editable_text_utils.dart
@@ -2,6 +2,7 @@
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
+import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter_test/flutter_test.dart';
@@ -42,7 +43,7 @@
renderEditable,
);
expect(endpoints.length, 1);
- return endpoints[0].point + const Offset(0.0, -2.0);
+ return endpoints[0].point + const Offset(kIsWeb? 1.0 : 0.0, -2.0);
}
// Simple controller that builds a WidgetSpan with 100 height.