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 ...@@ -1129,6 +1129,7 @@ class _RenderRangeSlider extends RenderBox with RelayoutWhenSystemFontsChangeMix
} }
void _updateLabelPainter(Thumb thumb) { void _updateLabelPainter(Thumb thumb) {
final RangeLabels? labels = this.labels;
if (labels == null) { if (labels == null) {
return; return;
} }
...@@ -1137,25 +1138,21 @@ class _RenderRangeSlider extends RenderBox with RelayoutWhenSystemFontsChangeMix ...@@ -1137,25 +1138,21 @@ class _RenderRangeSlider extends RenderBox with RelayoutWhenSystemFontsChangeMix
final TextPainter labelPainter; final TextPainter labelPainter;
switch (thumb) { switch (thumb) {
case Thumb.start: case Thumb.start:
text = labels!.start; text = labels.start;
labelPainter = _startLabelPainter; labelPainter = _startLabelPainter;
case Thumb.end: case Thumb.end:
text = labels!.end; text = labels.end;
labelPainter = _endLabelPainter; labelPainter = _endLabelPainter;
} }
if (labels != null) { labelPainter
labelPainter ..text = TextSpan(
..text = TextSpan( style: _sliderTheme.valueIndicatorTextStyle,
style: _sliderTheme.valueIndicatorTextStyle, text: text,
text: text, )
) ..textDirection = textDirection
..textDirection = textDirection ..textScaleFactor = textScaleFactor
..textScaleFactor = textScaleFactor ..layout();
..layout();
} else {
labelPainter.text = null;
}
// Changing the textDirection can result in the layout changing, because the // Changing the textDirection can result in the layout changing, because the
// bidi algorithm might line up the glyphs differently which can result in // bidi algorithm might line up the glyphs differently which can result in
// different ligatures, different shapes, etc. So we always markNeedsLayout. // different ligatures, different shapes, etc. So we always markNeedsLayout.
......
...@@ -124,7 +124,7 @@ class PlaceholderDimensions { ...@@ -124,7 +124,7 @@ class PlaceholderDimensions {
@override @override
String toString() { String toString() {
return 'PlaceholderDimensions($size, $baseline)'; return 'PlaceholderDimensions($size, $baseline${baselineOffset == null ? ", $baselineOffset" : ""})';
} }
} }
...@@ -187,28 +187,18 @@ class WordBoundary extends TextBoundary { ...@@ -187,28 +187,18 @@ class WordBoundary extends TextBoundary {
if (codeUnitAtIndex == null) { if (codeUnitAtIndex == null) {
return null; return null;
} }
switch (codeUnitAtIndex & 0xFC00) { return switch (codeUnitAtIndex & 0xFC00) {
case 0xD800: 0xD800 => _codePointFromSurrogates(codeUnitAtIndex, _text.codeUnitAt(index + 1)!),
return _codePointFromSurrogates(codeUnitAtIndex, _text.codeUnitAt(index + 1)!); 0xDC00 => _codePointFromSurrogates(_text.codeUnitAt(index - 1)!, codeUnitAtIndex),
case 0xDC00: _ => codeUnitAtIndex,
return _codePointFromSurrogates(_text.codeUnitAt(index - 1)!, codeUnitAtIndex); };
default:
return codeUnitAtIndex;
}
} }
static bool _isNewline(int codePoint) { static bool _isNewline(int codePoint) {
switch (codePoint) { return switch (codePoint) {
case 0x000A: 0x000A || 0x0085 || 0x000B || 0x000C || 0x2028 || 0x2029 => true,
case 0x0085: _ => false,
case 0x000B: };
case 0x000C:
case 0x2028:
case 0x2029:
return true;
default:
return false;
}
} }
bool _skipSpacesAndPunctuations(int offset, bool forward) { bool _skipSpacesAndPunctuations(int offset, bool forward) {
...@@ -270,6 +260,155 @@ class _UntilTextBoundary extends TextBoundary { ...@@ -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 /// This is used to cache and pass the computed metrics regarding the
/// caret's size and position. This is preferred due to the expensive /// caret's size and position. This is preferred due to the expensive
/// nature of the calculation. /// nature of the calculation.
...@@ -441,20 +580,27 @@ class TextPainter { ...@@ -441,20 +580,27 @@ class TextPainter {
} }
} }
// _paragraph being null means the text needs layout because of style changes. // Whether textWidthBasis has changed after the most recent `layout` call.
// Setting _paragraph to null invalidates all the layout cache. 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 // ui.Paragraph is entirely immutable, thus text style changes that can affect
// as `markNeedsLayout` is not called (i.e., the layout cache is still valid). // layout and those who can't both require the ui.Paragraph object being
// See: https://github.com/flutter/flutter/issues/85108 // recreated. The caller may not call `layout` again after text color is
ui.Paragraph? _paragraph; // updated. See: https://github.com/flutter/flutter/issues/85108
// Whether _paragraph contains outdated paint information and needs to be
// rebuilt before painting.
bool _rebuildParagraphForPaint = true; 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 { bool get _debugAssertTextLayoutIsValid {
assert(!debugDisposed); assert(!debugDisposed);
if (_paragraph == null) { if (_layoutCache == null) {
throw FlutterError.fromParts(<DiagnosticsNode>[ throw FlutterError.fromParts(<DiagnosticsNode>[
ErrorSummary('Text layout not available'), ErrorSummary('Text layout not available'),
if (_debugMarkNeedsLayoutCallStack != null) DiagnosticsStackTrace('The calls that first invalidated the text layout were', _debugMarkNeedsLayoutCallStack) if (_debugMarkNeedsLayoutCallStack != null) DiagnosticsStackTrace('The calls that first invalidated the text layout were', _debugMarkNeedsLayoutCallStack)
...@@ -474,15 +620,13 @@ class TextPainter { ...@@ -474,15 +620,13 @@ class TextPainter {
/// in framework will automatically invoke this method. /// in framework will automatically invoke this method.
void markNeedsLayout() { void markNeedsLayout() {
assert(() { assert(() {
if (_paragraph != null) { if (_layoutCache != null) {
_debugMarkNeedsLayoutCallStack ??= StackTrace.current; _debugMarkNeedsLayoutCallStack ??= StackTrace.current;
} }
return true; return true;
}()); }());
_paragraph?.dispose(); _layoutCache?.paragraph.dispose();
_paragraph = null; _layoutCache = null;
_lineMetricsCache = null;
_previousCaretPosition = null;
} }
/// The (potentially styled) text to paint. /// The (potentially styled) text to paint.
...@@ -515,8 +659,8 @@ class TextPainter { ...@@ -515,8 +659,8 @@ class TextPainter {
if (comparison.index >= RenderComparison.layout.index) { if (comparison.index >= RenderComparison.layout.index) {
markNeedsLayout(); markNeedsLayout();
} else if (comparison.index >= RenderComparison.paint.index) { } else if (comparison.index >= RenderComparison.paint.index) {
// Don't clear the _paragraph instance variable just yet. It still // Don't invalid the _layoutCache just yet. It still contains valid layout
// contains valid layout information. // information.
_rebuildParagraphForPaint = true; _rebuildParagraphForPaint = true;
} }
// Neither relayout or repaint is needed. // Neither relayout or repaint is needed.
...@@ -679,8 +823,8 @@ class TextPainter { ...@@ -679,8 +823,8 @@ class TextPainter {
if (_textWidthBasis == value) { if (_textWidthBasis == value) {
return; return;
} }
assert(() { return _debugNeedsRelayout = true; }());
_textWidthBasis = value; _textWidthBasis = value;
markNeedsLayout();
} }
/// {@macro dart.ui.textHeightBehavior} /// {@macro dart.ui.textHeightBehavior}
...@@ -699,8 +843,21 @@ class TextPainter { ...@@ -699,8 +843,21 @@ class TextPainter {
/// ///
/// Each box corresponds to a [PlaceholderSpan] in the order they were defined /// Each box corresponds to a [PlaceholderSpan] in the order they were defined
/// in the [InlineSpan] tree. /// in the [InlineSpan] tree.
List<TextBox>? get inlinePlaceholderBoxes => _inlinePlaceholderBoxes; List<TextBox>? get inlinePlaceholderBoxes {
List<TextBox>? _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. /// An ordered list of scales for each placeholder in the paragraph.
/// ///
...@@ -731,10 +888,10 @@ class TextPainter { ...@@ -731,10 +888,10 @@ class TextPainter {
if (span is PlaceholderSpan) { if (span is PlaceholderSpan) {
placeholderCount += 1; placeholderCount += 1;
} }
return true; return value.length >= placeholderCount ;
}); });
return placeholderCount; return placeholderCount == value.length;
}() == value.length); }());
_placeholderDimensions = value; _placeholderDimensions = value;
markNeedsLayout(); markNeedsLayout();
} }
...@@ -795,24 +952,13 @@ class TextPainter { ...@@ -795,24 +952,13 @@ class TextPainter {
/// sans-serif font). /// sans-serif font).
double get preferredLineHeight => (_layoutTemplate ??= _createLayoutTemplate()).height; 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 /// The width at which decreasing the width of the text would prevent it from
/// painting itself completely within its bounds. /// painting itself completely within its bounds.
/// ///
/// Valid only after [layout] has been called. /// Valid only after [layout] has been called.
double get minIntrinsicWidth { double get minIntrinsicWidth {
assert(_debugAssertTextLayoutIsValid); 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. /// The width at which increasing the width of the text no longer decreases the height.
...@@ -820,7 +966,7 @@ class TextPainter { ...@@ -820,7 +966,7 @@ class TextPainter {
/// Valid only after [layout] has been called. /// Valid only after [layout] has been called.
double get maxIntrinsicWidth { double get maxIntrinsicWidth {
assert(_debugAssertTextLayoutIsValid); assert(_debugAssertTextLayoutIsValid);
return _applyFloatingPointHack(_paragraph!.maxIntrinsicWidth); return _layoutCache!.layout.maxIntrinsicLineExtent;
} }
/// The horizontal space required to paint this text. /// The horizontal space required to paint this text.
...@@ -828,9 +974,8 @@ class TextPainter { ...@@ -828,9 +974,8 @@ class TextPainter {
/// Valid only after [layout] has been called. /// Valid only after [layout] has been called.
double get width { double get width {
assert(_debugAssertTextLayoutIsValid); assert(_debugAssertTextLayoutIsValid);
return _applyFloatingPointHack( assert(!_debugNeedsRelayout);
textWidthBasis == TextWidthBasis.longestLine ? _paragraph!.longestLine : _paragraph!.width, return _layoutCache!.contentWidth;
);
} }
/// The vertical space required to paint this text. /// The vertical space required to paint this text.
...@@ -838,7 +983,7 @@ class TextPainter { ...@@ -838,7 +983,7 @@ class TextPainter {
/// Valid only after [layout] has been called. /// Valid only after [layout] has been called.
double get height { double get height {
assert(_debugAssertTextLayoutIsValid); assert(_debugAssertTextLayoutIsValid);
return _applyFloatingPointHack(_paragraph!.height); return _layoutCache!.layout.height;
} }
/// The amount of space required to paint this text. /// The amount of space required to paint this text.
...@@ -846,6 +991,7 @@ class TextPainter { ...@@ -846,6 +991,7 @@ class TextPainter {
/// Valid only after [layout] has been called. /// Valid only after [layout] has been called.
Size get size { Size get size {
assert(_debugAssertTextLayoutIsValid); assert(_debugAssertTextLayoutIsValid);
assert(!_debugNeedsRelayout);
return Size(width, height); return Size(width, height);
} }
...@@ -855,10 +1001,7 @@ class TextPainter { ...@@ -855,10 +1001,7 @@ class TextPainter {
/// Valid only after [layout] has been called. /// Valid only after [layout] has been called.
double computeDistanceToActualBaseline(TextBaseline baseline) { double computeDistanceToActualBaseline(TextBaseline baseline) {
assert(_debugAssertTextLayoutIsValid); assert(_debugAssertTextLayoutIsValid);
return switch (baseline) { return _layoutCache!.layout.getDistanceToBaseline(baseline);
TextBaseline.alphabetic => _paragraph!.alphabeticBaseline,
TextBaseline.ideographic => _paragraph!.ideographicBaseline,
};
} }
/// Whether any text was truncated or ellipsized. /// Whether any text was truncated or ellipsized.
...@@ -874,20 +1017,12 @@ class TextPainter { ...@@ -874,20 +1017,12 @@ class TextPainter {
/// Valid only after [layout] has been called. /// Valid only after [layout] has been called.
bool get didExceedMaxLines { bool get didExceedMaxLines {
assert(_debugAssertTextLayoutIsValid); 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 // Creates a ui.Paragraph using the current configurations in this class and
// assign it to _paragraph. // assign it to _paragraph.
ui.Paragraph _createParagraph() { ui.Paragraph _createParagraph(InlineSpan text) {
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.');
}
final ui.ParagraphBuilder builder = ui.ParagraphBuilder(_createParagraphStyle()); final ui.ParagraphBuilder builder = ui.ParagraphBuilder(_createParagraphStyle());
text.build(builder, textScaleFactor: textScaleFactor, dimensions: _placeholderDimensions); text.build(builder, textScaleFactor: textScaleFactor, dimensions: _placeholderDimensions);
_inlinePlaceholderScales = builder.placeholderScales; _inlinePlaceholderScales = builder.placeholderScales;
...@@ -895,60 +1030,71 @@ class TextPainter { ...@@ -895,60 +1030,71 @@ class TextPainter {
_debugMarkNeedsLayoutCallStack = null; _debugMarkNeedsLayoutCallStack = null;
return true; return true;
}()); }());
final ui.Paragraph paragraph = _paragraph = builder.build();
_rebuildParagraphForPaint = false; _rebuildParagraphForPaint = false;
return paragraph; return builder.build();
}
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));
}
}
} }
/// Computes the visual position of the glyphs for painting the text. /// 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 /// 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 /// width (or its longest line, if [textWidthBasis] is set to
/// less than or equal to `maxWidth`. /// [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 /// The [text] and [textDirection] properties must be non-null before this is
/// called. /// called.
void layout({ double minWidth = 0.0, double maxWidth = double.infinity }) { 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(!maxWidth.isNaN);
assert(textDirection != null, 'TextPainter.textDirection must be set to a non-null value before using the TextPainter.'); assert(!minWidth.isNaN);
// Return early if the current layout information is not outdated, even if assert(() {
// _needsPaint is true (in which case _paragraph will be rebuilt in paint). _debugNeedsRelayout = false;
if (_paragraph != null && minWidth == _lastMinWidth && maxWidth == _lastMaxWidth) { return true;
}());
final _TextPainterLayoutCacheWithOffset? cachedLayout = _layoutCache;
if (cachedLayout != null && cachedLayout._resizeToFit(minWidth, maxWidth, textWidthBasis)) {
return; return;
} }
if (_rebuildParagraphForPaint || _paragraph == null) { final InlineSpan? text = this.text;
_createParagraph(); 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; _layoutCache = newLayoutCache;
_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();
} }
/// Paints the text onto the given canvas at the given offset. /// Paints the text onto the given canvas at the given offset.
...@@ -964,15 +1110,18 @@ class TextPainter { ...@@ -964,15 +1110,18 @@ class TextPainter {
/// To set the text style, specify a [TextStyle] when creating the [TextSpan] /// To set the text style, specify a [TextStyle] when creating the [TextSpan]
/// that you pass to the [TextPainter] constructor or to the [text] property. /// that you pass to the [TextPainter] constructor or to the [text] property.
void paint(Canvas canvas, Offset offset) { void paint(Canvas canvas, Offset offset) {
final double? minWidth = _lastMinWidth; final _TextPainterLayoutCacheWithOffset? layoutCache = _layoutCache;
final double? maxWidth = _lastMaxWidth; if (layoutCache == null) {
if (_paragraph == null || minWidth == null || maxWidth == null) {
throw StateError( throw StateError(
'TextPainter.paint called when text geometry was not yet calculated.\n' 'TextPainter.paint called when text geometry was not yet calculated.\n'
'Please call layout() before paint() to position the text before painting it.', 'Please call layout() before paint() to position the text before painting it.',
); );
} }
if (!layoutCache.paintOffset.dx.isFinite || !layoutCache.paintOffset.dy.isFinite) {
return;
}
if (_rebuildParagraphForPaint) { if (_rebuildParagraphForPaint) {
Size? debugSize; Size? debugSize;
assert(() { assert(() {
...@@ -980,15 +1129,18 @@ class TextPainter { ...@@ -980,15 +1129,18 @@ class TextPainter {
return true; return true;
}()); }());
_createParagraph(); final ui.Paragraph paragraph = layoutCache.layout._paragraph;
// Unfortunately we have to redo the layout using the same constraints, // Unfortunately even if we know that there is only paint changes, there's
// since we've created a new ui.Paragraph. But there's no extra work being // no API to only make those updates so the paragraph has to be recreated
// done: if _needsPaint is true and _paragraph is not null, the previous // and re-laid out.
// `layout` call didn't invoke _layoutParagraph. assert(!_inputWidth.isNaN);
_layoutParagraph(minWidth, maxWidth); layoutCache.layout._paragraph = _createParagraph(text!)..layout(ui.ParagraphConstraints(width: _inputWidth));
assert(paragraph.width == layoutCache.layout._paragraph.width);
paragraph.dispose();
assert(debugSize == size); 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. // Returns true if value falls in the valid range of the UTF16 encoding.
...@@ -1076,7 +1228,7 @@ class TextPainter { ...@@ -1076,7 +1228,7 @@ class TextPainter {
final int prevRuneOffset = offset - graphemeClusterLength; final int prevRuneOffset = offset - graphemeClusterLength;
// Use BoxHeightStyle.strut to ensure that the caret's height fits within // Use BoxHeightStyle.strut to ensure that the caret's height fits within
// the line's height and is consistent throughout the line. // 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. // When the range does not include a full cluster, no boxes will be returned.
if (boxes.isEmpty) { if (boxes.isEmpty) {
// When we are at the beginning of the line, a non-surrogate position will // When we are at the beginning of the line, a non-surrogate position will
...@@ -1100,7 +1252,6 @@ class TextPainter { ...@@ -1100,7 +1252,6 @@ class TextPainter {
// It may not work in bidi text: https://github.com/flutter/flutter/issues/123424 // It may not work in bidi text: https://github.com/flutter/flutter/issues/123424
final TextBox box = boxes.last.direction == TextDirection.ltr final TextBox box = boxes.last.direction == TextDirection.ltr
? boxes.last : boxes.first; ? boxes.last : boxes.first;
return prevCodeUnit == NEWLINE_CODE_UNIT return prevCodeUnit == NEWLINE_CODE_UNIT
? _EmptyLineCaretMetrics(lineVerticalOffset: box.bottom) ? _EmptyLineCaretMetrics(lineVerticalOffset: box.bottom)
: _LineCaretMetrics(offset: Offset(box.end, box.top), writingDirection: box.direction, fullHeight: box.bottom - box.top); : _LineCaretMetrics(offset: Offset(box.end, box.top), writingDirection: box.direction, fullHeight: box.bottom - box.top);
...@@ -1127,7 +1278,7 @@ class TextPainter { ...@@ -1127,7 +1278,7 @@ class TextPainter {
final int nextRuneOffset = offset + graphemeClusterLength; final int nextRuneOffset = offset + graphemeClusterLength;
// Use BoxHeightStyle.strut to ensure that the caret's height fits within // Use BoxHeightStyle.strut to ensure that the caret's height fits within
// the line's height and is consistent throughout the line. // 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. // When the range does not include a full cluster, no boxes will be returned.
if (boxes.isEmpty) { if (boxes.isEmpty) {
// When we are at the end of the line, a non-surrogate position will // When we are at the end of the line, a non-surrogate position will
...@@ -1151,7 +1302,6 @@ class TextPainter { ...@@ -1151,7 +1302,6 @@ class TextPainter {
// It may not work in bidi text: https://github.com/flutter/flutter/issues/123424 // It may not work in bidi text: https://github.com/flutter/flutter/issues/123424
final TextBox box = boxes.first.direction == TextDirection.ltr final TextBox box = boxes.first.direction == TextDirection.ltr
? boxes.first : boxes.last; ? boxes.first : boxes.last;
return _LineCaretMetrics(offset: Offset(box.start, box.top), writingDirection: box.direction, fullHeight: box.bottom - box.top); return _LineCaretMetrics(offset: Offset(box.start, box.top), writingDirection: box.direction, fullHeight: box.bottom - box.top);
} }
return null; return null;
...@@ -1176,6 +1326,7 @@ class TextPainter { ...@@ -1176,6 +1326,7 @@ class TextPainter {
/// Valid only after [layout] has been called. /// Valid only after [layout] has been called.
Offset getOffsetForCaret(TextPosition position, Rect caretPrototype) { Offset getOffsetForCaret(TextPosition position, Rect caretPrototype) {
final _CaretMetrics caretMetrics; final _CaretMetrics caretMetrics;
final _TextPainterLayoutCacheWithOffset layoutCache = _layoutCache!;
if (position.offset < 0) { if (position.offset < 0) {
// TODO(LongCatIsLooong): make this case impossible; see https://github.com/flutter/flutter/issues/79495 // TODO(LongCatIsLooong): make this case impossible; see https://github.com/flutter/flutter/issues/79495
caretMetrics = const _EmptyLineCaretMetrics(lineVerticalOffset: 0); caretMetrics = const _EmptyLineCaretMetrics(lineVerticalOffset: 0);
...@@ -1190,7 +1341,7 @@ class TextPainter { ...@@ -1190,7 +1341,7 @@ class TextPainter {
// The full width is not (width - caretPrototype.width) // The full width is not (width - caretPrototype.width)
// because RenderEditable reserves cursor width on the right. Ideally this // because RenderEditable reserves cursor width on the right. Ideally this
// should be handled by RenderEditable instead. // 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); return Offset(dx, lineVerticalOffset);
case _LineCaretMetrics(writingDirection: TextDirection.ltr, :final Offset offset): case _LineCaretMetrics(writingDirection: TextDirection.ltr, :final Offset offset):
rawOffset = offset; rawOffset = offset;
...@@ -1202,8 +1353,8 @@ class TextPainter { ...@@ -1202,8 +1353,8 @@ class TextPainter {
// should be handled by higher-level implementations (for instance, // should be handled by higher-level implementations (for instance,
// RenderEditable reserves width for showing the caret, it's best to handle // RenderEditable reserves width for showing the caret, it's best to handle
// the clamping there). // the clamping there).
final double adjustedDx = clampDouble(rawOffset.dx, 0, width); final double adjustedDx = clampDouble(rawOffset.dx + layoutCache.paintOffset.dx, 0, layoutCache.contentWidth);
return Offset(adjustedDx, rawOffset.dy); return Offset(adjustedDx, rawOffset.dy + layoutCache.paintOffset.dy);
} }
/// {@template flutter.painting.textPainter.getFullHeightForCaret} /// {@template flutter.painting.textPainter.getFullHeightForCaret}
...@@ -1227,16 +1378,13 @@ class TextPainter { ...@@ -1227,16 +1378,13 @@ class TextPainter {
// get rect calls to the paragraph. // get rect calls to the paragraph.
late _CaretMetrics _caretMetrics; 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 // Checks if the [position] and [caretPrototype] have changed from the cached
// version and recomputes the metrics required to position the caret. // version and recomputes the metrics required to position the caret.
_CaretMetrics _computeCaretMetrics(TextPosition position) { _CaretMetrics _computeCaretMetrics(TextPosition position) {
assert(_debugAssertTextLayoutIsValid); assert(_debugAssertTextLayoutIsValid);
if (position == _previousCaretPosition) { assert(!_debugNeedsRelayout);
final _TextPainterLayoutCacheWithOffset cachedLayout = _layoutCache!;
if (position == cachedLayout._previousCaretPosition) {
return _caretMetrics; return _caretMetrics;
} }
final int offset = position.offset; final int offset = position.offset;
...@@ -1245,7 +1393,7 @@ class TextPainter { ...@@ -1245,7 +1393,7 @@ class TextPainter {
TextAffinity.downstream => _getMetricsFromDownstream(offset) ?? _getMetricsFromUpstream(offset), TextAffinity.downstream => _getMetricsFromDownstream(offset) ?? _getMetricsFromUpstream(offset),
}; };
// Cache the input parameters to prevent repeat work later. // Cache the input parameters to prevent repeat work later.
_previousCaretPosition = position; cachedLayout._previousCaretPosition = position;
return _caretMetrics = metrics ?? const _EmptyLineCaretMetrics(lineVerticalOffset: 0); return _caretMetrics = metrics ?? const _EmptyLineCaretMetrics(lineVerticalOffset: 0);
} }
...@@ -1274,18 +1422,29 @@ class TextPainter { ...@@ -1274,18 +1422,29 @@ class TextPainter {
}) { }) {
assert(_debugAssertTextLayoutIsValid); assert(_debugAssertTextLayoutIsValid);
assert(selection.isValid); 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.start,
selection.end, selection.end,
boxHeightStyle: boxHeightStyle, boxHeightStyle: boxHeightStyle,
boxWidthStyle: boxWidthStyle, 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. /// Returns the position within the text for the given pixel offset.
TextPosition getPositionForOffset(Offset offset) { TextPosition getPositionForOffset(Offset offset) {
assert(_debugAssertTextLayoutIsValid); assert(_debugAssertTextLayoutIsValid);
return _paragraph!.getPositionForOffset(offset); assert(!_debugNeedsRelayout);
final _TextPainterLayoutCacheWithOffset cachedLayout = _layoutCache!;
return cachedLayout.paragraph.getPositionForOffset(offset - cachedLayout.paintOffset);
} }
/// {@template flutter.painting.TextPainter.getWordBoundary} /// {@template flutter.painting.TextPainter.getWordBoundary}
...@@ -1299,7 +1458,7 @@ class TextPainter { ...@@ -1299,7 +1458,7 @@ class TextPainter {
/// {@endtemplate} /// {@endtemplate}
TextRange getWordBoundary(TextPosition position) { TextRange getWordBoundary(TextPosition position) {
assert(_debugAssertTextLayoutIsValid); assert(_debugAssertTextLayoutIsValid);
return _paragraph!.getWordBoundary(position); return _layoutCache!.paragraph.getWordBoundary(position);
} }
/// {@template flutter.painting.TextPainter.wordBoundaries} /// {@template flutter.painting.TextPainter.wordBoundaries}
...@@ -1312,17 +1471,44 @@ class TextPainter { ...@@ -1312,17 +1471,44 @@ class TextPainter {
/// ///
/// Currently word boundary analysis can only be performed after [layout] /// Currently word boundary analysis can only be performed after [layout]
/// has been called. /// 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. /// Returns the text range of the line at the given offset.
/// ///
/// The newline (if any) is not returned as part of the range. /// The newline (if any) is not returned as part of the range.
TextRange getLineBoundary(TextPosition position) { TextRange getLineBoundary(TextPosition position) {
assert(_debugAssertTextLayoutIsValid); 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 /// Returns the full list of [LineMetrics] that describe in detail the various
/// metrics of each laid out line. /// metrics of each laid out line.
/// ///
...@@ -1336,7 +1522,16 @@ class TextPainter { ...@@ -1336,7 +1522,16 @@ class TextPainter {
/// Valid only after [layout] has been called. /// Valid only after [layout] has been called.
List<ui.LineMetrics> computeLineMetrics() { List<ui.LineMetrics> computeLineMetrics() {
assert(_debugAssertTextLayoutIsValid); 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; bool _disposed = false;
...@@ -1363,8 +1558,8 @@ class TextPainter { ...@@ -1363,8 +1558,8 @@ class TextPainter {
}()); }());
_layoutTemplate?.dispose(); _layoutTemplate?.dispose();
_layoutTemplate = null; _layoutTemplate = null;
_paragraph?.dispose(); _layoutCache?.paragraph.dispose();
_paragraph = null; _layoutCache = null;
_text = null; _text = null;
} }
} }
...@@ -5692,8 +5692,15 @@ void main() { ...@@ -5692,8 +5692,15 @@ void main() {
expect( expect(
find.text(longStringB), find.text(longStringB),
// 133.3 is approximately 100 / 0.75 (_kFinalLabelScale) paints..something((Symbol methodName, List<dynamic> arguments) {
paints..clipRect(rect: const Rect.fromLTWH(0, 0, 133.0, 16.0)), 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 }, skip: isBrowser); // TODO(yjbanov): https://github.com/flutter/flutter/issues/44020
......
...@@ -382,9 +382,9 @@ void main() { ...@@ -382,9 +382,9 @@ void main() {
test('TextPainter requires textDirection', () { test('TextPainter requires textDirection', () {
final TextPainter painter1 = TextPainter(text: const TextSpan(text: '')); 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); 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', () { test('TextPainter size test', () {
...@@ -1457,8 +1457,67 @@ void main() { ...@@ -1457,8 +1457,67 @@ void main() {
painter.dispose(); 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 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() { ...@@ -1225,9 +1225,11 @@ void main() {
width: 400, width: 400,
child: Center( child: Center(
child: RichText( 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'), text: const TextSpan(text: 'fwefwefwewfefewfwe fwfwfwefweabcdefghijklmnopqrstuvwxyz'),
textWidthBasis: TextWidthBasis.longestLine, textWidthBasis: TextWidthBasis.longestLine,
textDirection: TextDirection.ltr, textDirection: TextDirection.rtl,
), ),
), ),
), ),
...@@ -1239,11 +1241,12 @@ void main() { ...@@ -1239,11 +1241,12 @@ void main() {
return false; return false;
} }
final ui.Paragraph paragraph = arguments[0] as ui.Paragraph; final ui.Paragraph paragraph = arguments[0] as ui.Paragraph;
if (paragraph.longestLine > paragraph.width) { final Offset offset = arguments[1] as Offset;
throw 'paragraph width (${paragraph.width}) greater than its longest line (${paragraph.longestLine}).'; final List<ui.LineMetrics> lines = paragraph.computeLineMetrics();
} for (final ui.LineMetrics line in lines) {
if (paragraph.width >= 400) { if (line.left + offset.dx + line.width >= 400) {
throw 'paragraph.width (${paragraph.width}) >= 400'; throw 'line $line is greater than the max width constraints';
}
} }
return true; 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