mirror of
https://github.com/flutter/flutter
synced 2024-09-19 08:11:56 +00:00
Makes TextBoundary and its subclasses public (#110367)
This commit is contained in:
parent
a7f028f170
commit
bcc1dc6b99
|
@ -43,6 +43,7 @@ export 'src/services/system_channels.dart';
|
|||
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';
|
||||
|
|
|
@ -1129,6 +1129,12 @@ class TextPainter {
|
|||
/// {@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);
|
||||
}
|
||||
|
||||
|
|
|
@ -2040,7 +2040,6 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin,
|
|||
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 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin,
|
|||
|
||||
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 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin,
|
|||
// 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) {
|
||||
|
|
179
packages/flutter/lib/src/services/text_boundary.dart
Normal file
179
packages/flutter/lib/src/services/text_boundary.dart
Normal file
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
|
@ -3478,23 +3478,23 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
|
|||
|
||||
// --------------------------- 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 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
|
|||
: _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 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
|
|||
// 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 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
|
|||
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 @@ class _ScribblePlaceholder extends WidgetSpan {
|
|||
}
|
||||
}
|
||||
|
||||
/// 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 @@ class _WhitespaceBoundary extends _TextBoundary {
|
|||
|
||||
@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 @@ class _ExpandedTextBoundary extends _TextBoundary {
|
|||
}
|
||||
}
|
||||
|
||||
// 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 @@ class _DeleteTextAction<T extends DirectionalTextEditingIntent> extends ContextA
|
|||
_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 @@ class _DeleteTextAction<T extends DirectionalTextEditingIntent> extends ContextA
|
|||
);
|
||||
}
|
||||
|
||||
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 @@ class _UpdateTextSelectionAction<T extends DirectionalCaretMovementIntent> exten
|
|||
|
||||
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 @@ class _UpdateTextSelectionAction<T extends DirectionalCaretMovementIntent> exten
|
|||
&& 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 @@ class _UpdateTextSelectionAction<T extends DirectionalCaretMovementIntent> exten
|
|||
);
|
||||
}
|
||||
|
||||
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;
|
||||
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.
|
||||
if (intent.continuesAtWrap) {
|
||||
|
@ -4652,10 +4559,9 @@ class _UpdateTextSelectionAction<T extends DirectionalCaretMovementIntent> exten
|
|||
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 @@ class _UpdateTextSelectionAction<T extends DirectionalCaretMovementIntent> exten
|
|||
|
||||
return Actions.invoke(
|
||||
context!,
|
||||
UpdateSelectionIntent(textBoundary.textEditingValue, newSelection, SelectionChangedCause.keyboard),
|
||||
UpdateSelectionIntent(state._value, newSelection, SelectionChangedCause.keyboard),
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -4685,15 +4591,15 @@ class _ExtendSelectionOrCaretPositionAction extends ContextAction<ExtendSelectio
|
|||
_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 @@ class _ExtendSelectionOrCaretPositionAction extends ContextAction<ExtendSelectio
|
|||
|
||||
return Actions.invoke(
|
||||
context!,
|
||||
UpdateSelectionIntent(textBoundary.textEditingValue, newSelection, SelectionChangedCause.keyboard),
|
||||
UpdateSelectionIntent(state._value, newSelection, SelectionChangedCause.keyboard),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -199,7 +199,8 @@ void main() {
|
|||
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 @@ void main() {
|
|||
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 @@ void main() {
|
|||
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);
|
||||
|
||||
|
|
|
@ -2172,7 +2172,7 @@ void main() {
|
|||
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 @@ void main() {
|
|||
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 @@ void main() {
|
|||
// 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 @@ void main() {
|
|||
),
|
||||
),
|
||||
);
|
||||
|
||||
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 @@ void main() {
|
|||
);
|
||||
|
||||
// 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 @@ void main() {
|
|||
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 @@ void main() {
|
|||
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 @@ void main() {
|
|||
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();
|
||||
|
|
97
packages/flutter/test/services/text_boundary_test.dart
Normal file
97
packages/flutter/test/services/text_boundary_test.dart
Normal file
|
@ -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();
|
||||
}
|
||||
}
|
|
@ -1229,6 +1229,7 @@ void main() {
|
|||
|
||||
expect(controller.selection, const TextSelection.collapsed(
|
||||
offset: 21,
|
||||
affinity: TextAffinity.upstream,
|
||||
));
|
||||
}, variant: TargetPlatformVariant.all());
|
||||
|
||||
|
@ -1243,6 +1244,7 @@ void main() {
|
|||
|
||||
expect(controller.selection, const TextSelection.collapsed(
|
||||
offset: 10,
|
||||
affinity: TextAffinity.upstream,
|
||||
));
|
||||
}, variant: allExceptApple);
|
||||
|
||||
|
@ -1353,6 +1355,7 @@ void main() {
|
|||
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 @@ void main() {
|
|||
await tester.pump();
|
||||
expect(controller.selection, const TextSelection.collapsed(
|
||||
offset: 28, // After "good".
|
||||
affinity: TextAffinity.upstream,
|
||||
));
|
||||
}, variant: allExceptApple);
|
||||
|
||||
|
@ -1673,6 +1677,7 @@ void main() {
|
|||
|
||||
expect(controller.selection, const TextSelection.collapsed(
|
||||
offset: 10,
|
||||
affinity: TextAffinity.upstream,
|
||||
));
|
||||
}, variant: macOSOnly);
|
||||
|
||||
|
@ -1743,6 +1748,7 @@ void main() {
|
|||
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 @@ void main() {
|
|||
await tester.pump();
|
||||
expect(controller.selection, const TextSelection.collapsed(
|
||||
offset: 28, // After "good".
|
||||
affinity: TextAffinity.upstream,
|
||||
));
|
||||
}, variant: macOSOnly);
|
||||
|
||||
|
|
|
@ -5832,6 +5832,7 @@ void main() {
|
|||
equals(
|
||||
const TextSelection.collapsed(
|
||||
offset: 3,
|
||||
affinity: TextAffinity.upstream,
|
||||
),
|
||||
),
|
||||
reason: 'on $platform',
|
||||
|
@ -5941,6 +5942,7 @@ void main() {
|
|||
const TextSelection(
|
||||
baseOffset: 10,
|
||||
extentOffset: 10,
|
||||
affinity: TextAffinity.upstream,
|
||||
),
|
||||
),
|
||||
reason: 'on $platform',
|
||||
|
@ -6398,6 +6400,7 @@ void main() {
|
|||
equals(
|
||||
const TextSelection.collapsed(
|
||||
offset: 23,
|
||||
affinity: TextAffinity.upstream,
|
||||
),
|
||||
),
|
||||
reason: 'on $platform',
|
||||
|
@ -6422,6 +6425,7 @@ void main() {
|
|||
equals(
|
||||
const TextSelection.collapsed(
|
||||
offset: 23,
|
||||
affinity: TextAffinity.upstream,
|
||||
),
|
||||
),
|
||||
reason: 'on $platform',
|
||||
|
@ -6464,6 +6468,7 @@ void main() {
|
|||
equals(
|
||||
const TextSelection.collapsed(
|
||||
offset: 23,
|
||||
affinity: TextAffinity.upstream,
|
||||
),
|
||||
),
|
||||
reason: 'on $platform',
|
||||
|
@ -6549,6 +6554,7 @@ void main() {
|
|||
equals(
|
||||
const TextSelection.collapsed(
|
||||
offset: 32,
|
||||
affinity: TextAffinity.upstream,
|
||||
),
|
||||
),
|
||||
reason: 'on $platform',
|
||||
|
@ -6573,6 +6579,7 @@ void main() {
|
|||
equals(
|
||||
const TextSelection.collapsed(
|
||||
offset: 32,
|
||||
affinity: TextAffinity.upstream,
|
||||
),
|
||||
),
|
||||
reason: 'on $platform',
|
||||
|
@ -6615,6 +6622,7 @@ void main() {
|
|||
equals(
|
||||
const TextSelection.collapsed(
|
||||
offset: 32,
|
||||
affinity: TextAffinity.upstream,
|
||||
),
|
||||
),
|
||||
reason: 'on $platform',
|
||||
|
@ -6710,6 +6718,7 @@ void main() {
|
|||
equals(
|
||||
const TextSelection.collapsed(
|
||||
offset: 32,
|
||||
affinity: TextAffinity.upstream,
|
||||
),
|
||||
),
|
||||
reason: 'on $platform',
|
||||
|
@ -6734,6 +6743,7 @@ void main() {
|
|||
equals(
|
||||
const TextSelection.collapsed(
|
||||
offset: 32,
|
||||
affinity: TextAffinity.upstream,
|
||||
),
|
||||
),
|
||||
reason: 'on $platform',
|
||||
|
@ -6776,6 +6786,7 @@ void main() {
|
|||
equals(
|
||||
const TextSelection.collapsed(
|
||||
offset: 32,
|
||||
affinity: TextAffinity.upstream,
|
||||
),
|
||||
),
|
||||
reason: 'on $platform',
|
||||
|
@ -6872,6 +6883,7 @@ void main() {
|
|||
equals(
|
||||
const TextSelection.collapsed(
|
||||
offset: 23,
|
||||
affinity: TextAffinity.upstream,
|
||||
),
|
||||
),
|
||||
reason: 'on $platform',
|
||||
|
@ -6917,6 +6929,7 @@ void main() {
|
|||
const TextSelection(
|
||||
baseOffset: 23,
|
||||
extentOffset: 23,
|
||||
affinity: TextAffinity.upstream,
|
||||
),
|
||||
),
|
||||
reason: 'on $platform',
|
||||
|
@ -6927,6 +6940,7 @@ void main() {
|
|||
const TextSelection(
|
||||
baseOffset: 23,
|
||||
extentOffset: 23,
|
||||
affinity: TextAffinity.upstream,
|
||||
),
|
||||
),
|
||||
reason: 'on $platform',
|
||||
|
@ -7060,6 +7074,7 @@ void main() {
|
|||
controller.selection,
|
||||
equals(const TextSelection.collapsed(
|
||||
offset: 4,
|
||||
affinity: TextAffinity.upstream,
|
||||
)),
|
||||
);
|
||||
|
||||
|
@ -7243,6 +7258,7 @@ void main() {
|
|||
equals(
|
||||
const TextSelection.collapsed(
|
||||
offset: 32,
|
||||
affinity: TextAffinity.upstream,
|
||||
),
|
||||
),
|
||||
reason: 'on $platform',
|
||||
|
@ -7266,6 +7282,7 @@ void main() {
|
|||
equals(
|
||||
const TextSelection.collapsed(
|
||||
offset: 32,
|
||||
affinity: TextAffinity.upstream,
|
||||
),
|
||||
),
|
||||
reason: 'on $platform',
|
||||
|
@ -7323,6 +7340,7 @@ void main() {
|
|||
equals(
|
||||
const TextSelection.collapsed(
|
||||
offset: 32,
|
||||
affinity: TextAffinity.upstream,
|
||||
),
|
||||
),
|
||||
reason: 'on $platform',
|
||||
|
@ -7435,6 +7453,7 @@ void main() {
|
|||
equals(
|
||||
const TextSelection.collapsed(
|
||||
offset: 32,
|
||||
affinity: TextAffinity.upstream,
|
||||
),
|
||||
),
|
||||
reason: 'on $platform',
|
||||
|
@ -7458,6 +7477,7 @@ void main() {
|
|||
equals(
|
||||
const TextSelection.collapsed(
|
||||
offset: 32,
|
||||
affinity: TextAffinity.upstream,
|
||||
),
|
||||
),
|
||||
reason: 'on $platform',
|
||||
|
@ -7515,6 +7535,7 @@ void main() {
|
|||
equals(
|
||||
const TextSelection.collapsed(
|
||||
offset: 32,
|
||||
affinity: TextAffinity.upstream,
|
||||
),
|
||||
),
|
||||
reason: 'on $platform',
|
||||
|
@ -7626,6 +7647,7 @@ void main() {
|
|||
equals(
|
||||
const TextSelection.collapsed(
|
||||
offset: 32,
|
||||
affinity: TextAffinity.upstream,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
@ -10383,7 +10405,6 @@ void main() {
|
|||
expect(controller.selection.isCollapsed, false);
|
||||
expect(controller.selection.baseOffset, 7);
|
||||
expect(controller.selection.extentOffset, 10);
|
||||
|
||||
await sendKeys(
|
||||
tester,
|
||||
<LogicalKeyboardKey>[LogicalKeyboardKey.arrowLeft],
|
||||
|
|
|
@ -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 @@ Offset textOffsetToPosition(WidgetTester tester, int offset) {
|
|||
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.
|
||||
|
|
Loading…
Reference in a new issue