mirror of
https://github.com/flutter/flutter
synced 2024-07-16 10:29:14 +00:00
Relands "Changing TextPainter.getOffsetForCaret
implementation to remove the logarithmic search (#143281)" (reverted in #143801) (#143954)
The original PR was reverted because the new caret positioning callpath triggered a skparagraph assert. The assert has been removed. Relanding the PR with no changes applied.
This commit is contained in:
parent
c84565a6fa
commit
a0a854a78b
|
@ -46,8 +46,9 @@ void main() {
|
|||
const Duration durationBetweenActions = Duration(milliseconds: 20);
|
||||
const String defaultText = 'I am a magnifier, fear me!';
|
||||
|
||||
Future<void> showMagnifier(WidgetTester tester, String characterToTapOn) async {
|
||||
final Offset tapOffset = _textOffsetToPosition(tester, defaultText.indexOf(characterToTapOn));
|
||||
Future<void> showMagnifier(WidgetTester tester, int textOffset) async {
|
||||
assert(textOffset >= 0);
|
||||
final Offset tapOffset = _textOffsetToPosition(tester, textOffset);
|
||||
|
||||
// Double tap 'Magnifier' word to show the selection handles.
|
||||
final TestGesture testGesture = await tester.startGesture(tapOffset);
|
||||
|
@ -59,11 +60,11 @@ void main() {
|
|||
await testGesture.up();
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final TextSelection selection = tester
|
||||
.firstWidget<TextField>(find.byType(TextField))
|
||||
.controller!
|
||||
.selection;
|
||||
final TextEditingController controller = tester
|
||||
.firstWidget<TextField>(find.byType(TextField))
|
||||
.controller!;
|
||||
|
||||
final TextSelection selection = controller.selection;
|
||||
final RenderEditable renderEditable = _findRenderEditable(tester);
|
||||
final List<TextSelectionPoint> endpoints = _globalize(
|
||||
renderEditable.getEndpointsForSelection(selection),
|
||||
|
@ -86,7 +87,7 @@ void main() {
|
|||
testWidgets('should show custom magnifier on drag', (WidgetTester tester) async {
|
||||
await tester.pumpWidget(const example.TextMagnifierExampleApp(text: defaultText));
|
||||
|
||||
await showMagnifier(tester, 'e');
|
||||
await showMagnifier(tester, defaultText.indexOf('e'));
|
||||
expect(find.byType(example.CustomMagnifier), findsOneWidget);
|
||||
|
||||
await expectLater(
|
||||
|
@ -96,16 +97,15 @@ void main() {
|
|||
}, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS, TargetPlatform.android }));
|
||||
|
||||
|
||||
for (final TextDirection textDirection in TextDirection.values) {
|
||||
testWidgets('should show custom magnifier in $textDirection', (WidgetTester tester) async {
|
||||
final String text = textDirection == TextDirection.rtl ? 'أثارت زر' : defaultText;
|
||||
final String textToTapOn = textDirection == TextDirection.rtl ? 'ت' : 'e';
|
||||
testWidgets('should show custom magnifier in RTL', (WidgetTester tester) async {
|
||||
const String text = 'أثارت زر';
|
||||
const String textToTapOn = 'ت';
|
||||
|
||||
await tester.pumpWidget(example.TextMagnifierExampleApp(textDirection: textDirection, text: text));
|
||||
await tester.pumpWidget(const example.TextMagnifierExampleApp(textDirection: TextDirection.rtl, text: text));
|
||||
|
||||
await showMagnifier(tester, textToTapOn);
|
||||
await showMagnifier(tester, text.indexOf(textToTapOn));
|
||||
|
||||
expect(find.byType(example.CustomMagnifier), findsOneWidget);
|
||||
});
|
||||
|
||||
expect(find.byType(example.CustomMagnifier), findsOneWidget);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
// Use of this source code is governed by a BSD-style license that can be
|
||||
// found in the LICENSE file.
|
||||
|
||||
import 'dart:math' show max, min;
|
||||
import 'dart:math' show max;
|
||||
import 'dart:ui' as ui show
|
||||
BoxHeightStyle,
|
||||
BoxWidthStyle,
|
||||
|
@ -204,8 +204,14 @@ class WordBoundary extends TextBoundary {
|
|||
}
|
||||
|
||||
static bool _isNewline(int codePoint) {
|
||||
// Carriage Return is not treated as a hard line break.
|
||||
return switch (codePoint) {
|
||||
0x000A || 0x0085 || 0x000B || 0x000C || 0x2028 || 0x2029 => true,
|
||||
0x000A || // Line Feed
|
||||
0x0085 || // New Line
|
||||
0x000B || // Form Feed
|
||||
0x000C || // Vertical Feed
|
||||
0x2028 || // Line Separator
|
||||
0x2029 => true, // Paragraph Separator
|
||||
_ => false,
|
||||
};
|
||||
}
|
||||
|
@ -270,7 +276,10 @@ class _UntilTextBoundary extends TextBoundary {
|
|||
}
|
||||
|
||||
class _TextLayout {
|
||||
_TextLayout._(this._paragraph);
|
||||
_TextLayout._(this._paragraph, this.writingDirection, this.rawString);
|
||||
|
||||
final TextDirection writingDirection;
|
||||
final String rawString;
|
||||
|
||||
// This field is not final because the owner TextPainter could create a new
|
||||
// ui.Paragraph with the exact same text layout (for example, when only the
|
||||
|
@ -316,6 +325,57 @@ class _TextLayout {
|
|||
};
|
||||
}
|
||||
|
||||
/// The line caret metrics representing the end of text location.
|
||||
///
|
||||
/// This is usually used when the caret is placed at the end of the text
|
||||
/// (text.length, downstream), unless maxLines is set to a non-null value, in
|
||||
/// which case the caret is placed at the visual end of the last visible line.
|
||||
///
|
||||
/// This should not be called when the paragraph is emtpy as the implementation
|
||||
/// relies on line metrics.
|
||||
///
|
||||
/// When the last bidi level run in the paragraph and the parargraph's bidi
|
||||
/// levels have opposite parities (which implies opposite writing directions),
|
||||
/// this makes sure the caret is placed at the same "end" of the line as if the
|
||||
/// line ended with a line feed.
|
||||
late final _LineCaretMetrics _endOfTextCaretMetrics = _computeEndOfTextCaretAnchorOffset();
|
||||
_LineCaretMetrics _computeEndOfTextCaretAnchorOffset() {
|
||||
final int lastLineIndex = _paragraph.numberOfLines - 1;
|
||||
assert(lastLineIndex >= 0);
|
||||
final ui.LineMetrics lineMetrics = _paragraph.getLineMetricsAt(lastLineIndex)!;
|
||||
// SkParagraph currently treats " " and "\t" as white spaces. Trailing white
|
||||
// spaces don't contribute to the line width and thus require special handling
|
||||
// when they're present.
|
||||
// Luckily they have the same bidi embedding level as the paragraph as per
|
||||
// https://unicode.org/reports/tr9/#L1, so we can anchor the caret to the
|
||||
// last logical trailing space.
|
||||
final bool hasTrailingSpaces = switch (rawString.codeUnitAt(rawString.length - 1)) {
|
||||
0x9 || // horizontal tab
|
||||
0x20 => true, // space
|
||||
_ => false,
|
||||
};
|
||||
|
||||
final double baseline = lineMetrics.baseline;
|
||||
final double dx;
|
||||
late final ui.GlyphInfo? lastGlyph = _paragraph.getGlyphInfoAt(rawString.length - 1);
|
||||
// TODO(LongCatIsLooong): handle the case where maxLine is set to non-null
|
||||
// and the last line ends with trailing whitespaces.
|
||||
if (hasTrailingSpaces && lastGlyph != null) {
|
||||
final Rect glyphBounds = lastGlyph.graphemeClusterLayoutBounds;
|
||||
assert(!glyphBounds.isEmpty);
|
||||
dx = switch (writingDirection) {
|
||||
TextDirection.ltr => glyphBounds.right,
|
||||
TextDirection.rtl => glyphBounds.left,
|
||||
};
|
||||
} else {
|
||||
dx = switch (writingDirection) {
|
||||
TextDirection.ltr => lineMetrics.left + lineMetrics.width,
|
||||
TextDirection.rtl => lineMetrics.left,
|
||||
};
|
||||
}
|
||||
return _LineCaretMetrics(offset: Offset(dx, baseline), writingDirection: writingDirection);
|
||||
}
|
||||
|
||||
double _contentWidthFor(double minWidth, double maxWidth, TextWidthBasis widthBasis) {
|
||||
return switch (widthBasis) {
|
||||
TextWidthBasis.longestLine => clampDouble(longestLine, minWidth, maxWidth),
|
||||
|
@ -420,39 +480,29 @@ class _TextPainterLayoutCacheWithOffset {
|
|||
List<ui.LineMetrics> get lineMetrics => _cachedLineMetrics ??= paragraph.computeLineMetrics();
|
||||
List<ui.LineMetrics>? _cachedLineMetrics;
|
||||
|
||||
// Holds the TextPosition the last caret metrics were computed with. When new
|
||||
// values are passed in, we recompute the caret metrics only as necessary.
|
||||
TextPosition? _previousCaretPosition;
|
||||
// Used to determine whether the caret metrics cache should be invalidated.
|
||||
int? _previousCaretPositionKey;
|
||||
}
|
||||
|
||||
/// This is used to cache and pass the computed metrics regarding the
|
||||
/// caret's size and position. This is preferred due to the expensive
|
||||
/// nature of the calculation.
|
||||
///
|
||||
// A _CaretMetrics is either a _LineCaretMetrics or an _EmptyLineCaretMetrics.
|
||||
@immutable
|
||||
sealed class _CaretMetrics { }
|
||||
|
||||
/// The _CaretMetrics for carets located in a non-empty line. Carets located in a
|
||||
/// non-empty line are associated with a glyph within the same line.
|
||||
final class _LineCaretMetrics implements _CaretMetrics {
|
||||
const _LineCaretMetrics({required this.offset, required this.writingDirection, required this.fullHeight});
|
||||
/// The offset of the top left corner of the caret from the top left
|
||||
/// corner of the paragraph.
|
||||
/// The _CaretMetrics for carets located in a non-empty paragraph. Such carets
|
||||
/// are anchored to the trailing edge or the leading edge of a glyph, or a
|
||||
/// ligature component.
|
||||
final class _LineCaretMetrics {
|
||||
const _LineCaretMetrics({required this.offset, required this.writingDirection});
|
||||
/// The offset from the top left corner of the paragraph to the caret's top
|
||||
/// start location.
|
||||
final Offset offset;
|
||||
/// The writing direction of the glyph the _CaretMetrics is associated with.
|
||||
|
||||
/// The writing direction of the glyph the _LineCaretMetrics is associated with.
|
||||
/// The value determines whether the cursor is painted to the left or to the
|
||||
/// right of [offset].
|
||||
final TextDirection writingDirection;
|
||||
/// The full height of the glyph at the caret position.
|
||||
final double fullHeight;
|
||||
}
|
||||
|
||||
/// The _CaretMetrics for carets located in an empty line (when the text is
|
||||
/// empty, or the caret is between two a newline characters).
|
||||
final class _EmptyLineCaretMetrics implements _CaretMetrics {
|
||||
const _EmptyLineCaretMetrics({ required this.lineVerticalOffset });
|
||||
|
||||
/// The y offset of the unoccupied line.
|
||||
final double lineVerticalOffset;
|
||||
_LineCaretMetrics shift(Offset offset) {
|
||||
return offset == Offset.zero
|
||||
? this
|
||||
: _LineCaretMetrics(offset: offset + this.offset, writingDirection: writingDirection);
|
||||
}
|
||||
}
|
||||
|
||||
const String _flutterPaintingLibrary = 'package:flutter/painting.dart';
|
||||
|
@ -971,10 +1021,8 @@ class TextPainter {
|
|||
}
|
||||
List<PlaceholderDimensions>? _placeholderDimensions;
|
||||
|
||||
ui.ParagraphStyle _createParagraphStyle([ TextDirection? defaultTextDirection ]) {
|
||||
// The defaultTextDirection argument is used for preferredLineHeight in case
|
||||
// textDirection hasn't yet been set.
|
||||
assert(textDirection != null || defaultTextDirection != null, 'TextPainter.textDirection must be set to a non-null value before using the TextPainter.');
|
||||
ui.ParagraphStyle _createParagraphStyle([ TextAlign? textAlignOverride ]) {
|
||||
assert(textDirection != null, 'TextPainter.textDirection must be set to a non-null value before using the TextPainter.');
|
||||
final TextStyle baseStyle = _text?.style ?? const TextStyle();
|
||||
final StrutStyle? strutStyle = _strutStyle;
|
||||
|
||||
|
@ -996,8 +1044,8 @@ class TextPainter {
|
|||
);
|
||||
|
||||
return baseStyle.getParagraphStyle(
|
||||
textAlign: textAlign,
|
||||
textDirection: textDirection ?? defaultTextDirection,
|
||||
textAlign: textAlignOverride ?? textAlign,
|
||||
textDirection: textDirection,
|
||||
textScaler: textScaler,
|
||||
maxLines: _maxLines,
|
||||
textHeightBehavior: _textHeightBehavior,
|
||||
|
@ -1010,7 +1058,7 @@ class TextPainter {
|
|||
ui.Paragraph? _layoutTemplate;
|
||||
ui.Paragraph _createLayoutTemplate() {
|
||||
final ui.ParagraphBuilder builder = ui.ParagraphBuilder(
|
||||
_createParagraphStyle(TextDirection.rtl),
|
||||
_createParagraphStyle(TextAlign.left),
|
||||
); // direction doesn't matter, text is just a space
|
||||
final ui.TextStyle? textStyle = text?.style?.getTextStyle(textScaler: textScaler);
|
||||
if (textStyle != null) {
|
||||
|
@ -1021,6 +1069,7 @@ class TextPainter {
|
|||
..layout(const ui.ParagraphConstraints(width: double.infinity));
|
||||
}
|
||||
|
||||
ui.Paragraph _getOrCreateLayoutTemplate() => _layoutTemplate ??= _createLayoutTemplate();
|
||||
/// The height of a space in [text] in logical pixels.
|
||||
///
|
||||
/// Not every line of text in [text] will have this height, but this height
|
||||
|
@ -1033,7 +1082,7 @@ class TextPainter {
|
|||
/// that contribute to the [preferredLineHeight]. If [text] is null or if it
|
||||
/// specifies no styles, the default [TextStyle] values are used (a 10 pixel
|
||||
/// sans-serif font).
|
||||
double get preferredLineHeight => (_layoutTemplate ??= _createLayoutTemplate()).height;
|
||||
double get preferredLineHeight => _getOrCreateLayoutTemplate().height;
|
||||
|
||||
/// The width at which decreasing the width of the text would prevent it from
|
||||
/// painting itself completely within its bounds.
|
||||
|
@ -1164,7 +1213,7 @@ class TextPainter {
|
|||
// called.
|
||||
final ui.Paragraph paragraph = (cachedLayout?.paragraph ?? _createParagraph(text))
|
||||
..layout(ui.ParagraphConstraints(width: layoutMaxWidth));
|
||||
final _TextLayout layout = _TextLayout._(paragraph);
|
||||
final _TextLayout layout = _TextLayout._(paragraph, textDirection, plainText);
|
||||
final double contentWidth = layout._contentWidthFor(minWidth, maxWidth, textWidthBasis);
|
||||
|
||||
final _TextPainterLayoutCacheWithOffset newLayoutCache;
|
||||
|
@ -1259,14 +1308,6 @@ class TextPainter {
|
|||
return value & 0xFC00 == 0xDC00;
|
||||
}
|
||||
|
||||
// Checks if the glyph is either [Unicode.RLM] or [Unicode.LRM]. These values take
|
||||
// up zero space and do not have valid bounding boxes around them.
|
||||
//
|
||||
// We do not directly use the [Unicode] constants since they are strings.
|
||||
static bool _isUnicodeDirectionality(int value) {
|
||||
return value == 0x200F || value == 0x200E;
|
||||
}
|
||||
|
||||
/// Returns the closest offset after `offset` at which the input cursor can be
|
||||
/// positioned.
|
||||
int? getOffsetAfter(int offset) {
|
||||
|
@ -1289,118 +1330,15 @@ class TextPainter {
|
|||
return isLowSurrogate(prevCodeUnit) ? offset - 2 : offset - 1;
|
||||
}
|
||||
|
||||
// Unicode value for a zero width joiner character.
|
||||
static const int _zwjUtf16 = 0x200d;
|
||||
|
||||
// Get the caret metrics (in logical pixels) based off the near edge of the
|
||||
// Get the caret metrics (in logical pixels) based off the trailing edge of the
|
||||
// character upstream from the given string offset.
|
||||
_CaretMetrics? _getMetricsFromUpstream(int offset) {
|
||||
assert(offset >= 0);
|
||||
final int plainTextLength = plainText.length;
|
||||
if (plainTextLength == 0 || offset > plainTextLength) {
|
||||
return null;
|
||||
}
|
||||
final int prevCodeUnit = plainText.codeUnitAt(max(0, offset - 1));
|
||||
|
||||
// If the upstream character is a newline, cursor is at start of next line
|
||||
const int NEWLINE_CODE_UNIT = 10;
|
||||
|
||||
// Check for multi-code-unit glyphs such as emojis or zero width joiner.
|
||||
final bool needsSearch = isHighSurrogate(prevCodeUnit) || isLowSurrogate(prevCodeUnit) || _text!.codeUnitAt(offset) == _zwjUtf16 || _isUnicodeDirectionality(prevCodeUnit);
|
||||
int graphemeClusterLength = needsSearch ? 2 : 1;
|
||||
List<TextBox> boxes = <TextBox>[];
|
||||
while (boxes.isEmpty) {
|
||||
final int prevRuneOffset = offset - graphemeClusterLength;
|
||||
// Use BoxHeightStyle.strut to ensure that the caret's height fits within
|
||||
// the line's height and is consistent throughout the line.
|
||||
boxes = _layoutCache!.paragraph.getBoxesForRange(max(0, prevRuneOffset), offset, boxHeightStyle: ui.BoxHeightStyle.strut);
|
||||
// When the range does not include a full cluster, no boxes will be returned.
|
||||
if (boxes.isEmpty) {
|
||||
// When we are at the beginning of the line, a non-surrogate position will
|
||||
// return empty boxes. We break and try from downstream instead.
|
||||
if (!needsSearch && prevCodeUnit == NEWLINE_CODE_UNIT) {
|
||||
break; // Only perform one iteration if no search is required.
|
||||
}
|
||||
if (prevRuneOffset < -plainTextLength) {
|
||||
break; // Stop iterating when beyond the max length of the text.
|
||||
}
|
||||
// Multiply by two to log(n) time cover the entire text span. This allows
|
||||
// faster discovery of very long clusters and reduces the possibility
|
||||
// of certain large clusters taking much longer than others, which can
|
||||
// cause jank.
|
||||
graphemeClusterLength *= 2;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Try to identify the box nearest the offset. This logic works when
|
||||
// there's just one box, and when all boxes have the same direction.
|
||||
// It may not work in bidi text: https://github.com/flutter/flutter/issues/123424
|
||||
final TextBox box = boxes.last.direction == TextDirection.ltr
|
||||
? boxes.last : boxes.first;
|
||||
return prevCodeUnit == NEWLINE_CODE_UNIT
|
||||
? _EmptyLineCaretMetrics(lineVerticalOffset: box.bottom)
|
||||
: _LineCaretMetrics(offset: Offset(box.end, box.top), writingDirection: box.direction, fullHeight: box.bottom - box.top);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get the caret metrics (in logical pixels) based off the near edge of the
|
||||
// character downstream from the given string offset.
|
||||
_CaretMetrics? _getMetricsFromDownstream(int offset) {
|
||||
assert(offset >= 0);
|
||||
final int plainTextLength = plainText.length;
|
||||
if (plainTextLength == 0) {
|
||||
return null;
|
||||
}
|
||||
// We cap the offset at the final index of plain text.
|
||||
final int nextCodeUnit = plainText.codeUnitAt(min(offset, plainTextLength - 1));
|
||||
|
||||
// Check for multi-code-unit glyphs such as emojis or zero width joiner
|
||||
final bool needsSearch = isHighSurrogate(nextCodeUnit) || isLowSurrogate(nextCodeUnit) || nextCodeUnit == _zwjUtf16 || _isUnicodeDirectionality(nextCodeUnit);
|
||||
int graphemeClusterLength = needsSearch ? 2 : 1;
|
||||
List<TextBox> boxes = <TextBox>[];
|
||||
while (boxes.isEmpty) {
|
||||
final int nextRuneOffset = offset + graphemeClusterLength;
|
||||
// Use BoxHeightStyle.strut to ensure that the caret's height fits within
|
||||
// the line's height and is consistent throughout the line.
|
||||
boxes = _layoutCache!.paragraph.getBoxesForRange(offset, nextRuneOffset, boxHeightStyle: ui.BoxHeightStyle.strut);
|
||||
// When the range does not include a full cluster, no boxes will be returned.
|
||||
if (boxes.isEmpty) {
|
||||
// When we are at the end of the line, a non-surrogate position will
|
||||
// return empty boxes. We break and try from upstream instead.
|
||||
if (!needsSearch) {
|
||||
break; // Only perform one iteration if no search is required.
|
||||
}
|
||||
if (nextRuneOffset >= plainTextLength << 1) {
|
||||
break; // Stop iterating when beyond the max length of the text.
|
||||
}
|
||||
// Multiply by two to log(n) time cover the entire text span. This allows
|
||||
// faster discovery of very long clusters and reduces the possibility
|
||||
// of certain large clusters taking much longer than others, which can
|
||||
// cause jank.
|
||||
graphemeClusterLength *= 2;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Try to identify the box nearest the offset. This logic works when
|
||||
// there's just one box, and when all boxes have the same direction.
|
||||
// It may not work in bidi text: https://github.com/flutter/flutter/issues/123424
|
||||
final TextBox box = boxes.first.direction == TextDirection.ltr
|
||||
? boxes.first : boxes.last;
|
||||
return _LineCaretMetrics(offset: Offset(box.start, box.top), writingDirection: box.direction, fullHeight: box.bottom - box.top);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static double _computePaintOffsetFraction(TextAlign textAlign, TextDirection textDirection) {
|
||||
return switch ((textAlign, textDirection)) {
|
||||
(TextAlign.left, _) => 0.0,
|
||||
(TextAlign.right, _) => 1.0,
|
||||
(TextAlign.center, _) => 0.5,
|
||||
(TextAlign.start, TextDirection.ltr) => 0.0,
|
||||
(TextAlign.start, TextDirection.rtl) => 1.0,
|
||||
(TextAlign.justify, TextDirection.ltr) => 0.0,
|
||||
(TextAlign.justify, TextDirection.rtl) => 1.0,
|
||||
(TextAlign.start || TextAlign.justify, TextDirection.ltr) => 0.0,
|
||||
(TextAlign.start || TextAlign.justify, TextDirection.rtl) => 1.0,
|
||||
(TextAlign.end, TextDirection.ltr) => 1.0,
|
||||
(TextAlign.end, TextDirection.rtl) => 0.0,
|
||||
};
|
||||
|
@ -1410,31 +1348,24 @@ class TextPainter {
|
|||
///
|
||||
/// Valid only after [layout] has been called.
|
||||
Offset getOffsetForCaret(TextPosition position, Rect caretPrototype) {
|
||||
final _CaretMetrics caretMetrics;
|
||||
final _TextPainterLayoutCacheWithOffset layoutCache = _layoutCache!;
|
||||
if (position.offset < 0) {
|
||||
// TODO(LongCatIsLooong): make this case impossible; see https://github.com/flutter/flutter/issues/79495
|
||||
caretMetrics = const _EmptyLineCaretMetrics(lineVerticalOffset: 0);
|
||||
} else {
|
||||
caretMetrics = _computeCaretMetrics(position);
|
||||
final _LineCaretMetrics? caretMetrics = _computeCaretMetrics(position);
|
||||
|
||||
if (caretMetrics == null) {
|
||||
final double paintOffsetAlignment = _computePaintOffsetFraction(textAlign, textDirection!);
|
||||
// The full width is not (width - caretPrototype.width), because
|
||||
// RenderEditable reserves cursor width on the right. Ideally this
|
||||
// should be handled by RenderEditable instead.
|
||||
final double dx = paintOffsetAlignment == 0 ? 0 : paintOffsetAlignment * layoutCache.contentWidth;
|
||||
return Offset(dx, 0.0);
|
||||
}
|
||||
|
||||
final Offset rawOffset;
|
||||
switch (caretMetrics) {
|
||||
case _EmptyLineCaretMetrics(:final double lineVerticalOffset):
|
||||
final double paintOffsetAlignment = _computePaintOffsetFraction(textAlign, textDirection!);
|
||||
// The full width is not (width - caretPrototype.width)
|
||||
// because RenderEditable reserves cursor width on the right. Ideally this
|
||||
// should be handled by RenderEditable instead.
|
||||
final double dx = paintOffsetAlignment == 0 ? 0 : paintOffsetAlignment * layoutCache.contentWidth;
|
||||
return Offset(dx, lineVerticalOffset);
|
||||
case _LineCaretMetrics(writingDirection: TextDirection.ltr, :final Offset offset):
|
||||
rawOffset = offset;
|
||||
case _LineCaretMetrics(writingDirection: TextDirection.rtl, :final Offset offset):
|
||||
rawOffset = Offset(offset.dx - caretPrototype.width, offset.dy);
|
||||
}
|
||||
final Offset rawOffset = switch (caretMetrics) {
|
||||
_LineCaretMetrics(writingDirection: TextDirection.ltr, :final Offset offset) => offset,
|
||||
_LineCaretMetrics(writingDirection: TextDirection.rtl, :final Offset offset) => Offset(offset.dx - caretPrototype.width, offset.dy),
|
||||
};
|
||||
// If offset.dx is outside of the advertised content area, then the associated
|
||||
// glyph cluster belongs to a trailing newline character. Ideally the behavior
|
||||
// glyph belongs to a trailing whitespace character. Ideally the behavior
|
||||
// should be handled by higher-level implementations (for instance,
|
||||
// RenderEditable reserves width for showing the caret, it's best to handle
|
||||
// the clamping there).
|
||||
|
@ -1448,38 +1379,136 @@ class TextPainter {
|
|||
///
|
||||
/// Valid only after [layout] has been called.
|
||||
double? getFullHeightForCaret(TextPosition position, Rect caretPrototype) {
|
||||
if (position.offset < 0) {
|
||||
// TODO(LongCatIsLooong): make this case impossible; see https://github.com/flutter/flutter/issues/79495
|
||||
return null;
|
||||
}
|
||||
return switch (_computeCaretMetrics(position)) {
|
||||
_LineCaretMetrics(:final double fullHeight) => fullHeight,
|
||||
_EmptyLineCaretMetrics() => null,
|
||||
};
|
||||
final TextBox textBox = _getOrCreateLayoutTemplate().getBoxesForRange(0, 1, boxHeightStyle: ui.BoxHeightStyle.strut).single;
|
||||
return textBox.toRect().height;
|
||||
}
|
||||
|
||||
bool _isNewlineAtOffset(int offset) => 0 <= offset && offset < plainText.length
|
||||
&& WordBoundary._isNewline(plainText.codeUnitAt(offset));
|
||||
// Cached caret metrics. This allows multiple invokes of [getOffsetForCaret] and
|
||||
// [getFullHeightForCaret] in a row without performing redundant and expensive
|
||||
// get rect calls to the paragraph.
|
||||
late _CaretMetrics _caretMetrics;
|
||||
//
|
||||
// The cache implementation assumes there's only one cursor at any given time.
|
||||
late _LineCaretMetrics _caretMetrics;
|
||||
|
||||
// Checks if the [position] and [caretPrototype] have changed from the cached
|
||||
// version and recomputes the metrics required to position the caret.
|
||||
_CaretMetrics _computeCaretMetrics(TextPosition position) {
|
||||
// This function returns the caret's offset and height for the given
|
||||
// `position` in the text, or null if the paragraph is empty.
|
||||
//
|
||||
// For a TextPosition, typically when its TextAffinity is downstream, the
|
||||
// corresponding I-beam caret is anchored to the leading edge of the character
|
||||
// at `offset` in the text. When the TextAffinity is upstream, the I-beam is
|
||||
// then anchored to the trailing edge of the preceding character, except for a
|
||||
// few edge cases:
|
||||
//
|
||||
// 1. empty paragraph: this method returns null and the caller handles this
|
||||
// case.
|
||||
//
|
||||
// 2. (textLength, downstream), the end-of-text caret when the text is not
|
||||
// empty: it's placed next to the trailing edge of the last line of the
|
||||
// text, in case the text and its last bidi run have different writing
|
||||
// directions. See the `_computeEndOfTextCaretAnchorOffset` method for more
|
||||
// details.
|
||||
//
|
||||
// 3. (0, upstream), which isn't a valid position, but it's not a conventional
|
||||
// "invalid" caret location either (the offset isn't negative). For
|
||||
// historical reasons, this is treated as (0, downstream).
|
||||
//
|
||||
// 4. (x, upstream) where x - 1 points to a line break character. The caret
|
||||
// should be displayed at the beginning of the newline instead of at the
|
||||
// end of the previous line. Converts the location to (x, downstream). The
|
||||
// choice we makes in 5. allows us to still check (x - 1) in case x points
|
||||
// to a multi-code-unit character.
|
||||
//
|
||||
// 5. (x, downstream || upstream), where x points to a multi-code-unit
|
||||
// character. There's no perfect caret placement in this case. Here we chose
|
||||
// to draw the caret at the location that makes the most sense when the
|
||||
// user wants to backspace (which also means it's left-arrow-key-biased):
|
||||
//
|
||||
// * downstream: show the caret at the leading edge of the character only if
|
||||
// x points to the start of the grapheme. Otherwise show the caret at the
|
||||
// leading edge of the next logical character.
|
||||
// * upstream: show the caret at the trailing edge of the previous character
|
||||
// only if x points to the start of the grapheme. Otherwise place the
|
||||
// caret at the trailing edge of the character.
|
||||
_LineCaretMetrics? _computeCaretMetrics(TextPosition position) {
|
||||
assert(_debugAssertTextLayoutIsValid);
|
||||
assert(!_debugNeedsRelayout);
|
||||
|
||||
final _TextPainterLayoutCacheWithOffset cachedLayout = _layoutCache!;
|
||||
if (position == cachedLayout._previousCaretPosition) {
|
||||
// If nothing is laid out, top start is the only reasonable place to place
|
||||
// the cursor.
|
||||
// The HTML renderer reports numberOfLines == 1 when the text is empty:
|
||||
// https://github.com/flutter/flutter/issues/143331
|
||||
if (cachedLayout.paragraph.numberOfLines < 1 || plainText.isEmpty) {
|
||||
// TODO(LongCatIsLooong): assert when an invalid position is given.
|
||||
return null;
|
||||
}
|
||||
|
||||
final (int offset, bool anchorToLeadingEdge) = switch (position) {
|
||||
TextPosition(offset: 0) => (0, true), // As a special case, always anchor to the leading edge of the first grapheme regardless of the affinity.
|
||||
TextPosition(:final int offset, affinity: TextAffinity.downstream) => (offset, true),
|
||||
TextPosition(:final int offset, affinity: TextAffinity.upstream) when _isNewlineAtOffset(offset - 1) => (offset, true),
|
||||
TextPosition(:final int offset, affinity: TextAffinity.upstream) => (offset - 1, false)
|
||||
};
|
||||
|
||||
final int caretPositionCacheKey = anchorToLeadingEdge ? offset : -offset - 1;
|
||||
if (caretPositionCacheKey == cachedLayout._previousCaretPositionKey) {
|
||||
return _caretMetrics;
|
||||
}
|
||||
final int offset = position.offset;
|
||||
final _CaretMetrics? metrics = switch (position.affinity) {
|
||||
TextAffinity.upstream => _getMetricsFromUpstream(offset) ?? _getMetricsFromDownstream(offset),
|
||||
TextAffinity.downstream => _getMetricsFromDownstream(offset) ?? _getMetricsFromUpstream(offset),
|
||||
};
|
||||
// Cache the input parameters to prevent repeat work later.
|
||||
cachedLayout._previousCaretPosition = position;
|
||||
return _caretMetrics = metrics ?? const _EmptyLineCaretMetrics(lineVerticalOffset: 0);
|
||||
|
||||
final ui.GlyphInfo? glyphInfo = cachedLayout.paragraph.getGlyphInfoAt(offset);
|
||||
|
||||
if (glyphInfo == null) {
|
||||
// If the glyph isn't laid out, then the position points to a character
|
||||
// that is not laid out. Use the EOT caret.
|
||||
// TODO(LongCatIsLooong): assert when an invalid position is given.
|
||||
final ui.Paragraph template = _getOrCreateLayoutTemplate();
|
||||
assert(template.numberOfLines == 1);
|
||||
final double baselineOffset = template.getLineMetricsAt(0)!.baseline;
|
||||
return cachedLayout.layout._endOfTextCaretMetrics.shift(Offset(0.0, -baselineOffset));
|
||||
}
|
||||
|
||||
final TextRange graphemeRange = glyphInfo.graphemeClusterCodeUnitRange;
|
||||
|
||||
// Works around a SkParagraph bug (https://github.com/flutter/flutter/issues/120836#issuecomment-1937343854):
|
||||
// placeholders with a size of (0, 0) always have a rect of Rect.zero and a
|
||||
// range of (0, 0).
|
||||
if (graphemeRange.isCollapsed) {
|
||||
assert(graphemeRange.start == 0);
|
||||
return _computeCaretMetrics(TextPosition(offset: offset + 1));
|
||||
}
|
||||
if (anchorToLeadingEdge && graphemeRange.start != offset) {
|
||||
assert(graphemeRange.end > graphemeRange.start + 1);
|
||||
// Addresses the case where `offset` points to a multi-code-unit grapheme
|
||||
// that doesn't start at `offset`.
|
||||
return _computeCaretMetrics(TextPosition(offset: graphemeRange.end));
|
||||
}
|
||||
|
||||
final _LineCaretMetrics metrics;
|
||||
final List<TextBox> boxes = cachedLayout.paragraph
|
||||
.getBoxesForRange(graphemeRange.start, graphemeRange.end, boxHeightStyle: ui.BoxHeightStyle.strut);
|
||||
if (boxes.isNotEmpty) {
|
||||
final TextBox box = boxes.single;
|
||||
metrics =_LineCaretMetrics(
|
||||
offset: Offset(anchorToLeadingEdge ? box.start : box.end, box.top),
|
||||
writingDirection: box.direction,
|
||||
);
|
||||
} else {
|
||||
// Fall back to glyphInfo. This should only happen when using the HTML renderer.
|
||||
assert(kIsWeb && !isCanvasKit);
|
||||
final Rect graphemeBounds = glyphInfo.graphemeClusterLayoutBounds;
|
||||
final double dx = switch (glyphInfo.writingDirection) {
|
||||
TextDirection.ltr => anchorToLeadingEdge ? graphemeBounds.left : graphemeBounds.right,
|
||||
TextDirection.rtl => anchorToLeadingEdge ? graphemeBounds.right : graphemeBounds.left,
|
||||
};
|
||||
metrics = _LineCaretMetrics(
|
||||
offset: Offset(dx, graphemeBounds.top),
|
||||
writingDirection: glyphInfo.writingDirection,
|
||||
);
|
||||
}
|
||||
|
||||
cachedLayout._previousCaretPositionKey = caretPositionCacheKey;
|
||||
return _caretMetrics = metrics;
|
||||
}
|
||||
|
||||
/// Returns a list of rects that bound the given selection.
|
||||
|
|
|
@ -6941,7 +6941,7 @@ void main() {
|
|||
// the arrow should not point exactly to the caret because the caret is
|
||||
// too close to the right.
|
||||
controller.dispose();
|
||||
controller = TextEditingController(text: List<String>.filled(200, 'a').join());
|
||||
controller = TextEditingController(text: 'a' * 200);
|
||||
await tester.pumpWidget(
|
||||
CupertinoApp(
|
||||
debugShowCheckedModeBanner: false,
|
||||
|
@ -7002,7 +7002,7 @@ void main() {
|
|||
// Normal centered collapsed selection. The toolbar arrow should point down, and
|
||||
// it should point exactly to the caret.
|
||||
controller.dispose();
|
||||
controller = TextEditingController(text: List<String>.filled(200, 'a').join());
|
||||
controller = TextEditingController(text: 'a' * 200);
|
||||
addTearDown(controller.dispose);
|
||||
await tester.pumpWidget(
|
||||
CupertinoApp(
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -301,9 +301,9 @@ void main() {
|
|||
painter.getOffsetForCaret(const TextPosition(offset: 2, affinity: TextAffinity.upstream), Rect.zero),
|
||||
const Offset(0.0, 10.0),
|
||||
);
|
||||
expect( // after the Alef
|
||||
expect( // To the right of the Alef
|
||||
painter.getOffsetForCaret(const TextPosition(offset: 2), Rect.zero),
|
||||
const Offset(0.0, 10.0),
|
||||
const Offset(10.0, 10.0),
|
||||
);
|
||||
|
||||
expect(
|
||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -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),
|
||||
|
|
Loading…
Reference in a new issue