Unverified Commit 50cd7377 authored by chunhtai's avatar chunhtai Committed by GitHub

Expose text boundary combiner class (#112085)

parent 2adee31c
......@@ -1129,12 +1129,6 @@ 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);
}
......
......@@ -38,6 +38,18 @@ abstract class TextBoundary {
end: getTrailingTextBoundaryAt(position).offset,
);
}
/// Gets the boundary by calling the left-hand side and pipe the result to
/// right-hand side.
///
/// Combining two text boundaries can be useful if one wants to ignore certain
/// text before finding the text boundary. For example, use
/// [WhitespaceBoundary] + [WordBoundary] to ignores any white space before
/// finding word boundary if the input position happens to be a whitespace
/// character.
TextBoundary operator +(TextBoundary other) {
return _ExpandedTextBoundary(inner: other, outer: this);
}
}
/// A text boundary that uses characters as logical boundaries.
......@@ -110,7 +122,7 @@ class CharacterBoundary extends TextBoundary {
/// 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.
/// Creates a [WordBoundary] with the text and layout information.
const WordBoundary(this._textLayout);
final TextLayoutMetrics _textLayout;
......@@ -122,6 +134,7 @@ class WordBoundary extends TextBoundary {
affinity: TextAffinity.downstream, // ignore: avoid_redundant_argument_values
);
}
@override
TextPosition getTrailingTextBoundaryAt(TextPosition position) {
return TextPosition(
......@@ -129,6 +142,11 @@ class WordBoundary extends TextBoundary {
affinity: TextAffinity.upstream,
);
}
@override
TextRange getTextBoundaryAt(TextPosition position) {
return _textLayout.getWordBoundary(position);
}
}
/// A text boundary that uses line breaks as logical boundaries.
......@@ -136,7 +154,7 @@ class WordBoundary extends TextBoundary {
/// 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.
/// Creates a [LineBreak] with the text and layout information.
const LineBreak(this._textLayout);
final TextLayoutMetrics _textLayout;
......@@ -155,6 +173,11 @@ class LineBreak extends TextBoundary {
affinity: TextAffinity.upstream,
);
}
@override
TextRange getTextBoundaryAt(TextPosition position) {
return _textLayout.getLineAtOffset(position);
}
}
/// A text boundary that uses the entire document as logical boundary.
......@@ -162,7 +185,7 @@ class LineBreak extends TextBoundary {
/// The document boundary is unique and is a constant function of the input
/// position.
class DocumentBoundary extends TextBoundary {
/// Creates a [CharacterBoundary] with the text
/// Creates a [DocumentBoundary] with the text
const DocumentBoundary(this._text);
final String _text;
......@@ -177,3 +200,149 @@ class DocumentBoundary extends TextBoundary {
);
}
}
/// A text boundary that uses the first non-whitespace character as the logical
/// boundary.
///
/// This text boundary uses [TextLayoutMetrics.isWhitespace] to identify white
/// spaces, this includes newline characters from ASCII and separators from the
/// [unicode separator category](https://en.wikipedia.org/wiki/Whitespace_character).
class WhitespaceBoundary extends TextBoundary {
/// Creates a [WhitespaceBoundary] with the text.
const WhitespaceBoundary(this._text);
final String _text;
@override
TextPosition getLeadingTextBoundaryAt(TextPosition position) {
// Handles outside of string end.
if (position.offset > _text.length || (position.offset == _text.length && position.affinity == TextAffinity.downstream)) {
position = TextPosition(offset: _text.length, affinity: TextAffinity.upstream);
}
// Handles outside of string start.
if (position.offset <= 0) {
return const TextPosition(offset: 0);
}
int index = position.offset;
if (position.affinity == TextAffinity.downstream && !TextLayoutMetrics.isWhitespace(_text.codeUnitAt(index))) {
return position;
}
while ((index -= 1) >= 0) {
if (!TextLayoutMetrics.isWhitespace(_text.codeUnitAt(index))) {
return TextPosition(offset: index + 1, affinity: TextAffinity.upstream);
}
}
return const TextPosition(offset: 0);
}
@override
TextPosition getTrailingTextBoundaryAt(TextPosition position) {
// Handles outside of right bound.
if (position.offset >= _text.length) {
return TextPosition(offset: _text.length, affinity: TextAffinity.upstream);
}
// Handles outside of left bound.
if (position.offset < 0 || (position.offset == 0 && position.affinity == TextAffinity.upstream)) {
position = const TextPosition(offset: 0);
}
int index = position.offset;
if (position.affinity == TextAffinity.upstream && !TextLayoutMetrics.isWhitespace(_text.codeUnitAt(index - 1))) {
return position;
}
for (; index < _text.length; index += 1) {
if (!TextLayoutMetrics.isWhitespace(_text.codeUnitAt(index))) {
return TextPosition(offset: index);
}
}
return TextPosition(offset: _text.length, affinity: TextAffinity.upstream);
}
}
/// Gets the boundary by calling the [outer] and pipe the result to
/// [inner].
class _ExpandedTextBoundary extends TextBoundary {
/// Creates a [_ExpandedTextBoundary] with inner and outter boundaries
const _ExpandedTextBoundary({required this.inner, required this.outer});
/// The inner boundary to call with the result from [outer].
final TextBoundary inner;
/// The outer boundary to call with the input position.
///
/// The result is piped to the [inner] before returning to the caller.
final TextBoundary outer;
@override
TextPosition getLeadingTextBoundaryAt(TextPosition position) {
return inner.getLeadingTextBoundaryAt(
outer.getLeadingTextBoundaryAt(position),
);
}
@override
TextPosition getTrailingTextBoundaryAt(TextPosition position) {
return inner.getTrailingTextBoundaryAt(
outer.getTrailingTextBoundaryAt(position),
);
}
}
/// A text boundary that will push input text position forward or backward
/// one affinity
///
/// To push a text position forward one affinity unit, this proxy converts
/// affinity to downstream if it is upstream; otherwise it increase the offset
/// by one with its affinity sets to upstream. For example,
/// `TextPosition(1, upstream)` becomes `TextPosition(1, downstream)`,
/// `TextPosition(4, downstream)` becomes `TextPosition(5, upstream)`.
///
/// See also:
/// * [PushTextPosition.forward], a text boundary to push the input position
/// forward.
/// * [PushTextPosition.backward], a text boundary to push the input position
/// backward.
class PushTextPosition extends TextBoundary {
const PushTextPosition._(this._forward);
/// A text boundary that pushes the input position forward.
static const TextBoundary forward = PushTextPosition._(true);
/// A text boundary that pushes the input position backward.
static const TextBoundary backward = PushTextPosition._(false);
/// Whether to push the input position forward or backward.
final bool _forward;
TextPosition _calculateTargetPosition(TextPosition position) {
if (_forward) {
switch(position.affinity) {
case TextAffinity.upstream:
return TextPosition(offset: position.offset);
case TextAffinity.downstream:
return position = TextPosition(
offset: position.offset + 1,
affinity: TextAffinity.upstream,
);
}
} else {
switch(position.affinity) {
case TextAffinity.upstream:
return position = TextPosition(offset: position.offset - 1);
case TextAffinity.downstream:
return TextPosition(
offset: position.offset,
affinity: TextAffinity.upstream,
);
}
}
}
@override
TextPosition getLeadingTextBoundaryAt(TextPosition position) => _calculateTargetPosition(position);
@override
TextPosition getTrailingTextBoundaryAt(TextPosition position) => _calculateTargetPosition(position);
}
......@@ -3481,7 +3481,7 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
TextBoundary _characterBoundary(DirectionalTextEditingIntent intent) {
final TextBoundary atomicTextBoundary = widget.obscureText ? _CodeUnitBoundary(_value.text) : CharacterBoundary(_value.text);
return _PushTextPosition(atomicTextBoundary, intent.forward);
return intent.forward ? PushTextPosition.forward + atomicTextBoundary : PushTextPosition.backward + atomicTextBoundary;
}
TextBoundary _nextWordBoundary(DirectionalTextEditingIntent intent) {
......@@ -3495,7 +3495,7 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
final TextEditingValue textEditingValue = _textEditingValueforTextLayoutMetrics;
atomicTextBoundary = CharacterBoundary(textEditingValue.text);
// This isn't enough. Newline characters.
boundary = _ExpandedTextBoundary(_WhitespaceBoundary(textEditingValue.text), WordBoundary(renderEditable));
boundary = WhitespaceBoundary(textEditingValue.text) + WordBoundary(renderEditable);
}
final _MixedBoundary mixedBoundary = intent.forward
......@@ -3503,7 +3503,7 @@ 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 _PushTextPosition(mixedBoundary, intent.forward);
return intent.forward ? PushTextPosition.forward + mixedBoundary : PushTextPosition.backward + mixedBoundary;
}
TextBoundary _linebreak(DirectionalTextEditingIntent intent) {
......@@ -3524,9 +3524,10 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
// `boundary` doesn't need to be wrapped in a _CollapsedSelectionBoundary,
// since the document boundary is unique and the linebreak boundary is
// already caret-location based.
return intent.forward
? _MixedBoundary(_PushTextPosition(atomicTextBoundary, true), boundary)
: _MixedBoundary(boundary, _PushTextPosition(atomicTextBoundary, false));
final TextBoundary pushed = intent.forward
? PushTextPosition.forward + atomicTextBoundary
: PushTextPosition.backward + atomicTextBoundary;
return intent.forward ? _MixedBoundary(pushed, boundary) : _MixedBoundary(boundary, pushed);
}
TextBoundary _documentBoundary(DirectionalTextEditingIntent intent) => DocumentBoundary(_value.text);
......@@ -4270,149 +4271,14 @@ class _CodeUnitBoundary extends TextBoundary {
// ------------------------ 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);
}
@override
TextPosition getTrailingTextBoundaryAt(TextPosition position) {
// Handles outside of right bound.
if (position.offset >= _text.length) {
return TextPosition(offset: _text.length, affinity: TextAffinity.upstream);
}
// Handles outside of left bound.
if (position.offset < 0 || (position.offset == 0 && position.affinity == TextAffinity.upstream)) {
position = const TextPosition(offset: 0);
}
int index = position.offset;
if (!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: _text.length, affinity: TextAffinity.upstream);
}
}
// Expands the innerTextBoundary with outerTextBoundary.
class _ExpandedTextBoundary extends TextBoundary {
_ExpandedTextBoundary(this.innerTextBoundary, this.outerTextBoundary);
final TextBoundary innerTextBoundary;
final TextBoundary outerTextBoundary;
@override
TextPosition getLeadingTextBoundaryAt(TextPosition position) {
return outerTextBoundary.getLeadingTextBoundaryAt(
innerTextBoundary.getLeadingTextBoundaryAt(position),
);
}
@override
TextPosition getTrailingTextBoundaryAt(TextPosition position) {
return outerTextBoundary.getTrailingTextBoundaryAt(
innerTextBoundary.getTrailingTextBoundaryAt(position),
);
}
}
/// 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 bool isForward;
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 innerTextBoundary.getLeadingTextBoundaryAt(_calculateTargetPosition(position));
}
@override
TextPosition getTrailingTextBoundaryAt(TextPosition position) {
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 {
_MixedBoundary(this.leadingTextBoundary, this.trailingTextBoundary);
_MixedBoundary(
this.leadingTextBoundary,
this.trailingTextBoundary
);
final TextBoundary leadingTextBoundary;
final TextBoundary trailingTextBoundary;
......
......@@ -63,6 +63,72 @@ void main() {
expect(boundary.getLeadingTextBoundaryAt(position), const TextPosition(offset: 0));
expect(boundary.getTrailingTextBoundaryAt(position), const TextPosition(offset: text.length, affinity: TextAffinity.upstream));
});
test('white space boundary works', () {
const String text = 'abcd efg';
const WhitespaceBoundary boundary = WhitespaceBoundary(text);
TextPosition position = const TextPosition(offset: 1);
// Should return the same position if the position points to a non white space.
expect(boundary.getLeadingTextBoundaryAt(position), position);
expect(boundary.getTrailingTextBoundaryAt(position), position);
position = const TextPosition(offset: 1, affinity: TextAffinity.upstream);
expect(boundary.getLeadingTextBoundaryAt(position), position);
expect(boundary.getTrailingTextBoundaryAt(position), position);
position = const TextPosition(offset: 4, affinity: TextAffinity.upstream);
expect(boundary.getLeadingTextBoundaryAt(position), position);
expect(boundary.getTrailingTextBoundaryAt(position), position);
// white space
position = const TextPosition(offset: 4);
expect(boundary.getLeadingTextBoundaryAt(position), const TextPosition(offset: 4, affinity: TextAffinity.upstream));
expect(boundary.getTrailingTextBoundaryAt(position), const TextPosition(offset: 8));
// white space
position = const TextPosition(offset: 6);
expect(boundary.getLeadingTextBoundaryAt(position), const TextPosition(offset: 4, affinity: TextAffinity.upstream));
expect(boundary.getTrailingTextBoundaryAt(position), const TextPosition(offset: 8));
position = const TextPosition(offset: 8);
expect(boundary.getLeadingTextBoundaryAt(position), position);
expect(boundary.getTrailingTextBoundaryAt(position), position);
});
test('extended boundary should work', () {
const String text = 'abcd efg';
const WhitespaceBoundary outer = WhitespaceBoundary(text);
const CharacterBoundary inner = CharacterBoundary(text);
final TextBoundary expanded = outer + inner;
TextPosition position = const TextPosition(offset: 1);
expect(expanded.getLeadingTextBoundaryAt(position), position);
expect(expanded.getTrailingTextBoundaryAt(position), const TextPosition(offset: 2, affinity: TextAffinity.upstream));
position = const TextPosition(offset: 5);
// should skip white space
expect(expanded.getLeadingTextBoundaryAt(position), const TextPosition(offset: 3));
expect(expanded.getTrailingTextBoundaryAt(position), const TextPosition(offset: 9, affinity: TextAffinity.upstream));
});
test('push text position works', () {
const String text = 'abcd efg';
const CharacterBoundary inner = CharacterBoundary(text);
final TextBoundary forward = PushTextPosition.forward + inner;
final TextBoundary backward = PushTextPosition.backward + inner;
TextPosition position = const TextPosition(offset: 1, affinity: TextAffinity.upstream);
const TextPosition pushedForward = TextPosition(offset: 1);
// the forward should push position one affinity
expect(forward.getLeadingTextBoundaryAt(position), inner.getLeadingTextBoundaryAt(pushedForward));
expect(forward.getTrailingTextBoundaryAt(position), inner.getTrailingTextBoundaryAt(pushedForward));
position = const TextPosition(offset: 5);
const TextPosition pushedBackward = TextPosition(offset: 5, affinity: TextAffinity.upstream);
// should skip white space
expect(backward.getLeadingTextBoundaryAt(position), inner.getLeadingTextBoundaryAt(pushedBackward));
expect(backward.getTrailingTextBoundaryAt(position), inner.getTrailingTextBoundaryAt(pushedBackward));
});
}
class TestTextLayoutMetrics extends TextLayoutMetrics {
......
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