Makes TextBoundary and its subclasses public (#110367)

diff --git a/packages/flutter/lib/services.dart b/packages/flutter/lib/services.dart
index 9c77b1c..a0ff42f 100644
--- a/packages/flutter/lib/services.dart
+++ b/packages/flutter/lib/services.dart
@@ -43,6 +43,7 @@
 export 'src/services/system_chrome.dart';
 export 'src/services/system_navigator.dart';
 export 'src/services/system_sound.dart';
+export 'src/services/text_boundary.dart';
 export 'src/services/text_editing.dart';
 export 'src/services/text_editing_delta.dart';
 export 'src/services/text_formatter.dart';
diff --git a/packages/flutter/lib/src/painting/text_painter.dart b/packages/flutter/lib/src/painting/text_painter.dart
index 0c893ec..7cb35e2 100644
--- a/packages/flutter/lib/src/painting/text_painter.dart
+++ b/packages/flutter/lib/src/painting/text_painter.dart
@@ -1129,6 +1129,12 @@
   /// {@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/rendering/editable.dart b/packages/flutter/lib/src/rendering/editable.dart
index 91c9eff..0283f9e 100644
--- a/packages/flutter/lib/src/rendering/editable.dart
+++ b/packages/flutter/lib/src/rendering/editable.dart
@@ -2040,7 +2040,6 @@
     final TextSelection firstWord = _getWordAtOffset(firstPosition);
     final TextSelection lastWord = to == null ?
       firstWord : _getWordAtOffset(_textPainter.getPositionForOffset(globalToLocal(to - _paintOffset)));
-
     _setSelection(
       TextSelection(
         baseOffset: firstWord.base.offset,
@@ -2071,14 +2070,28 @@
 
   TextSelection _getWordAtOffset(TextPosition position) {
     debugAssertLayoutUpToDate();
-    final TextRange word = _textPainter.getWordBoundary(position);
     // When long-pressing past the end of the text, we want a collapsed cursor.
-    if (position.offset >= word.end) {
-      return TextSelection.fromPosition(position);
+    if (position.offset >= _plainText.length) {
+      return TextSelection.fromPosition(
+        TextPosition(offset: _plainText.length, affinity: TextAffinity.upstream)
+      );
     }
     // If text is obscured, the entire sentence should be treated as one word.
     if (obscureText) {
       return TextSelection(baseOffset: 0, extentOffset: _plainText.length);
+    }
+    final TextRange word = _textPainter.getWordBoundary(position);
+    final int effectiveOffset;
+    switch (position.affinity) {
+      case TextAffinity.upstream:
+        // upstream affinity is effectively -1 in text position.
+        effectiveOffset = position.offset - 1;
+        break;
+      case TextAffinity.downstream:
+        effectiveOffset = position.offset;
+        break;
+    }
+
     // On iOS, select the previous word if there is a previous word, or select
     // to the end of the next word if there is a next word. Select nothing if
     // there is neither a previous word nor a next word.
@@ -2086,8 +2099,8 @@
     // If the platform is Android and the text is read only, try to select the
     // previous word if there is one; otherwise, select the single whitespace at
     // the position.
-    } else if (TextLayoutMetrics.isWhitespace(_plainText.codeUnitAt(position.offset))
-        && position.offset > 0) {
+    if (TextLayoutMetrics.isWhitespace(_plainText.codeUnitAt(effectiveOffset))
+        && effectiveOffset > 0) {
       assert(defaultTargetPlatform != null);
       final TextRange? previousWord = _getPreviousWord(word.start);
       switch (defaultTargetPlatform) {
diff --git a/packages/flutter/lib/src/services/text_boundary.dart b/packages/flutter/lib/src/services/text_boundary.dart
new file mode 100644
index 0000000..1c94847
--- /dev/null
+++ b/packages/flutter/lib/src/services/text_boundary.dart
@@ -0,0 +1,179 @@
+// Copyright 2014 The Flutter Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import 'dart:math' as math;
+import 'dart:ui';
+
+import 'package:characters/characters.dart' show CharacterRange;
+
+import 'text_layout_metrics.dart';
+
+/// An interface for retrieving the logical text boundary (left-closed-right-open)
+/// at a given location in a document.
+///
+/// The input [TextPosition] points to a position between 2 code units (which
+/// can be visually represented by the caret if the selection were to collapse
+/// to that position). The [TextPosition.affinity] is used to determine which
+/// code unit it points. For example, `TextPosition(i, upstream)` points to
+/// code unit `i - 1` and `TextPosition(i, downstream)` points to code unit `i`.
+abstract class TextBoundary {
+  /// A constant constructor to enable subclass override.
+  const TextBoundary();
+
+  /// Returns the leading text boundary at the given location.
+  ///
+  /// The return value must be less or equal to the input position.
+  TextPosition getLeadingTextBoundaryAt(TextPosition position);
+
+  /// Returns the trailing text boundary at the given location, exclusive.
+  ///
+  /// The return value must be greater or equal to the input position.
+  TextPosition getTrailingTextBoundaryAt(TextPosition position);
+
+  /// Gets the text boundary range that encloses the input position.
+  TextRange getTextBoundaryAt(TextPosition position) {
+    return TextRange(
+      start: getLeadingTextBoundaryAt(position).offset,
+      end: getTrailingTextBoundaryAt(position).offset,
+    );
+  }
+}
+
+/// A text boundary that uses characters as logical boundaries.
+///
+/// This class takes grapheme clusters into account and avoid creating
+/// boundaries that generate malformed utf-16 characters.
+class CharacterBoundary extends TextBoundary {
+  /// Creates a [CharacterBoundary] with the text.
+  const CharacterBoundary(this._text);
+
+  final String _text;
+
+  @override
+  TextPosition getLeadingTextBoundaryAt(TextPosition position) {
+    if (position.offset <= 0) {
+      return const TextPosition(offset: 0);
+    }
+    if (position.offset > _text.length ||
+        (position.offset == _text.length && position.affinity == TextAffinity.downstream)) {
+      return TextPosition(offset: _text.length, affinity: TextAffinity.upstream);
+    }
+    final int endOffset;
+    final int startOffset;
+    switch (position.affinity) {
+      case TextAffinity.upstream:
+        startOffset = math.min(position.offset - 1, _text.length);
+        endOffset = math.min(position.offset, _text.length);
+        break;
+      case TextAffinity.downstream:
+        startOffset = math.min(position.offset, _text.length);
+        endOffset = math.min(position.offset + 1, _text.length);
+        break;
+    }
+    return TextPosition(
+      offset: CharacterRange.at(_text, startOffset, endOffset).stringBeforeLength,
+    );
+  }
+
+  @override
+  TextPosition getTrailingTextBoundaryAt(TextPosition position) {
+    if (position.offset < 0 ||
+        (position.offset == 0 && position.affinity == TextAffinity.upstream)) {
+      return const TextPosition(offset: 0);
+    }
+    if (position.offset >= _text.length) {
+      return TextPosition(offset: _text.length, affinity: TextAffinity.upstream);
+    }
+    final int endOffset;
+    final int startOffset;
+    switch (position.affinity) {
+      case TextAffinity.upstream:
+        startOffset = math.min(position.offset - 1, _text.length);
+        endOffset = math.min(position.offset, _text.length);
+        break;
+      case TextAffinity.downstream:
+        startOffset = math.min(position.offset, _text.length);
+        endOffset = math.min(position.offset + 1, _text.length);
+        break;
+    }
+    final CharacterRange range = CharacterRange.at(_text, startOffset, endOffset);
+    return TextPosition(
+      offset: _text.length - range.stringAfterLength,
+      affinity: TextAffinity.upstream,
+    );
+  }
+}
+
+/// A text boundary that uses words as logical boundaries.
+///
+/// This class uses [UAX #29](https://unicode.org/reports/tr29/) defined word
+/// boundaries to calculate its logical boundaries.
+class WordBoundary extends TextBoundary {
+  /// Creates a [CharacterBoundary] with the text and layout information.
+  const WordBoundary(this._textLayout);
+
+  final TextLayoutMetrics _textLayout;
+
+  @override
+  TextPosition getLeadingTextBoundaryAt(TextPosition position) {
+    return TextPosition(
+      offset: _textLayout.getWordBoundary(position).start,
+      affinity: TextAffinity.downstream,  // ignore: avoid_redundant_argument_values
+    );
+  }
+  @override
+  TextPosition getTrailingTextBoundaryAt(TextPosition position) {
+    return TextPosition(
+      offset: _textLayout.getWordBoundary(position).end,
+      affinity: TextAffinity.upstream,
+    );
+  }
+}
+
+/// A text boundary that uses line breaks as logical boundaries.
+///
+/// The input [TextPosition]s will be interpreted as caret locations if
+/// [TextLayoutMetrics.getLineAtOffset] is text-affinity-aware.
+class LineBreak extends TextBoundary {
+  /// Creates a [CharacterBoundary] with the text and layout information.
+  const LineBreak(this._textLayout);
+
+  final TextLayoutMetrics _textLayout;
+
+  @override
+  TextPosition getLeadingTextBoundaryAt(TextPosition position) {
+    return TextPosition(
+      offset: _textLayout.getLineAtOffset(position).start,
+    );
+  }
+
+  @override
+  TextPosition getTrailingTextBoundaryAt(TextPosition position) {
+    return TextPosition(
+      offset: _textLayout.getLineAtOffset(position).end,
+      affinity: TextAffinity.upstream,
+    );
+  }
+}
+
+/// A text boundary that uses the entire document as logical boundary.
+///
+/// The document boundary is unique and is a constant function of the input
+/// position.
+class DocumentBoundary extends TextBoundary {
+  /// Creates a [CharacterBoundary] with the text
+  const DocumentBoundary(this._text);
+
+  final String _text;
+
+  @override
+  TextPosition getLeadingTextBoundaryAt(TextPosition position) => const TextPosition(offset: 0);
+  @override
+  TextPosition getTrailingTextBoundaryAt(TextPosition position) {
+    return TextPosition(
+      offset: _text.length,
+      affinity: TextAffinity.upstream,
+    );
+  }
+}
diff --git a/packages/flutter/lib/src/widgets/editable_text.dart b/packages/flutter/lib/src/widgets/editable_text.dart
index a96148a..c0c7c73 100644
--- a/packages/flutter/lib/src/widgets/editable_text.dart
+++ b/packages/flutter/lib/src/widgets/editable_text.dart
@@ -3478,23 +3478,23 @@
 
   // --------------------------- Text Editing Actions ---------------------------
 
-  _TextBoundary _characterBoundary(DirectionalTextEditingIntent intent) {
-    final _TextBoundary atomicTextBoundary = widget.obscureText ? _CodeUnitBoundary(_value) : _CharacterBoundary(_value);
-    return _CollapsedSelectionBoundary(atomicTextBoundary, intent.forward);
+  TextBoundary _characterBoundary(DirectionalTextEditingIntent intent) {
+    final TextBoundary atomicTextBoundary = widget.obscureText ? _CodeUnitBoundary(_value.text) : CharacterBoundary(_value.text);
+    return _PushTextPosition(atomicTextBoundary, intent.forward);
   }
 
-  _TextBoundary _nextWordBoundary(DirectionalTextEditingIntent intent) {
-    final _TextBoundary atomicTextBoundary;
-    final _TextBoundary boundary;
+  TextBoundary _nextWordBoundary(DirectionalTextEditingIntent intent) {
+    final TextBoundary atomicTextBoundary;
+    final TextBoundary boundary;
 
     if (widget.obscureText) {
-      atomicTextBoundary = _CodeUnitBoundary(_value);
-      boundary = _DocumentBoundary(_value);
+      atomicTextBoundary = _CodeUnitBoundary(_value.text);
+      boundary = DocumentBoundary(_value.text);
     } else {
       final TextEditingValue textEditingValue = _textEditingValueforTextLayoutMetrics;
-      atomicTextBoundary = _CharacterBoundary(textEditingValue);
+      atomicTextBoundary = CharacterBoundary(textEditingValue.text);
       // This isn't enough. Newline characters.
-      boundary = _ExpandedTextBoundary(_WhitespaceBoundary(textEditingValue), _WordBoundary(renderEditable, textEditingValue));
+      boundary = _ExpandedTextBoundary(_WhitespaceBoundary(textEditingValue.text), WordBoundary(renderEditable));
     }
 
     final _MixedBoundary mixedBoundary = intent.forward
@@ -3502,20 +3502,20 @@
       : _MixedBoundary(boundary, atomicTextBoundary);
     // Use a _MixedBoundary to make sure we don't leave invalid codepoints in
     // the field after deletion.
-    return _CollapsedSelectionBoundary(mixedBoundary, intent.forward);
+    return _PushTextPosition(mixedBoundary, intent.forward);
   }
 
-  _TextBoundary _linebreak(DirectionalTextEditingIntent intent) {
-    final _TextBoundary atomicTextBoundary;
-    final _TextBoundary boundary;
+  TextBoundary _linebreak(DirectionalTextEditingIntent intent) {
+    final TextBoundary atomicTextBoundary;
+    final TextBoundary boundary;
 
     if (widget.obscureText) {
-      atomicTextBoundary = _CodeUnitBoundary(_value);
-      boundary = _DocumentBoundary(_value);
+      atomicTextBoundary = _CodeUnitBoundary(_value.text);
+      boundary = DocumentBoundary(_value.text);
     } else {
       final TextEditingValue textEditingValue = _textEditingValueforTextLayoutMetrics;
-      atomicTextBoundary = _CharacterBoundary(textEditingValue);
-      boundary = _LineBreak(renderEditable, textEditingValue);
+      atomicTextBoundary = CharacterBoundary(textEditingValue.text);
+      boundary = LineBreak(renderEditable);
     }
 
     // The _MixedBoundary is to make sure we don't leave invalid code units in
@@ -3524,11 +3524,11 @@
     // since the document boundary is unique and the linebreak boundary is
     // already caret-location based.
     return intent.forward
-      ? _MixedBoundary(_CollapsedSelectionBoundary(atomicTextBoundary, true), boundary)
-      : _MixedBoundary(boundary, _CollapsedSelectionBoundary(atomicTextBoundary, false));
+      ? _MixedBoundary(_PushTextPosition(atomicTextBoundary, true), boundary)
+      : _MixedBoundary(boundary, _PushTextPosition(atomicTextBoundary, false));
   }
 
-  _TextBoundary _documentBoundary(DirectionalTextEditingIntent intent) => _DocumentBoundary(_value);
+  TextBoundary _documentBoundary(DirectionalTextEditingIntent intent) => DocumentBoundary(_value.text);
 
   Action<T> _makeOverridable<T extends Intent>(Action<T> defaultAction) {
     return Action<T>.overridable(context: context, defaultAction: defaultAction);
@@ -3615,17 +3615,17 @@
   late final _UpdateTextSelectionToAdjacentLineAction<ExtendSelectionVerticallyToAdjacentLineIntent> _adjacentLineAction = _UpdateTextSelectionToAdjacentLineAction<ExtendSelectionVerticallyToAdjacentLineIntent>(this);
 
   void _expandSelectionToDocumentBoundary(ExpandSelectionToDocumentBoundaryIntent intent) {
-    final _TextBoundary textBoundary = _documentBoundary(intent);
+    final TextBoundary textBoundary = _documentBoundary(intent);
     _expandSelection(intent.forward, textBoundary, true);
   }
 
   void _expandSelectionToLinebreak(ExpandSelectionToLineBreakIntent intent) {
-    final _TextBoundary textBoundary = _linebreak(intent);
+    final TextBoundary textBoundary = _linebreak(intent);
     _expandSelection(intent.forward, textBoundary);
   }
 
-  void _expandSelection(bool forward, _TextBoundary textBoundary, [bool extentAtIndex = false]) {
-    final TextSelection textBoundarySelection = textBoundary.textEditingValue.selection;
+  void _expandSelection(bool forward, TextBoundary textBoundary, [bool extentAtIndex = false]) {
+    final TextSelection textBoundarySelection = _value.selection;
     if (!textBoundarySelection.isValid) {
       return;
     }
@@ -4220,71 +4220,84 @@
   }
 }
 
-/// An interface for retrieving the logical text boundary (left-closed-right-open)
-/// at a given location in a document.
+/// A text boundary that uses code units as logical boundaries.
 ///
-/// Depending on the implementation of the [_TextBoundary], the input
-/// [TextPosition] can either point to a code unit, or a position between 2 code
-/// units (which can be visually represented by the caret if the selection were
-/// to collapse to that position).
-///
-/// For example, [_LineBreak] interprets the input [TextPosition] as a caret
-/// location, since in Flutter the caret is generally painted between the
-/// character the [TextPosition] points to and its previous character, and
-/// [_LineBreak] cares about the affinity of the input [TextPosition]. Most
-/// other text boundaries however, interpret the input [TextPosition] as the
-/// location of a code unit in the document, since it's easier to reason about
-/// the text boundary given a code unit in the text.
-///
-/// To convert a "code-unit-based" [_TextBoundary] to "caret-location-based",
-/// use the [_CollapsedSelectionBoundary] combinator.
-abstract class _TextBoundary {
-  const _TextBoundary();
+/// This text boundary treats every character in input string as an utf-16 code
+/// unit. This can be useful when handling text without any grapheme cluster,
+/// e.g. the obscure string in [EditableText]. If you are handling text that may
+/// include grapheme clusters, consider using [CharacterBoundary].
+class _CodeUnitBoundary extends TextBoundary {
+  const _CodeUnitBoundary(this._text);
 
-  TextEditingValue get textEditingValue;
-
-  /// Returns the leading text boundary at the given location, inclusive.
-  TextPosition getLeadingTextBoundaryAt(TextPosition position);
-
-  /// Returns the trailing text boundary at the given location, exclusive.
-  TextPosition getTrailingTextBoundaryAt(TextPosition position);
-
-  TextRange getTextBoundaryAt(TextPosition position) {
-    return TextRange(
-      start: getLeadingTextBoundaryAt(position).offset,
-      end: getTrailingTextBoundaryAt(position).offset,
-    );
-  }
-}
-
-// -----------------------------  Text Boundaries -----------------------------
-
-class _CodeUnitBoundary extends _TextBoundary {
-  const _CodeUnitBoundary(this.textEditingValue);
-
-  @override
-  final TextEditingValue textEditingValue;
-
-  @override
-  TextPosition getLeadingTextBoundaryAt(TextPosition position) => TextPosition(offset: position.offset);
-  @override
-  TextPosition getTrailingTextBoundaryAt(TextPosition position) => TextPosition(offset: math.min(position.offset + 1, textEditingValue.text.length));
-}
-
-// The word modifier generally removes the word boundaries around white spaces
-// (and newlines), IOW white spaces and some other punctuations are considered
-// a part of the next word in the search direction.
-class _WhitespaceBoundary extends _TextBoundary {
-  const _WhitespaceBoundary(this.textEditingValue);
-
-  @override
-  final TextEditingValue textEditingValue;
+  final String _text;
 
   @override
   TextPosition getLeadingTextBoundaryAt(TextPosition position) {
-    for (int index = position.offset; index >= 0; index -= 1) {
-      if (!TextLayoutMetrics.isWhitespace(textEditingValue.text.codeUnitAt(index))) {
-        return TextPosition(offset: index);
+    if (position.offset <= 0) {
+      return const TextPosition(offset: 0);
+    }
+    if (position.offset > _text.length ||
+        (position.offset == _text.length && position.affinity == TextAffinity.downstream)) {
+      return TextPosition(offset: _text.length, affinity: TextAffinity.upstream);
+    }
+    switch (position.affinity) {
+      case TextAffinity.upstream:
+        return TextPosition(offset: math.min(position.offset - 1, _text.length));
+      case TextAffinity.downstream:
+        return TextPosition(offset: math.min(position.offset, _text.length));
+    }
+  }
+
+  @override
+  TextPosition getTrailingTextBoundaryAt(TextPosition position) {
+    if (position.offset < 0 ||
+        (position.offset == 0 && position.affinity == TextAffinity.upstream)) {
+      return const TextPosition(offset: 0);
+    }
+    if (position.offset >= _text.length) {
+      return TextPosition(offset: _text.length, affinity: TextAffinity.upstream);
+    }
+    switch (position.affinity) {
+      case TextAffinity.upstream:
+        return TextPosition(offset: math.min(position.offset, _text.length), affinity: TextAffinity.upstream);
+      case TextAffinity.downstream:
+        return TextPosition(offset: math.min(position.offset + 1, _text.length), affinity: TextAffinity.upstream);
+    }
+  }
+}
+
+// ------------------------  Text Boundary Combinators ------------------------
+
+/// A text boundary that use the first non-whitespace character as the logical
+/// boundary.
+///
+/// This text boundary uses [TextLayoutMetrics.isWhitespace] to identify white
+/// spaces, this include newline characters from ASCII and separators from the
+/// [unicode separator category](https://www.compart.com/en/unicode/category/Zs).
+class _WhitespaceBoundary extends TextBoundary {
+  /// Creates a [_WhitespaceBoundary] with the text.
+  const _WhitespaceBoundary(this._text);
+
+  final String _text;
+
+  @override
+  TextPosition getLeadingTextBoundaryAt(TextPosition position) {
+    // Handles outside of right bound.
+    if (position.offset > _text.length || (position.offset == _text.length  && position.affinity == TextAffinity.downstream)) {
+      position = TextPosition(offset: _text.length, affinity: TextAffinity.upstream);
+    }
+    // Handles outside of left bound.
+    if (position.offset <= 0) {
+      return const TextPosition(offset: 0);
+    }
+    int index = position.offset;
+    if (!TextLayoutMetrics.isWhitespace(_text.codeUnitAt(index)) && position.affinity == TextAffinity.downstream) {
+      return position;
+    }
+
+    for (index -= 1; index >= 0; index -= 1) {
+      if (!TextLayoutMetrics.isWhitespace(_text.codeUnitAt(index))) {
+        return TextPosition(offset: index + 1, affinity: TextAffinity.upstream);
       }
     }
     return const TextPosition(offset: 0);
@@ -4292,142 +4305,35 @@
 
   @override
   TextPosition getTrailingTextBoundaryAt(TextPosition position) {
-    for (int index = position.offset; index < textEditingValue.text.length; index += 1) {
-      if (!TextLayoutMetrics.isWhitespace(textEditingValue.text.codeUnitAt(index))) {
-        return TextPosition(offset: index + 1);
+    // Handles outside of right bound.
+    if (position.offset >= _text.length) {
+      return TextPosition(offset: _text.length, affinity: TextAffinity.upstream);
+    }
+    // Handles outside of left bound.
+    if (position.offset < 0 || (position.offset == 0 && position.affinity == TextAffinity.upstream)) {
+      position = const TextPosition(offset: 0);
+    }
+
+    int index = position.offset;
+    if (!TextLayoutMetrics.isWhitespace(_text.codeUnitAt(index)) && position.affinity == TextAffinity.downstream) {
+      return position;
+    }
+
+    for (index += 1; index < _text.length; index += 1) {
+      if (!TextLayoutMetrics.isWhitespace(_text.codeUnitAt(index))) {
+        return TextPosition(offset: index);
       }
     }
-    return TextPosition(offset: textEditingValue.text.length);
+    return TextPosition(offset: _text.length, affinity: TextAffinity.upstream);
   }
 }
 
-// Most apps delete the entire grapheme when the backspace key is pressed.
-// Also always put the new caret location to character boundaries to avoid
-// sending malformed UTF-16 code units to the paragraph builder.
-class _CharacterBoundary extends _TextBoundary {
-  const _CharacterBoundary(this.textEditingValue);
-
-  @override
-  final TextEditingValue textEditingValue;
-
-  @override
-  TextPosition getLeadingTextBoundaryAt(TextPosition position) {
-    final int endOffset = math.min(position.offset + 1, textEditingValue.text.length);
-    return TextPosition(
-      offset: CharacterRange.at(textEditingValue.text, position.offset, endOffset).stringBeforeLength,
-    );
-  }
-
-  @override
-  TextPosition getTrailingTextBoundaryAt(TextPosition position) {
-    final int endOffset = math.min(position.offset + 1, textEditingValue.text.length);
-    final CharacterRange range = CharacterRange.at(textEditingValue.text, position.offset, endOffset);
-    return TextPosition(
-      offset: textEditingValue.text.length - range.stringAfterLength,
-    );
-  }
-
-  @override
-  TextRange getTextBoundaryAt(TextPosition position) {
-    final int endOffset = math.min(position.offset + 1, textEditingValue.text.length);
-    final CharacterRange range = CharacterRange.at(textEditingValue.text, position.offset, endOffset);
-    return TextRange(
-      start: range.stringBeforeLength,
-      end: textEditingValue.text.length - range.stringAfterLength,
-    );
-  }
-}
-
-// [UAX #29](https://unicode.org/reports/tr29/) defined word boundaries.
-class _WordBoundary extends _TextBoundary {
-  const _WordBoundary(this.textLayout, this.textEditingValue);
-
-  final TextLayoutMetrics textLayout;
-
-  @override
-  final TextEditingValue textEditingValue;
-
-  @override
-  TextPosition getLeadingTextBoundaryAt(TextPosition position) {
-    return TextPosition(
-      offset: textLayout.getWordBoundary(position).start,
-      // Word boundary seems to always report downstream on many platforms.
-      affinity: TextAffinity.downstream,  // ignore: avoid_redundant_argument_values
-    );
-  }
-  @override
-  TextPosition getTrailingTextBoundaryAt(TextPosition position) {
-    return TextPosition(
-      offset: textLayout.getWordBoundary(position).end,
-      // Word boundary seems to always report downstream on many platforms.
-      affinity: TextAffinity.downstream,  // ignore: avoid_redundant_argument_values
-    );
-  }
-}
-
-// The linebreaks of the current text layout. The input [TextPosition]s are
-// interpreted as caret locations because [TextPainter.getLineAtOffset] is
-// text-affinity-aware.
-class _LineBreak extends _TextBoundary {
-  const _LineBreak(
-    this.textLayout,
-    this.textEditingValue,
-  );
-
-  final TextLayoutMetrics textLayout;
-
-  @override
-  final TextEditingValue textEditingValue;
-
-  @override
-  TextPosition getLeadingTextBoundaryAt(TextPosition position) {
-    return TextPosition(
-      offset: textLayout.getLineAtOffset(position).start,
-    );
-  }
-
-  @override
-  TextPosition getTrailingTextBoundaryAt(TextPosition position) {
-    return TextPosition(
-      offset: textLayout.getLineAtOffset(position).end,
-      affinity: TextAffinity.upstream,
-    );
-  }
-}
-
-// The document boundary is unique and is a constant function of the input
-// position.
-class _DocumentBoundary extends _TextBoundary {
-  const _DocumentBoundary(this.textEditingValue);
-
-  @override
-  final TextEditingValue textEditingValue;
-
-  @override
-  TextPosition getLeadingTextBoundaryAt(TextPosition position) => const TextPosition(offset: 0);
-  @override
-  TextPosition getTrailingTextBoundaryAt(TextPosition position) {
-    return TextPosition(
-      offset: textEditingValue.text.length,
-      affinity: TextAffinity.upstream,
-    );
-  }
-}
-
-// ------------------------  Text Boundary Combinators ------------------------
-
 // Expands the innerTextBoundary with outerTextBoundary.
-class _ExpandedTextBoundary extends _TextBoundary {
+class _ExpandedTextBoundary extends TextBoundary {
   _ExpandedTextBoundary(this.innerTextBoundary, this.outerTextBoundary);
 
-  final _TextBoundary innerTextBoundary;
-  final _TextBoundary outerTextBoundary;
-
-  @override
-  TextEditingValue get textEditingValue {
-    assert(innerTextBoundary.textEditingValue == outerTextBoundary.textEditingValue);
-    return innerTextBoundary.textEditingValue;
-  }
+  final TextBoundary innerTextBoundary;
+  final TextBoundary outerTextBoundary;
 
   @override
   TextPosition getLeadingTextBoundaryAt(TextPosition position) {
@@ -4444,49 +4350,70 @@
   }
 }
 
-// Force the innerTextBoundary to interpret the input [TextPosition]s as caret
-// locations instead of code unit positions.
-//
-// The innerTextBoundary must be a [_TextBoundary] that interprets the input
-// [TextPosition]s as code unit positions.
-class _CollapsedSelectionBoundary extends _TextBoundary {
-  _CollapsedSelectionBoundary(this.innerTextBoundary, this.isForward);
+/// A proxy text boundary that will push input text position forward or backward
+/// one affinity unit before sending it to the [innerTextBoundary].
+///
+/// If the [isForward] is true, this proxy text boundary push the position
+/// forward; otherwise, backward.
+///
+/// To push a text position forward one affinity unit, this proxy converts
+/// affinity to downstream if it is upstream; otherwise it increase the offset
+/// by one with its affinity sets to upstream. For example,
+/// `TextPosition(1, upstream)` becomes `TextPosition(1, downstream)`,
+/// `TextPosition(4, downstream)` becomes `TextPosition(5, upstream)`.
+///
+/// This class is used to kick-start the text position to find the next boundary
+/// determined by [innerTextBoundary] so that it won't be trapped if the input
+/// text position is right at the edge.
+class _PushTextPosition extends TextBoundary {
+  _PushTextPosition(this.innerTextBoundary, this.isForward);
 
-  final _TextBoundary innerTextBoundary;
+  final TextBoundary innerTextBoundary;
   final bool isForward;
 
-  @override
-  TextEditingValue get textEditingValue => innerTextBoundary.textEditingValue;
+  TextPosition _calculateTargetPosition(TextPosition position) {
+    if (isForward) {
+      switch(position.affinity) {
+        case TextAffinity.upstream:
+          return TextPosition(offset: position.offset);
+        case TextAffinity.downstream:
+          return position = TextPosition(
+            offset: position.offset + 1,
+            affinity: TextAffinity.upstream,
+          );
+      }
+    } else {
+      switch(position.affinity) {
+        case TextAffinity.upstream:
+          return position = TextPosition(offset: position.offset - 1);
+        case TextAffinity.downstream:
+          return TextPosition(
+            offset: position.offset,
+            affinity: TextAffinity.upstream,
+          );
+      }
+    }
+  }
 
   @override
   TextPosition getLeadingTextBoundaryAt(TextPosition position) {
-    return isForward
-      ? innerTextBoundary.getLeadingTextBoundaryAt(position)
-      : position.offset <= 0 ? const TextPosition(offset: 0) : innerTextBoundary.getLeadingTextBoundaryAt(TextPosition(offset: position.offset - 1));
+    return innerTextBoundary.getLeadingTextBoundaryAt(_calculateTargetPosition(position));
   }
 
   @override
   TextPosition getTrailingTextBoundaryAt(TextPosition position) {
-    return isForward
-      ? innerTextBoundary.getTrailingTextBoundaryAt(position)
-      : position.offset <= 0 ? const TextPosition(offset: 0) : innerTextBoundary.getTrailingTextBoundaryAt(TextPosition(offset: position.offset - 1));
+    return innerTextBoundary.getTrailingTextBoundaryAt(_calculateTargetPosition(position));
   }
 }
 
 // A _TextBoundary that creates a [TextRange] where its start is from the
 // specified leading text boundary and its end is from the specified trailing
 // text boundary.
-class _MixedBoundary extends _TextBoundary {
+class _MixedBoundary extends TextBoundary {
   _MixedBoundary(this.leadingTextBoundary, this.trailingTextBoundary);
 
-  final _TextBoundary leadingTextBoundary;
-  final _TextBoundary trailingTextBoundary;
-
-  @override
-  TextEditingValue get textEditingValue {
-    assert(leadingTextBoundary.textEditingValue == trailingTextBoundary.textEditingValue);
-    return leadingTextBoundary.textEditingValue;
-  }
+  final TextBoundary leadingTextBoundary;
+  final TextBoundary trailingTextBoundary;
 
   @override
   TextPosition getLeadingTextBoundaryAt(TextPosition position) => leadingTextBoundary.getLeadingTextBoundaryAt(position);
@@ -4500,15 +4427,15 @@
   _DeleteTextAction(this.state, this.getTextBoundariesForIntent);
 
   final EditableTextState state;
-  final _TextBoundary Function(T intent) getTextBoundariesForIntent;
+  final TextBoundary Function(T intent) getTextBoundariesForIntent;
 
   TextRange _expandNonCollapsedRange(TextEditingValue value) {
     final TextRange selection = value.selection;
     assert(selection.isValid);
     assert(!selection.isCollapsed);
-    final _TextBoundary atomicBoundary = state.widget.obscureText
-      ? _CodeUnitBoundary(value)
-      : _CharacterBoundary(value);
+    final TextBoundary atomicBoundary = state.widget.obscureText
+      ? _CodeUnitBoundary(value.text)
+      : CharacterBoundary(value.text);
 
     return TextRange(
       start: atomicBoundary.getLeadingTextBoundaryAt(TextPosition(offset: selection.start)).offset,
@@ -4528,23 +4455,23 @@
       );
     }
 
-    final _TextBoundary textBoundary = getTextBoundariesForIntent(intent);
-    if (!textBoundary.textEditingValue.selection.isValid) {
+    final TextBoundary textBoundary = getTextBoundariesForIntent(intent);
+    if (!state._value.selection.isValid) {
       return null;
     }
-    if (!textBoundary.textEditingValue.selection.isCollapsed) {
+    if (!state._value.selection.isCollapsed) {
       return Actions.invoke(
         context!,
-        ReplaceTextIntent(state._value, '', _expandNonCollapsedRange(textBoundary.textEditingValue), SelectionChangedCause.keyboard),
+        ReplaceTextIntent(state._value, '', _expandNonCollapsedRange(state._value), SelectionChangedCause.keyboard),
       );
     }
 
     return Actions.invoke(
       context!,
       ReplaceTextIntent(
-        textBoundary.textEditingValue,
+        state._value,
         '',
-        textBoundary.getTextBoundaryAt(textBoundary.textEditingValue.selection.base),
+        textBoundary.getTextBoundaryAt(state._value.selection.base),
         SelectionChangedCause.keyboard,
       ),
     );
@@ -4563,7 +4490,7 @@
 
   final EditableTextState state;
   final bool ignoreNonCollapsedSelection;
-  final _TextBoundary Function(T intent) getTextBoundariesForIntent;
+  final TextBoundary Function(T intent) getTextBoundariesForIntent;
 
   static const int NEWLINE_CODE_UNIT = 10;
 
@@ -4578,7 +4505,7 @@
         && state.textEditingValue.text.codeUnitAt(position.offset) != NEWLINE_CODE_UNIT;
   }
 
-  // Returns true iff the given position at a wordwrap boundary in the
+  // Returns true if the given position at a wordwrap boundary in the
   // downstream position.
   bool _isAtWordwrapDownstream(TextPosition position) {
     final TextPosition start = TextPosition(
@@ -4611,29 +4538,9 @@
       );
     }
 
-    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
     // move it just to the other side of the wordwrap.
     if (intent.continuesAtWrap) {
@@ -4652,10 +4559,9 @@
     final TextPosition newExtent = intent.forward
       ? textBoundary.getTrailingTextBoundaryAt(extent)
       : textBoundary.getLeadingTextBoundaryAt(extent);
-
     final TextSelection newSelection = collapseSelection
       ? TextSelection.fromPosition(newExtent)
-      : textBoundarySelection.extendTo(newExtent);
+      : selection.extendTo(newExtent);
 
     // If collapseAtReversal is true and would have an effect, collapse it.
     if (!selection.isCollapsed && intent.collapseAtReversal
@@ -4673,7 +4579,7 @@
 
     return Actions.invoke(
       context!,
-      UpdateSelectionIntent(textBoundary.textEditingValue, newSelection, SelectionChangedCause.keyboard),
+      UpdateSelectionIntent(state._value, newSelection, SelectionChangedCause.keyboard),
     );
   }
 
@@ -4685,15 +4591,15 @@
   _ExtendSelectionOrCaretPositionAction(this.state, this.getTextBoundariesForIntent);
 
   final EditableTextState state;
-  final _TextBoundary Function(ExtendSelectionToNextWordBoundaryOrCaretLocationIntent intent) getTextBoundariesForIntent;
+  final TextBoundary Function(ExtendSelectionToNextWordBoundaryOrCaretLocationIntent intent) getTextBoundariesForIntent;
 
   @override
   Object? invoke(ExtendSelectionToNextWordBoundaryOrCaretLocationIntent intent, [BuildContext? context]) {
     final TextSelection selection = state._value.selection;
     assert(selection.isValid);
 
-    final _TextBoundary textBoundary = getTextBoundariesForIntent(intent);
-    final TextSelection textBoundarySelection = textBoundary.textEditingValue.selection;
+    final TextBoundary textBoundary = getTextBoundariesForIntent(intent);
+    final TextSelection textBoundarySelection = state._value.selection;
     if (!textBoundarySelection.isValid) {
       return null;
     }
@@ -4712,7 +4618,7 @@
 
     return Actions.invoke(
       context!,
-      UpdateSelectionIntent(textBoundary.textEditingValue, newSelection, SelectionChangedCause.keyboard),
+      UpdateSelectionIntent(state._value, newSelection, SelectionChangedCause.keyboard),
     );
   }
 
diff --git a/packages/flutter/test/cupertino/text_field_test.dart b/packages/flutter/test/cupertino/text_field_test.dart
index e1d685b..d8ff496 100644
--- a/packages/flutter/test/cupertino/text_field_test.dart
+++ b/packages/flutter/test/cupertino/text_field_test.dart
@@ -199,7 +199,8 @@
     return endpoints[0].point;
   }
 
-  Offset textOffsetToPosition(WidgetTester tester, int offset) => textOffsetToBottomLeftPosition(tester, offset) + const Offset(0, -2);
+  // Web has a less threshold for downstream/upstream text position.
+  Offset textOffsetToPosition(WidgetTester tester, int offset) => textOffsetToBottomLeftPosition(tester, offset) + const Offset(kIsWeb ? 1 : 0, -2);
 
   setUp(() async {
     EditableText.debugDeterministicCursor = false;
@@ -2087,6 +2088,7 @@
     await tester.pump(const Duration(milliseconds: 50));
     await tester.tapAt(textOffsetToPosition(tester, 5));
     await tester.pumpAndSettle();
+
     expect(controller.value.selection, isNotNull);
     expect(controller.value.selection.baseOffset, 5);
     expect(controller.value.selection.extentOffset, 6);
@@ -3053,7 +3055,7 @@
     expect(controller.selection.extentOffset, 8);
 
     // Tiny movement shouldn't cause text selection to change.
-    await gesture.moveTo(gPos + const Offset(4.0, 0.0));
+    await gesture.moveTo(gPos + const Offset(2.0, 0.0));
     await tester.pumpAndSettle();
     expect(selectionChangedCount, 0);
 
diff --git a/packages/flutter/test/material/text_field_test.dart b/packages/flutter/test/material/text_field_test.dart
index 1172d9e..89a3f01 100644
--- a/packages/flutter/test/material/text_field_test.dart
+++ b/packages/flutter/test/material/text_field_test.dart
@@ -2172,7 +2172,7 @@
     expect(controller.selection.extentOffset, 8);
 
     // Tiny movement shouldn't cause text selection to change.
-    await gesture.moveTo(gPos + const Offset(4.0, 0.0));
+    await gesture.moveTo(gPos + const Offset(2.0, 0.0));
     await tester.pumpAndSettle();
     expect(selectionChangedCount, 0);
 
@@ -3372,10 +3372,7 @@
     final Offset secondPos = textOffsetToPosition(tester, testValue.indexOf('Second'));
     final Offset thirdPos = textOffsetToPosition(tester, testValue.indexOf('Third'));
     final Offset middleStringPos = textOffsetToPosition(tester, testValue.indexOf('irst'));
-    expect(firstPos.dx, 0);
-    expect(secondPos.dx, 0);
-    expect(thirdPos.dx, 0);
-    expect(middleStringPos.dx, 34);
+    expect(firstPos.dx, lessThan(middleStringPos.dx));
     expect(firstPos.dx, secondPos.dx);
     expect(firstPos.dx, thirdPos.dx);
     expect(firstPos.dy, lessThan(secondPos.dy));
@@ -3457,8 +3454,6 @@
     // Check that the last line of text is not displayed.
     final Offset firstPos = textOffsetToPosition(tester, kMoreThanFourLines.indexOf('First'));
     final Offset fourthPos = textOffsetToPosition(tester, kMoreThanFourLines.indexOf('Fourth'));
-    expect(firstPos.dx, 0);
-    expect(fourthPos.dx, 0);
     expect(firstPos.dx, fourthPos.dx);
     expect(firstPos.dy, lessThan(fourthPos.dy));
     expect(inputBox.hitTest(BoxHitTestResult(), position: inputBox.globalToLocal(firstPos)), isTrue);
@@ -8397,10 +8392,10 @@
         ),
       ),
     );
-
+    final Size screenSize = MediaQuery.of(tester.element(find.byType(TextField))).size;
     // Just testing the test and making sure that the last character is off
     // the right side of the screen.
-    expect(textOffsetToPosition(tester, 66).dx, 1056);
+    expect(textOffsetToPosition(tester, 66).dx, greaterThan(screenSize.width));
 
     final TestGesture gesture =
         await tester.startGesture(
@@ -8448,7 +8443,7 @@
     );
 
     // The first character is now offscreen to the left.
-    expect(textOffsetToPosition(tester, 0).dx, moreOrLessEquals(-257.0, epsilon: 1));
+    expect(textOffsetToPosition(tester, 0).dx, lessThan(-100.0));
   }, variant: TargetPlatformVariant.all());
 
   testWidgets('keyboard selection change scrolls the field', (WidgetTester tester) async {
@@ -8485,7 +8480,7 @@
     await tester.pumpAndSettle();
     expect(
       controller.selection,
-      const TextSelection.collapsed(offset: 56),
+      const TextSelection.collapsed(offset: 56, affinity: TextAffinity.upstream),
     );
 
     // Keep moving out.
@@ -8495,7 +8490,7 @@
     await tester.pumpAndSettle();
     expect(
       controller.selection,
-      const TextSelection.collapsed(offset: 62),
+      const TextSelection.collapsed(offset: 62, affinity: TextAffinity.upstream),
     );
     for (int i = 0; i < (66 - 62); i += 1) {
       await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight);
@@ -8503,7 +8498,7 @@
     await tester.pumpAndSettle();
     expect(
       controller.selection,
-      const TextSelection.collapsed(offset: 66),
+      const TextSelection.collapsed(offset: 66, affinity: TextAffinity.upstream),
     ); // We're at the edge now.
 
     await tester.pumpAndSettle();
diff --git a/packages/flutter/test/services/text_boundary_test.dart b/packages/flutter/test/services/text_boundary_test.dart
new file mode 100644
index 0000000..6ec9873
--- /dev/null
+++ b/packages/flutter/test/services/text_boundary_test.dart
@@ -0,0 +1,97 @@
+// Copyright 2014 The Flutter Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import 'package:flutter/services.dart';
+import 'package:flutter_test/flutter_test.dart';
+
+void main() {
+  test('Character boundary works', () {
+    const CharacterBoundary boundary = CharacterBoundary('abc');
+    const TextPosition midPosition = TextPosition(offset: 1);
+    expect(boundary.getLeadingTextBoundaryAt(midPosition), const TextPosition(offset: 1));
+    expect(boundary.getTrailingTextBoundaryAt(midPosition), const TextPosition(offset: 2, affinity: TextAffinity.upstream));
+
+    const TextPosition startPosition = TextPosition(offset: 0);
+    expect(boundary.getLeadingTextBoundaryAt(startPosition), const TextPosition(offset: 0));
+    expect(boundary.getTrailingTextBoundaryAt(startPosition), const TextPosition(offset: 1, affinity: TextAffinity.upstream));
+
+    const TextPosition endPosition = TextPosition(offset: 3);
+    expect(boundary.getLeadingTextBoundaryAt(endPosition), const TextPosition(offset: 3, affinity: TextAffinity.upstream));
+    expect(boundary.getTrailingTextBoundaryAt(endPosition), const TextPosition(offset: 3, affinity: TextAffinity.upstream));
+  });
+
+  test('Character boundary works with grapheme', () {
+    const String text = 'a❄︎c';
+    const CharacterBoundary boundary = CharacterBoundary(text);
+    TextPosition position = const TextPosition(offset: 1);
+    expect(boundary.getLeadingTextBoundaryAt(position), const TextPosition(offset: 1));
+    // The `❄` takes two character length.
+    expect(boundary.getTrailingTextBoundaryAt(position), const TextPosition(offset: 3, affinity: TextAffinity.upstream));
+
+    position = const TextPosition(offset: 2);
+    expect(boundary.getLeadingTextBoundaryAt(position), const TextPosition(offset: 1));
+    expect(boundary.getTrailingTextBoundaryAt(position), const TextPosition(offset: 3, affinity: TextAffinity.upstream));
+
+    position = const TextPosition(offset: 0);
+    expect(boundary.getLeadingTextBoundaryAt(position), const TextPosition(offset: 0));
+    expect(boundary.getTrailingTextBoundaryAt(position), const TextPosition(offset: 1, affinity: TextAffinity.upstream));
+
+    position = const TextPosition(offset: text.length);
+    expect(boundary.getLeadingTextBoundaryAt(position), const TextPosition(offset: text.length, affinity: TextAffinity.upstream));
+    expect(boundary.getTrailingTextBoundaryAt(position), const TextPosition(offset: text.length, affinity: TextAffinity.upstream));
+  });
+
+  test('word boundary works', () {
+    final WordBoundary boundary = WordBoundary(TestTextLayoutMetrics());
+    const TextPosition position = TextPosition(offset: 3);
+    expect(boundary.getLeadingTextBoundaryAt(position).offset, TestTextLayoutMetrics.wordBoundaryAt3.start);
+    expect(boundary.getTrailingTextBoundaryAt(position).offset, TestTextLayoutMetrics.wordBoundaryAt3.end);
+  });
+
+  test('line boundary works', () {
+    final LineBreak boundary = LineBreak(TestTextLayoutMetrics());
+    const TextPosition position = TextPosition(offset: 3);
+    expect(boundary.getLeadingTextBoundaryAt(position).offset, TestTextLayoutMetrics.lineAt3.start);
+    expect(boundary.getTrailingTextBoundaryAt(position).offset, TestTextLayoutMetrics.lineAt3.end);
+  });
+
+  test('document boundary works', () {
+    const String text = 'abcd efg hi\njklmno\npqrstuv';
+    const DocumentBoundary boundary = DocumentBoundary(text);
+    const TextPosition position = TextPosition(offset: 10);
+    expect(boundary.getLeadingTextBoundaryAt(position), const TextPosition(offset: 0));
+    expect(boundary.getTrailingTextBoundaryAt(position), const TextPosition(offset: text.length, affinity: TextAffinity.upstream));
+  });
+}
+
+class TestTextLayoutMetrics extends TextLayoutMetrics {
+  static const TextSelection lineAt3 = TextSelection(baseOffset: 0, extentOffset: 10);
+  static const TextRange wordBoundaryAt3 = TextRange(start: 4, end: 7);
+
+  @override
+  TextSelection getLineAtOffset(TextPosition position) {
+    if (position.offset == 3) {
+      return lineAt3;
+    }
+    throw UnimplementedError();
+  }
+
+  @override
+  TextPosition getTextPositionAbove(TextPosition position) {
+    throw UnimplementedError();
+  }
+
+  @override
+  TextPosition getTextPositionBelow(TextPosition position) {
+    throw UnimplementedError();
+  }
+
+  @override
+  TextRange getWordBoundary(TextPosition position) {
+    if (position.offset == 3) {
+      return wordBoundaryAt3;
+    }
+    throw UnimplementedError();
+  }
+}
diff --git a/packages/flutter/test/widgets/editable_text_shortcuts_test.dart b/packages/flutter/test/widgets/editable_text_shortcuts_test.dart
index 2c2b486..a0d8ee4 100644
--- a/packages/flutter/test/widgets/editable_text_shortcuts_test.dart
+++ b/packages/flutter/test/widgets/editable_text_shortcuts_test.dart
@@ -1229,6 +1229,7 @@
 
             expect(controller.selection, const TextSelection.collapsed(
               offset: 21,
+              affinity: TextAffinity.upstream,
             ));
           }, variant: TargetPlatformVariant.all());
 
@@ -1243,6 +1244,7 @@
 
             expect(controller.selection, const TextSelection.collapsed(
               offset: 10,
+              affinity: TextAffinity.upstream,
             ));
           }, variant: allExceptApple);
 
@@ -1353,6 +1355,7 @@
             await tester.pump();
             expect(controller.selection, const TextSelection.collapsed(
               offset: 46, // After "to".
+              affinity: TextAffinity.upstream,
             ));
 
             // "good" to "come" is selected.
@@ -1365,6 +1368,7 @@
             await tester.pump();
             expect(controller.selection, const TextSelection.collapsed(
               offset: 28, // After "good".
+              affinity: TextAffinity.upstream,
             ));
           }, variant: allExceptApple);
 
@@ -1673,6 +1677,7 @@
 
       expect(controller.selection, const TextSelection.collapsed(
         offset: 10,
+        affinity: TextAffinity.upstream,
       ));
     }, variant: macOSOnly);
 
@@ -1743,6 +1748,7 @@
       await tester.pump();
       expect(controller.selection, const TextSelection.collapsed(
         offset: 46, // After "to".
+        affinity: TextAffinity.upstream,
       ));
 
       // "good" to "come" is selected.
@@ -1755,6 +1761,7 @@
       await tester.pump();
       expect(controller.selection, const TextSelection.collapsed(
         offset: 28, // After "good".
+        affinity: TextAffinity.upstream,
       ));
     }, variant: macOSOnly);
 
diff --git a/packages/flutter/test/widgets/editable_text_test.dart b/packages/flutter/test/widgets/editable_text_test.dart
index 98cbbf0..c49091d 100644
--- a/packages/flutter/test/widgets/editable_text_test.dart
+++ b/packages/flutter/test/widgets/editable_text_test.dart
@@ -5832,6 +5832,7 @@
       equals(
         const TextSelection.collapsed(
           offset: 3,
+          affinity: TextAffinity.upstream,
         ),
       ),
       reason: 'on $platform',
@@ -5941,6 +5942,7 @@
         const TextSelection(
           baseOffset: 10,
           extentOffset: 10,
+          affinity: TextAffinity.upstream,
         ),
       ),
       reason: 'on $platform',
@@ -6398,6 +6400,7 @@
       equals(
         const TextSelection.collapsed(
           offset: 23,
+          affinity: TextAffinity.upstream,
         ),
       ),
       reason: 'on $platform',
@@ -6422,6 +6425,7 @@
           equals(
             const TextSelection.collapsed(
               offset: 23,
+              affinity: TextAffinity.upstream,
             ),
           ),
           reason: 'on $platform',
@@ -6464,6 +6468,7 @@
           equals(
             const TextSelection.collapsed(
               offset: 23,
+              affinity: TextAffinity.upstream,
             ),
           ),
           reason: 'on $platform',
@@ -6549,6 +6554,7 @@
       equals(
         const TextSelection.collapsed(
           offset: 32,
+          affinity: TextAffinity.upstream,
         ),
       ),
       reason: 'on $platform',
@@ -6573,6 +6579,7 @@
           equals(
             const TextSelection.collapsed(
               offset: 32,
+              affinity: TextAffinity.upstream,
             ),
           ),
           reason: 'on $platform',
@@ -6615,6 +6622,7 @@
           equals(
             const TextSelection.collapsed(
               offset: 32,
+              affinity: TextAffinity.upstream,
             ),
           ),
           reason: 'on $platform',
@@ -6710,6 +6718,7 @@
       equals(
         const TextSelection.collapsed(
           offset: 32,
+          affinity: TextAffinity.upstream,
         ),
       ),
       reason: 'on $platform',
@@ -6734,6 +6743,7 @@
           equals(
             const TextSelection.collapsed(
               offset: 32,
+              affinity: TextAffinity.upstream,
             ),
           ),
           reason: 'on $platform',
@@ -6776,6 +6786,7 @@
           equals(
             const TextSelection.collapsed(
               offset: 32,
+              affinity: TextAffinity.upstream,
             ),
           ),
           reason: 'on $platform',
@@ -6872,6 +6883,7 @@
       equals(
         const TextSelection.collapsed(
           offset: 23,
+          affinity: TextAffinity.upstream,
         ),
       ),
       reason: 'on $platform',
@@ -6917,6 +6929,7 @@
             const TextSelection(
               baseOffset: 23,
               extentOffset: 23,
+              affinity: TextAffinity.upstream,
             ),
           ),
           reason: 'on $platform',
@@ -6927,6 +6940,7 @@
             const TextSelection(
               baseOffset: 23,
               extentOffset: 23,
+              affinity: TextAffinity.upstream,
             ),
           ),
           reason: 'on $platform',
@@ -7060,6 +7074,7 @@
       controller.selection,
       equals(const TextSelection.collapsed(
         offset: 4,
+        affinity: TextAffinity.upstream,
       )),
     );
 
@@ -7243,6 +7258,7 @@
       equals(
         const TextSelection.collapsed(
           offset: 32,
+          affinity: TextAffinity.upstream,
         ),
       ),
       reason: 'on $platform',
@@ -7266,6 +7282,7 @@
           equals(
             const TextSelection.collapsed(
               offset: 32,
+              affinity: TextAffinity.upstream,
             ),
           ),
           reason: 'on $platform',
@@ -7323,6 +7340,7 @@
           equals(
             const TextSelection.collapsed(
               offset: 32,
+              affinity: TextAffinity.upstream,
             ),
           ),
           reason: 'on $platform',
@@ -7435,6 +7453,7 @@
       equals(
         const TextSelection.collapsed(
           offset: 32,
+          affinity: TextAffinity.upstream,
         ),
       ),
       reason: 'on $platform',
@@ -7458,6 +7477,7 @@
           equals(
             const TextSelection.collapsed(
               offset: 32,
+              affinity: TextAffinity.upstream,
             ),
           ),
           reason: 'on $platform',
@@ -7515,6 +7535,7 @@
           equals(
             const TextSelection.collapsed(
               offset: 32,
+              affinity: TextAffinity.upstream,
             ),
           ),
           reason: 'on $platform',
@@ -7626,6 +7647,7 @@
       equals(
         const TextSelection.collapsed(
           offset: 32,
+          affinity: TextAffinity.upstream,
         ),
       ),
     );
@@ -10383,7 +10405,6 @@
     expect(controller.selection.isCollapsed, false);
     expect(controller.selection.baseOffset, 7);
     expect(controller.selection.extentOffset, 10);
-
     await sendKeys(
       tester,
       <LogicalKeyboardKey>[LogicalKeyboardKey.arrowLeft],
diff --git a/packages/flutter/test/widgets/editable_text_utils.dart b/packages/flutter/test/widgets/editable_text_utils.dart
index 7a65bff..505e104 100644
--- a/packages/flutter/test/widgets/editable_text_utils.dart
+++ b/packages/flutter/test/widgets/editable_text_utils.dart
@@ -2,6 +2,7 @@
 // Use of this source code is governed by a BSD-style license that can be
 // found in the LICENSE file.
 
+import 'package:flutter/foundation.dart';
 import 'package:flutter/material.dart';
 import 'package:flutter/rendering.dart';
 import 'package:flutter_test/flutter_test.dart';
@@ -42,7 +43,7 @@
     renderEditable,
   );
   expect(endpoints.length, 1);
-  return endpoints[0].point + const Offset(0.0, -2.0);
+  return endpoints[0].point + const Offset(kIsWeb? 1.0 : 0.0, -2.0);
 }
 
 // Simple controller that builds a WidgetSpan with 100 height.