| // 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; |
| } |
| } |