Unverified Commit bcc1dc6b authored by chunhtai's avatar chunhtai Committed by GitHub

Makes TextBoundary and its subclasses public (#110367)

parent a7f028f1
...@@ -43,6 +43,7 @@ export 'src/services/system_channels.dart'; ...@@ -43,6 +43,7 @@ export 'src/services/system_channels.dart';
export 'src/services/system_chrome.dart'; export 'src/services/system_chrome.dart';
export 'src/services/system_navigator.dart'; export 'src/services/system_navigator.dart';
export 'src/services/system_sound.dart'; export 'src/services/system_sound.dart';
export 'src/services/text_boundary.dart';
export 'src/services/text_editing.dart'; export 'src/services/text_editing.dart';
export 'src/services/text_editing_delta.dart'; export 'src/services/text_editing_delta.dart';
export 'src/services/text_formatter.dart'; export 'src/services/text_formatter.dart';
......
...@@ -1129,6 +1129,12 @@ class TextPainter { ...@@ -1129,6 +1129,12 @@ class TextPainter {
/// {@endtemplate} /// {@endtemplate}
TextRange getWordBoundary(TextPosition position) { TextRange getWordBoundary(TextPosition position) {
assert(_debugAssertTextLayoutIsValid); 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); return _paragraph!.getWordBoundary(position);
} }
......
...@@ -2040,7 +2040,6 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin, ...@@ -2040,7 +2040,6 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin,
final TextSelection firstWord = _getWordAtOffset(firstPosition); final TextSelection firstWord = _getWordAtOffset(firstPosition);
final TextSelection lastWord = to == null ? final TextSelection lastWord = to == null ?
firstWord : _getWordAtOffset(_textPainter.getPositionForOffset(globalToLocal(to - _paintOffset))); firstWord : _getWordAtOffset(_textPainter.getPositionForOffset(globalToLocal(to - _paintOffset)));
_setSelection( _setSelection(
TextSelection( TextSelection(
baseOffset: firstWord.base.offset, baseOffset: firstWord.base.offset,
...@@ -2071,14 +2070,28 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin, ...@@ -2071,14 +2070,28 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin,
TextSelection _getWordAtOffset(TextPosition position) { TextSelection _getWordAtOffset(TextPosition position) {
debugAssertLayoutUpToDate(); debugAssertLayoutUpToDate();
final TextRange word = _textPainter.getWordBoundary(position);
// When long-pressing past the end of the text, we want a collapsed cursor. // When long-pressing past the end of the text, we want a collapsed cursor.
if (position.offset >= word.end) { if (position.offset >= _plainText.length) {
return TextSelection.fromPosition(position); return TextSelection.fromPosition(
TextPosition(offset: _plainText.length, affinity: TextAffinity.upstream)
);
} }
// If text is obscured, the entire sentence should be treated as one word. // If text is obscured, the entire sentence should be treated as one word.
if (obscureText) { if (obscureText) {
return TextSelection(baseOffset: 0, extentOffset: _plainText.length); 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 // 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 // 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. // there is neither a previous word nor a next word.
...@@ -2086,8 +2099,8 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin, ...@@ -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 // 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 // previous word if there is one; otherwise, select the single whitespace at
// the position. // the position.
} else if (TextLayoutMetrics.isWhitespace(_plainText.codeUnitAt(position.offset)) if (TextLayoutMetrics.isWhitespace(_plainText.codeUnitAt(effectiveOffset))
&& position.offset > 0) { && effectiveOffset > 0) {
assert(defaultTargetPlatform != null); assert(defaultTargetPlatform != null);
final TextRange? previousWord = _getPreviousWord(word.start); final TextRange? previousWord = _getPreviousWord(word.start);
switch (defaultTargetPlatform) { switch (defaultTargetPlatform) {
......
// 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 ...@@ -3478,23 +3478,23 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
// --------------------------- Text Editing Actions --------------------------- // --------------------------- Text Editing Actions ---------------------------
_TextBoundary _characterBoundary(DirectionalTextEditingIntent intent) { TextBoundary _characterBoundary(DirectionalTextEditingIntent intent) {
final _TextBoundary atomicTextBoundary = widget.obscureText ? _CodeUnitBoundary(_value) : _CharacterBoundary(_value); final TextBoundary atomicTextBoundary = widget.obscureText ? _CodeUnitBoundary(_value.text) : CharacterBoundary(_value.text);
return _CollapsedSelectionBoundary(atomicTextBoundary, intent.forward); return _PushTextPosition(atomicTextBoundary, intent.forward);
} }
_TextBoundary _nextWordBoundary(DirectionalTextEditingIntent intent) { TextBoundary _nextWordBoundary(DirectionalTextEditingIntent intent) {
final _TextBoundary atomicTextBoundary; final TextBoundary atomicTextBoundary;
final _TextBoundary boundary; final TextBoundary boundary;
if (widget.obscureText) { if (widget.obscureText) {
atomicTextBoundary = _CodeUnitBoundary(_value); atomicTextBoundary = _CodeUnitBoundary(_value.text);
boundary = _DocumentBoundary(_value); boundary = DocumentBoundary(_value.text);
} else { } else {
final TextEditingValue textEditingValue = _textEditingValueforTextLayoutMetrics; final TextEditingValue textEditingValue = _textEditingValueforTextLayoutMetrics;
atomicTextBoundary = _CharacterBoundary(textEditingValue); atomicTextBoundary = CharacterBoundary(textEditingValue.text);
// This isn't enough. Newline characters. // 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 final _MixedBoundary mixedBoundary = intent.forward
...@@ -3502,20 +3502,20 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien ...@@ -3502,20 +3502,20 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
: _MixedBoundary(boundary, atomicTextBoundary); : _MixedBoundary(boundary, atomicTextBoundary);
// Use a _MixedBoundary to make sure we don't leave invalid codepoints in // Use a _MixedBoundary to make sure we don't leave invalid codepoints in
// the field after deletion. // the field after deletion.
return _CollapsedSelectionBoundary(mixedBoundary, intent.forward); return _PushTextPosition(mixedBoundary, intent.forward);
} }
_TextBoundary _linebreak(DirectionalTextEditingIntent intent) { TextBoundary _linebreak(DirectionalTextEditingIntent intent) {
final _TextBoundary atomicTextBoundary; final TextBoundary atomicTextBoundary;
final _TextBoundary boundary; final TextBoundary boundary;
if (widget.obscureText) { if (widget.obscureText) {
atomicTextBoundary = _CodeUnitBoundary(_value); atomicTextBoundary = _CodeUnitBoundary(_value.text);
boundary = _DocumentBoundary(_value); boundary = DocumentBoundary(_value.text);
} else { } else {
final TextEditingValue textEditingValue = _textEditingValueforTextLayoutMetrics; final TextEditingValue textEditingValue = _textEditingValueforTextLayoutMetrics;
atomicTextBoundary = _CharacterBoundary(textEditingValue); atomicTextBoundary = CharacterBoundary(textEditingValue.text);
boundary = _LineBreak(renderEditable, textEditingValue); boundary = LineBreak(renderEditable);
} }
// The _MixedBoundary is to make sure we don't leave invalid code units in // 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 ...@@ -3524,11 +3524,11 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
// since the document boundary is unique and the linebreak boundary is // since the document boundary is unique and the linebreak boundary is
// already caret-location based. // already caret-location based.
return intent.forward return intent.forward
? _MixedBoundary(_CollapsedSelectionBoundary(atomicTextBoundary, true), boundary) ? _MixedBoundary(_PushTextPosition(atomicTextBoundary, true), boundary)
: _MixedBoundary(boundary, _CollapsedSelectionBoundary(atomicTextBoundary, false)); : _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) { Action<T> _makeOverridable<T extends Intent>(Action<T> defaultAction) {
return Action<T>.overridable(context: context, defaultAction: defaultAction); return Action<T>.overridable(context: context, defaultAction: defaultAction);
...@@ -3615,17 +3615,17 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien ...@@ -3615,17 +3615,17 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
late final _UpdateTextSelectionToAdjacentLineAction<ExtendSelectionVerticallyToAdjacentLineIntent> _adjacentLineAction = _UpdateTextSelectionToAdjacentLineAction<ExtendSelectionVerticallyToAdjacentLineIntent>(this); late final _UpdateTextSelectionToAdjacentLineAction<ExtendSelectionVerticallyToAdjacentLineIntent> _adjacentLineAction = _UpdateTextSelectionToAdjacentLineAction<ExtendSelectionVerticallyToAdjacentLineIntent>(this);
void _expandSelectionToDocumentBoundary(ExpandSelectionToDocumentBoundaryIntent intent) { void _expandSelectionToDocumentBoundary(ExpandSelectionToDocumentBoundaryIntent intent) {
final _TextBoundary textBoundary = _documentBoundary(intent); final TextBoundary textBoundary = _documentBoundary(intent);
_expandSelection(intent.forward, textBoundary, true); _expandSelection(intent.forward, textBoundary, true);
} }
void _expandSelectionToLinebreak(ExpandSelectionToLineBreakIntent intent) { void _expandSelectionToLinebreak(ExpandSelectionToLineBreakIntent intent) {
final _TextBoundary textBoundary = _linebreak(intent); final TextBoundary textBoundary = _linebreak(intent);
_expandSelection(intent.forward, textBoundary); _expandSelection(intent.forward, textBoundary);
} }
void _expandSelection(bool forward, _TextBoundary textBoundary, [bool extentAtIndex = false]) { void _expandSelection(bool forward, TextBoundary textBoundary, [bool extentAtIndex = false]) {
final TextSelection textBoundarySelection = textBoundary.textEditingValue.selection; final TextSelection textBoundarySelection = _value.selection;
if (!textBoundarySelection.isValid) { if (!textBoundarySelection.isValid) {
return; return;
} }
...@@ -4220,214 +4220,120 @@ class _ScribblePlaceholder extends WidgetSpan { ...@@ -4220,214 +4220,120 @@ class _ScribblePlaceholder extends WidgetSpan {
} }
} }
/// An interface for retrieving the logical text boundary (left-closed-right-open) /// A text boundary that uses code units as logical boundaries.
/// at a given location in a document.
/// ///
/// Depending on the implementation of the [_TextBoundary], the input /// This text boundary treats every character in input string as an utf-16 code
/// [TextPosition] can either point to a code unit, or a position between 2 code /// unit. This can be useful when handling text without any grapheme cluster,
/// units (which can be visually represented by the caret if the selection were /// e.g. the obscure string in [EditableText]. If you are handling text that may
/// to collapse to that position). /// include grapheme clusters, consider using [CharacterBoundary].
/// class _CodeUnitBoundary extends TextBoundary {
/// For example, [_LineBreak] interprets the input [TextPosition] as a caret const _CodeUnitBoundary(this._text);
/// 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();
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 String _text;
final TextEditingValue textEditingValue;
@override @override
TextPosition getLeadingTextBoundaryAt(TextPosition position) { TextPosition getLeadingTextBoundaryAt(TextPosition position) {
for (int index = position.offset; index >= 0; index -= 1) { if (position.offset <= 0) {
if (!TextLayoutMetrics.isWhitespace(textEditingValue.text.codeUnitAt(index))) { return const TextPosition(offset: 0);
return TextPosition(offset: index);
} }
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));
} }
return const TextPosition(offset: 0);
} }
@override @override
TextPosition getTrailingTextBoundaryAt(TextPosition position) { TextPosition getTrailingTextBoundaryAt(TextPosition position) {
for (int index = position.offset; index < textEditingValue.text.length; index += 1) { if (position.offset < 0 ||
if (!TextLayoutMetrics.isWhitespace(textEditingValue.text.codeUnitAt(index))) { (position.offset == 0 && position.affinity == TextAffinity.upstream)) {
return TextPosition(offset: index + 1); 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);
} }
return TextPosition(offset: textEditingValue.text.length);
} }
} }
// Most apps delete the entire grapheme when the backspace key is pressed. // ------------------------ Text Boundary Combinators ------------------------
// 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 /// A text boundary that use the first non-whitespace character as the logical
final TextEditingValue textEditingValue; /// 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 @override
TextPosition getLeadingTextBoundaryAt(TextPosition position) { TextPosition getLeadingTextBoundaryAt(TextPosition position) {
final int endOffset = math.min(position.offset + 1, textEditingValue.text.length); // Handles outside of right bound.
return TextPosition( if (position.offset > _text.length || (position.offset == _text.length && position.affinity == TextAffinity.downstream)) {
offset: CharacterRange.at(textEditingValue.text, position.offset, endOffset).stringBeforeLength, position = TextPosition(offset: _text.length, affinity: TextAffinity.upstream);
);
} }
// Handles outside of left bound.
@override if (position.offset <= 0) {
TextPosition getTrailingTextBoundaryAt(TextPosition position) { return const TextPosition(offset: 0);
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,
);
} }
int index = position.offset;
@override if (!TextLayoutMetrics.isWhitespace(_text.codeUnitAt(index)) && position.affinity == TextAffinity.downstream) {
TextRange getTextBoundaryAt(TextPosition position) { return 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 for (index -= 1; index >= 0; index -= 1) {
final TextEditingValue textEditingValue; if (!TextLayoutMetrics.isWhitespace(_text.codeUnitAt(index))) {
return TextPosition(offset: index + 1, affinity: TextAffinity.upstream);
@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
);
} }
} return const TextPosition(offset: 0);
// 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 @override
TextPosition getTrailingTextBoundaryAt(TextPosition position) { TextPosition getTrailingTextBoundaryAt(TextPosition position) {
return TextPosition( // Handles outside of right bound.
offset: textLayout.getLineAtOffset(position).end, if (position.offset >= _text.length) {
affinity: TextAffinity.upstream, 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);
} }
}
// The document boundary is unique and is a constant function of the input
// position.
class _DocumentBoundary extends _TextBoundary {
const _DocumentBoundary(this.textEditingValue);
@override int index = position.offset;
final TextEditingValue textEditingValue; if (!TextLayoutMetrics.isWhitespace(_text.codeUnitAt(index)) && position.affinity == TextAffinity.downstream) {
return position;
}
@override for (index += 1; index < _text.length; index += 1) {
TextPosition getLeadingTextBoundaryAt(TextPosition position) => const TextPosition(offset: 0); if (!TextLayoutMetrics.isWhitespace(_text.codeUnitAt(index))) {
@override return TextPosition(offset: index);
TextPosition getTrailingTextBoundaryAt(TextPosition position) { }
return TextPosition( }
offset: textEditingValue.text.length, return TextPosition(offset: _text.length, affinity: TextAffinity.upstream);
affinity: TextAffinity.upstream,
);
} }
} }
// ------------------------ Text Boundary Combinators ------------------------
// Expands the innerTextBoundary with outerTextBoundary. // Expands the innerTextBoundary with outerTextBoundary.
class _ExpandedTextBoundary extends _TextBoundary { class _ExpandedTextBoundary extends TextBoundary {
_ExpandedTextBoundary(this.innerTextBoundary, this.outerTextBoundary); _ExpandedTextBoundary(this.innerTextBoundary, this.outerTextBoundary);
final _TextBoundary innerTextBoundary; final TextBoundary innerTextBoundary;
final _TextBoundary outerTextBoundary; final TextBoundary outerTextBoundary;
@override
TextEditingValue get textEditingValue {
assert(innerTextBoundary.textEditingValue == outerTextBoundary.textEditingValue);
return innerTextBoundary.textEditingValue;
}
@override @override
TextPosition getLeadingTextBoundaryAt(TextPosition position) { TextPosition getLeadingTextBoundaryAt(TextPosition position) {
...@@ -4444,49 +4350,70 @@ class _ExpandedTextBoundary extends _TextBoundary { ...@@ -4444,49 +4350,70 @@ class _ExpandedTextBoundary extends _TextBoundary {
} }
} }
// Force the innerTextBoundary to interpret the input [TextPosition]s as caret /// A proxy text boundary that will push input text position forward or backward
// locations instead of code unit positions. /// one affinity unit before sending it to the [innerTextBoundary].
// ///
// The innerTextBoundary must be a [_TextBoundary] that interprets the input /// If the [isForward] is true, this proxy text boundary push the position
// [TextPosition]s as code unit positions. /// forward; otherwise, backward.
class _CollapsedSelectionBoundary extends _TextBoundary { ///
_CollapsedSelectionBoundary(this.innerTextBoundary, this.isForward); /// 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; final bool isForward;
@override TextPosition _calculateTargetPosition(TextPosition position) {
TextEditingValue get textEditingValue => innerTextBoundary.textEditingValue; 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 @override
TextPosition getLeadingTextBoundaryAt(TextPosition position) { TextPosition getLeadingTextBoundaryAt(TextPosition position) {
return isForward return innerTextBoundary.getLeadingTextBoundaryAt(_calculateTargetPosition(position));
? innerTextBoundary.getLeadingTextBoundaryAt(position)
: position.offset <= 0 ? const TextPosition(offset: 0) : innerTextBoundary.getLeadingTextBoundaryAt(TextPosition(offset: position.offset - 1));
} }
@override @override
TextPosition getTrailingTextBoundaryAt(TextPosition position) { TextPosition getTrailingTextBoundaryAt(TextPosition position) {
return isForward return innerTextBoundary.getTrailingTextBoundaryAt(_calculateTargetPosition(position));
? innerTextBoundary.getTrailingTextBoundaryAt(position)
: position.offset <= 0 ? const TextPosition(offset: 0) : innerTextBoundary.getTrailingTextBoundaryAt(TextPosition(offset: position.offset - 1));
} }
} }
// A _TextBoundary that creates a [TextRange] where its start is from the // A _TextBoundary that creates a [TextRange] where its start is from the
// specified leading text boundary and its end is from the specified trailing // specified leading text boundary and its end is from the specified trailing
// text boundary. // text boundary.
class _MixedBoundary extends _TextBoundary { class _MixedBoundary extends TextBoundary {
_MixedBoundary(this.leadingTextBoundary, this.trailingTextBoundary); _MixedBoundary(this.leadingTextBoundary, this.trailingTextBoundary);
final _TextBoundary leadingTextBoundary; final TextBoundary leadingTextBoundary;
final _TextBoundary trailingTextBoundary; final TextBoundary trailingTextBoundary;
@override
TextEditingValue get textEditingValue {
assert(leadingTextBoundary.textEditingValue == trailingTextBoundary.textEditingValue);
return leadingTextBoundary.textEditingValue;
}
@override @override
TextPosition getLeadingTextBoundaryAt(TextPosition position) => leadingTextBoundary.getLeadingTextBoundaryAt(position); TextPosition getLeadingTextBoundaryAt(TextPosition position) => leadingTextBoundary.getLeadingTextBoundaryAt(position);
...@@ -4500,15 +4427,15 @@ class _DeleteTextAction<T extends DirectionalTextEditingIntent> extends ContextA ...@@ -4500,15 +4427,15 @@ class _DeleteTextAction<T extends DirectionalTextEditingIntent> extends ContextA
_DeleteTextAction(this.state, this.getTextBoundariesForIntent); _DeleteTextAction(this.state, this.getTextBoundariesForIntent);
final EditableTextState state; final EditableTextState state;
final _TextBoundary Function(T intent) getTextBoundariesForIntent; final TextBoundary Function(T intent) getTextBoundariesForIntent;
TextRange _expandNonCollapsedRange(TextEditingValue value) { TextRange _expandNonCollapsedRange(TextEditingValue value) {
final TextRange selection = value.selection; final TextRange selection = value.selection;
assert(selection.isValid); assert(selection.isValid);
assert(!selection.isCollapsed); assert(!selection.isCollapsed);
final _TextBoundary atomicBoundary = state.widget.obscureText final TextBoundary atomicBoundary = state.widget.obscureText
? _CodeUnitBoundary(value) ? _CodeUnitBoundary(value.text)
: _CharacterBoundary(value); : CharacterBoundary(value.text);
return TextRange( return TextRange(
start: atomicBoundary.getLeadingTextBoundaryAt(TextPosition(offset: selection.start)).offset, start: atomicBoundary.getLeadingTextBoundaryAt(TextPosition(offset: selection.start)).offset,
...@@ -4528,23 +4455,23 @@ class _DeleteTextAction<T extends DirectionalTextEditingIntent> extends ContextA ...@@ -4528,23 +4455,23 @@ class _DeleteTextAction<T extends DirectionalTextEditingIntent> extends ContextA
); );
} }
final _TextBoundary textBoundary = getTextBoundariesForIntent(intent); final TextBoundary textBoundary = getTextBoundariesForIntent(intent);
if (!textBoundary.textEditingValue.selection.isValid) { if (!state._value.selection.isValid) {
return null; return null;
} }
if (!textBoundary.textEditingValue.selection.isCollapsed) { if (!state._value.selection.isCollapsed) {
return Actions.invoke( return Actions.invoke(
context!, context!,
ReplaceTextIntent(state._value, '', _expandNonCollapsedRange(textBoundary.textEditingValue), SelectionChangedCause.keyboard), ReplaceTextIntent(state._value, '', _expandNonCollapsedRange(state._value), SelectionChangedCause.keyboard),
); );
} }
return Actions.invoke( return Actions.invoke(
context!, context!,
ReplaceTextIntent( ReplaceTextIntent(
textBoundary.textEditingValue, state._value,
'', '',
textBoundary.getTextBoundaryAt(textBoundary.textEditingValue.selection.base), textBoundary.getTextBoundaryAt(state._value.selection.base),
SelectionChangedCause.keyboard, SelectionChangedCause.keyboard,
), ),
); );
...@@ -4563,7 +4490,7 @@ class _UpdateTextSelectionAction<T extends DirectionalCaretMovementIntent> exten ...@@ -4563,7 +4490,7 @@ class _UpdateTextSelectionAction<T extends DirectionalCaretMovementIntent> exten
final EditableTextState state; final EditableTextState state;
final bool ignoreNonCollapsedSelection; final bool ignoreNonCollapsedSelection;
final _TextBoundary Function(T intent) getTextBoundariesForIntent; final TextBoundary Function(T intent) getTextBoundariesForIntent;
static const int NEWLINE_CODE_UNIT = 10; static const int NEWLINE_CODE_UNIT = 10;
...@@ -4578,7 +4505,7 @@ class _UpdateTextSelectionAction<T extends DirectionalCaretMovementIntent> exten ...@@ -4578,7 +4505,7 @@ class _UpdateTextSelectionAction<T extends DirectionalCaretMovementIntent> exten
&& state.textEditingValue.text.codeUnitAt(position.offset) != NEWLINE_CODE_UNIT; && 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. // downstream position.
bool _isAtWordwrapDownstream(TextPosition position) { bool _isAtWordwrapDownstream(TextPosition position) {
final TextPosition start = TextPosition( final TextPosition start = TextPosition(
...@@ -4611,29 +4538,9 @@ class _UpdateTextSelectionAction<T extends DirectionalCaretMovementIntent> exten ...@@ -4611,29 +4538,9 @@ class _UpdateTextSelectionAction<T extends DirectionalCaretMovementIntent> exten
); );
} }
final _TextBoundary textBoundary = getTextBoundariesForIntent(intent); final TextBoundary textBoundary = getTextBoundariesForIntent(intent);
// "textBoundary's selection is only updated after rebuild; if the text
// is the same, use the selection from state, which is more recent.
// This is necessary on macOS where alt+up sends the moveBackward:
// and moveToBeginningOfParagraph: selectors at the same time.
final TextSelection textBoundarySelection =
textBoundary.textEditingValue.text == state._value.text
? state._value.selection
: textBoundary.textEditingValue.selection;
if (!textBoundarySelection.isValid) {
return null;
}
if (!textBoundarySelection.isCollapsed && !ignoreNonCollapsedSelection && collapseSelection) {
return Actions.invoke(
context!,
UpdateSelectionIntent(state._value, collapse(textBoundarySelection), SelectionChangedCause.keyboard),
);
}
TextPosition extent = textBoundarySelection.extent;
TextPosition extent = selection.extent;
// If continuesAtWrap is true extent and is at the relevant wordwrap, then // If continuesAtWrap is true extent and is at the relevant wordwrap, then
// move it just to the other side of the wordwrap. // move it just to the other side of the wordwrap.
if (intent.continuesAtWrap) { if (intent.continuesAtWrap) {
...@@ -4652,10 +4559,9 @@ class _UpdateTextSelectionAction<T extends DirectionalCaretMovementIntent> exten ...@@ -4652,10 +4559,9 @@ class _UpdateTextSelectionAction<T extends DirectionalCaretMovementIntent> exten
final TextPosition newExtent = intent.forward final TextPosition newExtent = intent.forward
? textBoundary.getTrailingTextBoundaryAt(extent) ? textBoundary.getTrailingTextBoundaryAt(extent)
: textBoundary.getLeadingTextBoundaryAt(extent); : textBoundary.getLeadingTextBoundaryAt(extent);
final TextSelection newSelection = collapseSelection final TextSelection newSelection = collapseSelection
? TextSelection.fromPosition(newExtent) ? TextSelection.fromPosition(newExtent)
: textBoundarySelection.extendTo(newExtent); : selection.extendTo(newExtent);
// If collapseAtReversal is true and would have an effect, collapse it. // If collapseAtReversal is true and would have an effect, collapse it.
if (!selection.isCollapsed && intent.collapseAtReversal if (!selection.isCollapsed && intent.collapseAtReversal
...@@ -4673,7 +4579,7 @@ class _UpdateTextSelectionAction<T extends DirectionalCaretMovementIntent> exten ...@@ -4673,7 +4579,7 @@ class _UpdateTextSelectionAction<T extends DirectionalCaretMovementIntent> exten
return Actions.invoke( return Actions.invoke(
context!, context!,
UpdateSelectionIntent(textBoundary.textEditingValue, newSelection, SelectionChangedCause.keyboard), UpdateSelectionIntent(state._value, newSelection, SelectionChangedCause.keyboard),
); );
} }
...@@ -4685,15 +4591,15 @@ class _ExtendSelectionOrCaretPositionAction extends ContextAction<ExtendSelectio ...@@ -4685,15 +4591,15 @@ class _ExtendSelectionOrCaretPositionAction extends ContextAction<ExtendSelectio
_ExtendSelectionOrCaretPositionAction(this.state, this.getTextBoundariesForIntent); _ExtendSelectionOrCaretPositionAction(this.state, this.getTextBoundariesForIntent);
final EditableTextState state; final EditableTextState state;
final _TextBoundary Function(ExtendSelectionToNextWordBoundaryOrCaretLocationIntent intent) getTextBoundariesForIntent; final TextBoundary Function(ExtendSelectionToNextWordBoundaryOrCaretLocationIntent intent) getTextBoundariesForIntent;
@override @override
Object? invoke(ExtendSelectionToNextWordBoundaryOrCaretLocationIntent intent, [BuildContext? context]) { Object? invoke(ExtendSelectionToNextWordBoundaryOrCaretLocationIntent intent, [BuildContext? context]) {
final TextSelection selection = state._value.selection; final TextSelection selection = state._value.selection;
assert(selection.isValid); assert(selection.isValid);
final _TextBoundary textBoundary = getTextBoundariesForIntent(intent); final TextBoundary textBoundary = getTextBoundariesForIntent(intent);
final TextSelection textBoundarySelection = textBoundary.textEditingValue.selection; final TextSelection textBoundarySelection = state._value.selection;
if (!textBoundarySelection.isValid) { if (!textBoundarySelection.isValid) {
return null; return null;
} }
...@@ -4712,7 +4618,7 @@ class _ExtendSelectionOrCaretPositionAction extends ContextAction<ExtendSelectio ...@@ -4712,7 +4618,7 @@ class _ExtendSelectionOrCaretPositionAction extends ContextAction<ExtendSelectio
return Actions.invoke( return Actions.invoke(
context!, context!,
UpdateSelectionIntent(textBoundary.textEditingValue, newSelection, SelectionChangedCause.keyboard), UpdateSelectionIntent(state._value, newSelection, SelectionChangedCause.keyboard),
); );
} }
......
...@@ -199,7 +199,8 @@ void main() { ...@@ -199,7 +199,8 @@ void main() {
return endpoints[0].point; 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 { setUp(() async {
EditableText.debugDeterministicCursor = false; EditableText.debugDeterministicCursor = false;
...@@ -2087,6 +2088,7 @@ void main() { ...@@ -2087,6 +2088,7 @@ void main() {
await tester.pump(const Duration(milliseconds: 50)); await tester.pump(const Duration(milliseconds: 50));
await tester.tapAt(textOffsetToPosition(tester, 5)); await tester.tapAt(textOffsetToPosition(tester, 5));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(controller.value.selection, isNotNull); expect(controller.value.selection, isNotNull);
expect(controller.value.selection.baseOffset, 5); expect(controller.value.selection.baseOffset, 5);
expect(controller.value.selection.extentOffset, 6); expect(controller.value.selection.extentOffset, 6);
...@@ -3053,7 +3055,7 @@ void main() { ...@@ -3053,7 +3055,7 @@ void main() {
expect(controller.selection.extentOffset, 8); expect(controller.selection.extentOffset, 8);
// Tiny movement shouldn't cause text selection to change. // 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(); await tester.pumpAndSettle();
expect(selectionChangedCount, 0); expect(selectionChangedCount, 0);
......
...@@ -2172,7 +2172,7 @@ void main() { ...@@ -2172,7 +2172,7 @@ void main() {
expect(controller.selection.extentOffset, 8); expect(controller.selection.extentOffset, 8);
// Tiny movement shouldn't cause text selection to change. // 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(); await tester.pumpAndSettle();
expect(selectionChangedCount, 0); expect(selectionChangedCount, 0);
...@@ -3372,10 +3372,7 @@ void main() { ...@@ -3372,10 +3372,7 @@ void main() {
final Offset secondPos = textOffsetToPosition(tester, testValue.indexOf('Second')); final Offset secondPos = textOffsetToPosition(tester, testValue.indexOf('Second'));
final Offset thirdPos = textOffsetToPosition(tester, testValue.indexOf('Third')); final Offset thirdPos = textOffsetToPosition(tester, testValue.indexOf('Third'));
final Offset middleStringPos = textOffsetToPosition(tester, testValue.indexOf('irst')); final Offset middleStringPos = textOffsetToPosition(tester, testValue.indexOf('irst'));
expect(firstPos.dx, 0); expect(firstPos.dx, lessThan(middleStringPos.dx));
expect(secondPos.dx, 0);
expect(thirdPos.dx, 0);
expect(middleStringPos.dx, 34);
expect(firstPos.dx, secondPos.dx); expect(firstPos.dx, secondPos.dx);
expect(firstPos.dx, thirdPos.dx); expect(firstPos.dx, thirdPos.dx);
expect(firstPos.dy, lessThan(secondPos.dy)); expect(firstPos.dy, lessThan(secondPos.dy));
...@@ -3457,8 +3454,6 @@ void main() { ...@@ -3457,8 +3454,6 @@ void main() {
// Check that the last line of text is not displayed. // Check that the last line of text is not displayed.
final Offset firstPos = textOffsetToPosition(tester, kMoreThanFourLines.indexOf('First')); final Offset firstPos = textOffsetToPosition(tester, kMoreThanFourLines.indexOf('First'));
final Offset fourthPos = textOffsetToPosition(tester, kMoreThanFourLines.indexOf('Fourth')); final Offset fourthPos = textOffsetToPosition(tester, kMoreThanFourLines.indexOf('Fourth'));
expect(firstPos.dx, 0);
expect(fourthPos.dx, 0);
expect(firstPos.dx, fourthPos.dx); expect(firstPos.dx, fourthPos.dx);
expect(firstPos.dy, lessThan(fourthPos.dy)); expect(firstPos.dy, lessThan(fourthPos.dy));
expect(inputBox.hitTest(BoxHitTestResult(), position: inputBox.globalToLocal(firstPos)), isTrue); expect(inputBox.hitTest(BoxHitTestResult(), position: inputBox.globalToLocal(firstPos)), isTrue);
...@@ -8397,10 +8392,10 @@ void main() { ...@@ -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 // Just testing the test and making sure that the last character is off
// the right side of the screen. // the right side of the screen.
expect(textOffsetToPosition(tester, 66).dx, 1056); expect(textOffsetToPosition(tester, 66).dx, greaterThan(screenSize.width));
final TestGesture gesture = final TestGesture gesture =
await tester.startGesture( await tester.startGesture(
...@@ -8448,7 +8443,7 @@ void main() { ...@@ -8448,7 +8443,7 @@ void main() {
); );
// The first character is now offscreen to the left. // 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()); }, variant: TargetPlatformVariant.all());
testWidgets('keyboard selection change scrolls the field', (WidgetTester tester) async { testWidgets('keyboard selection change scrolls the field', (WidgetTester tester) async {
...@@ -8485,7 +8480,7 @@ void main() { ...@@ -8485,7 +8480,7 @@ void main() {
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect( expect(
controller.selection, controller.selection,
const TextSelection.collapsed(offset: 56), const TextSelection.collapsed(offset: 56, affinity: TextAffinity.upstream),
); );
// Keep moving out. // Keep moving out.
...@@ -8495,7 +8490,7 @@ void main() { ...@@ -8495,7 +8490,7 @@ void main() {
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect( expect(
controller.selection, controller.selection,
const TextSelection.collapsed(offset: 62), const TextSelection.collapsed(offset: 62, affinity: TextAffinity.upstream),
); );
for (int i = 0; i < (66 - 62); i += 1) { for (int i = 0; i < (66 - 62); i += 1) {
await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight);
...@@ -8503,7 +8498,7 @@ void main() { ...@@ -8503,7 +8498,7 @@ void main() {
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect( expect(
controller.selection, controller.selection,
const TextSelection.collapsed(offset: 66), const TextSelection.collapsed(offset: 66, affinity: TextAffinity.upstream),
); // We're at the edge now. ); // We're at the edge now.
await tester.pumpAndSettle(); await tester.pumpAndSettle();
......
// 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() { ...@@ -1229,6 +1229,7 @@ void main() {
expect(controller.selection, const TextSelection.collapsed( expect(controller.selection, const TextSelection.collapsed(
offset: 21, offset: 21,
affinity: TextAffinity.upstream,
)); ));
}, variant: TargetPlatformVariant.all()); }, variant: TargetPlatformVariant.all());
...@@ -1243,6 +1244,7 @@ void main() { ...@@ -1243,6 +1244,7 @@ void main() {
expect(controller.selection, const TextSelection.collapsed( expect(controller.selection, const TextSelection.collapsed(
offset: 10, offset: 10,
affinity: TextAffinity.upstream,
)); ));
}, variant: allExceptApple); }, variant: allExceptApple);
...@@ -1353,6 +1355,7 @@ void main() { ...@@ -1353,6 +1355,7 @@ void main() {
await tester.pump(); await tester.pump();
expect(controller.selection, const TextSelection.collapsed( expect(controller.selection, const TextSelection.collapsed(
offset: 46, // After "to". offset: 46, // After "to".
affinity: TextAffinity.upstream,
)); ));
// "good" to "come" is selected. // "good" to "come" is selected.
...@@ -1365,6 +1368,7 @@ void main() { ...@@ -1365,6 +1368,7 @@ void main() {
await tester.pump(); await tester.pump();
expect(controller.selection, const TextSelection.collapsed( expect(controller.selection, const TextSelection.collapsed(
offset: 28, // After "good". offset: 28, // After "good".
affinity: TextAffinity.upstream,
)); ));
}, variant: allExceptApple); }, variant: allExceptApple);
...@@ -1673,6 +1677,7 @@ void main() { ...@@ -1673,6 +1677,7 @@ void main() {
expect(controller.selection, const TextSelection.collapsed( expect(controller.selection, const TextSelection.collapsed(
offset: 10, offset: 10,
affinity: TextAffinity.upstream,
)); ));
}, variant: macOSOnly); }, variant: macOSOnly);
...@@ -1743,6 +1748,7 @@ void main() { ...@@ -1743,6 +1748,7 @@ void main() {
await tester.pump(); await tester.pump();
expect(controller.selection, const TextSelection.collapsed( expect(controller.selection, const TextSelection.collapsed(
offset: 46, // After "to". offset: 46, // After "to".
affinity: TextAffinity.upstream,
)); ));
// "good" to "come" is selected. // "good" to "come" is selected.
...@@ -1755,6 +1761,7 @@ void main() { ...@@ -1755,6 +1761,7 @@ void main() {
await tester.pump(); await tester.pump();
expect(controller.selection, const TextSelection.collapsed( expect(controller.selection, const TextSelection.collapsed(
offset: 28, // After "good". offset: 28, // After "good".
affinity: TextAffinity.upstream,
)); ));
}, variant: macOSOnly); }, variant: macOSOnly);
......
...@@ -5832,6 +5832,7 @@ void main() { ...@@ -5832,6 +5832,7 @@ void main() {
equals( equals(
const TextSelection.collapsed( const TextSelection.collapsed(
offset: 3, offset: 3,
affinity: TextAffinity.upstream,
), ),
), ),
reason: 'on $platform', reason: 'on $platform',
...@@ -5941,6 +5942,7 @@ void main() { ...@@ -5941,6 +5942,7 @@ void main() {
const TextSelection( const TextSelection(
baseOffset: 10, baseOffset: 10,
extentOffset: 10, extentOffset: 10,
affinity: TextAffinity.upstream,
), ),
), ),
reason: 'on $platform', reason: 'on $platform',
...@@ -6398,6 +6400,7 @@ void main() { ...@@ -6398,6 +6400,7 @@ void main() {
equals( equals(
const TextSelection.collapsed( const TextSelection.collapsed(
offset: 23, offset: 23,
affinity: TextAffinity.upstream,
), ),
), ),
reason: 'on $platform', reason: 'on $platform',
...@@ -6422,6 +6425,7 @@ void main() { ...@@ -6422,6 +6425,7 @@ void main() {
equals( equals(
const TextSelection.collapsed( const TextSelection.collapsed(
offset: 23, offset: 23,
affinity: TextAffinity.upstream,
), ),
), ),
reason: 'on $platform', reason: 'on $platform',
...@@ -6464,6 +6468,7 @@ void main() { ...@@ -6464,6 +6468,7 @@ void main() {
equals( equals(
const TextSelection.collapsed( const TextSelection.collapsed(
offset: 23, offset: 23,
affinity: TextAffinity.upstream,
), ),
), ),
reason: 'on $platform', reason: 'on $platform',
...@@ -6549,6 +6554,7 @@ void main() { ...@@ -6549,6 +6554,7 @@ void main() {
equals( equals(
const TextSelection.collapsed( const TextSelection.collapsed(
offset: 32, offset: 32,
affinity: TextAffinity.upstream,
), ),
), ),
reason: 'on $platform', reason: 'on $platform',
...@@ -6573,6 +6579,7 @@ void main() { ...@@ -6573,6 +6579,7 @@ void main() {
equals( equals(
const TextSelection.collapsed( const TextSelection.collapsed(
offset: 32, offset: 32,
affinity: TextAffinity.upstream,
), ),
), ),
reason: 'on $platform', reason: 'on $platform',
...@@ -6615,6 +6622,7 @@ void main() { ...@@ -6615,6 +6622,7 @@ void main() {
equals( equals(
const TextSelection.collapsed( const TextSelection.collapsed(
offset: 32, offset: 32,
affinity: TextAffinity.upstream,
), ),
), ),
reason: 'on $platform', reason: 'on $platform',
...@@ -6710,6 +6718,7 @@ void main() { ...@@ -6710,6 +6718,7 @@ void main() {
equals( equals(
const TextSelection.collapsed( const TextSelection.collapsed(
offset: 32, offset: 32,
affinity: TextAffinity.upstream,
), ),
), ),
reason: 'on $platform', reason: 'on $platform',
...@@ -6734,6 +6743,7 @@ void main() { ...@@ -6734,6 +6743,7 @@ void main() {
equals( equals(
const TextSelection.collapsed( const TextSelection.collapsed(
offset: 32, offset: 32,
affinity: TextAffinity.upstream,
), ),
), ),
reason: 'on $platform', reason: 'on $platform',
...@@ -6776,6 +6786,7 @@ void main() { ...@@ -6776,6 +6786,7 @@ void main() {
equals( equals(
const TextSelection.collapsed( const TextSelection.collapsed(
offset: 32, offset: 32,
affinity: TextAffinity.upstream,
), ),
), ),
reason: 'on $platform', reason: 'on $platform',
...@@ -6872,6 +6883,7 @@ void main() { ...@@ -6872,6 +6883,7 @@ void main() {
equals( equals(
const TextSelection.collapsed( const TextSelection.collapsed(
offset: 23, offset: 23,
affinity: TextAffinity.upstream,
), ),
), ),
reason: 'on $platform', reason: 'on $platform',
...@@ -6917,6 +6929,7 @@ void main() { ...@@ -6917,6 +6929,7 @@ void main() {
const TextSelection( const TextSelection(
baseOffset: 23, baseOffset: 23,
extentOffset: 23, extentOffset: 23,
affinity: TextAffinity.upstream,
), ),
), ),
reason: 'on $platform', reason: 'on $platform',
...@@ -6927,6 +6940,7 @@ void main() { ...@@ -6927,6 +6940,7 @@ void main() {
const TextSelection( const TextSelection(
baseOffset: 23, baseOffset: 23,
extentOffset: 23, extentOffset: 23,
affinity: TextAffinity.upstream,
), ),
), ),
reason: 'on $platform', reason: 'on $platform',
...@@ -7060,6 +7074,7 @@ void main() { ...@@ -7060,6 +7074,7 @@ void main() {
controller.selection, controller.selection,
equals(const TextSelection.collapsed( equals(const TextSelection.collapsed(
offset: 4, offset: 4,
affinity: TextAffinity.upstream,
)), )),
); );
...@@ -7243,6 +7258,7 @@ void main() { ...@@ -7243,6 +7258,7 @@ void main() {
equals( equals(
const TextSelection.collapsed( const TextSelection.collapsed(
offset: 32, offset: 32,
affinity: TextAffinity.upstream,
), ),
), ),
reason: 'on $platform', reason: 'on $platform',
...@@ -7266,6 +7282,7 @@ void main() { ...@@ -7266,6 +7282,7 @@ void main() {
equals( equals(
const TextSelection.collapsed( const TextSelection.collapsed(
offset: 32, offset: 32,
affinity: TextAffinity.upstream,
), ),
), ),
reason: 'on $platform', reason: 'on $platform',
...@@ -7323,6 +7340,7 @@ void main() { ...@@ -7323,6 +7340,7 @@ void main() {
equals( equals(
const TextSelection.collapsed( const TextSelection.collapsed(
offset: 32, offset: 32,
affinity: TextAffinity.upstream,
), ),
), ),
reason: 'on $platform', reason: 'on $platform',
...@@ -7435,6 +7453,7 @@ void main() { ...@@ -7435,6 +7453,7 @@ void main() {
equals( equals(
const TextSelection.collapsed( const TextSelection.collapsed(
offset: 32, offset: 32,
affinity: TextAffinity.upstream,
), ),
), ),
reason: 'on $platform', reason: 'on $platform',
...@@ -7458,6 +7477,7 @@ void main() { ...@@ -7458,6 +7477,7 @@ void main() {
equals( equals(
const TextSelection.collapsed( const TextSelection.collapsed(
offset: 32, offset: 32,
affinity: TextAffinity.upstream,
), ),
), ),
reason: 'on $platform', reason: 'on $platform',
...@@ -7515,6 +7535,7 @@ void main() { ...@@ -7515,6 +7535,7 @@ void main() {
equals( equals(
const TextSelection.collapsed( const TextSelection.collapsed(
offset: 32, offset: 32,
affinity: TextAffinity.upstream,
), ),
), ),
reason: 'on $platform', reason: 'on $platform',
...@@ -7626,6 +7647,7 @@ void main() { ...@@ -7626,6 +7647,7 @@ void main() {
equals( equals(
const TextSelection.collapsed( const TextSelection.collapsed(
offset: 32, offset: 32,
affinity: TextAffinity.upstream,
), ),
), ),
); );
...@@ -10383,7 +10405,6 @@ void main() { ...@@ -10383,7 +10405,6 @@ void main() {
expect(controller.selection.isCollapsed, false); expect(controller.selection.isCollapsed, false);
expect(controller.selection.baseOffset, 7); expect(controller.selection.baseOffset, 7);
expect(controller.selection.extentOffset, 10); expect(controller.selection.extentOffset, 10);
await sendKeys( await sendKeys(
tester, tester,
<LogicalKeyboardKey>[LogicalKeyboardKey.arrowLeft], <LogicalKeyboardKey>[LogicalKeyboardKey.arrowLeft],
......
...@@ -2,6 +2,7 @@ ...@@ -2,6 +2,7 @@
// Use of this source code is governed by a BSD-style license that can be // Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file. // found in the LICENSE file.
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart'; import 'package:flutter/rendering.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
...@@ -42,7 +43,7 @@ Offset textOffsetToPosition(WidgetTester tester, int offset) { ...@@ -42,7 +43,7 @@ Offset textOffsetToPosition(WidgetTester tester, int offset) {
renderEditable, renderEditable,
); );
expect(endpoints.length, 1); 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. // Simple controller that builds a WidgetSpan with 100 height.
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment