Relands "Changing TextPainter.getOffsetForCaret implementation to remove the logarithmic search (#143281)" (reverted in #143801) (#143954)

The original PR was reverted because the new caret positioning callpath triggered a skparagraph assert. The assert has been removed. Relanding the PR with no changes applied.
This commit is contained in:
LongCatIsLooong 2024-02-23 11:20:14 -08:00 committed by GitHub
parent c84565a6fa
commit a0a854a78b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 923 additions and 826 deletions

View file

@ -46,8 +46,9 @@ void main() {
const Duration durationBetweenActions = Duration(milliseconds: 20);
const String defaultText = 'I am a magnifier, fear me!';
Future<void> showMagnifier(WidgetTester tester, String characterToTapOn) async {
final Offset tapOffset = _textOffsetToPosition(tester, defaultText.indexOf(characterToTapOn));
Future<void> showMagnifier(WidgetTester tester, int textOffset) async {
assert(textOffset >= 0);
final Offset tapOffset = _textOffsetToPosition(tester, textOffset);
// Double tap 'Magnifier' word to show the selection handles.
final TestGesture testGesture = await tester.startGesture(tapOffset);
@ -59,11 +60,11 @@ void main() {
await testGesture.up();
await tester.pumpAndSettle();
final TextSelection selection = tester
.firstWidget<TextField>(find.byType(TextField))
.controller!
.selection;
final TextEditingController controller = tester
.firstWidget<TextField>(find.byType(TextField))
.controller!;
final TextSelection selection = controller.selection;
final RenderEditable renderEditable = _findRenderEditable(tester);
final List<TextSelectionPoint> endpoints = _globalize(
renderEditable.getEndpointsForSelection(selection),
@ -86,7 +87,7 @@ void main() {
testWidgets('should show custom magnifier on drag', (WidgetTester tester) async {
await tester.pumpWidget(const example.TextMagnifierExampleApp(text: defaultText));
await showMagnifier(tester, 'e');
await showMagnifier(tester, defaultText.indexOf('e'));
expect(find.byType(example.CustomMagnifier), findsOneWidget);
await expectLater(
@ -96,16 +97,15 @@ void main() {
}, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS, TargetPlatform.android }));
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';
testWidgets('should show custom magnifier in RTL', (WidgetTester tester) async {
const String text = 'أثارت زر';
const String textToTapOn = 'ت';
await tester.pumpWidget(example.TextMagnifierExampleApp(textDirection: textDirection, text: text));
await tester.pumpWidget(const example.TextMagnifierExampleApp(textDirection: TextDirection.rtl, text: text));
await showMagnifier(tester, textToTapOn);
await showMagnifier(tester, text.indexOf(textToTapOn));
expect(find.byType(example.CustomMagnifier), findsOneWidget);
});
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, min;
import 'dart:math' show max;
import 'dart:ui' as ui show
BoxHeightStyle,
BoxWidthStyle,
@ -204,8 +204,14 @@ class WordBoundary extends TextBoundary {
}
static bool _isNewline(int codePoint) {
// Carriage Return is not treated as a hard line break.
return switch (codePoint) {
0x000A || 0x0085 || 0x000B || 0x000C || 0x2028 || 0x2029 => true,
0x000A || // Line Feed
0x0085 || // New Line
0x000B || // Form Feed
0x000C || // Vertical Feed
0x2028 || // Line Separator
0x2029 => true, // Paragraph Separator
_ => false,
};
}
@ -270,7 +276,10 @@ class _UntilTextBoundary extends TextBoundary {
}
class _TextLayout {
_TextLayout._(this._paragraph);
_TextLayout._(this._paragraph, this.writingDirection, this.rawString);
final TextDirection writingDirection;
final String rawString;
// 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
@ -316,6 +325,57 @@ 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),
@ -420,39 +480,29 @@ class _TextPainterLayoutCacheWithOffset {
List<ui.LineMetrics> get lineMetrics => _cachedLineMetrics ??= paragraph.computeLineMetrics();
List<ui.LineMetrics>? _cachedLineMetrics;
// 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;
// Used to determine whether the caret metrics cache should be invalidated.
int? _previousCaretPositionKey;
}
/// 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.
/// 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.
final Offset offset;
/// The writing direction of the glyph the _CaretMetrics is associated with.
/// 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].
final TextDirection writingDirection;
/// The full height of the glyph at the caret position.
final double fullHeight;
}
/// 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;
_LineCaretMetrics shift(Offset offset) {
return offset == Offset.zero
? this
: _LineCaretMetrics(offset: offset + this.offset, writingDirection: writingDirection);
}
}
const String _flutterPaintingLibrary = 'package:flutter/painting.dart';
@ -971,10 +1021,8 @@ class TextPainter {
}
List<PlaceholderDimensions>? _placeholderDimensions;
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.');
ui.ParagraphStyle _createParagraphStyle([ TextAlign? textAlignOverride ]) {
assert(textDirection != null, 'TextPainter.textDirection must be set to a non-null value before using the TextPainter.');
final TextStyle baseStyle = _text?.style ?? const TextStyle();
final StrutStyle? strutStyle = _strutStyle;
@ -996,8 +1044,8 @@ class TextPainter {
);
return baseStyle.getParagraphStyle(
textAlign: textAlign,
textDirection: textDirection ?? defaultTextDirection,
textAlign: textAlignOverride ?? textAlign,
textDirection: textDirection,
textScaler: textScaler,
maxLines: _maxLines,
textHeightBehavior: _textHeightBehavior,
@ -1010,7 +1058,7 @@ class TextPainter {
ui.Paragraph? _layoutTemplate;
ui.Paragraph _createLayoutTemplate() {
final ui.ParagraphBuilder builder = ui.ParagraphBuilder(
_createParagraphStyle(TextDirection.rtl),
_createParagraphStyle(TextAlign.left),
); // direction doesn't matter, text is just a space
final ui.TextStyle? textStyle = text?.style?.getTextStyle(textScaler: textScaler);
if (textStyle != null) {
@ -1021,6 +1069,7 @@ 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
@ -1033,7 +1082,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 => (_layoutTemplate ??= _createLayoutTemplate()).height;
double get preferredLineHeight => _getOrCreateLayoutTemplate().height;
/// The width at which decreasing the width of the text would prevent it from
/// painting itself completely within its bounds.
@ -1164,7 +1213,7 @@ class TextPainter {
// called.
final ui.Paragraph paragraph = (cachedLayout?.paragraph ?? _createParagraph(text))
..layout(ui.ParagraphConstraints(width: layoutMaxWidth));
final _TextLayout layout = _TextLayout._(paragraph);
final _TextLayout layout = _TextLayout._(paragraph, textDirection, plainText);
final double contentWidth = layout._contentWidthFor(minWidth, maxWidth, textWidthBasis);
final _TextPainterLayoutCacheWithOffset newLayoutCache;
@ -1259,14 +1308,6 @@ 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) {
@ -1289,118 +1330,15 @@ class TextPainter {
return isLowSurrogate(prevCodeUnit) ? offset - 2 : offset - 1;
}
// 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
// Get the caret metrics (in logical pixels) based off the trailing 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, TextDirection.ltr) => 0.0,
(TextAlign.start, TextDirection.rtl) => 1.0,
(TextAlign.justify, TextDirection.ltr) => 0.0,
(TextAlign.justify, TextDirection.rtl) => 1.0,
(TextAlign.start || TextAlign.justify, TextDirection.ltr) => 0.0,
(TextAlign.start || TextAlign.justify, TextDirection.rtl) => 1.0,
(TextAlign.end, TextDirection.ltr) => 1.0,
(TextAlign.end, TextDirection.rtl) => 0.0,
};
@ -1410,31 +1348,24 @@ class TextPainter {
///
/// Valid only after [layout] has been called.
Offset getOffsetForCaret(TextPosition position, Rect caretPrototype) {
final _CaretMetrics caretMetrics;
final _TextPainterLayoutCacheWithOffset layoutCache = _layoutCache!;
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 _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);
}
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);
}
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),
};
// If offset.dx is outside of the advertised content area, then the associated
// glyph cluster belongs to a trailing newline character. Ideally the behavior
// glyph belongs to a trailing whitespace 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).
@ -1448,38 +1379,136 @@ class TextPainter {
///
/// Valid only after [layout] has been called.
double? getFullHeightForCaret(TextPosition position, Rect caretPrototype) {
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,
};
final TextBox textBox = _getOrCreateLayoutTemplate().getBoxesForRange(0, 1, boxHeightStyle: ui.BoxHeightStyle.strut).single;
return textBox.toRect().height;
}
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.
late _CaretMetrics _caretMetrics;
//
// The cache implementation assumes there's only one cursor at any given time.
late _LineCaretMetrics _caretMetrics;
// 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) {
// 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) {
assert(_debugAssertTextLayoutIsValid);
assert(!_debugNeedsRelayout);
final _TextPainterLayoutCacheWithOffset cachedLayout = _layoutCache!;
if (position == cachedLayout._previousCaretPosition) {
// 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) {
return _caretMetrics;
}
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);
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;
}
/// 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: List<String>.filled(200, 'a').join());
controller = TextEditingController(text: 'a' * 200);
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: List<String>.filled(200, 'a').join());
controller = TextEditingController(text: 'a' * 200);
addTearDown(controller.dispose);
await tester.pumpWidget(
CupertinoApp(

View file

@ -15222,6 +15222,8 @@ 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(
@ -15234,6 +15236,7 @@ void main() {
key: textFieldKey,
controller: controller,
textDirection: TextDirection.rtl,
style: style,
),
);
},
@ -15250,15 +15253,17 @@ void main() {
expect(inputWidth, narrowWidth);
expect(cursorRight, inputWidth - kCaretGap);
// After entering some text, the cursor remains on the right of the input.
await tester.enterText(find.byType(TextField), '12345');
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);
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);
expect(cursorRight, inputWidth - kCaretGap - text.length * 10 - caretWidth);
// 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
@ -15273,7 +15278,7 @@ void main() {
).topRight.dx;
inputWidth = editable.size.width;
expect(inputWidth, wideWidth);
expect(cursorRight, inputWidth - kCaretGap);
expect(cursorRight, inputWidth - kCaretGap - text.length * 10 - caretWidth);
});
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( // after the Alef
expect( // To the right of the Alef
painter.getOffsetForCaret(const TextPosition(offset: 2), Rect.zero),
const Offset(0.0, 10.0),
const Offset(10.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.fromLTRB(193.83334350585938, -0.916666666666668, 196.83334350585938, 19.083333969116211),
const Rect.fromLTWH(193.83334350585938, -0.916666666666668, 3.0, 20.0),
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.fromLTRB(719.3333333333333, -0.9166666666666679, 721.3333333333333, 17.083333333333332),
const Rect.fromLTWH(719.3333333333333, -0.9166666666666679, 2.0, 18.0),
const Radius.circular(2.0),
),
color: const Color(0xff999999),