Unverified Commit 62e78bf1 authored by LongCatIsLooong's avatar LongCatIsLooong Committed by GitHub

Improve `TextPainter.layout` caching (#118128)

Improves `TextPainter.layout` caching when only the input constraints change: 
- removes the double layout calls in `TextPainter._layoutParagraph`: now double layout is only needed when `TextAlign` is not left, and the input `maxWidth == double.infinity`.  
- skip calls to `ui.Paragraph.layout` when it's guaranteed that there's no soft line breaks before/after the layout call.

This doesn't introduce new APIs but may slightly shift text rendered on screen.
This reduces the number of `layout` calls but since shaping results are already cached so it only skips the relatively cheap line-breaking process when possible.

528 scuba failures but all of them seem reasonable.
parent 4d1c6a43
......@@ -1129,6 +1129,7 @@ class _RenderRangeSlider extends RenderBox with RelayoutWhenSystemFontsChangeMix
}
void _updateLabelPainter(Thumb thumb) {
final RangeLabels? labels = this.labels;
if (labels == null) {
return;
}
......@@ -1137,25 +1138,21 @@ class _RenderRangeSlider extends RenderBox with RelayoutWhenSystemFontsChangeMix
final TextPainter labelPainter;
switch (thumb) {
case Thumb.start:
text = labels!.start;
text = labels.start;
labelPainter = _startLabelPainter;
case Thumb.end:
text = labels!.end;
text = labels.end;
labelPainter = _endLabelPainter;
}
if (labels != null) {
labelPainter
..text = TextSpan(
style: _sliderTheme.valueIndicatorTextStyle,
text: text,
)
..textDirection = textDirection
..textScaleFactor = textScaleFactor
..layout();
} else {
labelPainter.text = null;
}
labelPainter
..text = TextSpan(
style: _sliderTheme.valueIndicatorTextStyle,
text: text,
)
..textDirection = textDirection
..textScaleFactor = textScaleFactor
..layout();
// Changing the textDirection can result in the layout changing, because the
// bidi algorithm might line up the glyphs differently which can result in
// different ligatures, different shapes, etc. So we always markNeedsLayout.
......
......@@ -124,7 +124,7 @@ class PlaceholderDimensions {
@override
String toString() {
return 'PlaceholderDimensions($size, $baseline)';
return 'PlaceholderDimensions($size, $baseline${baselineOffset == null ? ", $baselineOffset" : ""})';
}
}
......@@ -187,28 +187,18 @@ class WordBoundary extends TextBoundary {
if (codeUnitAtIndex == null) {
return null;
}
switch (codeUnitAtIndex & 0xFC00) {
case 0xD800:
return _codePointFromSurrogates(codeUnitAtIndex, _text.codeUnitAt(index + 1)!);
case 0xDC00:
return _codePointFromSurrogates(_text.codeUnitAt(index - 1)!, codeUnitAtIndex);
default:
return codeUnitAtIndex;
}
return switch (codeUnitAtIndex & 0xFC00) {
0xD800 => _codePointFromSurrogates(codeUnitAtIndex, _text.codeUnitAt(index + 1)!),
0xDC00 => _codePointFromSurrogates(_text.codeUnitAt(index - 1)!, codeUnitAtIndex),
_ => codeUnitAtIndex,
};
}
static bool _isNewline(int codePoint) {
switch (codePoint) {
case 0x000A:
case 0x0085:
case 0x000B:
case 0x000C:
case 0x2028:
case 0x2029:
return true;
default:
return false;
}
return switch (codePoint) {
0x000A || 0x0085 || 0x000B || 0x000C || 0x2028 || 0x2029 => true,
_ => false,
};
}
bool _skipSpacesAndPunctuations(int offset, bool forward) {
......@@ -270,6 +260,155 @@ class _UntilTextBoundary extends TextBoundary {
}
}
class _TextLayout {
_TextLayout._(this._paragraph);
// This field is not final because the owner TextPainter could create a new
// ui.Paragraph with the exact same text layout (for example, when only the
// color of the text is changed).
//
// The creator of this _TextLayout is also responsible for disposing this
// object when it's no logner needed.
ui.Paragraph _paragraph;
// TODO(LongCatIsLooong): https://github.com/flutter/flutter/issues/31707
// remove this hack as well as the flooring in `layout`.
static double _applyFloatingPointHack(double layoutValue) => layoutValue.ceilToDouble();
/// Whether this layout has been invalidated and disposed.
///
/// Only for use when asserts are enabled.
bool get debugDisposed => _paragraph.debugDisposed;
/// The horizontal space required to paint this text.
///
/// If a line ends with trailing spaces, the trailing spaces may extend
/// outside of the horizontal paint bounds defined by [width].
double get width => _applyFloatingPointHack(_paragraph.width);
/// The vertical space required to paint this text.
double get height => _applyFloatingPointHack(_paragraph.height);
/// The width at which decreasing the width of the text would prevent it from
/// painting itself completely within its bounds.
double get minIntrinsicLineExtent => _applyFloatingPointHack(_paragraph.minIntrinsicWidth);
/// The width at which increasing the width of the text no longer decreases the height.
///
/// Includes trailing spaces if any.
double get maxIntrinsicLineExtent => _applyFloatingPointHack(_paragraph.maxIntrinsicWidth);
/// The distance from the left edge of the leftmost glyph to the right edge of
/// the rightmost glyph in the paragraph.
double get longestLine => _applyFloatingPointHack(_paragraph.longestLine);
/// Returns the distance from the top of the text to the first baseline of the
/// given type.
double getDistanceToBaseline(TextBaseline baseline) {
return switch (baseline) {
TextBaseline.alphabetic => _paragraph.alphabeticBaseline,
TextBaseline.ideographic => _paragraph.ideographicBaseline,
};
}
}
// This class stores the current text layout and the corresponding
// paintOffset/contentWidth, as well as some cached text metrics values that
// depends on the current text layout, which will be invalidated as soon as the
// text layout is invalidated.
class _TextPainterLayoutCacheWithOffset {
_TextPainterLayoutCacheWithOffset(this.layout, this.textAlignment, double minWidth, double maxWidth, TextWidthBasis widthBasis)
: contentWidth = _contentWidthFor(minWidth, maxWidth, widthBasis, layout),
assert(textAlignment >= 0.0 && textAlignment <= 1.0);
final _TextLayout layout;
// The content width the text painter should report in TextPainter.width.
// This is also used to compute `paintOffset`
double contentWidth;
// The effective text alignment in the TextPainter's canvas. The value is
// within the [0, 1] interval: 0 for left aligned and 1 for right aligned.
final double textAlignment;
// The paintOffset of the `paragraph` in the TextPainter's canvas.
//
// It's coordinate values are guaranteed to not be NaN.
Offset get paintOffset {
if (textAlignment == 0) {
return Offset.zero;
}
if (!paragraph.width.isFinite) {
return const Offset(double.infinity, 0.0);
}
final double dx = textAlignment * (contentWidth - paragraph.width);
assert(!dx.isNaN);
return Offset(dx, 0);
}
ui.Paragraph get paragraph => layout._paragraph;
static double _contentWidthFor(double minWidth, double maxWidth, TextWidthBasis widthBasis, _TextLayout layout) {
// TODO(LongCatIsLooong): remove the rounding when _applyFloatingPointHack
// is removed.
minWidth = minWidth.floorToDouble();
maxWidth = maxWidth.floorToDouble();
return switch (widthBasis) {
TextWidthBasis.longestLine => clampDouble(layout.longestLine, minWidth, maxWidth),
TextWidthBasis.parent => clampDouble(layout.maxIntrinsicLineExtent, minWidth, maxWidth),
};
}
// Try to resize the contentWidth to fit the new input constraints, by just
// adjusting the paint offset (so no line-breaking changes needed).
//
// Returns false if the new constraints require re-computing the line breaks,
// in which case no side effects will occur.
bool _resizeToFit(double minWidth, double maxWidth, TextWidthBasis widthBasis) {
assert(layout.maxIntrinsicLineExtent.isFinite);
// The assumption here is that if a Paragraph's width is already >= its
// maxIntrinsicWidth, further increasing the input width does not change its
// layout (but may change the paint offset if it's not left-aligned). This is
// true even for TextAlign.justify: when width >= maxIntrinsicWidth
// TextAlign.justify will behave exactly the same as TextAlign.start.
//
// An exception to this is when the text is not left-aligned, and the input
// width is double.infinity. Since the resulting Paragraph will have a width
// of double.infinity, and to make the text visible the paintOffset.dx is
// bound to be double.negativeInfinity, which invalidates all arithmetic
// operations.
final double newContentWidth = _contentWidthFor(minWidth, maxWidth, widthBasis, layout);
if (newContentWidth == contentWidth) {
return true;
}
assert(minWidth <= maxWidth);
// Always needsLayout when the current paintOffset and the paragraph width are not finite.
if (!paintOffset.dx.isFinite && !paragraph.width.isFinite && minWidth.isFinite) {
assert(paintOffset.dx == double.infinity);
assert(paragraph.width == double.infinity);
return false;
}
final double maxIntrinsicWidth = layout._paragraph.maxIntrinsicWidth;
if ((layout._paragraph.width - maxIntrinsicWidth) > -precisionErrorTolerance && (maxWidth - maxIntrinsicWidth) > -precisionErrorTolerance) {
// Adjust the paintOffset and contentWidth to the new input constraints.
contentWidth = newContentWidth;
return true;
}
return false;
}
// ---- Cached Values ----
List<TextBox> get inlinePlaceholderBoxes => _cachedInlinePlaceholderBoxes ??= paragraph.getBoxesForPlaceholders();
List<TextBox>? _cachedInlinePlaceholderBoxes;
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;
}
/// 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.
......@@ -441,20 +580,27 @@ class TextPainter {
}
}
// _paragraph being null means the text needs layout because of style changes.
// Setting _paragraph to null invalidates all the layout cache.
// Whether textWidthBasis has changed after the most recent `layout` call.
bool _debugNeedsRelayout = true;
// The result of the most recent `layout` call.
_TextPainterLayoutCacheWithOffset? _layoutCache;
// Whether _layoutCache contains outdated paint information and needs to be
// updated before painting.
//
// The TextPainter class should not aggressively invalidate the layout as long
// as `markNeedsLayout` is not called (i.e., the layout cache is still valid).
// See: https://github.com/flutter/flutter/issues/85108
ui.Paragraph? _paragraph;
// Whether _paragraph contains outdated paint information and needs to be
// rebuilt before painting.
// ui.Paragraph is entirely immutable, thus text style changes that can affect
// layout and those who can't both require the ui.Paragraph object being
// recreated. The caller may not call `layout` again after text color is
// updated. See: https://github.com/flutter/flutter/issues/85108
bool _rebuildParagraphForPaint = true;
// `_layoutCache`'s input width. This is only needed because there's no API to
// create paint only updates that don't affect the text layout (e.g., changing
// the color of the text), on ui.Paragraph or ui.ParagraphBuilder.
double _inputWidth = double.nan;
bool get _debugAssertTextLayoutIsValid {
assert(!debugDisposed);
if (_paragraph == null) {
if (_layoutCache == null) {
throw FlutterError.fromParts(<DiagnosticsNode>[
ErrorSummary('Text layout not available'),
if (_debugMarkNeedsLayoutCallStack != null) DiagnosticsStackTrace('The calls that first invalidated the text layout were', _debugMarkNeedsLayoutCallStack)
......@@ -474,15 +620,13 @@ class TextPainter {
/// in framework will automatically invoke this method.
void markNeedsLayout() {
assert(() {
if (_paragraph != null) {
if (_layoutCache != null) {
_debugMarkNeedsLayoutCallStack ??= StackTrace.current;
}
return true;
}());
_paragraph?.dispose();
_paragraph = null;
_lineMetricsCache = null;
_previousCaretPosition = null;
_layoutCache?.paragraph.dispose();
_layoutCache = null;
}
/// The (potentially styled) text to paint.
......@@ -515,8 +659,8 @@ class TextPainter {
if (comparison.index >= RenderComparison.layout.index) {
markNeedsLayout();
} else if (comparison.index >= RenderComparison.paint.index) {
// Don't clear the _paragraph instance variable just yet. It still
// contains valid layout information.
// Don't invalid the _layoutCache just yet. It still contains valid layout
// information.
_rebuildParagraphForPaint = true;
}
// Neither relayout or repaint is needed.
......@@ -679,8 +823,8 @@ class TextPainter {
if (_textWidthBasis == value) {
return;
}
assert(() { return _debugNeedsRelayout = true; }());
_textWidthBasis = value;
markNeedsLayout();
}
/// {@macro dart.ui.textHeightBehavior}
......@@ -699,8 +843,21 @@ class TextPainter {
///
/// Each box corresponds to a [PlaceholderSpan] in the order they were defined
/// in the [InlineSpan] tree.
List<TextBox>? get inlinePlaceholderBoxes => _inlinePlaceholderBoxes;
List<TextBox>? _inlinePlaceholderBoxes;
List<TextBox>? get inlinePlaceholderBoxes {
final _TextPainterLayoutCacheWithOffset? layout = _layoutCache;
if (layout == null) {
return null;
}
final Offset offset = layout.paintOffset;
if (!offset.dx.isFinite || !offset.dy.isFinite) {
return <TextBox>[];
}
final List<TextBox> rawBoxes = layout.inlinePlaceholderBoxes;
if (offset == Offset.zero) {
return rawBoxes;
}
return rawBoxes.map((TextBox box) => _shiftTextBox(box, offset)).toList(growable: false);
}
/// An ordered list of scales for each placeholder in the paragraph.
///
......@@ -731,10 +888,10 @@ class TextPainter {
if (span is PlaceholderSpan) {
placeholderCount += 1;
}
return true;
return value.length >= placeholderCount ;
});
return placeholderCount;
}() == value.length);
return placeholderCount == value.length;
}());
_placeholderDimensions = value;
markNeedsLayout();
}
......@@ -795,24 +952,13 @@ class TextPainter {
/// sans-serif font).
double get preferredLineHeight => (_layoutTemplate ??= _createLayoutTemplate()).height;
// Unfortunately, using full precision floating point here causes bad layouts
// because floating point math isn't associative. If we add and subtract
// padding, for example, we'll get different values when we estimate sizes and
// when we actually compute layout because the operations will end up associated
// differently. To work around this problem for now, we round fractional pixel
// values up to the nearest whole pixel value. The right long-term fix is to do
// layout using fixed precision arithmetic.
double _applyFloatingPointHack(double layoutValue) {
return layoutValue.ceilToDouble();
}
/// The width at which decreasing the width of the text would prevent it from
/// painting itself completely within its bounds.
///
/// Valid only after [layout] has been called.
double get minIntrinsicWidth {
assert(_debugAssertTextLayoutIsValid);
return _applyFloatingPointHack(_paragraph!.minIntrinsicWidth);
return _layoutCache!.layout.minIntrinsicLineExtent;
}
/// The width at which increasing the width of the text no longer decreases the height.
......@@ -820,7 +966,7 @@ class TextPainter {
/// Valid only after [layout] has been called.
double get maxIntrinsicWidth {
assert(_debugAssertTextLayoutIsValid);
return _applyFloatingPointHack(_paragraph!.maxIntrinsicWidth);
return _layoutCache!.layout.maxIntrinsicLineExtent;
}
/// The horizontal space required to paint this text.
......@@ -828,9 +974,8 @@ class TextPainter {
/// Valid only after [layout] has been called.
double get width {
assert(_debugAssertTextLayoutIsValid);
return _applyFloatingPointHack(
textWidthBasis == TextWidthBasis.longestLine ? _paragraph!.longestLine : _paragraph!.width,
);
assert(!_debugNeedsRelayout);
return _layoutCache!.contentWidth;
}
/// The vertical space required to paint this text.
......@@ -838,7 +983,7 @@ class TextPainter {
/// Valid only after [layout] has been called.
double get height {
assert(_debugAssertTextLayoutIsValid);
return _applyFloatingPointHack(_paragraph!.height);
return _layoutCache!.layout.height;
}
/// The amount of space required to paint this text.
......@@ -846,6 +991,7 @@ class TextPainter {
/// Valid only after [layout] has been called.
Size get size {
assert(_debugAssertTextLayoutIsValid);
assert(!_debugNeedsRelayout);
return Size(width, height);
}
......@@ -855,10 +1001,7 @@ class TextPainter {
/// Valid only after [layout] has been called.
double computeDistanceToActualBaseline(TextBaseline baseline) {
assert(_debugAssertTextLayoutIsValid);
return switch (baseline) {
TextBaseline.alphabetic => _paragraph!.alphabeticBaseline,
TextBaseline.ideographic => _paragraph!.ideographicBaseline,
};
return _layoutCache!.layout.getDistanceToBaseline(baseline);
}
/// Whether any text was truncated or ellipsized.
......@@ -874,20 +1017,12 @@ class TextPainter {
/// Valid only after [layout] has been called.
bool get didExceedMaxLines {
assert(_debugAssertTextLayoutIsValid);
return _paragraph!.didExceedMaxLines;
return _layoutCache!.paragraph.didExceedMaxLines;
}
double? _lastMinWidth;
double? _lastMaxWidth;
// Creates a ui.Paragraph using the current configurations in this class and
// assign it to _paragraph.
ui.Paragraph _createParagraph() {
assert(_paragraph == null || _rebuildParagraphForPaint);
final InlineSpan? text = this.text;
if (text == null) {
throw StateError('TextPainter.text must be set to a non-null value before using the TextPainter.');
}
ui.Paragraph _createParagraph(InlineSpan text) {
final ui.ParagraphBuilder builder = ui.ParagraphBuilder(_createParagraphStyle());
text.build(builder, textScaleFactor: textScaleFactor, dimensions: _placeholderDimensions);
_inlinePlaceholderScales = builder.placeholderScales;
......@@ -895,60 +1030,71 @@ class TextPainter {
_debugMarkNeedsLayoutCallStack = null;
return true;
}());
final ui.Paragraph paragraph = _paragraph = builder.build();
_rebuildParagraphForPaint = false;
return paragraph;
}
void _layoutParagraph(double minWidth, double maxWidth) {
_paragraph!.layout(ui.ParagraphConstraints(width: maxWidth));
if (minWidth != maxWidth) {
double newWidth;
switch (textWidthBasis) {
case TextWidthBasis.longestLine:
// The parent widget expects the paragraph to be exactly
// `TextPainter.width` wide, if that value satisfies the constraints
// it gave to the TextPainter. So when `textWidthBasis` is longestLine,
// the paragraph's width needs to be as close to the width of its
// longest line as possible.
newWidth = _applyFloatingPointHack(_paragraph!.longestLine);
case TextWidthBasis.parent:
newWidth = maxIntrinsicWidth;
}
newWidth = clampDouble(newWidth, minWidth, maxWidth);
if (newWidth != _applyFloatingPointHack(_paragraph!.width)) {
_paragraph!.layout(ui.ParagraphConstraints(width: newWidth));
}
}
return builder.build();
}
/// Computes the visual position of the glyphs for painting the text.
///
/// The text will layout with a width that's as close to its max intrinsic
/// width as possible while still being greater than or equal to `minWidth` and
/// less than or equal to `maxWidth`.
/// width (or its longest line, if [textWidthBasis] is set to
/// [TextWidthBasis.parent]) as possible while still being greater than or
/// equal to `minWidth` and less than or equal to `maxWidth`.
///
/// The [text] and [textDirection] properties must be non-null before this is
/// called.
void layout({ double minWidth = 0.0, double maxWidth = double.infinity }) {
assert(text != null, 'TextPainter.text must be set to a non-null value before using the TextPainter.');
assert(textDirection != null, 'TextPainter.textDirection must be set to a non-null value before using the TextPainter.');
// Return early if the current layout information is not outdated, even if
// _needsPaint is true (in which case _paragraph will be rebuilt in paint).
if (_paragraph != null && minWidth == _lastMinWidth && maxWidth == _lastMaxWidth) {
assert(!maxWidth.isNaN);
assert(!minWidth.isNaN);
assert(() {
_debugNeedsRelayout = false;
return true;
}());
final _TextPainterLayoutCacheWithOffset? cachedLayout = _layoutCache;
if (cachedLayout != null && cachedLayout._resizeToFit(minWidth, maxWidth, textWidthBasis)) {
return;
}
if (_rebuildParagraphForPaint || _paragraph == null) {
_createParagraph();
final InlineSpan? text = this.text;
if (text == null) {
throw StateError('TextPainter.text must be set to a non-null value before using the TextPainter.');
}
final TextDirection? textDirection = this.textDirection;
if (textDirection == null) {
throw StateError('TextPainter.textDirection must be set to a non-null value before using the TextPainter.');
}
final double paintOffsetAlignment = _computePaintOffsetFraction(textAlign, textDirection);
// Try to avoid laying out the paragraph with maxWidth=double.infinity
// when the text is not left-aligned, so we don't have to deal with an
// infinite paint offset.
final bool adjustMaxWidth = !maxWidth.isFinite && paintOffsetAlignment != 0;
final double? adjustedMaxWidth = !adjustMaxWidth ? maxWidth : cachedLayout?.layout.maxIntrinsicLineExtent;
_inputWidth = adjustedMaxWidth ?? maxWidth;
// Only rebuild the paragraph when there're layout changes, even when
// `_rebuildParagraphForPaint` is true. It's best to not eagerly rebuild
// the paragraph to avoid the extra work, because:
// 1. the text color could change again before `paint` is called (so one of
// the paragraph rebuilds is unnecessary)
// 2. the user could be measuring the text layout so `paint` will never be
// called.
final ui.Paragraph paragraph = (cachedLayout?.paragraph ?? _createParagraph(text))
..layout(ui.ParagraphConstraints(width: _inputWidth));
final _TextPainterLayoutCacheWithOffset newLayoutCache = _TextPainterLayoutCacheWithOffset(
_TextLayout._(paragraph), paintOffsetAlignment, minWidth, maxWidth, textWidthBasis,
);
// Call layout again if newLayoutCache had an infinite paint offset.
// This is not as expensive as it seems, line breaking is relatively cheap
// as compared to shaping.
if (adjustedMaxWidth == null && minWidth.isFinite) {
assert(maxWidth.isInfinite);
final double newInputWidth = newLayoutCache.layout.maxIntrinsicLineExtent;
paragraph.layout(ui.ParagraphConstraints(width: newInputWidth));
_inputWidth = newInputWidth;
}
_lastMinWidth = minWidth;
_lastMaxWidth = maxWidth;
// A change in layout invalidates the cached caret and line metrics as well.
_lineMetricsCache = null;
_previousCaretPosition = null;
_layoutParagraph(minWidth, maxWidth);
_inlinePlaceholderBoxes = _paragraph!.getBoxesForPlaceholders();
_layoutCache = newLayoutCache;
}
/// Paints the text onto the given canvas at the given offset.
......@@ -964,15 +1110,18 @@ class TextPainter {
/// To set the text style, specify a [TextStyle] when creating the [TextSpan]
/// that you pass to the [TextPainter] constructor or to the [text] property.
void paint(Canvas canvas, Offset offset) {
final double? minWidth = _lastMinWidth;
final double? maxWidth = _lastMaxWidth;
if (_paragraph == null || minWidth == null || maxWidth == null) {
final _TextPainterLayoutCacheWithOffset? layoutCache = _layoutCache;
if (layoutCache == null) {
throw StateError(
'TextPainter.paint called when text geometry was not yet calculated.\n'
'Please call layout() before paint() to position the text before painting it.',
);
}
if (!layoutCache.paintOffset.dx.isFinite || !layoutCache.paintOffset.dy.isFinite) {
return;
}
if (_rebuildParagraphForPaint) {
Size? debugSize;
assert(() {
......@@ -980,15 +1129,18 @@ class TextPainter {
return true;
}());
_createParagraph();
// Unfortunately we have to redo the layout using the same constraints,
// since we've created a new ui.Paragraph. But there's no extra work being
// done: if _needsPaint is true and _paragraph is not null, the previous
// `layout` call didn't invoke _layoutParagraph.
_layoutParagraph(minWidth, maxWidth);
final ui.Paragraph paragraph = layoutCache.layout._paragraph;
// Unfortunately even if we know that there is only paint changes, there's
// no API to only make those updates so the paragraph has to be recreated
// and re-laid out.
assert(!_inputWidth.isNaN);
layoutCache.layout._paragraph = _createParagraph(text!)..layout(ui.ParagraphConstraints(width: _inputWidth));
assert(paragraph.width == layoutCache.layout._paragraph.width);
paragraph.dispose();
assert(debugSize == size);
}
canvas.drawParagraph(_paragraph!, offset);
assert(!_rebuildParagraphForPaint);
canvas.drawParagraph(layoutCache.paragraph, offset + layoutCache.paintOffset);
}
// Returns true if value falls in the valid range of the UTF16 encoding.
......@@ -1076,7 +1228,7 @@ class TextPainter {
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 = _paragraph!.getBoxesForRange(max(0, prevRuneOffset), offset, boxHeightStyle: ui.BoxHeightStyle.strut);
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
......@@ -1100,7 +1252,6 @@ class TextPainter {
// 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);
......@@ -1127,7 +1278,7 @@ class TextPainter {
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 = _paragraph!.getBoxesForRange(offset, nextRuneOffset, boxHeightStyle: ui.BoxHeightStyle.strut);
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
......@@ -1151,7 +1302,6 @@ class TextPainter {
// 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;
......@@ -1176,6 +1326,7 @@ 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);
......@@ -1190,7 +1341,7 @@ class TextPainter {
// 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 * width;
final double dx = paintOffsetAlignment == 0 ? 0 : paintOffsetAlignment * layoutCache.contentWidth;
return Offset(dx, lineVerticalOffset);
case _LineCaretMetrics(writingDirection: TextDirection.ltr, :final Offset offset):
rawOffset = offset;
......@@ -1202,8 +1353,8 @@ class TextPainter {
// should be handled by higher-level implementations (for instance,
// RenderEditable reserves width for showing the caret, it's best to handle
// the clamping there).
final double adjustedDx = clampDouble(rawOffset.dx, 0, width);
return Offset(adjustedDx, rawOffset.dy);
final double adjustedDx = clampDouble(rawOffset.dx + layoutCache.paintOffset.dx, 0, layoutCache.contentWidth);
return Offset(adjustedDx, rawOffset.dy + layoutCache.paintOffset.dy);
}
/// {@template flutter.painting.textPainter.getFullHeightForCaret}
......@@ -1227,16 +1378,13 @@ class TextPainter {
// get rect calls to the paragraph.
late _CaretMetrics _caretMetrics;
// Holds the TextPosition and caretPrototype the last caret metrics were
// computed with. When new values are passed in, we recompute the caret metrics.
// only as necessary.
TextPosition? _previousCaretPosition;
// Checks if the [position] and [caretPrototype] have changed from the cached
// version and recomputes the metrics required to position the caret.
_CaretMetrics _computeCaretMetrics(TextPosition position) {
assert(_debugAssertTextLayoutIsValid);
if (position == _previousCaretPosition) {
assert(!_debugNeedsRelayout);
final _TextPainterLayoutCacheWithOffset cachedLayout = _layoutCache!;
if (position == cachedLayout._previousCaretPosition) {
return _caretMetrics;
}
final int offset = position.offset;
......@@ -1245,7 +1393,7 @@ class TextPainter {
TextAffinity.downstream => _getMetricsFromDownstream(offset) ?? _getMetricsFromUpstream(offset),
};
// Cache the input parameters to prevent repeat work later.
_previousCaretPosition = position;
cachedLayout._previousCaretPosition = position;
return _caretMetrics = metrics ?? const _EmptyLineCaretMetrics(lineVerticalOffset: 0);
}
......@@ -1274,18 +1422,29 @@ class TextPainter {
}) {
assert(_debugAssertTextLayoutIsValid);
assert(selection.isValid);
return _paragraph!.getBoxesForRange(
assert(!_debugNeedsRelayout);
final _TextPainterLayoutCacheWithOffset cachedLayout = _layoutCache!;
final Offset offset = cachedLayout.paintOffset;
if (!offset.dx.isFinite || !offset.dy.isFinite) {
return <TextBox>[];
}
final List<TextBox> boxes = cachedLayout.paragraph.getBoxesForRange(
selection.start,
selection.end,
boxHeightStyle: boxHeightStyle,
boxWidthStyle: boxWidthStyle,
);
return offset == Offset.zero
? boxes
: boxes.map((TextBox box) => _shiftTextBox(box, offset)).toList(growable: false);
}
/// Returns the position within the text for the given pixel offset.
TextPosition getPositionForOffset(Offset offset) {
assert(_debugAssertTextLayoutIsValid);
return _paragraph!.getPositionForOffset(offset);
assert(!_debugNeedsRelayout);
final _TextPainterLayoutCacheWithOffset cachedLayout = _layoutCache!;
return cachedLayout.paragraph.getPositionForOffset(offset - cachedLayout.paintOffset);
}
/// {@template flutter.painting.TextPainter.getWordBoundary}
......@@ -1299,7 +1458,7 @@ class TextPainter {
/// {@endtemplate}
TextRange getWordBoundary(TextPosition position) {
assert(_debugAssertTextLayoutIsValid);
return _paragraph!.getWordBoundary(position);
return _layoutCache!.paragraph.getWordBoundary(position);
}
/// {@template flutter.painting.TextPainter.wordBoundaries}
......@@ -1312,17 +1471,44 @@ class TextPainter {
///
/// Currently word boundary analysis can only be performed after [layout]
/// has been called.
WordBoundary get wordBoundaries => WordBoundary._(text!, _paragraph!);
WordBoundary get wordBoundaries => WordBoundary._(text!, _layoutCache!.paragraph);
/// Returns the text range of the line at the given offset.
///
/// The newline (if any) is not returned as part of the range.
TextRange getLineBoundary(TextPosition position) {
assert(_debugAssertTextLayoutIsValid);
return _paragraph!.getLineBoundary(position);
return _layoutCache!.paragraph.getLineBoundary(position);
}
static ui.LineMetrics _shiftLineMetrics(ui.LineMetrics metrics, Offset offset) {
assert(offset.dx.isFinite);
assert(offset.dy.isFinite);
return ui.LineMetrics(
hardBreak: metrics.hardBreak,
ascent: metrics.ascent,
descent: metrics.descent,
unscaledAscent: metrics.unscaledAscent,
height: metrics.height,
width: metrics.width,
left: metrics.left + offset.dx,
baseline: metrics.baseline + offset.dy,
lineNumber: metrics.lineNumber,
);
}
static TextBox _shiftTextBox(TextBox box, Offset offset) {
assert(offset.dx.isFinite);
assert(offset.dy.isFinite);
return TextBox.fromLTRBD(
box.left + offset.dx,
box.top + offset.dy,
box.right + offset.dx,
box.bottom + offset.dy,
box.direction,
);
}
List<ui.LineMetrics>? _lineMetricsCache;
/// Returns the full list of [LineMetrics] that describe in detail the various
/// metrics of each laid out line.
///
......@@ -1336,7 +1522,16 @@ class TextPainter {
/// Valid only after [layout] has been called.
List<ui.LineMetrics> computeLineMetrics() {
assert(_debugAssertTextLayoutIsValid);
return _lineMetricsCache ??= _paragraph!.computeLineMetrics();
assert(!_debugNeedsRelayout);
final _TextPainterLayoutCacheWithOffset layout = _layoutCache!;
final Offset offset = layout.paintOffset;
if (!offset.dx.isFinite || !offset.dy.isFinite) {
return const <ui.LineMetrics>[];
}
final List<ui.LineMetrics> rawMetrics = layout.lineMetrics;
return offset == Offset.zero
? rawMetrics
: rawMetrics.map((ui.LineMetrics metrics) => _shiftLineMetrics(metrics, offset)).toList(growable: false);
}
bool _disposed = false;
......@@ -1363,8 +1558,8 @@ class TextPainter {
}());
_layoutTemplate?.dispose();
_layoutTemplate = null;
_paragraph?.dispose();
_paragraph = null;
_layoutCache?.paragraph.dispose();
_layoutCache = null;
_text = null;
}
}
......@@ -5692,8 +5692,15 @@ void main() {
expect(
find.text(longStringB),
// 133.3 is approximately 100 / 0.75 (_kFinalLabelScale)
paints..clipRect(rect: const Rect.fromLTWH(0, 0, 133.0, 16.0)),
paints..something((Symbol methodName, List<dynamic> arguments) {
if (methodName != #clipRect) {
return false;
}
final Rect clipRect = arguments[0] as Rect;
// 133.3 is approximately 100 / 0.75 (_kFinalLabelScale)
expect(clipRect, rectMoreOrLessEquals(const Rect.fromLTWH(0, 0, 133.0, 16.0)));
return true;
}),
);
}, skip: isBrowser); // TODO(yjbanov): https://github.com/flutter/flutter/issues/44020
......
......@@ -382,9 +382,9 @@ void main() {
test('TextPainter requires textDirection', () {
final TextPainter painter1 = TextPainter(text: const TextSpan(text: ''));
expect(() { painter1.layout(); }, throwsAssertionError);
expect(painter1.layout, throwsStateError);
final TextPainter painter2 = TextPainter(text: const TextSpan(text: ''), textDirection: TextDirection.rtl);
expect(() { painter2.layout(); }, isNot(throwsException));
expect(painter2.layout, isNot(throwsStateError));
});
test('TextPainter size test', () {
......@@ -1457,8 +1457,67 @@ void main() {
painter.dispose();
});
test('TextPainter infinite width - centered', () {
final TextPainter painter = TextPainter()
..textAlign = TextAlign.center
..textDirection = TextDirection.ltr;
painter.text = const TextSpan(text: 'A', style: TextStyle(fontSize: 10));
MockCanvasWithDrawParagraph mockCanvas = MockCanvasWithDrawParagraph();
painter.layout(minWidth: double.infinity);
expect(painter.width, double.infinity);
expect(() => painter.paint(mockCanvas = MockCanvasWithDrawParagraph(), Offset.zero), returnsNormally);
expect(mockCanvas.centerX, isNull);
painter.layout();
expect(painter.width, 10);
expect(() => painter.paint(mockCanvas = MockCanvasWithDrawParagraph(), Offset.zero), returnsNormally);
expect(mockCanvas.centerX, 5);
painter.layout(minWidth: 100);
expect(painter.width, 100);
expect(() => painter.paint(mockCanvas = MockCanvasWithDrawParagraph(), Offset.zero), returnsNormally);
expect(mockCanvas.centerX, 50);
painter.dispose();
});
test('TextPainter infinite width - LTR justified', () {
final TextPainter painter = TextPainter()
..textAlign = TextAlign.justify
..textDirection = TextDirection.ltr;
painter.text = const TextSpan(text: 'A', style: TextStyle(fontSize: 10));
MockCanvasWithDrawParagraph mockCanvas = MockCanvasWithDrawParagraph();
painter.layout(minWidth: double.infinity);
expect(painter.width, double.infinity);
expect(() => painter.paint(mockCanvas = MockCanvasWithDrawParagraph(), Offset.zero), returnsNormally);
expect(mockCanvas.offsetX, 0);
painter.layout();
expect(painter.width, 10);
expect(() => painter.paint(mockCanvas = MockCanvasWithDrawParagraph(), Offset.zero), returnsNormally);
expect(mockCanvas.offsetX, 0);
painter.layout(minWidth: 100);
expect(painter.width, 100);
expect(() => painter.paint(mockCanvas = MockCanvasWithDrawParagraph(), Offset.zero), returnsNormally);
expect(mockCanvas.offsetX, 0);
painter.dispose();
});
}
class MockCanvas extends Fake implements Canvas {
}
class MockCanvasWithDrawParagraph extends Fake implements Canvas {
double? centerX;
double? offsetX;
@override
void drawParagraph(ui.Paragraph paragraph, Offset offset) {
offsetX = offset.dx;
centerX = offset.dx + paragraph.width / 2;
}
}
......@@ -1225,9 +1225,11 @@ void main() {
width: 400,
child: Center(
child: RichText(
// 400 is not wide enough for this string. The part after the
// whitespace is going to be broken into a 2nd line.
text: const TextSpan(text: 'fwefwefwewfefewfwe fwfwfwefweabcdefghijklmnopqrstuvwxyz'),
textWidthBasis: TextWidthBasis.longestLine,
textDirection: TextDirection.ltr,
textDirection: TextDirection.rtl,
),
),
),
......@@ -1239,11 +1241,12 @@ void main() {
return false;
}
final ui.Paragraph paragraph = arguments[0] as ui.Paragraph;
if (paragraph.longestLine > paragraph.width) {
throw 'paragraph width (${paragraph.width}) greater than its longest line (${paragraph.longestLine}).';
}
if (paragraph.width >= 400) {
throw 'paragraph.width (${paragraph.width}) >= 400';
final Offset offset = arguments[1] as Offset;
final List<ui.LineMetrics> lines = paragraph.computeLineMetrics();
for (final ui.LineMetrics line in lines) {
if (line.left + offset.dx + line.width >= 400) {
throw 'line $line is greater than the max width constraints';
}
}
return true;
}));
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment