blob: 30ae994538c1a30b0e5f9c7c4280c605083bac46 [file] [log] [blame]
// Copyright 2013 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
// @dart = 2.6
part of engine;
// TODO(yjbanov): this is a hack we use to compute ideographic baseline; this
// number is the ratio ideographic/alphabetic for font Ahem,
// which matches the Flutter number. It may be completely wrong
// for any other font. We'll need to eventually fix this. That
// said Flutter doesn't seem to use ideographic baseline for
// anything as of this writing.
const double _baselineRatioHack = 1.1662499904632568;
/// Signature of a function that takes a character and returns true or false.
typedef CharPredicate = bool Function(int char);
bool _whitespacePredicate(int char) =>
char == CharCode.space || char == CharCode.tab || _newlinePredicate(char);
bool _newlinePredicate(int char) =>
char == CharCode.cr || char == CharCode.lf || char == CharCode.nl;
/// Manages [ParagraphRuler] instances and caches them per unique
/// [ParagraphGeometricStyle].
///
/// All instances of [ParagraphRuler] should be created through this class.
class RulerManager {
RulerManager({@required this.rulerCacheCapacity}) {
_rulerHost.style
..position = 'fixed'
..visibility = 'hidden'
..overflow = 'hidden'
..top = '0'
..left = '0'
..width = '0'
..height = '0';
html.document.body.append(_rulerHost);
registerHotRestartListener(dispose);
}
final int rulerCacheCapacity;
/// Hosts a cache of rulers that measure text.
///
/// This element exists purely for organizational purposes. Otherwise the
/// rulers would be attached to the `<body>` element polluting the element
/// tree and making it hard to navigate. It does not serve any functional
/// purpose.
final html.Element _rulerHost = html.Element.tag('flt-ruler-host');
/// The cache of rulers used to measure text.
///
/// Each ruler is keyed by paragraph style. This allows us to setup the
/// ruler's DOM structure once during the very first measurement of a given
/// paragraph style. Subsequent measurements could reuse the same ruler and
/// only swap the text contents. This minimizes the amount of work a browser
/// needs to do when measure many pieces of text with the same style.
///
/// What makes this cache effective is the fact that a typical application
/// only uses a limited number of text styles. Using too many text styles on
/// the same screen is considered bad for user experience.
Map<ParagraphGeometricStyle, ParagraphRuler> get rulers => _rulers;
Map<ParagraphGeometricStyle, ParagraphRuler> _rulers =
<ParagraphGeometricStyle, ParagraphRuler>{};
bool _rulerCacheCleanupScheduled = false;
void _scheduleRulerCacheCleanup() {
if (!_rulerCacheCleanupScheduled) {
_rulerCacheCleanupScheduled = true;
scheduleMicrotask(() {
_rulerCacheCleanupScheduled = false;
cleanUpRulerCache();
});
}
}
/// Releases the resources used by this [RulerManager].
///
/// After this is called, this object is no longer usable.
void dispose() {
_rulerHost?.remove();
}
// Evicts all rulers from the cache.
void _evictAllRulers() {
_rulers.forEach((ParagraphGeometricStyle style, ParagraphRuler ruler) {
ruler.dispose();
});
_rulers = <ParagraphGeometricStyle, ParagraphRuler>{};
}
/// If [window._isPhysicalSizeActuallyEmpty], evicts all rulers from the cache.
/// If ruler cache size exceeds [rulerCacheCapacity], evicts those rulers that
/// were used the least.
///
/// Resets hit counts back to zero.
@visibleForTesting
void cleanUpRulerCache() {
// Measurements performed (and cached) inside a hidden iframe (with
// display:none) are wrong.
// Evict all rulers, so text gets re-measured when the iframe becomes
// visible.
// see: https://github.com/flutter/flutter/issues/36341
if (window.physicalSize.isEmpty) {
_evictAllRulers();
return;
}
if (_rulers.length > rulerCacheCapacity) {
final List<ParagraphRuler> sortedByUsage = _rulers.values.toList();
sortedByUsage.sort((ParagraphRuler a, ParagraphRuler b) {
return b.hitCount - a.hitCount;
});
_rulers = <ParagraphGeometricStyle, ParagraphRuler>{};
for (int i = 0; i < sortedByUsage.length; i++) {
final ParagraphRuler ruler = sortedByUsage[i];
ruler.resetHitCount();
if (i < rulerCacheCapacity) {
// Retain this ruler.
_rulers[ruler.style] = ruler;
} else {
// This ruler did not have enough usage this frame to be retained.
ruler.dispose();
}
}
}
}
/// Adds an element used for measuring text as a child of [_rulerHost].
void addHostElement(html.DivElement element) {
_rulerHost.append(element);
}
/// Performs a cache lookup to find an existing [ParagraphRuler] for the given
/// [style] and if it can't find one in the cache, it would create one.
///
/// The returned ruler is marked as hit so there's no need to do that
/// elsewhere.
@visibleForTesting
ParagraphRuler findOrCreateRuler(ParagraphGeometricStyle style) {
ParagraphRuler ruler = _rulers[style];
if (ruler == null) {
if (assertionsEnabled) {
domRenderer.debugRulerCacheMiss();
}
ruler = _rulers[style] = ParagraphRuler(style, this);
_scheduleRulerCacheCleanup();
} else {
if (assertionsEnabled) {
domRenderer.debugRulerCacheHit();
}
}
ruler.hit();
return ruler;
}
}
/// Provides various text measurement APIs using either a dom-based approach
/// in [DomTextMeasurementService], or a canvas-based approach in
/// [CanvasTextMeasurementService].
abstract class TextMeasurementService {
/// Whether this service uses a canvas to make the text measurements.
///
/// If [isCanvas] is false, it indicates that this service uses DOM elements
/// to make the text measurements.
bool get isCanvas;
/// Initializes the text measurement service with a specific
/// [rulerCacheCapacity] that gets passed to the [RulerManager].
static void initialize({@required int rulerCacheCapacity}) {
rulerManager?.dispose();
rulerManager = null;
rulerManager = RulerManager(rulerCacheCapacity: rulerCacheCapacity);
}
@visibleForTesting
static RulerManager rulerManager;
/// The DOM-based text measurement service.
@visibleForTesting
static TextMeasurementService get domInstance =>
DomTextMeasurementService.instance;
/// The canvas-based text measurement service.
@visibleForTesting
static TextMeasurementService get canvasInstance =>
CanvasTextMeasurementService.instance;
/// Gets the appropriate [TextMeasurementService] instance for the given
/// [paragraph].
static TextMeasurementService forParagraph(ui.Paragraph paragraph) {
// TODO(flutter_web): https://github.com/flutter/flutter/issues/33523
// When the canvas-based implementation is complete and passes all the
// tests, get rid of [_experimentalEnableCanvasImplementation].
// We need to check [window.physicalSize.isEmpty] because some canvas
// commands don't work as expected when they run inside a hidden iframe
// (with display:none)
// Skip using canvas measurements until the iframe becomes visible.
// see: https://github.com/flutter/flutter/issues/36341
if (!window.physicalSize.isEmpty &&
WebExperiments.instance.useCanvasText &&
_canUseCanvasMeasurement(paragraph)) {
return canvasInstance;
}
return domInstance;
}
/// Clears the cache of paragraph rulers that are used for measuring paragraph
/// metrics.
static void clearCache() {
rulerManager?._evictAllRulers();
}
static bool _canUseCanvasMeasurement(EngineParagraph paragraph) {
// Currently, the canvas-based approach only works on plain text that
// doesn't have any of the following styles:
// - decoration
// - word spacing
final ParagraphGeometricStyle style = paragraph._geometricStyle;
return paragraph._plainText != null &&
style.decoration == null &&
style.wordSpacing == null;
}
/// Measures the paragraph and returns a [MeasurementResult] object.
MeasurementResult measure(
EngineParagraph paragraph,
ui.ParagraphConstraints constraints,
) {
assert(rulerManager != null);
final ParagraphGeometricStyle style = paragraph._geometricStyle;
final ParagraphRuler ruler =
TextMeasurementService.rulerManager.findOrCreateRuler(style);
if (assertionsEnabled) {
if (paragraph._plainText == null) {
domRenderer.debugRichTextLayout();
} else {
domRenderer.debugPlainTextLayout();
}
}
MeasurementResult result = ruler.cacheLookup(paragraph, constraints);
if (result != null) {
return result;
}
result = _doMeasure(paragraph, constraints, ruler);
ruler.cacheMeasurement(paragraph, result);
return result;
}
/// Measures the width of a substring of the given [paragraph] with no
/// constraints.
double measureSubstringWidth(EngineParagraph paragraph, int start, int end);
/// Returns text position given a paragraph, constraints and offset.
ui.TextPosition getTextPositionForOffset(EngineParagraph paragraph,
ui.ParagraphConstraints constraints, ui.Offset offset);
/// Delegates to a [ParagraphRuler] to measure a list of text boxes that
/// enclose the given range of text.
List<ui.TextBox> measureBoxesForRange(
EngineParagraph paragraph,
ui.ParagraphConstraints constraints, {
int start,
int end,
double alignOffset,
ui.TextDirection textDirection,
}) {
final ParagraphGeometricStyle style = paragraph._geometricStyle;
final ParagraphRuler ruler =
TextMeasurementService.rulerManager.findOrCreateRuler(style);
return ruler.measureBoxesForRange(
paragraph._plainText,
constraints,
start: start,
end: end,
alignOffset: alignOffset,
textDirection: textDirection,
);
}
/// Performs the actual measurement of the following values for the given
/// paragraph:
///
/// * isSingleLine: whether the paragraph can be rendered in a single line.
/// * height: constrained measure of the entire paragraph's height.
/// * lineHeight: the height of a single line of the paragraph.
/// * alphabeticBaseline: single line measure.
/// * ideographicBaseline: based on [alphabeticBaseline].
/// * maxIntrinsicWidth: the width of the paragraph with no line-wrapping.
/// * minIntrinsicWidth: the min width the paragraph fits in without overflowing.
///
/// [MeasurementResult.width] is set to the same value of [constraints.width].
///
/// It also optionally computes [MeasurementResult.lines] in the given
/// paragraph. When that's available, it can be used by a canvas to render
/// the text line.
MeasurementResult _doMeasure(
EngineParagraph paragraph,
ui.ParagraphConstraints constraints,
ParagraphRuler ruler,
);
}
/// A DOM-based text measurement implementation.
///
/// This implementation is slower than [CanvasTextMeasurementService] but it's
/// needed for some cases that aren't yet supported in the canvas-based
/// implementation such as letter-spacing, word-spacing, etc.
class DomTextMeasurementService extends TextMeasurementService {
@override
final bool isCanvas = false;
/// The text measurement service singleton.
static DomTextMeasurementService get instance =>
_instance ??= DomTextMeasurementService();
static DomTextMeasurementService _instance;
@override
MeasurementResult _doMeasure(
EngineParagraph paragraph,
ui.ParagraphConstraints constraints,
ParagraphRuler ruler,
) {
ruler.willMeasure(paragraph);
final String plainText = paragraph._plainText;
ruler.measureAll(constraints);
MeasurementResult result;
// When the text has a new line, we should always use multi-line mode.
final bool hasNewline = plainText?.contains('\n') ?? false;
if (!hasNewline && ruler.singleLineDimensions.width <= constraints.width) {
result = _measureSingleLineParagraph(ruler, paragraph, constraints);
} else {
// Assert: If text doesn't have new line for infinite constraints we
// should have called single line measure paragraph instead.
assert(hasNewline || constraints.width != double.infinity);
result = _measureMultiLineParagraph(ruler, paragraph, constraints);
}
ruler.didMeasure();
return result;
}
@override
double measureSubstringWidth(EngineParagraph paragraph, int start, int end) {
assert(paragraph._plainText != null);
final ParagraphGeometricStyle style = paragraph._geometricStyle;
final ParagraphRuler ruler =
TextMeasurementService.rulerManager.findOrCreateRuler(style);
final String text = paragraph._plainText.substring(start, end);
final ui.Paragraph substringParagraph = paragraph._cloneWithText(text);
ruler.willMeasure(substringParagraph);
ruler.measureAsSingleLine();
final TextDimensions dimensions = ruler.singleLineDimensions;
ruler.didMeasure();
return dimensions.width;
}
@override
ui.TextPosition getTextPositionForOffset(EngineParagraph paragraph,
ui.ParagraphConstraints constraints, ui.Offset offset) {
assert(
paragraph._measurementResult.lines == null,
'should only be called when the faster lines-based approach is not possible',
);
final ParagraphGeometricStyle style = paragraph._geometricStyle;
final ParagraphRuler ruler =
TextMeasurementService.rulerManager.findOrCreateRuler(style);
ruler.willMeasure(paragraph);
final int position = ruler.hitTest(constraints, offset);
ruler.didMeasure();
return ui.TextPosition(offset: position);
}
/// Called when we have determined that the paragraph fits the [constraints]
/// without wrapping.
///
/// This means that:
/// * `width == maxIntrinsicWidth` - we gave it more horizontal space than
/// it needs and so the paragraph won't expand beyond `maxIntrinsicWidth`.
/// * `height` is the height computed by `measureAsSingleLine`; giving the
/// paragraph the width constraint won't change its height as we already
/// determined that it fits within the constraint without wrapping.
/// * `alphabeticBaseline` is also final for the same reason as the `height`
/// value.
///
/// This method still needs to measure `minIntrinsicWidth`.
MeasurementResult _measureSingleLineParagraph(
ParagraphRuler ruler,
EngineParagraph paragraph,
ui.ParagraphConstraints constraints,
) {
final double width = constraints.width;
final double minIntrinsicWidth = ruler.minIntrinsicDimensions.width;
double maxIntrinsicWidth = ruler.singleLineDimensions.width;
final double alphabeticBaseline = ruler.alphabeticBaseline;
final double height = ruler.singleLineDimensions.height;
maxIntrinsicWidth =
_applySubPixelRoundingHack(minIntrinsicWidth, maxIntrinsicWidth);
final double ideographicBaseline = alphabeticBaseline * _baselineRatioHack;
final String text = paragraph._plainText;
List<EngineLineMetrics> lines;
if (text != null) {
final double lineWidth = maxIntrinsicWidth;
final double alignOffset = _calculateAlignOffsetForLine(
paragraph: paragraph,
lineWidth: lineWidth,
maxWidth: width,
);
lines = <EngineLineMetrics>[
EngineLineMetrics.withText(
text,
startIndex: 0,
endIndex: text.length,
endIndexWithoutNewlines:
_excludeTrailing(text, 0, text.length, _newlinePredicate),
hardBreak: true,
width: lineWidth,
left: alignOffset,
lineNumber: 0,
),
];
}
return MeasurementResult(
constraints.width,
isSingleLine: true,
width: width,
height: height,
naturalHeight: height,
lineHeight: height,
minIntrinsicWidth: minIntrinsicWidth,
maxIntrinsicWidth: maxIntrinsicWidth,
alphabeticBaseline: alphabeticBaseline,
ideographicBaseline: ideographicBaseline,
lines: lines,
textAlign: paragraph._textAlign,
textDirection: paragraph._textDirection,
);
}
/// Called when we have determined that the paragraph needs to wrap into
/// multiple lines to fit the [constraints], i.e. its `maxIntrinsicWidth` is
/// bigger than the available horizontal space.
///
/// While `maxIntrinsicWidth` is still good from the call to
/// `measureAsSingleLine`, we need to re-measure with the width constraint
/// and get new values for width, height and alphabetic baseline. We also need
/// to measure `minIntrinsicWidth`.
MeasurementResult _measureMultiLineParagraph(ParagraphRuler ruler,
EngineParagraph paragraph, ui.ParagraphConstraints constraints) {
// If constraint is infinite, we must use _measureSingleLineParagraph
final double width = constraints.width;
final double minIntrinsicWidth = ruler.minIntrinsicDimensions.width;
double maxIntrinsicWidth = ruler.singleLineDimensions.width;
final double alphabeticBaseline = ruler.alphabeticBaseline;
// Natural height is the full height of text ignoring height constraints.
final double naturalHeight = ruler.constrainedDimensions.height;
double height;
double lineHeight;
final int maxLines = paragraph._geometricStyle.maxLines;
if (maxLines == null) {
height = naturalHeight;
} else {
// Lazily compute [lineHeight] when [maxLines] is not null.
lineHeight = ruler.lineHeightDimensions.height;
height = math.min(naturalHeight, maxLines * lineHeight);
}
maxIntrinsicWidth =
_applySubPixelRoundingHack(minIntrinsicWidth, maxIntrinsicWidth);
assert(minIntrinsicWidth <= maxIntrinsicWidth);
final double ideographicBaseline = alphabeticBaseline * _baselineRatioHack;
return MeasurementResult(
constraints.width,
isSingleLine: false,
width: width,
height: height,
lineHeight: lineHeight,
naturalHeight: naturalHeight,
minIntrinsicWidth: minIntrinsicWidth,
maxIntrinsicWidth: maxIntrinsicWidth,
alphabeticBaseline: alphabeticBaseline,
ideographicBaseline: ideographicBaseline,
lines: null,
textAlign: paragraph._textAlign,
textDirection: paragraph._textDirection,
);
}
/// This hack is needed because `offsetWidth` rounds the value to the nearest
/// whole number. On a very rare occasion the minimum intrinsic width reported
/// by the browser is slightly bigger than the reported maximum intrinsic
/// width. If the discrepancy overlaps 0.5 then the rounding happens in
/// opposite directions.
///
/// For example, if minIntrinsicWidth == 99.5 and maxIntrinsicWidth == 99.48,
/// then minIntrinsicWidth is rounded up to 100, and maxIntrinsicWidth is
/// rounded down to 99.
// TODO(yjbanov): remove the need for this hack.
static double _applySubPixelRoundingHack(
double minIntrinsicWidth, double maxIntrinsicWidth) {
if (minIntrinsicWidth <= maxIntrinsicWidth) {
return maxIntrinsicWidth;
}
if (minIntrinsicWidth - maxIntrinsicWidth < 2.0) {
return minIntrinsicWidth;
}
throw Exception('minIntrinsicWidth ($minIntrinsicWidth) is greater than '
'maxIntrinsicWidth ($maxIntrinsicWidth).');
}
}
/// A canvas-based text measurement implementation.
///
/// This is a faster implementation than [DomTextMeasurementService] and
/// provides line breaks information that can be useful for multi-line text.
class CanvasTextMeasurementService extends TextMeasurementService {
@override
final bool isCanvas = true;
/// The text measurement service singleton.
static CanvasTextMeasurementService get instance =>
_instance ??= CanvasTextMeasurementService();
static CanvasTextMeasurementService _instance;
final html.CanvasRenderingContext2D _canvasContext =
html.CanvasElement().context2D;
@override
MeasurementResult _doMeasure(
EngineParagraph paragraph,
ui.ParagraphConstraints constraints,
ParagraphRuler ruler,
) {
final String text = paragraph._plainText;
final ParagraphGeometricStyle style = paragraph._geometricStyle;
assert(text != null);
// TODO(mdebbar): Check if the whole text can fit in a single-line. Then avoid all this ceremony.
_canvasContext.font = style.cssFontString;
final LinesCalculator linesCalculator =
LinesCalculator(_canvasContext, paragraph, constraints.width);
final MinIntrinsicCalculator minIntrinsicCalculator =
MinIntrinsicCalculator(_canvasContext, text, style);
final MaxIntrinsicCalculator maxIntrinsicCalculator =
MaxIntrinsicCalculator(_canvasContext, text, style);
// Indicates whether we've reached the end of text or not. Even if the index
// [i] reaches the end of text, we don't want to stop looping until we hit
// [LineBreakType.endOfText] because there could be a "\n" at the end of the
// string and that would mess things up.
bool reachedEndOfText = false;
// TODO(flutter_web): Chrome & Safari return more info from [canvasContext.measureText].
int i = 0;
while (!reachedEndOfText) {
final LineBreakResult brk = nextLineBreak(text, i);
linesCalculator.update(brk);
minIntrinsicCalculator.update(brk);
maxIntrinsicCalculator.update(brk);
i = brk.index;
if (brk.type == LineBreakType.endOfText) {
reachedEndOfText = true;
}
}
final int lineCount = linesCalculator.lines.length;
final double lineHeight = ruler.lineHeightDimensions.height;
final double naturalHeight = lineCount * lineHeight;
final double height = style.maxLines == null
? naturalHeight
: math.min(lineCount, style.maxLines) * lineHeight;
final MeasurementResult result = MeasurementResult(
constraints.width,
isSingleLine: lineCount == 1,
alphabeticBaseline: ruler.alphabeticBaseline,
ideographicBaseline: ruler.alphabeticBaseline * _baselineRatioHack,
height: height,
naturalHeight: naturalHeight,
lineHeight: lineHeight,
// `minIntrinsicWidth` is the greatest width of text that can't
// be broken down into multiple lines.
minIntrinsicWidth: minIntrinsicCalculator.value,
// `maxIntrinsicWidth` is the width of the widest piece of text
// that doesn't contain mandatory line breaks.
maxIntrinsicWidth: maxIntrinsicCalculator.value,
width: constraints.width,
lines: linesCalculator.lines,
textAlign: paragraph._textAlign,
textDirection: paragraph._textDirection,
);
return result;
}
@override
double measureSubstringWidth(EngineParagraph paragraph, int start, int end) {
assert(paragraph._plainText != null);
final String text = paragraph._plainText;
final ParagraphGeometricStyle style = paragraph._geometricStyle;
_canvasContext.font = style.cssFontString;
return _measureSubstring(
_canvasContext,
paragraph._geometricStyle,
text,
start,
end,
);
}
@override
ui.TextPosition getTextPositionForOffset(EngineParagraph paragraph,
ui.ParagraphConstraints constraints, ui.Offset offset) {
// TODO(flutter_web): implement.
return const ui.TextPosition(offset: 0);
}
}
// These global variables are used to memoize calls to [_measureSubstring]. They
// are used to remember the last arguments passed to it, and the last return
// value.
// They are being initialized so that the compiler knows they'll never be null.
int _lastStart = -1;
int _lastEnd = -1;
String _lastText = '';
ParagraphGeometricStyle _lastStyle;
double _lastWidth = -1;
/// Measures the width of the substring of [text] starting from the index
/// [start] (inclusive) to [end] (exclusive).
///
/// This method assumes that the correct font has already been set on
/// [_canvasContext].
double _measureSubstring(
html.CanvasRenderingContext2D _canvasContext,
ParagraphGeometricStyle style,
String text,
int start,
int end,
) {
assert(0 <= start);
assert(start <= end);
assert(end <= text.length);
if (start == end) {
return 0;
}
if (start == _lastStart &&
end == _lastEnd &&
text == _lastText &&
_lastStyle == style) {
// TODO(mdebbar): Explore caching all widths in a map, not only the last one.
return _lastWidth;
}
_lastStart = start;
_lastEnd = end;
_lastText = text;
_lastStyle = style;
final double letterSpacing = style.letterSpacing ?? 0.0;
final String sub =
start == 0 && end == text.length ? text : text.substring(start, end);
final double width =
_canvasContext.measureText(sub).width + letterSpacing * sub.length;
// What we are doing here is we are rounding to the nearest 2nd decimal
// point. So 39.999423 becomes 40, and 11.243982 becomes 11.24.
// The reason we are doing this is because we noticed that canvas API has a
// ±0.001 error margin.
return _lastWidth = _roundWidth(width);
}
double _roundWidth(double width) {
return (width * 100).round() / 100;
}
/// From the substring defined by [text], [start] (inclusive) and [end]
/// (exclusive), exclude trailing characters that satisfy the given [predicate].
///
/// The return value is the new end of the substring after excluding the
/// trailing characters.
int _excludeTrailing(String text, int start, int end, CharPredicate predicate) {
assert(0 <= start);
assert(start <= end);
assert(end <= text.length);
while (start < end && predicate(text.codeUnitAt(end - 1))) {
end--;
}
return end;
}
/// During the text layout phase, this class splits the lines of text so that it
/// ends up fitting into the given width constraint.
///
/// It implements the Flutter engine's behavior when it comes to handling
/// ellipsis and max lines.
class LinesCalculator {
LinesCalculator(this._canvasContext, this._paragraph, this._maxWidth);
final html.CanvasRenderingContext2D _canvasContext;
final EngineParagraph _paragraph;
final double _maxWidth;
String get _text => _paragraph._plainText;
ParagraphGeometricStyle get _style => _paragraph._geometricStyle;
/// The lines that have been consumed so far.
List<EngineLineMetrics> lines = <EngineLineMetrics>[];
int _lineStart = 0;
int _chunkStart = 0;
bool _reachedMaxLines = false;
double _cachedEllipsisWidth;
double get _ellipsisWidth => _cachedEllipsisWidth ??=
_roundWidth(_canvasContext.measureText(_style.ellipsis).width);
bool get hasEllipsis => _style.ellipsis != null;
bool get unlimitedLines => _style.maxLines == null;
/// Consumes the next line break opportunity in [_text].
///
/// This method should be called for every line break. As soon as it reaches
/// the maximum number of lines required
void update(LineBreakResult brk) {
final bool isHardBreak = brk.type == LineBreakType.mandatory ||
brk.type == LineBreakType.endOfText;
final int chunkEnd = brk.index;
final int chunkEndWithoutSpace =
_excludeTrailing(_text, _chunkStart, chunkEnd, _whitespacePredicate);
// A single chunk of text could be force-broken into multiple lines if it
// doesn't fit in a single line. That's why we need a loop.
while (!_reachedMaxLines) {
final double lineWidth =
measureSubstring(_lineStart, chunkEndWithoutSpace);
// The current chunk doesn't reach the maximum width, so we stop here and
// wait for the next line break.
if (lineWidth <= _maxWidth) {
break;
}
// If the current chunk starts at the beginning of the line and exceeds
// [maxWidth], then we will need to force-break it.
final bool isChunkTooLong = _chunkStart == _lineStart;
// When ellipsis is set, and maxLines is null, we stop at the first line
// that exceeds [maxWidth].
final bool isLastLine = _reachedMaxLines =
(hasEllipsis && unlimitedLines) ||
lines.length + 1 == _style.maxLines;
if (isLastLine && hasEllipsis) {
// When there's an ellipsis, truncate text to leave enough space for
// the ellipsis.
final double availableWidth = _maxWidth - _ellipsisWidth;
final int breakingPoint = forceBreakSubstring(
maxWidth: availableWidth,
start: _lineStart,
end: chunkEndWithoutSpace,
);
final double widthOfResultingLine =
measureSubstring(_lineStart, breakingPoint) + _ellipsisWidth;
final double alignOffset = _calculateAlignOffsetForLine(
paragraph: _paragraph,
lineWidth: widthOfResultingLine,
maxWidth: _maxWidth,
);
lines.add(EngineLineMetrics.withText(
_text.substring(_lineStart, breakingPoint) + _style.ellipsis,
startIndex: _lineStart,
endIndex: chunkEnd,
endIndexWithoutNewlines:
_excludeTrailing(_text, _chunkStart, chunkEnd, _newlinePredicate),
hardBreak: false,
width: widthOfResultingLine,
left: alignOffset,
lineNumber: lines.length,
));
} else if (isChunkTooLong) {
final int breakingPoint = forceBreakSubstring(
maxWidth: _maxWidth,
start: _lineStart,
end: chunkEndWithoutSpace,
);
if (breakingPoint == chunkEndWithoutSpace) {
// We couldn't force-break the chunk any further which means we reached
// the last character and there isn't enough space for it to fit in
// its own line. Since this is the last character in the chunk, we
// don't do anything here and we rely on the next iteration (or the
// [isHardBreak] check below) to break the line.
break;
}
_addLineBreak(lineEnd: breakingPoint, isHardBreak: false);
_chunkStart = breakingPoint;
} else {
// The control case of current line exceeding [_maxWidth], we break the
// line.
_addLineBreak(lineEnd: _chunkStart, isHardBreak: false);
}
}
if (_reachedMaxLines) {
return;
}
if (isHardBreak) {
_addLineBreak(lineEnd: chunkEnd, isHardBreak: true);
}
_chunkStart = chunkEnd;
}
void _addLineBreak({
@required int lineEnd,
@required bool isHardBreak,
}) {
final int endWithoutNewlines = _excludeTrailing(
_text,
_lineStart,
lineEnd,
_newlinePredicate,
);
final int endWithoutSpace = _excludeTrailing(
_text,
_lineStart,
endWithoutNewlines,
_whitespacePredicate,
);
final int lineNumber = lines.length;
final double lineWidth = measureSubstring(_lineStart, endWithoutSpace);
final double alignOffset = _calculateAlignOffsetForLine(
paragraph: _paragraph,
lineWidth: lineWidth,
maxWidth: _maxWidth,
);
final EngineLineMetrics metrics = EngineLineMetrics.withText(
_text.substring(_lineStart, endWithoutNewlines),
startIndex: _lineStart,
endIndex: lineEnd,
endIndexWithoutNewlines: endWithoutNewlines,
hardBreak: isHardBreak,
width: lineWidth,
left: alignOffset,
lineNumber: lineNumber,
);
lines.add(metrics);
_lineStart = lineEnd;
if (lines.length == _style.maxLines) {
_reachedMaxLines = true;
}
}
/// Measures the width of a substring of [_text] starting from the index
/// [start] (inclusive) to [end] (exclusive).
///
/// This method uses [_text], [_style] and [_canvasContext] to perform the
/// measurement.
double measureSubstring(int start, int end) {
return _measureSubstring(_canvasContext, _style, _text, start, end);
}
/// In a continuous block of text, finds the point where text can be broken to
/// fit in the given constraint [maxWidth].
///
/// This always returns at least one character even if there isn't enough
/// space for it.
int forceBreakSubstring({
@required double maxWidth,
@required int start,
@required int end,
}) {
assert(0 <= start);
assert(start < end);
assert(end <= _text.length);
// When there's no ellipsis, the breaking point should be at least one
// character away from [start].
int low = hasEllipsis ? start : start + 1;
int high = end;
do {
final int mid = (low + high) ~/ 2;
final double width = measureSubstring(start, mid);
if (width < maxWidth) {
low = mid;
} else if (width > maxWidth) {
high = mid;
} else {
low = high = mid;
}
} while (high - low > 1);
return low;
}
}
/// During the text layout phase, this class takes care of calculating the
/// minimum intrinsic width of the given text.
class MinIntrinsicCalculator {
MinIntrinsicCalculator(this._canvasContext, this._text, this._style);
final html.CanvasRenderingContext2D _canvasContext;
final String _text;
final ParagraphGeometricStyle _style;
/// The value of minimum intrinsic width calculated so far.
double value = 0.0;
int _lastChunkEnd = 0;
/// Consumes the next line break opportunity in [_text].
///
/// As this method gets called, it updates the [value] to the minimum
/// intrinsic width calculated so far. When the whole text is consumed,
/// [value] will contain the final minimum intrinsic width.
void update(LineBreakResult brk) {
final int chunkEnd = brk.index;
final int chunkEndWithoutSpace =
_excludeTrailing(_text, _lastChunkEnd, chunkEnd, _whitespacePredicate);
final double width = _measureSubstring(
_canvasContext, _style, _text, _lastChunkEnd, chunkEndWithoutSpace);
if (width > value) {
value = width;
}
_lastChunkEnd = chunkEnd;
}
}
/// During text layout, this class is responsible for calculating the maximum
/// intrinsic width of the given text.
class MaxIntrinsicCalculator {
MaxIntrinsicCalculator(this._canvasContext, this._text, this._style);
final html.CanvasRenderingContext2D _canvasContext;
final String _text;
final ParagraphGeometricStyle _style;
/// The value of maximum intrinsic width calculated so far.
double value = 0.0;
int _lastHardLineEnd = 0;
/// Consumes the next line break opportunity in [_text].
///
/// As this method gets called, it updates the [value] to the maximum
/// intrinsic width calculated so far. When the whole text is consumed,
/// [value] will contain the final maximum intrinsic width.
void update(LineBreakResult brk) {
if (brk.type == LineBreakType.opportunity) {
return;
}
final int hardLineEnd = brk.index;
final int hardLineEndWithoutNewlines = _excludeTrailing(
_text,
_lastHardLineEnd,
hardLineEnd,
_newlinePredicate,
);
final double lineWidth = _measureSubstring(
_canvasContext,
_style,
_text,
_lastHardLineEnd,
hardLineEndWithoutNewlines,
);
if (lineWidth > value) {
value = lineWidth;
}
_lastHardLineEnd = hardLineEnd;
}
}
/// Calculates the offset necessary for the given line to be correctly aligned.
double _calculateAlignOffsetForLine({
@required EngineParagraph paragraph,
@required double lineWidth,
@required double maxWidth,
}) {
final double emptySpace = maxWidth - lineWidth;
// WARNING: the [paragraph] may not be laid out yet at this point. This
// function must not use layout metrics, such as [paragraph.height].
switch (paragraph._textAlign) {
case ui.TextAlign.center:
return emptySpace / 2.0;
case ui.TextAlign.right:
return emptySpace;
case ui.TextAlign.start:
return paragraph._textDirection == ui.TextDirection.rtl
? emptySpace
: 0.0;
case ui.TextAlign.end:
return paragraph._textDirection == ui.TextDirection.rtl
? 0.0
: emptySpace;
default:
return 0.0;
}
}