Expose text boundary combiner class (#112085)
diff --git a/packages/flutter/lib/src/painting/text_painter.dart b/packages/flutter/lib/src/painting/text_painter.dart
index 7cb35e2..0c893ec 100644
--- a/packages/flutter/lib/src/painting/text_painter.dart
+++ b/packages/flutter/lib/src/painting/text_painter.dart
@@ -1129,12 +1129,6 @@
/// {@endtemplate}
TextRange getWordBoundary(TextPosition position) {
assert(_debugAssertTextLayoutIsValid);
- // TODO(chunhtai): remove this workaround once ui.Paragraph.getWordBoundary
- // can handle caret position.
- // https://github.com/flutter/flutter/issues/111751.
- if (position.affinity == TextAffinity.upstream) {
- position = TextPosition(offset: position.offset - 1);
- }
return _paragraph!.getWordBoundary(position);
}
diff --git a/packages/flutter/lib/src/services/text_boundary.dart b/packages/flutter/lib/src/services/text_boundary.dart
index 1c94847..cb8259a 100644
--- a/packages/flutter/lib/src/services/text_boundary.dart
+++ b/packages/flutter/lib/src/services/text_boundary.dart
@@ -38,6 +38,18 @@
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 @@
/// 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 @@
affinity: TextAffinity.downstream, // ignore: avoid_redundant_argument_values
);
}
+
@override
TextPosition getTrailingTextBoundaryAt(TextPosition position) {
return TextPosition(
@@ -129,6 +142,11 @@
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 @@
/// 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 @@
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 @@
/// 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 @@
);
}
}
+
+/// 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);
+}
diff --git a/packages/flutter/lib/src/widgets/editable_text.dart b/packages/flutter/lib/src/widgets/editable_text.dart
index 9399cf5..1b000e0 100644
--- a/packages/flutter/lib/src/widgets/editable_text.dart
+++ b/packages/flutter/lib/src/widgets/editable_text.dart
@@ -3481,7 +3481,7 @@
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 @@
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 @@
: _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 @@
// `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 @@
// ------------------------ 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;
diff --git a/packages/flutter/test/services/text_boundary_test.dart b/packages/flutter/test/services/text_boundary_test.dart
index 6ec9873..67dbc5a 100644
--- a/packages/flutter/test/services/text_boundary_test.dart
+++ b/packages/flutter/test/services/text_boundary_test.dart
@@ -63,6 +63,72 @@
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 {