From a0a854a78ba3ff06c01c36eb975b9a25cbce9063 Mon Sep 17 00:00:00 2001 From: LongCatIsLooong <31859944+LongCatIsLooong@users.noreply.github.com> Date: Fri, 23 Feb 2024 11:20:14 -0800 Subject: [PATCH] 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. --- .../text_magnifier/text_magnifier.0_test.dart | 32 +- .../lib/src/painting/text_painter.dart | 423 +++--- .../test/cupertino/text_field_test.dart | 4 +- .../test/material/text_field_test.dart | 13 +- .../test/painting/text_painter_rtl_test.dart | 4 +- .../test/painting/text_painter_test.dart | 1269 +++++++++-------- .../widgets/editable_text_cursor_test.dart | 4 +- 7 files changed, 923 insertions(+), 826 deletions(-) diff --git a/examples/api/test/widgets/text_magnifier/text_magnifier.0_test.dart b/examples/api/test/widgets/text_magnifier/text_magnifier.0_test.dart index 9d94d9373b4..31cd7a47f23 100644 --- a/examples/api/test/widgets/text_magnifier/text_magnifier.0_test.dart +++ b/examples/api/test/widgets/text_magnifier/text_magnifier.0_test.dart @@ -46,8 +46,9 @@ void main() { const Duration durationBetweenActions = Duration(milliseconds: 20); const String defaultText = 'I am a magnifier, fear me!'; - Future showMagnifier(WidgetTester tester, String characterToTapOn) async { - final Offset tapOffset = _textOffsetToPosition(tester, defaultText.indexOf(characterToTapOn)); + Future 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(find.byType(TextField)) - .controller! - .selection; + final TextEditingController controller = tester + .firstWidget(find.byType(TextField)) + .controller!; + final TextSelection selection = controller.selection; final RenderEditable renderEditable = _findRenderEditable(tester); final List 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.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); - }); - } } diff --git a/packages/flutter/lib/src/painting/text_painter.dart b/packages/flutter/lib/src/painting/text_painter.dart index 50ca4fd2ffc..2a74499c78b 100644 --- a/packages/flutter/lib/src/painting/text_painter.dart +++ b/packages/flutter/lib/src/painting/text_painter.dart @@ -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 get lineMetrics => _cachedLineMetrics ??= paragraph.computeLineMetrics(); List? _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; - 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 boxes = []; - 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 boxes = []; - 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 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. diff --git a/packages/flutter/test/cupertino/text_field_test.dart b/packages/flutter/test/cupertino/text_field_test.dart index 91a44d4c651..3f2b163fba9 100644 --- a/packages/flutter/test/cupertino/text_field_test.dart +++ b/packages/flutter/test/cupertino/text_field_test.dart @@ -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.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.filled(200, 'a').join()); + controller = TextEditingController(text: 'a' * 200); addTearDown(controller.dispose); await tester.pumpWidget( CupertinoApp( diff --git a/packages/flutter/test/material/text_field_test.dart b/packages/flutter/test/material/text_field_test.dart index c7a0a4509c6..5ca5573d3ae 100644 --- a/packages/flutter/test/material/text_field_test.dart +++ b/packages/flutter/test/material/text_field_test.dart @@ -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 { diff --git a/packages/flutter/test/painting/text_painter_rtl_test.dart b/packages/flutter/test/painting/text_painter_rtl_test.dart index 16d16c1bd23..353bf92ff0b 100644 --- a/packages/flutter/test/painting/text_painter_rtl_test.dart +++ b/packages/flutter/test/painting/text_painter_rtl_test.dart @@ -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( diff --git a/packages/flutter/test/painting/text_painter_test.dart b/packages/flutter/test/painting/text_painter_test.dart index 00cc50cd8d9..2acf6e8ad0d 100644 --- a/packages/flutter/test/painting/text_painter_test.dart +++ b/packages/flutter/test/painting/text_painter_test.dart @@ -117,256 +117,678 @@ List caretOffsetsForTextSpan(TextDirection textDirection, TextSpan text) } void main() { - test('TextPainter caret test', () { - final TextPainter painter = TextPainter() - ..textDirection = TextDirection.ltr; + group('caret', () { + test('TextPainter caret test', () { + final TextPainter painter = TextPainter() + ..textDirection = TextDirection.ltr; - String text = 'A'; - checkCaretOffsetsLtr(text); + String text = 'A'; + checkCaretOffsetsLtr(text); - painter.text = TextSpan(text: text); - painter.layout(); + painter.text = TextSpan(text: text); + painter.layout(); - Offset caretOffset = painter.getOffsetForCaret( - const ui.TextPosition(offset: 0), - ui.Rect.zero, - ); - expect(caretOffset.dx, 0); - caretOffset = painter.getOffsetForCaret(ui.TextPosition(offset: text.length), ui.Rect.zero); - expect(caretOffset.dx, painter.width); + Offset caretOffset = painter.getOffsetForCaret( + const ui.TextPosition(offset: 0), + ui.Rect.zero, + ); + expect(caretOffset.dx, 0); + caretOffset = painter.getOffsetForCaret(ui.TextPosition(offset: text.length), ui.Rect.zero); + expect(caretOffset.dx, painter.width); - // Check that getOffsetForCaret handles a character that is encoded as a - // surrogate pair. - text = 'A\u{1F600}'; - checkCaretOffsetsLtr(text); - painter.text = TextSpan(text: text); - painter.layout(); - caretOffset = painter.getOffsetForCaret(ui.TextPosition(offset: text.length), ui.Rect.zero); - expect(caretOffset.dx, painter.width); - painter.dispose(); + // Check that getOffsetForCaret handles a character that is encoded as a + // surrogate pair. + text = 'A\u{1F600}'; + checkCaretOffsetsLtr(text); + painter.text = TextSpan(text: text); + painter.layout(); + caretOffset = painter.getOffsetForCaret(ui.TextPosition(offset: text.length), ui.Rect.zero); + expect(caretOffset.dx, painter.width); + painter.dispose(); + }); + + test('TextPainter caret test with WidgetSpan', () { + // Regression test for https://github.com/flutter/flutter/issues/98458. + final TextPainter painter = TextPainter() + ..textDirection = TextDirection.ltr; + + painter.text = const TextSpan(children: [ + TextSpan(text: 'before'), + WidgetSpan(child: Text('widget')), + TextSpan(text: 'after'), + ]); + painter.setPlaceholderDimensions(const [ + PlaceholderDimensions(size: Size(50, 30), baselineOffset: 25, alignment: ui.PlaceholderAlignment.bottom), + ]); + painter.layout(); + final Offset caretOffset = painter.getOffsetForCaret(ui.TextPosition(offset: painter.text!.toPlainText().length), ui.Rect.zero); + expect(caretOffset.dx, painter.width); + painter.dispose(); + }, skip: isBrowser && !isCanvasKit); // https://github.com/flutter/flutter/issues/56308 + + test('TextPainter null text test', () { + final TextPainter painter = TextPainter() + ..textDirection = TextDirection.ltr; + + List children = [const TextSpan(text: 'B'), const TextSpan(text: 'C')]; + painter.text = TextSpan(children: children); + painter.layout(); + + Offset caretOffset = painter.getOffsetForCaret(const ui.TextPosition(offset: 0), ui.Rect.zero); + expect(caretOffset.dx, 0); + caretOffset = painter.getOffsetForCaret(const ui.TextPosition(offset: 1), ui.Rect.zero); + expect(caretOffset.dx, painter.width / 2); + caretOffset = painter.getOffsetForCaret(const ui.TextPosition(offset: 2), ui.Rect.zero); + expect(caretOffset.dx, painter.width); + + children = []; + painter.text = TextSpan(children: children); + painter.layout(); + + caretOffset = painter.getOffsetForCaret(const ui.TextPosition(offset: 0), ui.Rect.zero); + expect(caretOffset.dx, 0); + caretOffset = painter.getOffsetForCaret(const ui.TextPosition(offset: 1), ui.Rect.zero); + expect(caretOffset.dx, 0); + painter.dispose(); + }); + + test('TextPainter caret emoji test', () { + final TextPainter painter = TextPainter() + ..textDirection = TextDirection.ltr; + + // Format: '👩‍👩‍👦👩‍👩‍👧‍👧👏' + // One three-person family, one four-person family, one clapping hands (medium skin tone). + const String text = '👩‍👩‍👦👩‍👩‍👧‍👧👏🏽'; + checkCaretOffsetsLtr(text); + + painter.text = const TextSpan(text: text); + painter.layout(maxWidth: 10000); + + expect(text.length, 23); + + Offset caretOffset = painter.getOffsetForCaret(const ui.TextPosition(offset: 0), ui.Rect.zero); + expect(caretOffset.dx, 0); // 👩‍ + caretOffset = painter.getOffsetForCaret(const ui.TextPosition(offset: text.length), ui.Rect.zero); + expect(caretOffset.dx, painter.width); + + // Two UTF-16 codepoints per emoji, one codepoint per zwj + caretOffset = painter.getOffsetForCaret(const ui.TextPosition(offset: 1), ui.Rect.zero); + expect(caretOffset.dx, 42); // 👩‍ + caretOffset = painter.getOffsetForCaret(const ui.TextPosition(offset: 2), ui.Rect.zero); + expect(caretOffset.dx, 42); // + caretOffset = painter.getOffsetForCaret(const ui.TextPosition(offset: 3), ui.Rect.zero); + expect(caretOffset.dx, 42); // 👩‍ + caretOffset = painter.getOffsetForCaret(const ui.TextPosition(offset: 4), ui.Rect.zero); + expect(caretOffset.dx, 42); // 👩‍ + caretOffset = painter.getOffsetForCaret(const ui.TextPosition(offset: 5), ui.Rect.zero); + expect(caretOffset.dx, 42); // + caretOffset = painter.getOffsetForCaret(const ui.TextPosition(offset: 6), ui.Rect.zero); + expect(caretOffset.dx, 42); // 👦 + caretOffset = painter.getOffsetForCaret(const ui.TextPosition(offset: 7), ui.Rect.zero); + expect(caretOffset.dx, 42); // 👦 + caretOffset = painter.getOffsetForCaret(const ui.TextPosition(offset: 8), ui.Rect.zero); + expect(caretOffset.dx, 42); // 👩‍ + caretOffset = painter.getOffsetForCaret(const ui.TextPosition(offset: 9), ui.Rect.zero); + expect(caretOffset.dx, 98); // 👩‍ + caretOffset = painter.getOffsetForCaret(const ui.TextPosition(offset: 10), ui.Rect.zero); + expect(caretOffset.dx, 98); // + caretOffset = painter.getOffsetForCaret(const ui.TextPosition(offset: 11), ui.Rect.zero); + expect(caretOffset.dx, 98); // 👩‍ + caretOffset = painter.getOffsetForCaret(const ui.TextPosition(offset: 12), ui.Rect.zero); + expect(caretOffset.dx, 98); // 👩‍ + caretOffset = painter.getOffsetForCaret(const ui.TextPosition(offset: 13), ui.Rect.zero); + expect(caretOffset.dx, 98); // + caretOffset = painter.getOffsetForCaret(const ui.TextPosition(offset: 14), ui.Rect.zero); + expect(caretOffset.dx, 98); // 👧‍ + caretOffset = painter.getOffsetForCaret(const ui.TextPosition(offset: 15), ui.Rect.zero); + expect(caretOffset.dx, 98); // 👧‍ + caretOffset = painter.getOffsetForCaret(const ui.TextPosition(offset: 16), ui.Rect.zero); + expect(caretOffset.dx, 98); // + caretOffset = painter.getOffsetForCaret(const ui.TextPosition(offset: 17), ui.Rect.zero); + expect(caretOffset.dx, 98); // 👧 + caretOffset = painter.getOffsetForCaret(const ui.TextPosition(offset: 18), ui.Rect.zero); + expect(caretOffset.dx, 98); // 👧 + caretOffset = painter.getOffsetForCaret(const ui.TextPosition(offset: 19), ui.Rect.zero); + expect(caretOffset.dx, 98); // 👏 + caretOffset = painter.getOffsetForCaret(const ui.TextPosition(offset: 20), ui.Rect.zero); + expect(caretOffset.dx, 126); // 👏 + caretOffset = painter.getOffsetForCaret(const ui.TextPosition(offset: 21), ui.Rect.zero); + expect(caretOffset.dx, 126); // + caretOffset = painter.getOffsetForCaret(const ui.TextPosition(offset: 22), ui.Rect.zero); + expect(caretOffset.dx, 126); // + caretOffset = painter.getOffsetForCaret(const ui.TextPosition(offset: 23), ui.Rect.zero); + expect(caretOffset.dx, 126); // end of string + painter.dispose(); + }, skip: isBrowser && !isCanvasKit); // https://github.com/flutter/flutter/issues/56308 + + test('TextPainter caret emoji tests: single, long emoji', () { + // Regression test for https://github.com/flutter/flutter/issues/50563 + checkCaretOffsetsLtr('👩‍🚀'); + checkCaretOffsetsLtr('👩‍❤️‍💋‍👩'); + checkCaretOffsetsLtr('👨‍👩‍👦‍👦'); + checkCaretOffsetsLtr('👨🏾‍🤝‍👨🏻'); + checkCaretOffsetsLtr('👨‍👦'); + checkCaretOffsetsLtr('👩‍👦'); + checkCaretOffsetsLtr('🏌🏿‍♀️'); + checkCaretOffsetsLtr('🏊‍♀️'); + checkCaretOffsetsLtr('🏄🏻‍♂️'); + + // These actually worked even before #50563 was fixed (because + // their lengths in code units are powers of 2, namely 4 and 8). + checkCaretOffsetsLtr('🇺🇳'); + checkCaretOffsetsLtr('👩‍❤️‍👨'); + }, skip: isBrowser && !isCanvasKit); // https://github.com/flutter/flutter/issues/56308 + + test('TextPainter caret emoji test: letters, then 1 emoji of 5 code units', () { + // Regression test for https://github.com/flutter/flutter/issues/50563 + checkCaretOffsetsLtr('a👩‍🚀'); + checkCaretOffsetsLtr('ab👩‍🚀'); + checkCaretOffsetsLtr('abc👩‍🚀'); + checkCaretOffsetsLtr('abcd👩‍🚀'); + }, skip: isBrowser && !isCanvasKit); // https://github.com/flutter/flutter/issues/56308 + + test('TextPainter caret zalgo test', () { + // Regression test for https://github.com/flutter/flutter/issues/98516 + checkCaretOffsetsLtr('Z͉̳̺ͥͬ̾a̴͕̲̒̒͌̋ͪl̨͎̰̘͉̟ͤ̀̈̚͜g͕͔̤͖̟̒͝ͅo̵̡̡̼͚̐ͯ̅ͪ̆ͣ̚'); + }, skip: isBrowser && !isCanvasKit); // https://github.com/flutter/flutter/issues/56308 + + test('TextPainter caret Devanagari test', () { + // Regression test for https://github.com/flutter/flutter/issues/118403 + checkCaretOffsetsLtrFromPieces( + ['प्रा', 'प्त', ' ', 'व', 'र्ण', 'न', ' ', 'प्र', 'व्रु', 'ति']); + }, skip: isBrowser && !isCanvasKit); // https://github.com/flutter/flutter/issues/56308 + + test('TextPainter caret Devanagari test, full strength', () { + // Regression test for https://github.com/flutter/flutter/issues/118403 + checkCaretOffsetsLtr('प्राप्त वर्णन प्रव्रुति'); + }, skip: true); // https://github.com/flutter/flutter/issues/122478 + + test('TextPainter caret emoji test LTR: letters next to emoji, as separate TextBoxes', () { + // Regression test for https://github.com/flutter/flutter/issues/122477 + // The trigger for this bug was to have SkParagraph report separate + // TextBoxes for the emoji and for the characters next to it. + // In normal usage on a real device, this can happen by simply typing + // letters and then an emoji, presumably because they get different fonts. + // In these tests, our single test font covers both letters and emoji, + // so we provoke the same effect by adding styles. + expect(caretOffsetsForTextSpan( + TextDirection.ltr, + const TextSpan(children: [ + TextSpan(text: '👩‍🚀', style: TextStyle()), + TextSpan(text: ' words', style: TextStyle(fontWeight: FontWeight.bold)), + ])), + [0, 28, 28, 28, 28, 28, 42, 56, 70, 84, 98, 112]); + expect(caretOffsetsForTextSpan( + TextDirection.ltr, + const TextSpan(children: [ + TextSpan(text: 'words ', style: TextStyle(fontWeight: FontWeight.bold)), + TextSpan(text: '👩‍🚀', style: TextStyle()), + ])), + [0, 14, 28, 42, 56, 70, 84, 112, 112, 112, 112, 112]); + }, skip: isBrowser && !isCanvasKit); // https://github.com/flutter/flutter/issues/56308 + + test('TextPainter caret emoji test RTL: letters next to emoji, as separate TextBoxes', () { + // Regression test for https://github.com/flutter/flutter/issues/122477 + expect(caretOffsetsForTextSpan( + TextDirection.rtl, + const TextSpan(children: [ + TextSpan(text: '👩‍🚀', style: TextStyle()), + TextSpan(text: ' מילים', style: TextStyle(fontWeight: FontWeight.bold)), + ])), + [112, 84, 84, 84, 84, 84, 70, 56, 42, 28, 14, 0]); + expect(caretOffsetsForTextSpan( + TextDirection.rtl, + const TextSpan(children: [ + TextSpan(text: 'מילים ', style: TextStyle(fontWeight: FontWeight.bold)), + TextSpan(text: '👩‍🚀', style: TextStyle()), + ])), + [112, 98, 84, 70, 56, 42, 28, 0, 0, 0, 0, 0]); + }, skip: isBrowser && !isCanvasKit); // https://github.com/flutter/flutter/issues/56308 + + test('TextPainter caret center space test', () { + final TextPainter painter = TextPainter() + ..textDirection = TextDirection.ltr; + + const String text = 'test text with space at end '; + painter.text = const TextSpan(text: text); + painter.textAlign = TextAlign.center; + painter.layout(); + + Offset caretOffset = painter.getOffsetForCaret(const ui.TextPosition(offset: 0), ui.Rect.zero); + expect(caretOffset.dx, 21); + caretOffset = painter.getOffsetForCaret(const ui.TextPosition(offset: text.length), ui.Rect.zero); + // The end of the line is 441, but the width is only 420, so the cursor is + // stopped there without overflowing. + expect(caretOffset.dx, painter.width); + + caretOffset = painter.getOffsetForCaret(const ui.TextPosition(offset: 1), ui.Rect.zero); + expect(caretOffset.dx, 35); + caretOffset = painter.getOffsetForCaret(const ui.TextPosition(offset: 2), ui.Rect.zero); + expect(caretOffset.dx, 49); + painter.dispose(); + }, skip: isBrowser && !isCanvasKit); // https://github.com/flutter/flutter/issues/56308 + + test('TextPainter caret height and line height', () { + final TextPainter painter = TextPainter() + ..textDirection = TextDirection.ltr + ..strutStyle = const StrutStyle(fontSize: 50.0); + + const String text = 'A'; + painter.text = const TextSpan(text: text, style: TextStyle(height: 1.0)); + painter.layout(); + + final double caretHeight = painter.getFullHeightForCaret( + const ui.TextPosition(offset: 0), + ui.Rect.zero, + )!; + expect(caretHeight, 50.0); + painter.dispose(); + }, skip: isBrowser && !isCanvasKit); // https://github.com/flutter/flutter/issues/56308 + + test('upstream downstream makes no difference in the same line within the same bidi run', () { + final TextPainter painter = TextPainter(textDirection: TextDirection.ltr) + ..text = const TextSpan(text: 'aa') + ..layout(); + + final Rect largeRect = Offset.zero & const Size.square(5); + expect( + painter.getOffsetForCaret(const TextPosition(offset: 1), largeRect), + painter.getOffsetForCaret(const TextPosition(offset: 1, affinity: TextAffinity.upstream), largeRect), + ); + }); + + test('trailing newlines', () { + const double fontSize = 14.0; + final TextPainter painter = TextPainter(); + final Rect largeRect = Offset.zero & const Size.square(5); + String text = 'a '; + painter + ..text = TextSpan(text: text) + ..textDirection = TextDirection.ltr + ..layout(minWidth: 1000.0, maxWidth: 1000.0); + expect( + painter.getOffsetForCaret(TextPosition(offset: text.length), largeRect).dx, + text.length * fontSize, + ); + + text = 'ل '; + painter + ..text = TextSpan(text: text) + ..textDirection = TextDirection.rtl + ..layout(minWidth: 1000.0, maxWidth: 1000.0); + expect( + painter.getOffsetForCaret(TextPosition(offset: text.length), largeRect).dx, + 1000 - text.length * fontSize - largeRect.width, + ); + }, skip: isBrowser && !isCanvasKit); // https://github.com/flutter/flutter/issues/56308 + + test('End of text caret when the text ends with +1 bidi level', () { + const double fontSize = 14.0; + final TextPainter painter = TextPainter(); + final Rect largeRect = Offset.zero & const Size.square(5); + const String text = 'aل'; + painter + ..text = const TextSpan(text: text) + ..textDirection = TextDirection.ltr + ..layout(minWidth: 1000.0, maxWidth: 1000.0); + + expect( + painter.getOffsetForCaret(const TextPosition(offset: 0), largeRect).dx, + 0.0, + ); + expect( + painter.getOffsetForCaret(const TextPosition(offset: 1), largeRect).dx, + fontSize * 2 - largeRect.width, + ); + expect( + painter.getOffsetForCaret(const TextPosition(offset: 2), largeRect).dx, + fontSize * 2, + ); + }, skip: isBrowser && !isCanvasKit); // https://github.com/flutter/flutter/issues/56308 + + test('handles newlines properly', () { + final TextPainter painter = TextPainter() + ..textDirection = TextDirection.ltr; + + const double SIZE_OF_A = 14.0; // square size of "a" character + String text = 'aaa'; + painter.text = TextSpan(text: text); + painter.layout(); + + // getOffsetForCaret in a plain one-line string is the same for either affinity. + int offset = 0; + painter.text = TextSpan(text: text); + painter.layout(); + Offset caretOffset = painter.getOffsetForCaret( + ui.TextPosition(offset: offset), + ui.Rect.zero, + ); + expect(caretOffset.dx, SIZE_OF_A * offset); + expect(caretOffset.dy, 0.0); + caretOffset = painter.getOffsetForCaret( + ui.TextPosition(offset: offset, affinity: ui.TextAffinity.upstream), + ui.Rect.zero, + ); + expect(caretOffset.dx, SIZE_OF_A * offset); + expect(caretOffset.dy, 0.0); + offset = 1; + caretOffset = painter.getOffsetForCaret( + ui.TextPosition(offset: offset), + ui.Rect.zero, + ); + expect(caretOffset.dx, SIZE_OF_A * offset); + expect(caretOffset.dy, 0.0); + caretOffset = painter.getOffsetForCaret( + ui.TextPosition(offset: offset, affinity: ui.TextAffinity.upstream), + ui.Rect.zero, + ); + expect(caretOffset.dx, SIZE_OF_A * offset); + expect(caretOffset.dy, 0.0); + offset = 2; + caretOffset = painter.getOffsetForCaret( + ui.TextPosition(offset: offset), + ui.Rect.zero, + ); + expect(caretOffset.dx, SIZE_OF_A * offset); + expect(caretOffset.dy, 0.0); + caretOffset = painter.getOffsetForCaret( + ui.TextPosition(offset: offset, affinity: ui.TextAffinity.upstream), + ui.Rect.zero, + ); + expect(caretOffset.dx, SIZE_OF_A * offset); + expect(caretOffset.dy, 0.0); + offset = 3; + caretOffset = painter.getOffsetForCaret( + ui.TextPosition(offset: offset), + ui.Rect.zero, + ); + expect(caretOffset.dx, SIZE_OF_A * offset); + expect(caretOffset.dy, 0.0); + caretOffset = painter.getOffsetForCaret( + ui.TextPosition(offset: offset, affinity: ui.TextAffinity.upstream), + ui.Rect.zero, + ); + expect(caretOffset.dx, SIZE_OF_A * offset); + expect(caretOffset.dy, 0.0); + + // For explicit newlines, getOffsetForCaret places the caret at the location + // indicated by offset regardless of affinity. + text = '\n\n'; + painter.text = TextSpan(text: text); + painter.layout(); + offset = 0; + caretOffset = painter.getOffsetForCaret( + ui.TextPosition(offset: offset), + ui.Rect.zero, + ); + expect(caretOffset.dx, 0.0); + expect(caretOffset.dy, 0.0); + caretOffset = painter.getOffsetForCaret( + ui.TextPosition(offset: offset, affinity: ui.TextAffinity.upstream), + ui.Rect.zero, + ); + expect(caretOffset.dx, 0.0); + expect(caretOffset.dy, 0.0); + offset = 1; + caretOffset = painter.getOffsetForCaret( + ui.TextPosition(offset: offset), + ui.Rect.zero, + ); + expect(caretOffset.dx, 0.0); + expect(caretOffset.dy, SIZE_OF_A); + caretOffset = painter.getOffsetForCaret( + ui.TextPosition(offset: offset, affinity: ui.TextAffinity.upstream), + ui.Rect.zero, + ); + expect(caretOffset.dx, 0.0); + expect(caretOffset.dy, SIZE_OF_A); + offset = 2; + caretOffset = painter.getOffsetForCaret( + ui.TextPosition(offset: offset), + ui.Rect.zero, + ); + expect(caretOffset.dx, 0.0); + expect(caretOffset.dy, SIZE_OF_A * 2); + caretOffset = painter.getOffsetForCaret( + ui.TextPosition(offset: offset, affinity: ui.TextAffinity.upstream), + ui.Rect.zero, + ); + expect(caretOffset.dx, 0.0); + expect(caretOffset.dy, SIZE_OF_A * 2); + + // getOffsetForCaret in an unwrapped string with explicit newlines is the + // same for either affinity. + text = '\naaa'; + painter.text = TextSpan(text: text); + painter.layout(); + offset = 0; + caretOffset = painter.getOffsetForCaret( + ui.TextPosition(offset: offset), + ui.Rect.zero, + ); + expect(caretOffset.dx, 0.0); + expect(caretOffset.dy, 0.0); + caretOffset = painter.getOffsetForCaret( + ui.TextPosition(offset: offset, affinity: ui.TextAffinity.upstream), + ui.Rect.zero, + ); + expect(caretOffset.dx, 0.0); + expect(caretOffset.dy, 0.0); + offset = 1; + caretOffset = painter.getOffsetForCaret( + ui.TextPosition(offset: offset), + ui.Rect.zero, + ); + expect(caretOffset.dx, 0.0); + expect(caretOffset.dy, SIZE_OF_A); + caretOffset = painter.getOffsetForCaret( + ui.TextPosition(offset: offset, affinity: ui.TextAffinity.upstream), + ui.Rect.zero, + ); + expect(caretOffset.dx, 0.0); + expect(caretOffset.dy, SIZE_OF_A); + + // When text wraps on its own, getOffsetForCaret disambiguates between the + // end of one line and start of next using affinity. + text = 'aaaaaaaa'; // Just enough to wrap one character down to second line + painter.text = TextSpan(text: text); + painter.layout(maxWidth: 100); // SIZE_OF_A * text.length > 100, so it wraps + caretOffset = painter.getOffsetForCaret( + ui.TextPosition(offset: text.length - 1), + ui.Rect.zero, + ); + // When affinity is downstream, cursor is at beginning of second line + expect(caretOffset.dx, 0.0); + expect(caretOffset.dy, SIZE_OF_A); + caretOffset = painter.getOffsetForCaret( + ui.TextPosition(offset: text.length - 1, affinity: ui.TextAffinity.upstream), + ui.Rect.zero, + ); + // When affinity is upstream, cursor is at end of first line + expect(caretOffset.dx, 98.0); + expect(caretOffset.dy, 0.0); + + // When given a string with a newline at the end, getOffsetForCaret puts + // the cursor at the start of the next line regardless of affinity + text = 'aaa\n'; + painter.text = TextSpan(text: text); + painter.layout(); + caretOffset = painter.getOffsetForCaret( + ui.TextPosition(offset: text.length), + ui.Rect.zero, + ); + expect(caretOffset.dx, 0.0); + expect(caretOffset.dy, SIZE_OF_A); + offset = text.length; + caretOffset = painter.getOffsetForCaret( + ui.TextPosition(offset: offset, affinity: TextAffinity.upstream), + ui.Rect.zero, + ); + expect(caretOffset.dx, 0.0); + expect(caretOffset.dy, SIZE_OF_A); + + // Given a one-line right aligned string, positioning the cursor at offset 0 + // means that it appears at the "end" of the string, after the character + // that was typed first, at x=0. + painter.textAlign = TextAlign.right; + text = 'aaa'; + painter.text = TextSpan(text: text); + painter.layout(); + offset = 0; + caretOffset = painter.getOffsetForCaret( + ui.TextPosition(offset: offset), + ui.Rect.zero, + ); + expect(caretOffset.dx, 0.0); + expect(caretOffset.dy, 0.0); + painter.textAlign = TextAlign.left; + + // When given an offset after a newline in the middle of a string, + // getOffsetForCaret returns the start of the next line regardless of + // affinity. + text = 'aaa\naaa'; + painter.text = TextSpan(text: text); + painter.layout(); + offset = 4; + caretOffset = painter.getOffsetForCaret( + ui.TextPosition(offset: offset), + ui.Rect.zero, + ); + expect(caretOffset.dx, 0.0); + expect(caretOffset.dy, SIZE_OF_A); + caretOffset = painter.getOffsetForCaret( + ui.TextPosition(offset: offset, affinity: TextAffinity.upstream), + ui.Rect.zero, + ); + expect(caretOffset.dx, 0.0); + expect(caretOffset.dy, SIZE_OF_A); + + // When given a string with multiple trailing newlines, places the caret + // in the position given by offset regardless of affinity. + text = 'aaa\n\n\n'; + offset = 3; + caretOffset = painter.getOffsetForCaret( + ui.TextPosition(offset: offset), + ui.Rect.zero, + ); + expect(caretOffset.dx, SIZE_OF_A * 3); + expect(caretOffset.dy, 0.0); + caretOffset = painter.getOffsetForCaret( + ui.TextPosition(offset: offset, affinity: TextAffinity.upstream), + ui.Rect.zero, + ); + expect(caretOffset.dx, SIZE_OF_A * 3); + expect(caretOffset.dy, 0.0); + + offset = 4; + painter.text = TextSpan(text: text); + painter.layout(); + caretOffset = painter.getOffsetForCaret( + ui.TextPosition(offset: offset), + ui.Rect.zero, + ); + expect(caretOffset.dx, 0.0); + expect(caretOffset.dy, SIZE_OF_A); + caretOffset = painter.getOffsetForCaret( + ui.TextPosition(offset: offset, affinity: TextAffinity.upstream), + ui.Rect.zero, + ); + expect(caretOffset.dx, 0.0); + expect(caretOffset.dy, SIZE_OF_A); + + offset = 5; + caretOffset = painter.getOffsetForCaret( + ui.TextPosition(offset: offset), + ui.Rect.zero, + ); + expect(caretOffset.dx, 0.0); + expect(caretOffset.dy, SIZE_OF_A * 2); + caretOffset = painter.getOffsetForCaret( + ui.TextPosition(offset: offset, affinity: TextAffinity.upstream), + ui.Rect.zero, + ); + expect(caretOffset.dx, 0.0); + expect(caretOffset.dy, SIZE_OF_A * 2); + + offset = 6; + caretOffset = painter.getOffsetForCaret( + ui.TextPosition(offset: offset), + ui.Rect.zero, + ); + expect(caretOffset.dx, 0.0); + expect(caretOffset.dy, SIZE_OF_A * 3); + + caretOffset = painter.getOffsetForCaret( + ui.TextPosition(offset: offset, affinity: TextAffinity.upstream), + ui.Rect.zero, + ); + expect(caretOffset.dx, 0.0); + expect(caretOffset.dy, SIZE_OF_A * 3); + + // When given a string with multiple leading newlines, places the caret in + // the position given by offset regardless of affinity. + text = '\n\n\naaa'; + offset = 3; + painter.text = TextSpan(text: text); + painter.layout(); + caretOffset = painter.getOffsetForCaret( + ui.TextPosition(offset: offset), + ui.Rect.zero, + ); + expect(caretOffset.dx, 0.0); + expect(caretOffset.dy, SIZE_OF_A * 3); + caretOffset = painter.getOffsetForCaret( + ui.TextPosition(offset: offset, affinity: TextAffinity.upstream), + ui.Rect.zero, + ); + expect(caretOffset.dx, 0.0); + expect(caretOffset.dy, SIZE_OF_A * 3); + + offset = 2; + caretOffset = painter.getOffsetForCaret( + ui.TextPosition(offset: offset), + ui.Rect.zero, + ); + expect(caretOffset.dx, 0.0); + expect(caretOffset.dy, SIZE_OF_A * 2); + caretOffset = painter.getOffsetForCaret( + ui.TextPosition(offset: offset, affinity: TextAffinity.upstream), + ui.Rect.zero, + ); + expect(caretOffset.dx, 0.0); + expect(caretOffset.dy, SIZE_OF_A * 2); + + offset = 1; + caretOffset = painter.getOffsetForCaret( + ui.TextPosition(offset: offset), + ui.Rect.zero, + ); + expect(caretOffset.dx, 0.0); + expect(caretOffset.dy,SIZE_OF_A); + caretOffset = painter.getOffsetForCaret( + ui.TextPosition(offset: offset, affinity: TextAffinity.upstream), + ui.Rect.zero, + ); + expect(caretOffset.dx, 0.0); + expect(caretOffset.dy, SIZE_OF_A); + + offset = 0; + caretOffset = painter.getOffsetForCaret( + ui.TextPosition(offset: offset), + ui.Rect.zero, + ); + expect(caretOffset.dx, 0.0); + expect(caretOffset.dy, 0.0); + caretOffset = painter.getOffsetForCaret( + ui.TextPosition(offset: offset, affinity: TextAffinity.upstream), + ui.Rect.zero, + ); + expect(caretOffset.dx, 0.0); + expect(caretOffset.dy, 0.0); + painter.dispose(); + }); }); - test('TextPainter caret test with WidgetSpan', () { - // Regression test for https://github.com/flutter/flutter/issues/98458. - final TextPainter painter = TextPainter() - ..textDirection = TextDirection.ltr; - - painter.text = const TextSpan(children: [ - TextSpan(text: 'before'), - WidgetSpan(child: Text('widget')), - TextSpan(text: 'after'), - ]); - painter.setPlaceholderDimensions(const [ - PlaceholderDimensions(size: Size(50, 30), baselineOffset: 25, alignment: ui.PlaceholderAlignment.bottom), - ]); - painter.layout(); - final Offset caretOffset = painter.getOffsetForCaret(ui.TextPosition(offset: painter.text!.toPlainText().length), ui.Rect.zero); - expect(caretOffset.dx, painter.width); - painter.dispose(); - }, skip: isBrowser && !isCanvasKit); // https://github.com/flutter/flutter/issues/56308 - - test('TextPainter null text test', () { - final TextPainter painter = TextPainter() - ..textDirection = TextDirection.ltr; - - List children = [const TextSpan(text: 'B'), const TextSpan(text: 'C')]; - painter.text = TextSpan(children: children); - painter.layout(); - - Offset caretOffset = painter.getOffsetForCaret(const ui.TextPosition(offset: 0), ui.Rect.zero); - expect(caretOffset.dx, 0); - caretOffset = painter.getOffsetForCaret(const ui.TextPosition(offset: 1), ui.Rect.zero); - expect(caretOffset.dx, painter.width / 2); - caretOffset = painter.getOffsetForCaret(const ui.TextPosition(offset: 2), ui.Rect.zero); - expect(caretOffset.dx, painter.width); - - children = []; - painter.text = TextSpan(children: children); - painter.layout(); - - caretOffset = painter.getOffsetForCaret(const ui.TextPosition(offset: 0), ui.Rect.zero); - expect(caretOffset.dx, 0); - caretOffset = painter.getOffsetForCaret(const ui.TextPosition(offset: 1), ui.Rect.zero); - expect(caretOffset.dx, 0); - painter.dispose(); - }); - - test('TextPainter caret emoji test', () { - final TextPainter painter = TextPainter() - ..textDirection = TextDirection.ltr; - - // Format: '👩‍👩‍👦👩‍👩‍👧‍👧👏' - // One three-person family, one four-person family, one clapping hands (medium skin tone). - const String text = '👩‍👩‍👦👩‍👩‍👧‍👧👏🏽'; - checkCaretOffsetsLtr(text); - - painter.text = const TextSpan(text: text); - painter.layout(maxWidth: 10000); - - expect(text.length, 23); - - Offset caretOffset = painter.getOffsetForCaret(const ui.TextPosition(offset: 0), ui.Rect.zero); - expect(caretOffset.dx, 0); // 👩‍ - caretOffset = painter.getOffsetForCaret(const ui.TextPosition(offset: text.length), ui.Rect.zero); - expect(caretOffset.dx, painter.width); - - // Two UTF-16 codepoints per emoji, one codepoint per zwj - caretOffset = painter.getOffsetForCaret(const ui.TextPosition(offset: 1), ui.Rect.zero); - expect(caretOffset.dx, 42); // 👩‍ - caretOffset = painter.getOffsetForCaret(const ui.TextPosition(offset: 2), ui.Rect.zero); - expect(caretOffset.dx, 42); // - caretOffset = painter.getOffsetForCaret(const ui.TextPosition(offset: 3), ui.Rect.zero); - expect(caretOffset.dx, 42); // 👩‍ - caretOffset = painter.getOffsetForCaret(const ui.TextPosition(offset: 4), ui.Rect.zero); - expect(caretOffset.dx, 42); // 👩‍ - caretOffset = painter.getOffsetForCaret(const ui.TextPosition(offset: 5), ui.Rect.zero); - expect(caretOffset.dx, 42); // - caretOffset = painter.getOffsetForCaret(const ui.TextPosition(offset: 6), ui.Rect.zero); - expect(caretOffset.dx, 42); // 👦 - caretOffset = painter.getOffsetForCaret(const ui.TextPosition(offset: 7), ui.Rect.zero); - expect(caretOffset.dx, 42); // 👦 - caretOffset = painter.getOffsetForCaret(const ui.TextPosition(offset: 8), ui.Rect.zero); - expect(caretOffset.dx, 42); // 👩‍ - caretOffset = painter.getOffsetForCaret(const ui.TextPosition(offset: 9), ui.Rect.zero); - expect(caretOffset.dx, 98); // 👩‍ - caretOffset = painter.getOffsetForCaret(const ui.TextPosition(offset: 10), ui.Rect.zero); - expect(caretOffset.dx, 98); // - caretOffset = painter.getOffsetForCaret(const ui.TextPosition(offset: 11), ui.Rect.zero); - expect(caretOffset.dx, 98); // 👩‍ - caretOffset = painter.getOffsetForCaret(const ui.TextPosition(offset: 12), ui.Rect.zero); - expect(caretOffset.dx, 98); // 👩‍ - caretOffset = painter.getOffsetForCaret(const ui.TextPosition(offset: 13), ui.Rect.zero); - expect(caretOffset.dx, 98); // - caretOffset = painter.getOffsetForCaret(const ui.TextPosition(offset: 14), ui.Rect.zero); - expect(caretOffset.dx, 98); // 👧‍ - caretOffset = painter.getOffsetForCaret(const ui.TextPosition(offset: 15), ui.Rect.zero); - expect(caretOffset.dx, 98); // 👧‍ - caretOffset = painter.getOffsetForCaret(const ui.TextPosition(offset: 16), ui.Rect.zero); - expect(caretOffset.dx, 98); // - caretOffset = painter.getOffsetForCaret(const ui.TextPosition(offset: 17), ui.Rect.zero); - expect(caretOffset.dx, 98); // 👧 - caretOffset = painter.getOffsetForCaret(const ui.TextPosition(offset: 18), ui.Rect.zero); - expect(caretOffset.dx, 98); // 👧 - caretOffset = painter.getOffsetForCaret(const ui.TextPosition(offset: 19), ui.Rect.zero); - expect(caretOffset.dx, 98); // 👏 - caretOffset = painter.getOffsetForCaret(const ui.TextPosition(offset: 20), ui.Rect.zero); - expect(caretOffset.dx, 98); // 👏 - caretOffset = painter.getOffsetForCaret(const ui.TextPosition(offset: 21), ui.Rect.zero); - expect(caretOffset.dx, 98); // - caretOffset = painter.getOffsetForCaret(const ui.TextPosition(offset: 22), ui.Rect.zero); - expect(caretOffset.dx, 98); // - caretOffset = painter.getOffsetForCaret(const ui.TextPosition(offset: 23), ui.Rect.zero); - expect(caretOffset.dx, 126); // end of string - painter.dispose(); - }, skip: isBrowser && !isCanvasKit); // https://github.com/flutter/flutter/issues/56308 - - test('TextPainter caret emoji tests: single, long emoji', () { - // Regression test for https://github.com/flutter/flutter/issues/50563 - checkCaretOffsetsLtr('👩‍🚀'); - checkCaretOffsetsLtr('👩‍❤️‍💋‍👩'); - checkCaretOffsetsLtr('👨‍👩‍👦‍👦'); - checkCaretOffsetsLtr('👨🏾‍🤝‍👨🏻'); - checkCaretOffsetsLtr('👨‍👦'); - checkCaretOffsetsLtr('👩‍👦'); - checkCaretOffsetsLtr('🏌🏿‍♀️'); - checkCaretOffsetsLtr('🏊‍♀️'); - checkCaretOffsetsLtr('🏄🏻‍♂️'); - - // These actually worked even before #50563 was fixed (because - // their lengths in code units are powers of 2, namely 4 and 8). - checkCaretOffsetsLtr('🇺🇳'); - checkCaretOffsetsLtr('👩‍❤️‍👨'); - }, skip: isBrowser && !isCanvasKit); // https://github.com/flutter/flutter/issues/56308 - - test('TextPainter caret emoji test: letters, then 1 emoji of 5 code units', () { - // Regression test for https://github.com/flutter/flutter/issues/50563 - checkCaretOffsetsLtr('a👩‍🚀'); - checkCaretOffsetsLtr('ab👩‍🚀'); - checkCaretOffsetsLtr('abc👩‍🚀'); - checkCaretOffsetsLtr('abcd👩‍🚀'); - }, skip: isBrowser && !isCanvasKit); // https://github.com/flutter/flutter/issues/56308 - - test('TextPainter caret zalgo test', () { - // Regression test for https://github.com/flutter/flutter/issues/98516 - checkCaretOffsetsLtr('Z͉̳̺ͥͬ̾a̴͕̲̒̒͌̋ͪl̨͎̰̘͉̟ͤ̀̈̚͜g͕͔̤͖̟̒͝ͅo̵̡̡̼͚̐ͯ̅ͪ̆ͣ̚'); - }, skip: isBrowser && !isCanvasKit); // https://github.com/flutter/flutter/issues/56308 - - test('TextPainter caret Devanagari test', () { - // Regression test for https://github.com/flutter/flutter/issues/118403 - checkCaretOffsetsLtrFromPieces( - ['प्रा', 'प्त', ' ', 'व', 'र्ण', 'न', ' ', 'प्र', 'व्रु', 'ति']); - }, skip: isBrowser && !isCanvasKit); // https://github.com/flutter/flutter/issues/56308 - - test('TextPainter caret Devanagari test, full strength', () { - // Regression test for https://github.com/flutter/flutter/issues/118403 - checkCaretOffsetsLtr('प्राप्त वर्णन प्रव्रुति'); - }, skip: true); // https://github.com/flutter/flutter/issues/122478 - - test('TextPainter caret emoji test LTR: letters next to emoji, as separate TextBoxes', () { - // Regression test for https://github.com/flutter/flutter/issues/122477 - // The trigger for this bug was to have SkParagraph report separate - // TextBoxes for the emoji and for the characters next to it. - // In normal usage on a real device, this can happen by simply typing - // letters and then an emoji, presumably because they get different fonts. - // In these tests, our single test font covers both letters and emoji, - // so we provoke the same effect by adding styles. - expect(caretOffsetsForTextSpan( - TextDirection.ltr, - const TextSpan(children: [ - TextSpan(text: '👩‍🚀', style: TextStyle()), - TextSpan(text: ' words', style: TextStyle(fontWeight: FontWeight.bold)), - ])), - [0, 28, 28, 28, 28, 28, 42, 56, 70, 84, 98, 112]); - expect(caretOffsetsForTextSpan( - TextDirection.ltr, - const TextSpan(children: [ - TextSpan(text: 'words ', style: TextStyle(fontWeight: FontWeight.bold)), - TextSpan(text: '👩‍🚀', style: TextStyle()), - ])), - [0, 14, 28, 42, 56, 70, 84, 84, 84, 84, 84, 112]); - }, skip: isBrowser && !isCanvasKit); // https://github.com/flutter/flutter/issues/56308 - - test('TextPainter caret emoji test RTL: letters next to emoji, as separate TextBoxes', () { - // Regression test for https://github.com/flutter/flutter/issues/122477 - expect(caretOffsetsForTextSpan( - TextDirection.rtl, - const TextSpan(children: [ - TextSpan(text: '👩‍🚀', style: TextStyle()), - TextSpan(text: ' מילים', style: TextStyle(fontWeight: FontWeight.bold)), - ])), - [112, 84, 84, 84, 84, 84, 70, 56, 42, 28, 14, 0]); - expect(caretOffsetsForTextSpan( - TextDirection.rtl, - const TextSpan(children: [ - TextSpan(text: 'מילים ', style: TextStyle(fontWeight: FontWeight.bold)), - TextSpan(text: '👩‍🚀', style: TextStyle()), - ])), - [112, 98, 84, 70, 56, 42, 28, 28, 28, 28, 28, 0]); - }, skip: isBrowser && !isCanvasKit); // https://github.com/flutter/flutter/issues/56308 - - test('TextPainter caret center space test', () { - final TextPainter painter = TextPainter() - ..textDirection = TextDirection.ltr; - - const String text = 'test text with space at end '; - painter.text = const TextSpan(text: text); - painter.textAlign = TextAlign.center; - painter.layout(); - - Offset caretOffset = painter.getOffsetForCaret(const ui.TextPosition(offset: 0), ui.Rect.zero); - expect(caretOffset.dx, 21); - caretOffset = painter.getOffsetForCaret(const ui.TextPosition(offset: text.length), ui.Rect.zero); - // The end of the line is 441, but the width is only 420, so the cursor is - // stopped there without overflowing. - expect(caretOffset.dx, painter.width); - - caretOffset = painter.getOffsetForCaret(const ui.TextPosition(offset: 1), ui.Rect.zero); - expect(caretOffset.dx, 35); - caretOffset = painter.getOffsetForCaret(const ui.TextPosition(offset: 2), ui.Rect.zero); - expect(caretOffset.dx, 49); - painter.dispose(); - }, skip: isBrowser && !isCanvasKit); // https://github.com/flutter/flutter/issues/56308 - test('TextPainter error test', () { final TextPainter painter = TextPainter(textDirection: TextDirection.ltr); @@ -541,348 +963,6 @@ void main() { painter.dispose(); }, skip: true); // https://github.com/flutter/flutter/issues/13512 - test('TextPainter handles newlines properly', () { - final TextPainter painter = TextPainter() - ..textDirection = TextDirection.ltr; - - const double SIZE_OF_A = 14.0; // square size of "a" character - String text = 'aaa'; - painter.text = TextSpan(text: text); - painter.layout(); - - // getOffsetForCaret in a plain one-line string is the same for either affinity. - int offset = 0; - painter.text = TextSpan(text: text); - painter.layout(); - Offset caretOffset = painter.getOffsetForCaret( - ui.TextPosition(offset: offset), - ui.Rect.zero, - ); - expect(caretOffset.dx, moreOrLessEquals(SIZE_OF_A * offset, epsilon: 0.0001)); - expect(caretOffset.dy, moreOrLessEquals(0.0, epsilon: 0.0001)); - caretOffset = painter.getOffsetForCaret( - ui.TextPosition(offset: offset, affinity: ui.TextAffinity.upstream), - ui.Rect.zero, - ); - expect(caretOffset.dx, moreOrLessEquals(SIZE_OF_A * offset, epsilon: 0.0001)); - expect(caretOffset.dy, moreOrLessEquals(0.0, epsilon: 0.0001)); - offset = 1; - caretOffset = painter.getOffsetForCaret( - ui.TextPosition(offset: offset), - ui.Rect.zero, - ); - expect(caretOffset.dx, moreOrLessEquals(SIZE_OF_A * offset, epsilon: 0.0001)); - expect(caretOffset.dy, moreOrLessEquals(0.0, epsilon: 0.0001)); - caretOffset = painter.getOffsetForCaret( - ui.TextPosition(offset: offset, affinity: ui.TextAffinity.upstream), - ui.Rect.zero, - ); - expect(caretOffset.dx, moreOrLessEquals(SIZE_OF_A * offset, epsilon: 0.0001)); - expect(caretOffset.dy, moreOrLessEquals(0.0, epsilon: 0.0001)); - offset = 2; - caretOffset = painter.getOffsetForCaret( - ui.TextPosition(offset: offset), - ui.Rect.zero, - ); - expect(caretOffset.dx, moreOrLessEquals(SIZE_OF_A * offset, epsilon: 0.0001)); - expect(caretOffset.dy, moreOrLessEquals(0.0, epsilon: 0.0001)); - caretOffset = painter.getOffsetForCaret( - ui.TextPosition(offset: offset, affinity: ui.TextAffinity.upstream), - ui.Rect.zero, - ); - expect(caretOffset.dx, moreOrLessEquals(SIZE_OF_A * offset, epsilon: 0.0001)); - expect(caretOffset.dy, moreOrLessEquals(0.0, epsilon: 0.0001)); - offset = 3; - caretOffset = painter.getOffsetForCaret( - ui.TextPosition(offset: offset), - ui.Rect.zero, - ); - expect(caretOffset.dx, moreOrLessEquals(SIZE_OF_A * offset, epsilon: 0.0001)); - expect(caretOffset.dy, moreOrLessEquals(0.0, epsilon: 0.0001)); - caretOffset = painter.getOffsetForCaret( - ui.TextPosition(offset: offset, affinity: ui.TextAffinity.upstream), - ui.Rect.zero, - ); - expect(caretOffset.dx, moreOrLessEquals(SIZE_OF_A * offset, epsilon: 0.0001)); - expect(caretOffset.dy, moreOrLessEquals(0.0, epsilon: 0.0001)); - - // For explicit newlines, getOffsetForCaret places the caret at the location - // indicated by offset regardless of affinity. - text = '\n\n'; - painter.text = TextSpan(text: text); - painter.layout(); - offset = 0; - caretOffset = painter.getOffsetForCaret( - ui.TextPosition(offset: offset), - ui.Rect.zero, - ); - expect(caretOffset.dx, moreOrLessEquals(0.0, epsilon: 0.0001)); - expect(caretOffset.dy, moreOrLessEquals(0.0, epsilon: 0.0001)); - caretOffset = painter.getOffsetForCaret( - ui.TextPosition(offset: offset, affinity: ui.TextAffinity.upstream), - ui.Rect.zero, - ); - expect(caretOffset.dx, moreOrLessEquals(0.0, epsilon: 0.0001)); - expect(caretOffset.dy, moreOrLessEquals(0.0, epsilon: 0.0001)); - offset = 1; - caretOffset = painter.getOffsetForCaret( - ui.TextPosition(offset: offset), - ui.Rect.zero, - ); - expect(caretOffset.dx, moreOrLessEquals(0.0, epsilon: 0.0001)); - expect(caretOffset.dy, moreOrLessEquals(SIZE_OF_A, epsilon: 0.0001)); - caretOffset = painter.getOffsetForCaret( - ui.TextPosition(offset: offset, affinity: ui.TextAffinity.upstream), - ui.Rect.zero, - ); - expect(caretOffset.dx, moreOrLessEquals(0.0, epsilon: 0.0001)); - expect(caretOffset.dy, moreOrLessEquals(SIZE_OF_A, epsilon: 0.0001)); - offset = 2; - caretOffset = painter.getOffsetForCaret( - ui.TextPosition(offset: offset), - ui.Rect.zero, - ); - expect(caretOffset.dx, moreOrLessEquals(0.0, epsilon: 0.0001)); - expect(caretOffset.dy, moreOrLessEquals(SIZE_OF_A * 2, epsilon: 0.0001)); - caretOffset = painter.getOffsetForCaret( - ui.TextPosition(offset: offset, affinity: ui.TextAffinity.upstream), - ui.Rect.zero, - ); - expect(caretOffset.dx, moreOrLessEquals(0.0, epsilon: 0.0001)); - expect(caretOffset.dy, moreOrLessEquals(SIZE_OF_A * 2, epsilon: 0.0001)); - - // getOffsetForCaret in an unwrapped string with explicit newlines is the - // same for either affinity. - text = '\naaa'; - painter.text = TextSpan(text: text); - painter.layout(); - offset = 0; - caretOffset = painter.getOffsetForCaret( - ui.TextPosition(offset: offset), - ui.Rect.zero, - ); - expect(caretOffset.dx, moreOrLessEquals(0.0, epsilon: 0.0001)); - expect(caretOffset.dy, moreOrLessEquals(0.0, epsilon: 0.0001)); - caretOffset = painter.getOffsetForCaret( - ui.TextPosition(offset: offset, affinity: ui.TextAffinity.upstream), - ui.Rect.zero, - ); - expect(caretOffset.dx, moreOrLessEquals(0.0, epsilon: 0.0001)); - expect(caretOffset.dy, moreOrLessEquals(0.0, epsilon: 0.0001)); - offset = 1; - caretOffset = painter.getOffsetForCaret( - ui.TextPosition(offset: offset), - ui.Rect.zero, - ); - expect(caretOffset.dx, moreOrLessEquals(0.0, epsilon: 0.0001)); - expect(caretOffset.dy, moreOrLessEquals(SIZE_OF_A, epsilon: 0.0001)); - caretOffset = painter.getOffsetForCaret( - ui.TextPosition(offset: offset, affinity: ui.TextAffinity.upstream), - ui.Rect.zero, - ); - expect(caretOffset.dx, moreOrLessEquals(0.0, epsilon: 0.0001)); - expect(caretOffset.dy, moreOrLessEquals(SIZE_OF_A, epsilon: 0.0001)); - - // When text wraps on its own, getOffsetForCaret disambiguates between the - // end of one line and start of next using affinity. - text = 'aaaaaaaa'; // Just enough to wrap one character down to second line - painter.text = TextSpan(text: text); - painter.layout(maxWidth: 100); // SIZE_OF_A * text.length > 100, so it wraps - caretOffset = painter.getOffsetForCaret( - ui.TextPosition(offset: text.length - 1), - ui.Rect.zero, - ); - // When affinity is downstream, cursor is at beginning of second line - expect(caretOffset.dx, moreOrLessEquals(0.0, epsilon: 0.0001)); - expect(caretOffset.dy, moreOrLessEquals(SIZE_OF_A, epsilon: 0.0001)); - caretOffset = painter.getOffsetForCaret( - ui.TextPosition(offset: text.length - 1, affinity: ui.TextAffinity.upstream), - ui.Rect.zero, - ); - // When affinity is upstream, cursor is at end of first line - expect(caretOffset.dx, moreOrLessEquals(98.0, epsilon: 0.0001)); - expect(caretOffset.dy, moreOrLessEquals(0.0, epsilon: 0.0001)); - - // When given a string with a newline at the end, getOffsetForCaret puts - // the cursor at the start of the next line regardless of affinity - text = 'aaa\n'; - painter.text = TextSpan(text: text); - painter.layout(); - caretOffset = painter.getOffsetForCaret( - ui.TextPosition(offset: text.length), - ui.Rect.zero, - ); - expect(caretOffset.dx, moreOrLessEquals(0.0, epsilon: 0.0001)); - expect(caretOffset.dy, moreOrLessEquals(SIZE_OF_A, epsilon: 0.0001)); - offset = text.length; - caretOffset = painter.getOffsetForCaret( - ui.TextPosition(offset: offset, affinity: TextAffinity.upstream), - ui.Rect.zero, - ); - expect(caretOffset.dx, moreOrLessEquals(0.0, epsilon: 0.0001)); - expect(caretOffset.dy, moreOrLessEquals(SIZE_OF_A, epsilon: 0.0001)); - - // Given a one-line right aligned string, positioning the cursor at offset 0 - // means that it appears at the "end" of the string, after the character - // that was typed first, at x=0. - painter.textAlign = TextAlign.right; - text = 'aaa'; - painter.text = TextSpan(text: text); - painter.layout(); - offset = 0; - caretOffset = painter.getOffsetForCaret( - ui.TextPosition(offset: offset), - ui.Rect.zero, - ); - expect(caretOffset.dx, moreOrLessEquals(0.0, epsilon: 0.0001)); - expect(caretOffset.dy, moreOrLessEquals(0.0, epsilon: 0.0001)); - painter.textAlign = TextAlign.left; - - // When given an offset after a newline in the middle of a string, - // getOffsetForCaret returns the start of the next line regardless of - // affinity. - text = 'aaa\naaa'; - painter.text = TextSpan(text: text); - painter.layout(); - offset = 4; - caretOffset = painter.getOffsetForCaret( - ui.TextPosition(offset: offset), - ui.Rect.zero, - ); - expect(caretOffset.dx, moreOrLessEquals(0.0, epsilon: 0.0001)); - expect(caretOffset.dy, moreOrLessEquals(SIZE_OF_A, epsilon: 0.0001)); - caretOffset = painter.getOffsetForCaret( - ui.TextPosition(offset: offset, affinity: TextAffinity.upstream), - ui.Rect.zero, - ); - expect(caretOffset.dx, moreOrLessEquals(0.0, epsilon: 0.0001)); - expect(caretOffset.dy, moreOrLessEquals(SIZE_OF_A, epsilon: 0.0001)); - - // When given a string with multiple trailing newlines, places the caret - // in the position given by offset regardless of affinity. - text = 'aaa\n\n\n'; - offset = 3; - caretOffset = painter.getOffsetForCaret( - ui.TextPosition(offset: offset), - ui.Rect.zero, - ); - expect(caretOffset.dx, moreOrLessEquals(SIZE_OF_A * 3, epsilon: 0.0001)); - expect(caretOffset.dy, moreOrLessEquals(0.0, epsilon: 0.0001)); - caretOffset = painter.getOffsetForCaret( - ui.TextPosition(offset: offset, affinity: TextAffinity.upstream), - ui.Rect.zero, - ); - expect(caretOffset.dx, moreOrLessEquals(SIZE_OF_A * 3, epsilon: 0.0001)); - expect(caretOffset.dy, moreOrLessEquals(0.0, epsilon: 0.0001)); - - offset = 4; - painter.text = TextSpan(text: text); - painter.layout(); - caretOffset = painter.getOffsetForCaret( - ui.TextPosition(offset: offset), - ui.Rect.zero, - ); - expect(caretOffset.dx, moreOrLessEquals(0.0, epsilon: 0.0001)); - expect(caretOffset.dy, moreOrLessEquals(SIZE_OF_A, epsilon: 0.001)); - caretOffset = painter.getOffsetForCaret( - ui.TextPosition(offset: offset, affinity: TextAffinity.upstream), - ui.Rect.zero, - ); - expect(caretOffset.dx, moreOrLessEquals(0.0, epsilon: 0.0001)); - expect(caretOffset.dy, moreOrLessEquals(SIZE_OF_A, epsilon: 0.0001)); - - offset = 5; - caretOffset = painter.getOffsetForCaret( - ui.TextPosition(offset: offset), - ui.Rect.zero, - ); - expect(caretOffset.dx, moreOrLessEquals(0.0, epsilon: 0.0001)); - expect(caretOffset.dy, moreOrLessEquals(SIZE_OF_A * 2, epsilon: 0.001)); - caretOffset = painter.getOffsetForCaret( - ui.TextPosition(offset: offset, affinity: TextAffinity.upstream), - ui.Rect.zero, - ); - expect(caretOffset.dx, moreOrLessEquals(0.0, epsilon: 0.0001)); - expect(caretOffset.dy, moreOrLessEquals(SIZE_OF_A * 2, epsilon: 0.0001)); - - offset = 6; - caretOffset = painter.getOffsetForCaret( - ui.TextPosition(offset: offset), - ui.Rect.zero, - ); - expect(caretOffset.dx, moreOrLessEquals(0.0, epsilon: 0.0001)); - expect(caretOffset.dy, moreOrLessEquals(SIZE_OF_A * 3, epsilon: 0.0001)); - - caretOffset = painter.getOffsetForCaret( - ui.TextPosition(offset: offset, affinity: TextAffinity.upstream), - ui.Rect.zero, - ); - expect(caretOffset.dx, moreOrLessEquals(0.0, epsilon: 0.0001)); - expect(caretOffset.dy, moreOrLessEquals(SIZE_OF_A * 3, epsilon: 0.0001)); - - // When given a string with multiple leading newlines, places the caret in - // the position given by offset regardless of affinity. - text = '\n\n\naaa'; - offset = 3; - painter.text = TextSpan(text: text); - painter.layout(); - caretOffset = painter.getOffsetForCaret( - ui.TextPosition(offset: offset), - ui.Rect.zero, - ); - expect(caretOffset.dx, moreOrLessEquals(0.0, epsilon: 0.0001)); - expect(caretOffset.dy, moreOrLessEquals(SIZE_OF_A * 3, epsilon: 0.0001)); - caretOffset = painter.getOffsetForCaret( - ui.TextPosition(offset: offset, affinity: TextAffinity.upstream), - ui.Rect.zero, - ); - expect(caretOffset.dx, moreOrLessEquals(0.0, epsilon: 0.0001)); - expect(caretOffset.dy, moreOrLessEquals(SIZE_OF_A * 3, epsilon: 0.0001)); - - offset = 2; - caretOffset = painter.getOffsetForCaret( - ui.TextPosition(offset: offset), - ui.Rect.zero, - ); - expect(caretOffset.dx, moreOrLessEquals(0.0, epsilon: 0.0001)); - expect(caretOffset.dy, moreOrLessEquals(SIZE_OF_A * 2, epsilon: 0.0001)); - caretOffset = painter.getOffsetForCaret( - ui.TextPosition(offset: offset, affinity: TextAffinity.upstream), - ui.Rect.zero, - ); - expect(caretOffset.dx, moreOrLessEquals(0.0, epsilon: 0.0001)); - expect(caretOffset.dy, moreOrLessEquals(SIZE_OF_A * 2, epsilon: 0.0001)); - - offset = 1; - caretOffset = painter.getOffsetForCaret( - ui.TextPosition(offset: offset), - ui.Rect.zero, - ); - expect(caretOffset.dx, moreOrLessEquals(0.0, epsilon: 0.0001)); - expect(caretOffset.dy,moreOrLessEquals(SIZE_OF_A, epsilon: 0.0001)); - caretOffset = painter.getOffsetForCaret( - ui.TextPosition(offset: offset, affinity: TextAffinity.upstream), - ui.Rect.zero, - ); - expect(caretOffset.dx, moreOrLessEquals(0.0, epsilon: 0.0001)); - expect(caretOffset.dy, moreOrLessEquals(SIZE_OF_A, epsilon: 0.0001)); - - offset = 0; - caretOffset = painter.getOffsetForCaret( - ui.TextPosition(offset: offset), - ui.Rect.zero, - ); - expect(caretOffset.dx, moreOrLessEquals(0.0, epsilon: 0.0001)); - expect(caretOffset.dy, moreOrLessEquals(0.0, epsilon: 0.0001)); - caretOffset = painter.getOffsetForCaret( - ui.TextPosition(offset: offset, affinity: TextAffinity.upstream), - ui.Rect.zero, - ); - expect(caretOffset.dx, moreOrLessEquals(0.0, epsilon: 0.0001)); - expect(caretOffset.dy, moreOrLessEquals(0.0, epsilon: 0.0001)); - painter.dispose(); - }); - test('TextPainter widget span', () { final TextPainter painter = TextPainter() ..textDirection = TextDirection.ltr; @@ -1053,23 +1133,6 @@ void main() { painter.dispose(); }, skip: kIsWeb && !isCanvasKit); // https://github.com/flutter/flutter/issues/122066 - test('TextPainter caret height and line height', () { - final TextPainter painter = TextPainter() - ..textDirection = TextDirection.ltr - ..strutStyle = const StrutStyle(fontSize: 50.0); - - const String text = 'A'; - painter.text = const TextSpan(text: text, style: TextStyle(height: 1.0)); - painter.layout(); - - final double caretHeight = painter.getFullHeightForCaret( - const ui.TextPosition(offset: 0), - ui.Rect.zero, - )!; - expect(caretHeight, 50.0); - painter.dispose(); - }, skip: isBrowser && !isCanvasKit); // https://github.com/flutter/flutter/issues/56308 - group('TextPainter line-height', () { test('half-leading', () { const TextStyle style = TextStyle( diff --git a/packages/flutter/test/widgets/editable_text_cursor_test.dart b/packages/flutter/test/widgets/editable_text_cursor_test.dart index b6299268083..59e3a4e62f1 100644 --- a/packages/flutter/test/widgets/editable_text_cursor_test.dart +++ b/packages/flutter/test/widgets/editable_text_cursor_test.dart @@ -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),