Skip to content

Commit 1306d7f

Browse files
Fix Caret Height On Empty Lines (#120834)
* improve caret caching, fix caret for empty text/line, `getLocalRectForCaret` now reports the real rect that will be painted. move caret x-coordinate clamping to RenderEditable since TextPainter doesn't know about clipping. * comments * review
1 parent 7dd53fe commit 1306d7f

File tree

8 files changed

+297
-115
lines changed

8 files changed

+297
-115
lines changed

packages/flutter/lib/src/painting/text_painter.dart

Lines changed: 79 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -273,14 +273,32 @@ class _UntilTextBoundary extends TextBoundary {
273273
/// This is used to cache and pass the computed metrics regarding the
274274
/// caret's size and position. This is preferred due to the expensive
275275
/// nature of the calculation.
276-
class _CaretMetrics {
277-
const _CaretMetrics({required this.offset, this.fullHeight});
276+
///
277+
// This should be a sealed class: A _CaretMetrics is either a _LineCaretMetrics
278+
// or an _EmptyLineCaretMetrics.
279+
@immutable
280+
abstract class _CaretMetrics { }
281+
282+
/// The _CaretMetrics for carets located in a non-empty line. Carets located in a
283+
/// non-empty line are associated with a glyph within the same line.
284+
class _LineCaretMetrics implements _CaretMetrics {
285+
const _LineCaretMetrics({required this.offset, required this.writingDirection, required this.fullHeight});
278286
/// The offset of the top left corner of the caret from the top left
279287
/// corner of the paragraph.
280288
final Offset offset;
281-
289+
/// The writing direction of the glyph the _CaretMetrics is associated with.
290+
final TextDirection writingDirection;
282291
/// The full height of the glyph at the caret position.
283-
final double? fullHeight;
292+
final double fullHeight;
293+
}
294+
295+
/// The _CaretMetrics for carets located in an empty line (when the text is
296+
/// empty, or the caret is between two a newline characters).
297+
class _EmptyLineCaretMetrics implements _CaretMetrics {
298+
const _EmptyLineCaretMetrics({ required this.lineVerticalOffset });
299+
300+
/// The y offset of the unoccupied line.
301+
final double lineVerticalOffset;
284302
}
285303

286304
/// An object that paints a [TextSpan] tree into a [Canvas].
@@ -466,7 +484,6 @@ class TextPainter {
466484
_paragraph = null;
467485
_lineMetricsCache = null;
468486
_previousCaretPosition = null;
469-
_previousCaretPrototype = null;
470487
}
471488

472489
/// The (potentially styled) text to paint.
@@ -935,7 +952,6 @@ class TextPainter {
935952
// A change in layout invalidates the cached caret and line metrics as well.
936953
_lineMetricsCache = null;
937954
_previousCaretPosition = null;
938-
_previousCaretPrototype = null;
939955
_layoutParagraph(minWidth, maxWidth);
940956
_inlinePlaceholderBoxes = _paragraph!.getBoxesForPlaceholders();
941957
}
@@ -1027,9 +1043,9 @@ class TextPainter {
10271043
// Unicode value for a zero width joiner character.
10281044
static const int _zwjUtf16 = 0x200d;
10291045

1030-
// Get the Rect of the cursor (in logical pixels) based off the near edge
1031-
// of the character upstream from the given string offset.
1032-
Rect? _getRectFromUpstream(int offset, Rect caretPrototype) {
1046+
// Get the caret metrics (in logical pixels) based off the near edge of the
1047+
// character upstream from the given string offset.
1048+
_CaretMetrics? _getMetricsFromUpstream(int offset) {
10331049
final int plainTextLength = plainText.length;
10341050
if (plainTextLength == 0 || offset > plainTextLength) {
10351051
return null;
@@ -1067,21 +1083,15 @@ class TextPainter {
10671083
}
10681084
final TextBox box = boxes.first;
10691085

1070-
if (prevCodeUnit == NEWLINE_CODE_UNIT) {
1071-
return Rect.fromLTRB(_emptyOffset.dx, box.bottom, _emptyOffset.dx, box.bottom + box.bottom - box.top);
1072-
}
1073-
1074-
final double caretEnd = box.end;
1075-
final double dx = box.direction == TextDirection.rtl ? caretEnd - caretPrototype.width : caretEnd;
1076-
return Rect.fromLTRB(clampDouble(dx, 0, _paragraph!.width), box.top,
1077-
clampDouble(dx, 0, _paragraph!.width), box.bottom);
1086+
return prevCodeUnit == NEWLINE_CODE_UNIT
1087+
? _EmptyLineCaretMetrics(lineVerticalOffset: box.bottom)
1088+
: _LineCaretMetrics(offset: Offset(box.end, box.top), writingDirection: box.direction, fullHeight: box.bottom - box.top);
10781089
}
10791090
return null;
10801091
}
1081-
1082-
// Get the Rect of the cursor (in logical pixels) based off the near edge
1083-
// of the character downstream from the given string offset.
1084-
Rect? _getRectFromDownstream(int offset, Rect caretPrototype) {
1092+
// Get the caret metrics (in logical pixels) based off the near edge of the
1093+
// character downstream from the given string offset.
1094+
_CaretMetrics? _getMetricsFromDownstream(int offset) {
10851095
final int plainTextLength = plainText.length;
10861096
if (plainTextLength == 0 || offset < 0) {
10871097
return null;
@@ -1116,38 +1126,33 @@ class TextPainter {
11161126
continue;
11171127
}
11181128
final TextBox box = boxes.last;
1119-
final double caretStart = box.start;
1120-
final double dx = box.direction == TextDirection.rtl ? caretStart - caretPrototype.width : caretStart;
1121-
return Rect.fromLTRB(clampDouble(dx, 0, _paragraph!.width), box.top, clampDouble(dx, 0, _paragraph!.width), box.bottom);
1129+
return _LineCaretMetrics(offset: Offset(box.start, box.top), writingDirection: box.direction, fullHeight: box.bottom - box.top);
11221130
}
11231131
return null;
11241132
}
11251133

1126-
Offset get _emptyOffset {
1127-
assert(_debugAssertTextLayoutIsValid); // implies textDirection is non-null
1134+
static double _computePaintOffsetFraction(TextAlign textAlign, TextDirection textDirection) {
11281135
switch (textAlign) {
11291136
case TextAlign.left:
1130-
return Offset.zero;
1137+
return 0.0;
11311138
case TextAlign.right:
1132-
return Offset(width, 0.0);
1139+
return 1.0;
11331140
case TextAlign.center:
1134-
return Offset(width / 2.0, 0.0);
1135-
case TextAlign.justify:
1141+
return 0.5;
11361142
case TextAlign.start:
1137-
assert(textDirection != null);
1138-
switch (textDirection!) {
1143+
case TextAlign.justify:
1144+
switch (textDirection) {
11391145
case TextDirection.rtl:
1140-
return Offset(width, 0.0);
1146+
return 1.0;
11411147
case TextDirection.ltr:
1142-
return Offset.zero;
1148+
return 0.0;
11431149
}
11441150
case TextAlign.end:
1145-
assert(textDirection != null);
1146-
switch (textDirection!) {
1151+
switch (textDirection) {
11471152
case TextDirection.rtl:
1148-
return Offset.zero;
1153+
return 0.0;
11491154
case TextDirection.ltr:
1150-
return Offset(width, 0.0);
1155+
return 1.0;
11511156
}
11521157
}
11531158
}
@@ -1156,8 +1161,33 @@ class TextPainter {
11561161
///
11571162
/// Valid only after [layout] has been called.
11581163
Offset getOffsetForCaret(TextPosition position, Rect caretPrototype) {
1159-
_computeCaretMetrics(position, caretPrototype);
1160-
return _caretMetrics.offset;
1164+
final _CaretMetrics caretMetrics = _computeCaretMetrics(position);
1165+
1166+
if (caretMetrics is _EmptyLineCaretMetrics) {
1167+
final double paintOffsetAlignment = _computePaintOffsetFraction(textAlign, textDirection!);
1168+
// The full width is not (width - caretPrototype.width)
1169+
// because RenderEditable reserves cursor width on the right. Ideally this
1170+
// should be handled by RenderEditable instead.
1171+
final double dx = paintOffsetAlignment == 0 ? 0 : paintOffsetAlignment * width;
1172+
return Offset(dx, caretMetrics.lineVerticalOffset);
1173+
}
1174+
1175+
final Offset offset;
1176+
switch ((caretMetrics as _LineCaretMetrics).writingDirection) {
1177+
case TextDirection.rtl:
1178+
offset = Offset(caretMetrics.offset.dx - caretPrototype.width, caretMetrics.offset.dy);
1179+
break;
1180+
case TextDirection.ltr:
1181+
offset = caretMetrics.offset;
1182+
break;
1183+
}
1184+
// If offset.dx is outside of the advertised content area, then the associated
1185+
// glyph cluster belongs to a trailing newline character. Ideally the behavior
1186+
// should be handled by higher-level implementations (for instance,
1187+
// RenderEditable reserves width for showing the caret, it's best to handle
1188+
// the clamping there).
1189+
final double adjustedDx = clampDouble(offset.dx, 0, width);
1190+
return Offset(adjustedDx, offset.dy);
11611191
}
11621192

11631193
/// {@template flutter.painting.textPainter.getFullHeightForCaret}
@@ -1166,8 +1196,8 @@ class TextPainter {
11661196
///
11671197
/// Valid only after [layout] has been called.
11681198
double? getFullHeightForCaret(TextPosition position, Rect caretPrototype) {
1169-
_computeCaretMetrics(position, caretPrototype);
1170-
return _caretMetrics.fullHeight;
1199+
final _CaretMetrics caretMetrics = _computeCaretMetrics(position);
1200+
return caretMetrics is _LineCaretMetrics ? caretMetrics.fullHeight : null;
11711201
}
11721202

11731203
// Cached caret metrics. This allows multiple invokes of [getOffsetForCaret] and
@@ -1179,35 +1209,29 @@ class TextPainter {
11791209
// computed with. When new values are passed in, we recompute the caret metrics.
11801210
// only as necessary.
11811211
TextPosition? _previousCaretPosition;
1182-
Rect? _previousCaretPrototype;
11831212

11841213
// Checks if the [position] and [caretPrototype] have changed from the cached
11851214
// version and recomputes the metrics required to position the caret.
1186-
void _computeCaretMetrics(TextPosition position, Rect caretPrototype) {
1215+
_CaretMetrics _computeCaretMetrics(TextPosition position) {
11871216
assert(_debugAssertTextLayoutIsValid);
1188-
if (position == _previousCaretPosition && caretPrototype == _previousCaretPrototype) {
1189-
return;
1217+
if (position == _previousCaretPosition) {
1218+
return _caretMetrics;
11901219
}
11911220
final int offset = position.offset;
1192-
Rect? rect;
1221+
final _CaretMetrics? metrics;
11931222
switch (position.affinity) {
11941223
case TextAffinity.upstream: {
1195-
rect = _getRectFromUpstream(offset, caretPrototype) ?? _getRectFromDownstream(offset, caretPrototype);
1224+
metrics = _getMetricsFromUpstream(offset) ?? _getMetricsFromDownstream(offset);
11961225
break;
11971226
}
11981227
case TextAffinity.downstream: {
1199-
rect = _getRectFromDownstream(offset, caretPrototype) ?? _getRectFromUpstream(offset, caretPrototype);
1228+
metrics = _getMetricsFromDownstream(offset) ?? _getMetricsFromUpstream(offset);
12001229
break;
12011230
}
12021231
}
1203-
_caretMetrics = _CaretMetrics(
1204-
offset: rect != null ? Offset(rect.left, rect.top) : _emptyOffset,
1205-
fullHeight: rect != null ? rect.bottom - rect.top : null,
1206-
);
1207-
12081232
// Cache the input parameters to prevent repeat work later.
12091233
_previousCaretPosition = position;
1210-
_previousCaretPrototype = caretPrototype;
1234+
return _caretMetrics = metrics ?? const _EmptyLineCaretMetrics(lineVerticalOffset: 0);
12111235
}
12121236

12131237
/// Returns a list of rects that bound the given selection.

packages/flutter/lib/src/painting/text_span.dart

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -406,14 +406,14 @@ class TextSpan extends InlineSpan implements HitTestTarget, MouseTrackerAnnotati
406406

407407
@override
408408
int? codeUnitAtVisitor(int index, Accumulator offset) {
409+
final String? text = this.text;
409410
if (text == null) {
410411
return null;
411412
}
412-
if (index - offset.value < text!.length) {
413-
return text!.codeUnitAt(index - offset.value);
414-
}
415-
offset.increment(text!.length);
416-
return null;
413+
final int localOffset = index - offset.value;
414+
assert(localOffset >= 0);
415+
offset.increment(text.length);
416+
return localOffset < text.length ? text.codeUnitAt(localOffset) : null;
417417
}
418418

419419
/// Populates the `semanticsOffsets` and `semanticsElements` with the appropriate data

packages/flutter/lib/src/rendering/editable.dart

Lines changed: 42 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -1790,11 +1790,46 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin,
17901790
/// [TextPainter] object.
17911791
Rect getLocalRectForCaret(TextPosition caretPosition) {
17921792
_computeTextMetricsIfNeeded();
1793-
final Offset caretOffset = _textPainter.getOffsetForCaret(caretPosition, _caretPrototype);
1794-
// This rect is the same as _caretPrototype but without the vertical padding.
1795-
final Rect rect = Rect.fromLTWH(0.0, 0.0, cursorWidth, cursorHeight).shift(caretOffset + _paintOffset + cursorOffset);
1796-
// Add additional cursor offset (generally only if on iOS).
1797-
return rect.shift(_snapToPhysicalPixel(rect.topLeft));
1793+
final Rect caretPrototype = _caretPrototype;
1794+
final Offset caretOffset = _textPainter.getOffsetForCaret(caretPosition, caretPrototype);
1795+
Rect caretRect = caretPrototype.shift(caretOffset + cursorOffset);
1796+
final double scrollableWidth = math.max(_textPainter.width + _caretMargin, size.width);
1797+
1798+
final double caretX = clampDouble(caretRect.left, 0, math.max(scrollableWidth - _caretMargin, 0));
1799+
caretRect = Offset(caretX, caretRect.top) & caretRect.size;
1800+
1801+
final double caretHeight = cursorHeight;
1802+
switch (defaultTargetPlatform) {
1803+
case TargetPlatform.iOS:
1804+
case TargetPlatform.macOS:
1805+
final double fullHeight = _textPainter.getFullHeightForCaret(caretPosition, caretPrototype) ?? _textPainter.preferredLineHeight;
1806+
final double heightDiff = fullHeight - caretRect.height;
1807+
// Center the caret vertically along the text.
1808+
caretRect = Rect.fromLTWH(
1809+
caretRect.left,
1810+
caretRect.top + heightDiff / 2,
1811+
caretRect.width,
1812+
caretRect.height,
1813+
);
1814+
break;
1815+
case TargetPlatform.android:
1816+
case TargetPlatform.fuchsia:
1817+
case TargetPlatform.linux:
1818+
case TargetPlatform.windows:
1819+
// Override the height to take the full height of the glyph at the TextPosition
1820+
// when not on iOS. iOS has special handling that creates a taller caret.
1821+
// TODO(garyq): See the TODO for _computeCaretPrototype().
1822+
caretRect = Rect.fromLTWH(
1823+
caretRect.left,
1824+
caretRect.top - _kCaretHeightOffset,
1825+
caretRect.width,
1826+
caretHeight,
1827+
);
1828+
break;
1829+
}
1830+
1831+
caretRect = caretRect.shift(_paintOffset);
1832+
return caretRect.shift(_snapToPhysicalPixel(caretRect.topLeft));
17981833
}
17991834

18001835
@override
@@ -2311,13 +2346,7 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin,
23112346

23122347
late Rect _caretPrototype;
23132348

2314-
// TODO(garyq): This is no longer producing the highest-fidelity caret
2315-
// heights for Android, especially when non-alphabetic languages
2316-
// are involved. The current implementation overrides the height set
2317-
// here with the full measured height of the text on Android which looks
2318-
// superior (subjectively and in terms of fidelity) in _paintCaret. We
2319-
// should rework this properly to once again match the platform. The constant
2320-
// _kCaretHeightOffset scales poorly for small font sizes.
2349+
// TODO(LongCatIsLooong): https://github.com/flutter/flutter/issues/120836
23212350
//
23222351
/// On iOS, the cursor is taller than the cursor on Android. The height
23232352
/// of the cursor for iOS is approximate and obtained through an eyeball
@@ -2970,44 +2999,7 @@ class _FloatingCursorPainter extends RenderEditablePainter {
29702999
}
29713000

29723001
void paintRegularCursor(Canvas canvas, RenderEditable renderEditable, Color caretColor, TextPosition textPosition) {
2973-
final Rect caretPrototype = renderEditable._caretPrototype;
2974-
final Offset caretOffset = renderEditable._textPainter.getOffsetForCaret(textPosition, caretPrototype);
2975-
Rect caretRect = caretPrototype.shift(caretOffset + cursorOffset);
2976-
2977-
final double? caretHeight = renderEditable._textPainter.getFullHeightForCaret(textPosition, caretPrototype);
2978-
if (caretHeight != null) {
2979-
switch (defaultTargetPlatform) {
2980-
case TargetPlatform.iOS:
2981-
case TargetPlatform.macOS:
2982-
final double heightDiff = caretHeight - caretRect.height;
2983-
// Center the caret vertically along the text.
2984-
caretRect = Rect.fromLTWH(
2985-
caretRect.left,
2986-
caretRect.top + heightDiff / 2,
2987-
caretRect.width,
2988-
caretRect.height,
2989-
);
2990-
break;
2991-
case TargetPlatform.android:
2992-
case TargetPlatform.fuchsia:
2993-
case TargetPlatform.linux:
2994-
case TargetPlatform.windows:
2995-
// Override the height to take the full height of the glyph at the TextPosition
2996-
// when not on iOS. iOS has special handling that creates a taller caret.
2997-
// TODO(garyq): See the TODO for _computeCaretPrototype().
2998-
caretRect = Rect.fromLTWH(
2999-
caretRect.left,
3000-
caretRect.top - _kCaretHeightOffset,
3001-
caretRect.width,
3002-
caretHeight,
3003-
);
3004-
break;
3005-
}
3006-
}
3007-
3008-
caretRect = caretRect.shift(renderEditable._paintOffset);
3009-
final Rect integralRect = caretRect.shift(renderEditable._snapToPhysicalPixel(caretRect.topLeft));
3010-
3002+
final Rect integralRect = renderEditable.getLocalRectForCaret(textPosition);
30113003
if (shouldPaint) {
30123004
final Radius? radius = cursorRadius;
30133005
caretPaint.color = caretColor;

packages/flutter/lib/src/widgets/widget_span.dart

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -136,8 +136,10 @@ class WidgetSpan extends PlaceholderSpan {
136136

137137
@override
138138
int? codeUnitAtVisitor(int index, Accumulator offset) {
139+
final int localOffset = index - offset.value;
140+
assert(localOffset >= 0);
139141
offset.increment(1);
140-
return PlaceholderSpan.placeholderCodeUnit;
142+
return localOffset == 0 ? PlaceholderSpan.placeholderCodeUnit : null;
141143
}
142144

143145
@override

packages/flutter/test/cupertino/text_field_test.dart

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3108,7 +3108,7 @@ void main() {
31083108
);
31093109
expect(firstCharEndpoint.length, 1);
31103110
// The first character is now offscreen to the left.
3111-
expect(firstCharEndpoint[0].point.dx, moreOrLessEquals(-309.30, epsilon: 1));
3111+
expect(firstCharEndpoint[0].point.dx, moreOrLessEquals(-310.30, epsilon: 1));
31123112
}, variant: TargetPlatformVariant.all(excluding: <TargetPlatform>{ TargetPlatform.iOS, TargetPlatform.macOS }));
31133113

31143114
testWidgets('long press drag can edge scroll on Apple platforms', (WidgetTester tester) async {
@@ -4813,7 +4813,7 @@ void main() {
48134813

48144814
// The ListView has scrolled to keep the TextField and cursor handle
48154815
// visible.
4816-
expect(scrollController.offset, 25.0);
4816+
expect(scrollController.offset, 27.0);
48174817
});
48184818

48194819
testWidgets('disabled state golden', (WidgetTester tester) async {

0 commit comments

Comments
 (0)