Reverts "Changing TextPainter.getOffsetForCaret implementation to remove the logarithmic search (#143281)" (#143801)

Reverts flutter/flutter#143281

Initiated by: LongCatIsLooong

Reason for reverting: https://github.com/flutter/flutter/issues/143797

Original PR Author: LongCatIsLooong

Reviewed By: {justinmc, jason-simmons}

This change reverts the following previous change:
Original Description:
The behavior largely remains the same, except:

1. The EOT cursor `(textLength, downstream)` for text ending in the opposite writing direction as the paragraph is now placed at the visual end of the last line. 
  For example, in a LTR paragraph, the EOT cursor for `aA` (lowercase for LTR and uppercase for RTL) is placed to the right of the line: `aA|` (it was `a|A` before). 
  This matches the behavior of most applications that do logical order arrow key navigation instead of visual order navigation. 
  And it makes the navigation order consistent for `aA\naA`:
```
  |aA    =>  aA|  => aA|  => aA  => aA   => aA 
   aA        aA      aA     |aA     aA|     aA|     
   (1)       (2)     (3)    (4)    (5)      (6)
```
This is indeed still pretty confusing as (2) and (3), as well as (5) and (6) are hard to distinguish (when the I beam has a large width they are actually visually distinguishable -- they use the same anchor but one gets painted to the left and the other to the right. I noticed that emacs does the same). 
But logical order navigation will always be confusing in bidi text, in one way or another.

Interestingly there are 3 different behaviors I've observed in chrome:
- the chrome download dialog (which I think uses GTK text widgets but not sure which version) gives me 2 cursors when navigating bidi text, and 
- its HTML fields only show one, and presumably they place the I beam at the **trailing edge** of the character (which makes more sense for backspacing I guess). 
- On the other hand, its (new) omnibar seems to use visual order arrow navigation

Side note: we may need to update the "tap to place the caret here" logic to handle the case where the tap lands outside of the text and the text ends in the opposite writing direction. 

2. Removed the logarithmic search. The same could be done using the characters package but when glyphInfo tells you about the baseline location in the future we probably don't need the `getBoxesForRange` call. This should fix https://github.com/flutter/flutter/issues/123424.

## Internal Tests

This is going to change the image output of some internal golden tests. I'm planning to merge https://github.com/flutter/flutter/pull/143281 before this to avoid updating the same golden files twice for invalid selections.
This commit is contained in:
auto-submit[bot] 2024-02-21 00:10:18 +00:00 committed by GitHub
parent 84b5e799d3
commit f9b3b84d4e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 828 additions and 925 deletions

View file

@ -46,9 +46,8 @@ void main() {
const Duration durationBetweenActions = Duration(milliseconds: 20);
const String defaultText = 'I am a magnifier, fear me!';
Future<void> showMagnifier(WidgetTester tester, int textOffset) async {
assert(textOffset >= 0);
final Offset tapOffset = _textOffsetToPosition(tester, textOffset);
Future<void> showMagnifier(WidgetTester tester, String characterToTapOn) async {
final Offset tapOffset = _textOffsetToPosition(tester, defaultText.indexOf(characterToTapOn));
// Double tap 'Magnifier' word to show the selection handles.
final TestGesture testGesture = await tester.startGesture(tapOffset);
@ -60,11 +59,11 @@ void main() {
await testGesture.up();
await tester.pumpAndSettle();
final TextEditingController controller = tester
.firstWidget<TextField>(find.byType(TextField))
.controller!;
final TextSelection selection = tester
.firstWidget<TextField>(find.byType(TextField))
.controller!
.selection;
final TextSelection selection = controller.selection;
final RenderEditable renderEditable = _findRenderEditable(tester);
final List<TextSelectionPoint> endpoints = _globalize(
renderEditable.getEndpointsForSelection(selection),
@ -87,7 +86,7 @@ void main() {
testWidgets('should show custom magnifier on drag', (WidgetTester tester) async {
await tester.pumpWidget(const example.TextMagnifierExampleApp(text: defaultText));
await showMagnifier(tester, defaultText.indexOf('e'));
await showMagnifier(tester, 'e');
expect(find.byType(example.CustomMagnifier), findsOneWidget);
await expectLater(
@ -97,15 +96,16 @@ void main() {
}, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS, TargetPlatform.android }));
testWidgets('should show custom magnifier in RTL', (WidgetTester tester) async {
const String text = 'أثارت زر';
const String textToTapOn = 'ت';
for (final TextDirection textDirection in TextDirection.values) {
testWidgets('should show custom magnifier in $textDirection', (WidgetTester tester) async {
final String text = textDirection == TextDirection.rtl ? 'أثارت زر' : defaultText;
final String textToTapOn = textDirection == TextDirection.rtl ? 'ت' : 'e';
await tester.pumpWidget(const example.TextMagnifierExampleApp(textDirection: TextDirection.rtl, text: text));
await tester.pumpWidget(example.TextMagnifierExampleApp(textDirection: textDirection, text: text));
await showMagnifier(tester, text.indexOf(textToTapOn));
expect(find.byType(example.CustomMagnifier), findsOneWidget);
});
await showMagnifier(tester, textToTapOn);
expect(find.byType(example.CustomMagnifier), findsOneWidget);
});
}
}

View file

@ -2,7 +2,7 @@
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'dart:math' show max;
import 'dart:math' show max, min;
import 'dart:ui' as ui show
BoxHeightStyle,
BoxWidthStyle,
@ -203,14 +203,8 @@ class WordBoundary extends TextBoundary {
}
static bool _isNewline(int codePoint) {
// Carriage Return is not treated as a hard line break.
return switch (codePoint) {
0x000A || // Line Feed
0x0085 || // New Line
0x000B || // Form Feed
0x000C || // Vertical Feed
0x2028 || // Line Separator
0x2029 => true, // Paragraph Separator
0x000A || 0x0085 || 0x000B || 0x000C || 0x2028 || 0x2029 => true,
_ => false,
};
}
@ -275,10 +269,7 @@ class _UntilTextBoundary extends TextBoundary {
}
class _TextLayout {
_TextLayout._(this._paragraph, this.writingDirection, this.rawString);
final TextDirection writingDirection;
final String rawString;
_TextLayout._(this._paragraph);
// This field is not final because the owner TextPainter could create a new
// ui.Paragraph with the exact same text layout (for example, when only the
@ -324,57 +315,6 @@ class _TextLayout {
};
}
/// The line caret metrics representing the end of text location.
///
/// This is usually used when the caret is placed at the end of the text
/// (text.length, downstream), unless maxLines is set to a non-null value, in
/// which case the caret is placed at the visual end of the last visible line.
///
/// This should not be called when the paragraph is emtpy as the implementation
/// relies on line metrics.
///
/// When the last bidi level run in the paragraph and the parargraph's bidi
/// levels have opposite parities (which implies opposite writing directions),
/// this makes sure the caret is placed at the same "end" of the line as if the
/// line ended with a line feed.
late final _LineCaretMetrics _endOfTextCaretMetrics = _computeEndOfTextCaretAnchorOffset();
_LineCaretMetrics _computeEndOfTextCaretAnchorOffset() {
final int lastLineIndex = _paragraph.numberOfLines - 1;
assert(lastLineIndex >= 0);
final ui.LineMetrics lineMetrics = _paragraph.getLineMetricsAt(lastLineIndex)!;
// SkParagraph currently treats " " and "\t" as white spaces. Trailing white
// spaces don't contribute to the line width and thus require special handling
// when they're present.
// Luckily they have the same bidi embedding level as the paragraph as per
// https://unicode.org/reports/tr9/#L1, so we can anchor the caret to the
// last logical trailing space.
final bool hasTrailingSpaces = switch (rawString.codeUnitAt(rawString.length - 1)) {
0x9 || // horizontal tab
0x20 => true, // space
_ => false,
};
final double baseline = lineMetrics.baseline;
final double dx;
late final ui.GlyphInfo? lastGlyph = _paragraph.getGlyphInfoAt(rawString.length - 1);
// TODO(LongCatIsLooong): handle the case where maxLine is set to non-null
// and the last line ends with trailing whitespaces.
if (hasTrailingSpaces && lastGlyph != null) {
final Rect glyphBounds = lastGlyph.graphemeClusterLayoutBounds;
assert(!glyphBounds.isEmpty);
dx = switch (writingDirection) {
TextDirection.ltr => glyphBounds.right,
TextDirection.rtl => glyphBounds.left,
};
} else {
dx = switch (writingDirection) {
TextDirection.ltr => lineMetrics.left + lineMetrics.width,
TextDirection.rtl => lineMetrics.left,
};
}
return _LineCaretMetrics(offset: Offset(dx, baseline), writingDirection: writingDirection);
}
double _contentWidthFor(double minWidth, double maxWidth, TextWidthBasis widthBasis) {
return switch (widthBasis) {
TextWidthBasis.longestLine => clampDouble(longestLine, minWidth, maxWidth),
@ -479,29 +419,39 @@ class _TextPainterLayoutCacheWithOffset {
List<ui.LineMetrics> get lineMetrics => _cachedLineMetrics ??= paragraph.computeLineMetrics();
List<ui.LineMetrics>? _cachedLineMetrics;
// Used to determine whether the caret metrics cache should be invalidated.
int? _previousCaretPositionKey;
// Holds the TextPosition the last caret metrics were computed with. When new
// values are passed in, we recompute the caret metrics only as necessary.
TextPosition? _previousCaretPosition;
}
/// The _CaretMetrics for carets located in a non-empty paragraph. Such carets
/// are anchored to the trailing edge or the leading edge of a glyph, or a
/// ligature component.
final class _LineCaretMetrics {
const _LineCaretMetrics({required this.offset, required this.writingDirection});
/// The offset from the top left corner of the paragraph to the caret's top
/// start location.
/// This is used to cache and pass the computed metrics regarding the
/// caret's size and position. This is preferred due to the expensive
/// nature of the calculation.
///
// A _CaretMetrics is either a _LineCaretMetrics or an _EmptyLineCaretMetrics.
@immutable
sealed class _CaretMetrics { }
/// The _CaretMetrics for carets located in a non-empty line. Carets located in a
/// non-empty line are associated with a glyph within the same line.
final class _LineCaretMetrics implements _CaretMetrics {
const _LineCaretMetrics({required this.offset, required this.writingDirection, required this.fullHeight});
/// The offset of the top left corner of the caret from the top left
/// corner of the paragraph.
final Offset offset;
/// The writing direction of the glyph the _LineCaretMetrics is associated with.
/// The value determines whether the cursor is painted to the left or to the
/// right of [offset].
/// The writing direction of the glyph the _CaretMetrics is associated with.
final TextDirection writingDirection;
/// The full height of the glyph at the caret position.
final double fullHeight;
}
_LineCaretMetrics shift(Offset offset) {
return offset == Offset.zero
? this
: _LineCaretMetrics(offset: offset + this.offset, writingDirection: writingDirection);
}
/// The _CaretMetrics for carets located in an empty line (when the text is
/// empty, or the caret is between two a newline characters).
final class _EmptyLineCaretMetrics implements _CaretMetrics {
const _EmptyLineCaretMetrics({ required this.lineVerticalOffset });
/// The y offset of the unoccupied line.
final double lineVerticalOffset;
}
const String _flutterPaintingLibrary = 'package:flutter/painting.dart';
@ -1014,11 +964,13 @@ class TextPainter {
}
List<PlaceholderDimensions>? _placeholderDimensions;
ui.ParagraphStyle _createParagraphStyle([ TextAlign? defaultTextAlign ]) {
assert(textDirection != null, 'TextPainter.textDirection must be set to a non-null value before using the TextPainter.');
ui.ParagraphStyle _createParagraphStyle([ TextDirection? defaultTextDirection ]) {
// The defaultTextDirection argument is used for preferredLineHeight in case
// textDirection hasn't yet been set.
assert(textDirection != null || defaultTextDirection != null, 'TextPainter.textDirection must be set to a non-null value before using the TextPainter.');
return _text!.style?.getParagraphStyle(
textAlign: defaultTextAlign ?? textAlign,
textDirection: textDirection,
textAlign: textAlign,
textDirection: textDirection ?? defaultTextDirection,
textScaler: textScaler,
maxLines: _maxLines,
textHeightBehavior: _textHeightBehavior,
@ -1026,8 +978,8 @@ class TextPainter {
locale: _locale,
strutStyle: _strutStyle,
) ?? ui.ParagraphStyle(
textAlign: defaultTextAlign ?? textAlign,
textDirection: textDirection,
textAlign: textAlign,
textDirection: textDirection ?? defaultTextDirection,
// Use the default font size to multiply by as RichText does not
// perform inheriting [TextStyle]s and would otherwise
// fail to apply textScaler.
@ -1042,7 +994,7 @@ class TextPainter {
ui.Paragraph? _layoutTemplate;
ui.Paragraph _createLayoutTemplate() {
final ui.ParagraphBuilder builder = ui.ParagraphBuilder(
_createParagraphStyle(TextAlign.left),
_createParagraphStyle(TextDirection.rtl),
); // direction doesn't matter, text is just a space
final ui.TextStyle? textStyle = text?.style?.getTextStyle(textScaler: textScaler);
if (textStyle != null) {
@ -1053,7 +1005,6 @@ class TextPainter {
..layout(const ui.ParagraphConstraints(width: double.infinity));
}
ui.Paragraph _getOrCreateLayoutTemplate() => _layoutTemplate ??= _createLayoutTemplate();
/// The height of a space in [text] in logical pixels.
///
/// Not every line of text in [text] will have this height, but this height
@ -1066,7 +1017,7 @@ class TextPainter {
/// that contribute to the [preferredLineHeight]. If [text] is null or if it
/// specifies no styles, the default [TextStyle] values are used (a 10 pixel
/// sans-serif font).
double get preferredLineHeight => _getOrCreateLayoutTemplate().height;
double get preferredLineHeight => (_layoutTemplate ??= _createLayoutTemplate()).height;
/// The width at which decreasing the width of the text would prevent it from
/// painting itself completely within its bounds.
@ -1197,7 +1148,7 @@ class TextPainter {
// called.
final ui.Paragraph paragraph = (cachedLayout?.paragraph ?? _createParagraph(text))
..layout(ui.ParagraphConstraints(width: layoutMaxWidth));
final _TextLayout layout = _TextLayout._(paragraph, textDirection, plainText);
final _TextLayout layout = _TextLayout._(paragraph);
final double contentWidth = layout._contentWidthFor(minWidth, maxWidth, textWidthBasis);
final _TextPainterLayoutCacheWithOffset newLayoutCache;
@ -1292,6 +1243,14 @@ class TextPainter {
return value & 0xFC00 == 0xDC00;
}
// Checks if the glyph is either [Unicode.RLM] or [Unicode.LRM]. These values take
// up zero space and do not have valid bounding boxes around them.
//
// We do not directly use the [Unicode] constants since they are strings.
static bool _isUnicodeDirectionality(int value) {
return value == 0x200F || value == 0x200E;
}
/// Returns the closest offset after `offset` at which the input cursor can be
/// positioned.
int? getOffsetAfter(int offset) {
@ -1314,15 +1273,118 @@ class TextPainter {
return isLowSurrogate(prevCodeUnit) ? offset - 2 : offset - 1;
}
// Get the caret metrics (in logical pixels) based off the trailing edge of the
// Unicode value for a zero width joiner character.
static const int _zwjUtf16 = 0x200d;
// Get the caret metrics (in logical pixels) based off the near edge of the
// character upstream from the given string offset.
_CaretMetrics? _getMetricsFromUpstream(int offset) {
assert(offset >= 0);
final int plainTextLength = plainText.length;
if (plainTextLength == 0 || offset > plainTextLength) {
return null;
}
final int prevCodeUnit = plainText.codeUnitAt(max(0, offset - 1));
// If the upstream character is a newline, cursor is at start of next line
const int NEWLINE_CODE_UNIT = 10;
// Check for multi-code-unit glyphs such as emojis or zero width joiner.
final bool needsSearch = isHighSurrogate(prevCodeUnit) || isLowSurrogate(prevCodeUnit) || _text!.codeUnitAt(offset) == _zwjUtf16 || _isUnicodeDirectionality(prevCodeUnit);
int graphemeClusterLength = needsSearch ? 2 : 1;
List<TextBox> boxes = <TextBox>[];
while (boxes.isEmpty) {
final int prevRuneOffset = offset - graphemeClusterLength;
// Use BoxHeightStyle.strut to ensure that the caret's height fits within
// the line's height and is consistent throughout the line.
boxes = _layoutCache!.paragraph.getBoxesForRange(max(0, prevRuneOffset), offset, boxHeightStyle: ui.BoxHeightStyle.strut);
// When the range does not include a full cluster, no boxes will be returned.
if (boxes.isEmpty) {
// When we are at the beginning of the line, a non-surrogate position will
// return empty boxes. We break and try from downstream instead.
if (!needsSearch && prevCodeUnit == NEWLINE_CODE_UNIT) {
break; // Only perform one iteration if no search is required.
}
if (prevRuneOffset < -plainTextLength) {
break; // Stop iterating when beyond the max length of the text.
}
// Multiply by two to log(n) time cover the entire text span. This allows
// faster discovery of very long clusters and reduces the possibility
// of certain large clusters taking much longer than others, which can
// cause jank.
graphemeClusterLength *= 2;
continue;
}
// Try to identify the box nearest the offset. This logic works when
// there's just one box, and when all boxes have the same direction.
// It may not work in bidi text: https://github.com/flutter/flutter/issues/123424
final TextBox box = boxes.last.direction == TextDirection.ltr
? boxes.last : boxes.first;
return prevCodeUnit == NEWLINE_CODE_UNIT
? _EmptyLineCaretMetrics(lineVerticalOffset: box.bottom)
: _LineCaretMetrics(offset: Offset(box.end, box.top), writingDirection: box.direction, fullHeight: box.bottom - box.top);
}
return null;
}
// Get the caret metrics (in logical pixels) based off the near edge of the
// character downstream from the given string offset.
_CaretMetrics? _getMetricsFromDownstream(int offset) {
assert(offset >= 0);
final int plainTextLength = plainText.length;
if (plainTextLength == 0) {
return null;
}
// We cap the offset at the final index of plain text.
final int nextCodeUnit = plainText.codeUnitAt(min(offset, plainTextLength - 1));
// Check for multi-code-unit glyphs such as emojis or zero width joiner
final bool needsSearch = isHighSurrogate(nextCodeUnit) || isLowSurrogate(nextCodeUnit) || nextCodeUnit == _zwjUtf16 || _isUnicodeDirectionality(nextCodeUnit);
int graphemeClusterLength = needsSearch ? 2 : 1;
List<TextBox> boxes = <TextBox>[];
while (boxes.isEmpty) {
final int nextRuneOffset = offset + graphemeClusterLength;
// Use BoxHeightStyle.strut to ensure that the caret's height fits within
// the line's height and is consistent throughout the line.
boxes = _layoutCache!.paragraph.getBoxesForRange(offset, nextRuneOffset, boxHeightStyle: ui.BoxHeightStyle.strut);
// When the range does not include a full cluster, no boxes will be returned.
if (boxes.isEmpty) {
// When we are at the end of the line, a non-surrogate position will
// return empty boxes. We break and try from upstream instead.
if (!needsSearch) {
break; // Only perform one iteration if no search is required.
}
if (nextRuneOffset >= plainTextLength << 1) {
break; // Stop iterating when beyond the max length of the text.
}
// Multiply by two to log(n) time cover the entire text span. This allows
// faster discovery of very long clusters and reduces the possibility
// of certain large clusters taking much longer than others, which can
// cause jank.
graphemeClusterLength *= 2;
continue;
}
// Try to identify the box nearest the offset. This logic works when
// there's just one box, and when all boxes have the same direction.
// It may not work in bidi text: https://github.com/flutter/flutter/issues/123424
final TextBox box = boxes.first.direction == TextDirection.ltr
? boxes.first : boxes.last;
return _LineCaretMetrics(offset: Offset(box.start, box.top), writingDirection: box.direction, fullHeight: box.bottom - box.top);
}
return null;
}
static double _computePaintOffsetFraction(TextAlign textAlign, TextDirection textDirection) {
return switch ((textAlign, textDirection)) {
(TextAlign.left, _) => 0.0,
(TextAlign.right, _) => 1.0,
(TextAlign.center, _) => 0.5,
(TextAlign.start || TextAlign.justify, TextDirection.ltr) => 0.0,
(TextAlign.start || TextAlign.justify, TextDirection.rtl) => 1.0,
(TextAlign.start, TextDirection.ltr) => 0.0,
(TextAlign.start, TextDirection.rtl) => 1.0,
(TextAlign.justify, TextDirection.ltr) => 0.0,
(TextAlign.justify, TextDirection.rtl) => 1.0,
(TextAlign.end, TextDirection.ltr) => 1.0,
(TextAlign.end, TextDirection.rtl) => 0.0,
};
@ -1332,24 +1394,31 @@ class TextPainter {
///
/// Valid only after [layout] has been called.
Offset getOffsetForCaret(TextPosition position, Rect caretPrototype) {
final _CaretMetrics caretMetrics;
final _TextPainterLayoutCacheWithOffset layoutCache = _layoutCache!;
final _LineCaretMetrics? caretMetrics = _computeCaretMetrics(position);
if (caretMetrics == null) {
final double paintOffsetAlignment = _computePaintOffsetFraction(textAlign, textDirection!);
// The full width is not (width - caretPrototype.width), because
// RenderEditable reserves cursor width on the right. Ideally this
// should be handled by RenderEditable instead.
final double dx = paintOffsetAlignment == 0 ? 0 : paintOffsetAlignment * layoutCache.contentWidth;
return Offset(dx, 0.0);
if (position.offset < 0) {
// TODO(LongCatIsLooong): make this case impossible; see https://github.com/flutter/flutter/issues/79495
caretMetrics = const _EmptyLineCaretMetrics(lineVerticalOffset: 0);
} else {
caretMetrics = _computeCaretMetrics(position);
}
final Offset rawOffset = switch (caretMetrics) {
_LineCaretMetrics(writingDirection: TextDirection.ltr, :final Offset offset) => offset,
_LineCaretMetrics(writingDirection: TextDirection.rtl, :final Offset offset) => Offset(offset.dx - caretPrototype.width, offset.dy),
};
final Offset rawOffset;
switch (caretMetrics) {
case _EmptyLineCaretMetrics(:final double lineVerticalOffset):
final double paintOffsetAlignment = _computePaintOffsetFraction(textAlign, textDirection!);
// The full width is not (width - caretPrototype.width)
// because RenderEditable reserves cursor width on the right. Ideally this
// should be handled by RenderEditable instead.
final double dx = paintOffsetAlignment == 0 ? 0 : paintOffsetAlignment * layoutCache.contentWidth;
return Offset(dx, lineVerticalOffset);
case _LineCaretMetrics(writingDirection: TextDirection.ltr, :final Offset offset):
rawOffset = offset;
case _LineCaretMetrics(writingDirection: TextDirection.rtl, :final Offset offset):
rawOffset = Offset(offset.dx - caretPrototype.width, offset.dy);
}
// If offset.dx is outside of the advertised content area, then the associated
// glyph belongs to a trailing whitespace character. Ideally the behavior
// glyph cluster belongs to a trailing newline character. Ideally the behavior
// should be handled by higher-level implementations (for instance,
// RenderEditable reserves width for showing the caret, it's best to handle
// the clamping there).
@ -1363,136 +1432,38 @@ class TextPainter {
///
/// Valid only after [layout] has been called.
double? getFullHeightForCaret(TextPosition position, Rect caretPrototype) {
final TextBox textBox = _getOrCreateLayoutTemplate().getBoxesForRange(0, 1, boxHeightStyle: ui.BoxHeightStyle.strut).single;
return textBox.toRect().height;
if (position.offset < 0) {
// TODO(LongCatIsLooong): make this case impossible; see https://github.com/flutter/flutter/issues/79495
return null;
}
return switch (_computeCaretMetrics(position)) {
_LineCaretMetrics(:final double fullHeight) => fullHeight,
_EmptyLineCaretMetrics() => null,
};
}
bool _isNewlineAtOffset(int offset) => 0 <= offset && offset < plainText.length
&& WordBoundary._isNewline(plainText.codeUnitAt(offset));
// Cached caret metrics. This allows multiple invokes of [getOffsetForCaret] and
// [getFullHeightForCaret] in a row without performing redundant and expensive
// get rect calls to the paragraph.
//
// The cache implementation assumes there's only one cursor at any given time.
late _LineCaretMetrics _caretMetrics;
late _CaretMetrics _caretMetrics;
// This function returns the caret's offset and height for the given
// `position` in the text, or null if the paragraph is empty.
//
// For a TextPosition, typically when its TextAffinity is downstream, the
// corresponding I-beam caret is anchored to the leading edge of the character
// at `offset` in the text. When the TextAffinity is upstream, the I-beam is
// then anchored to the trailing edge of the preceding character, except for a
// few edge cases:
//
// 1. empty paragraph: this method returns null and the caller handles this
// case.
//
// 2. (textLength, downstream), the end-of-text caret when the text is not
// empty: it's placed next to the trailing edge of the last line of the
// text, in case the text and its last bidi run have different writing
// directions. See the `_computeEndOfTextCaretAnchorOffset` method for more
// details.
//
// 3. (0, upstream), which isn't a valid position, but it's not a conventional
// "invalid" caret location either (the offset isn't negative). For
// historical reasons, this is treated as (0, downstream).
//
// 4. (x, upstream) where x - 1 points to a line break character. The caret
// should be displayed at the beginning of the newline instead of at the
// end of the previous line. Converts the location to (x, downstream). The
// choice we makes in 5. allows us to still check (x - 1) in case x points
// to a multi-code-unit character.
//
// 5. (x, downstream || upstream), where x points to a multi-code-unit
// character. There's no perfect caret placement in this case. Here we chose
// to draw the caret at the location that makes the most sense when the
// user wants to backspace (which also means it's left-arrow-key-biased):
//
// * downstream: show the caret at the leading edge of the character only if
// x points to the start of the grapheme. Otherwise show the caret at the
// leading edge of the next logical character.
// * upstream: show the caret at the trailing edge of the previous character
// only if x points to the start of the grapheme. Otherwise place the
// caret at the trailing edge of the character.
_LineCaretMetrics? _computeCaretMetrics(TextPosition position) {
// Checks if the [position] and [caretPrototype] have changed from the cached
// version and recomputes the metrics required to position the caret.
_CaretMetrics _computeCaretMetrics(TextPosition position) {
assert(_debugAssertTextLayoutIsValid);
assert(!_debugNeedsRelayout);
final _TextPainterLayoutCacheWithOffset cachedLayout = _layoutCache!;
// If nothing is laid out, top start is the only reasonable place to place
// the cursor.
// The HTML renderer reports numberOfLines == 1 when the text is empty:
// https://github.com/flutter/flutter/issues/143331
if (cachedLayout.paragraph.numberOfLines < 1 || plainText.isEmpty) {
// TODO(LongCatIsLooong): assert when an invalid position is given.
return null;
}
final (int offset, bool anchorToLeadingEdge) = switch (position) {
TextPosition(offset: 0) => (0, true), // As a special case, always anchor to the leading edge of the first grapheme regardless of the affinity.
TextPosition(:final int offset, affinity: TextAffinity.downstream) => (offset, true),
TextPosition(:final int offset, affinity: TextAffinity.upstream) when _isNewlineAtOffset(offset - 1) => (offset, true),
TextPosition(:final int offset, affinity: TextAffinity.upstream) => (offset - 1, false)
};
final int caretPositionCacheKey = anchorToLeadingEdge ? offset : -offset - 1;
if (caretPositionCacheKey == cachedLayout._previousCaretPositionKey) {
if (position == cachedLayout._previousCaretPosition) {
return _caretMetrics;
}
final ui.GlyphInfo? glyphInfo = cachedLayout.paragraph.getGlyphInfoAt(offset);
if (glyphInfo == null) {
// If the glyph isn't laid out, then the position points to a character
// that is not laid out. Use the EOT caret.
// TODO(LongCatIsLooong): assert when an invalid position is given.
final ui.Paragraph template = _getOrCreateLayoutTemplate();
assert(template.numberOfLines == 1);
final double baselineOffset = template.getLineMetricsAt(0)!.baseline;
return cachedLayout.layout._endOfTextCaretMetrics.shift(Offset(0.0, -baselineOffset));
}
final TextRange graphemeRange = glyphInfo.graphemeClusterCodeUnitRange;
// Works around a SkParagraph bug (https://github.com/flutter/flutter/issues/120836#issuecomment-1937343854):
// placeholders with a size of (0, 0) always have a rect of Rect.zero and a
// range of (0, 0).
if (graphemeRange.isCollapsed) {
assert(graphemeRange.start == 0);
return _computeCaretMetrics(TextPosition(offset: offset + 1));
}
if (anchorToLeadingEdge && graphemeRange.start != offset) {
assert(graphemeRange.end > graphemeRange.start + 1);
// Addresses the case where `offset` points to a multi-code-unit grapheme
// that doesn't start at `offset`.
return _computeCaretMetrics(TextPosition(offset: graphemeRange.end));
}
final _LineCaretMetrics metrics;
final List<TextBox> boxes = cachedLayout.paragraph
.getBoxesForRange(graphemeRange.start, graphemeRange.end, boxHeightStyle: ui.BoxHeightStyle.strut);
if (boxes.isNotEmpty) {
final TextBox box = boxes.single;
metrics =_LineCaretMetrics(
offset: Offset(anchorToLeadingEdge ? box.start : box.end, box.top),
writingDirection: box.direction,
);
} else {
// Fall back to glyphInfo. This should only happen when using the HTML renderer.
assert(kIsWeb && !isCanvasKit);
final Rect graphemeBounds = glyphInfo.graphemeClusterLayoutBounds;
final double dx = switch (glyphInfo.writingDirection) {
TextDirection.ltr => anchorToLeadingEdge ? graphemeBounds.left : graphemeBounds.right,
TextDirection.rtl => anchorToLeadingEdge ? graphemeBounds.right : graphemeBounds.left,
};
metrics = _LineCaretMetrics(
offset: Offset(dx, graphemeBounds.top),
writingDirection: glyphInfo.writingDirection,
);
}
cachedLayout._previousCaretPositionKey = caretPositionCacheKey;
return _caretMetrics = metrics;
final int offset = position.offset;
final _CaretMetrics? metrics = switch (position.affinity) {
TextAffinity.upstream => _getMetricsFromUpstream(offset) ?? _getMetricsFromDownstream(offset),
TextAffinity.downstream => _getMetricsFromDownstream(offset) ?? _getMetricsFromUpstream(offset),
};
// Cache the input parameters to prevent repeat work later.
cachedLayout._previousCaretPosition = position;
return _caretMetrics = metrics ?? const _EmptyLineCaretMetrics(lineVerticalOffset: 0);
}
/// Returns a list of rects that bound the given selection.

View file

@ -6941,7 +6941,7 @@ void main() {
// the arrow should not point exactly to the caret because the caret is
// too close to the right.
controller.dispose();
controller = TextEditingController(text: 'a' * 200);
controller = TextEditingController(text: List<String>.filled(200, 'a').join());
await tester.pumpWidget(
CupertinoApp(
debugShowCheckedModeBanner: false,
@ -7002,7 +7002,7 @@ void main() {
// Normal centered collapsed selection. The toolbar arrow should point down, and
// it should point exactly to the caret.
controller.dispose();
controller = TextEditingController(text: 'a' * 200);
controller = TextEditingController(text: List<String>.filled(200, 'a').join());
addTearDown(controller.dispose);
await tester.pumpWidget(
CupertinoApp(

View file

@ -15222,8 +15222,6 @@ void main() {
bool isWide = false;
const double wideWidth = 300.0;
const double narrowWidth = 200.0;
const TextStyle style = TextStyle(fontSize: 10, height: 1.0, letterSpacing: 0.0, wordSpacing: 0.0);
const double caretWidth = 2.0;
final TextEditingController controller = _textEditingController();
await tester.pumpWidget(
boilerplate(
@ -15236,7 +15234,6 @@ void main() {
key: textFieldKey,
controller: controller,
textDirection: TextDirection.rtl,
style: style,
),
);
},
@ -15253,17 +15250,15 @@ void main() {
expect(inputWidth, narrowWidth);
expect(cursorRight, inputWidth - kCaretGap);
const String text = '12345';
// After entering some text, the cursor is placed to the left of the text
// because the paragraph's writing direction is RTL.
await tester.enterText(find.byType(TextField), text);
// After entering some text, the cursor remains on the right of the input.
await tester.enterText(find.byType(TextField), '12345');
await tester.pump();
editable = findRenderEditable(tester);
cursorRight = editable.getLocalRectForCaret(
TextPosition(offset: controller.value.text.length),
).topRight.dx;
inputWidth = editable.size.width;
expect(cursorRight, inputWidth - kCaretGap - text.length * 10 - caretWidth);
expect(cursorRight, inputWidth - kCaretGap);
// Since increasing the width of the input moves its right edge further to
// the right, the cursor has followed this change and still appears on the
@ -15278,7 +15273,7 @@ void main() {
).topRight.dx;
inputWidth = editable.size.width;
expect(inputWidth, wideWidth);
expect(cursorRight, inputWidth - kCaretGap - text.length * 10 - caretWidth);
expect(cursorRight, inputWidth - kCaretGap);
});
testWidgets('Text selection menu hides after select all on desktop', (WidgetTester tester) async {

View file

@ -301,9 +301,9 @@ void main() {
painter.getOffsetForCaret(const TextPosition(offset: 2, affinity: TextAffinity.upstream), Rect.zero),
const Offset(0.0, 10.0),
);
expect( // To the right of the Alef
expect( // after the Alef
painter.getOffsetForCaret(const TextPosition(offset: 2), Rect.zero),
const Offset(10.0, 10.0),
const Offset(0.0, 10.0),
);
expect(

File diff suppressed because it is too large Load diff

View file

@ -976,7 +976,7 @@ void main() {
expect(find.byType(EditableText), paints
..rrect(
rrect: RRect.fromRectAndRadius(
const Rect.fromLTWH(193.83334350585938, -0.916666666666668, 3.0, 20.0),
const Rect.fromLTRB(193.83334350585938, -0.916666666666668, 196.83334350585938, 19.083333969116211),
const Radius.circular(1.0),
),
color: const Color(0xbf2196f3),
@ -994,7 +994,7 @@ void main() {
expect(find.byType(EditableText), paints
..rrect(
rrect: RRect.fromRectAndRadius(
const Rect.fromLTWH(719.3333333333333, -0.9166666666666679, 2.0, 18.0),
const Rect.fromLTRB(719.3333333333333, -0.9166666666666679, 721.3333333333333, 17.083333333333332),
const Radius.circular(2.0),
),
color: const Color(0xff999999),