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