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 {