• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1// Copyright 2013 The Flutter Authors. All rights reserved.
2// Use of this source code is governed by a BSD-style license that can be
3// found in the LICENSE file.
4
5part of engine;
6
7// TODO(yjbanov): this is a hack we use to compute ideographic baseline; this
8//                number is the ratio ideographic/alphabetic for font Ahem,
9//                which matches the Flutter number. It may be completely wrong
10//                for any other font. We'll need to eventually fix this. That
11//                said Flutter doesn't seem to use ideographic baseline for
12//                anything as of this writing.
13const double _baselineRatioHack = 1.1662499904632568;
14
15/// Signature of a function that takes a character and returns true or false.
16typedef CharPredicate = bool Function(int char);
17
18bool _whitespacePredicate(int char) =>
19    char == CharCode.space || char == CharCode.tab || _newlinePredicate(char);
20bool _newlinePredicate(int char) =>
21    char == CharCode.cr || char == CharCode.lf || char == CharCode.nl;
22
23/// Manages [ParagraphRuler] instances and caches them per unique
24/// [ParagraphGeometricStyle].
25///
26/// All instances of [ParagraphRuler] should be created through this class.
27class RulerManager {
28  RulerManager({@required this.rulerCacheCapacity}) {
29    _rulerHost.style
30      ..position = 'fixed'
31      ..visibility = 'hidden'
32      ..overflow = 'hidden'
33      ..top = '0'
34      ..left = '0'
35      ..width = '0'
36      ..height = '0';
37    html.document.body.append(_rulerHost);
38    registerHotRestartListener(dispose);
39  }
40
41  final int rulerCacheCapacity;
42
43  /// Hosts a cache of rulers that measure text.
44  ///
45  /// This element exists purely for organizational purposes. Otherwise the
46  /// rulers would be attached to the `<body>` element polluting the element
47  /// tree and making it hard to navigate. It does not serve any functional
48  /// purpose.
49  final html.Element _rulerHost = html.Element.tag('flt-ruler-host');
50
51  /// The cache of rulers used to measure text.
52  ///
53  /// Each ruler is keyed by paragraph style. This allows us to setup the
54  /// ruler's DOM structure once during the very first measurement of a given
55  /// paragraph style. Subsequent measurements could reuse the same ruler and
56  /// only swap the text contents. This minimizes the amount of work a browser
57  /// needs to do when measure many pieces of text with the same style.
58  ///
59  /// What makes this cache effective is the fact that a typical application
60  /// only uses a limited number of text styles. Using too many text styles on
61  /// the same screen is considered bad for user experience.
62  Map<ParagraphGeometricStyle, ParagraphRuler> get rulers => _rulers;
63  Map<ParagraphGeometricStyle, ParagraphRuler> _rulers =
64      <ParagraphGeometricStyle, ParagraphRuler>{};
65
66  bool _rulerCacheCleanupScheduled = false;
67
68  void _scheduleRulerCacheCleanup() {
69    if (!_rulerCacheCleanupScheduled) {
70      _rulerCacheCleanupScheduled = true;
71      scheduleMicrotask(() {
72        _rulerCacheCleanupScheduled = false;
73        cleanUpRulerCache();
74      });
75    }
76  }
77
78  /// Releases the resources used by this [RulerManager].
79  ///
80  /// After this is called, this object is no longer usable.
81  void dispose() {
82    _rulerHost?.remove();
83  }
84
85  /// If ruler cache size exceeds [rulerCacheCapacity], evicts those rulers that
86  /// were used the least.
87  ///
88  /// Resets hit counts back to zero.
89  @visibleForTesting
90  void cleanUpRulerCache() {
91    if (_rulers.length > rulerCacheCapacity) {
92      final List<ParagraphRuler> sortedByUsage = _rulers.values.toList();
93      sortedByUsage.sort((ParagraphRuler a, ParagraphRuler b) {
94        return b.hitCount - a.hitCount;
95      });
96      _rulers = <ParagraphGeometricStyle, ParagraphRuler>{};
97      for (int i = 0; i < sortedByUsage.length; i++) {
98        final ParagraphRuler ruler = sortedByUsage[i];
99        ruler.resetHitCount();
100        if (i < rulerCacheCapacity) {
101          // Retain this ruler.
102          _rulers[ruler.style] = ruler;
103        } else {
104          // This ruler did not have enough usage this frame to be retained.
105          ruler.dispose();
106        }
107      }
108    }
109  }
110
111  /// Adds an element used for measuring text as a child of [_rulerHost].
112  void addHostElement(html.DivElement element) {
113    _rulerHost.append(element);
114  }
115
116  /// Performs a cache lookup to find an existing [ParagraphRuler] for the given
117  /// [style] and if it can't find one in the cache, it would create one.
118  ///
119  /// The returned ruler is marked as hit so there's no need to do that
120  /// elsewhere.
121  @visibleForTesting
122  ParagraphRuler findOrCreateRuler(ParagraphGeometricStyle style) {
123    ParagraphRuler ruler = _rulers[style];
124    if (ruler == null) {
125      if (assertionsEnabled) {
126        domRenderer.debugRulerCacheMiss();
127      }
128      ruler = _rulers[style] = ParagraphRuler(style, this);
129      _scheduleRulerCacheCleanup();
130    } else {
131      if (assertionsEnabled) {
132        domRenderer.debugRulerCacheHit();
133      }
134    }
135    ruler.hit();
136    return ruler;
137  }
138}
139
140/// Provides various text measurement APIs using either a dom-based approach
141/// in [DomTextMeasurementService], or a canvas-based approach in
142/// [CanvasTextMeasurementService].
143abstract class TextMeasurementService {
144  /// Initializes the text measurement service with a specific
145  /// [rulerCacheCapacity] that gets passed to the [RulerManager].
146  static void initialize({@required int rulerCacheCapacity}) {
147    clearCache();
148    rulerManager = RulerManager(rulerCacheCapacity: rulerCacheCapacity);
149  }
150
151  @visibleForTesting
152  static RulerManager rulerManager;
153
154  /// The DOM-based text measurement service.
155  @visibleForTesting
156  static TextMeasurementService get domInstance =>
157      DomTextMeasurementService.instance;
158
159  /// The canvas-based text measurement service.
160  @visibleForTesting
161  static TextMeasurementService get canvasInstance =>
162      CanvasTextMeasurementService.instance;
163
164  /// Whether the new experimental implementation of canvas-based text
165  /// measurement is enabled or not.
166  ///
167  /// This is only used for testing at the moment. Once the implementation is
168  /// complete and production-ready, we'll get rid of this flag.
169  static bool enableExperimentalCanvasImplementation = false;
170
171  /// Gets the appropriate [TextMeasurementService] instance for the given
172  /// [paragraph].
173  static TextMeasurementService forParagraph(ui.Paragraph paragraph) {
174    // TODO(flutter_web): https://github.com/flutter/flutter/issues/33523
175    // When the canvas-based implementation is complete and passes all the
176    // tests, get rid of [_experimentalEnableCanvasImplementation].
177    if (enableExperimentalCanvasImplementation &&
178        _canUseCanvasMeasurement(paragraph)) {
179      return canvasInstance;
180    }
181    return domInstance;
182  }
183
184  /// Clears the cache of paragraph rulers.
185  @visibleForTesting
186  static void clearCache() {
187    rulerManager?.dispose();
188    rulerManager = null;
189  }
190
191  static bool _canUseCanvasMeasurement(EngineParagraph paragraph) {
192    // Currently, the canvas-based approach only works on plain text that
193    // doesn't have any of the following styles:
194    // - decoration
195    // - word spacing
196    final ParagraphGeometricStyle style = paragraph._geometricStyle;
197    return paragraph._plainText != null &&
198        style.decoration == null &&
199        style.wordSpacing == null;
200  }
201
202  /// Measures the paragraph and returns a [MeasurementResult] object.
203  MeasurementResult measure(
204    EngineParagraph paragraph,
205    ui.ParagraphConstraints constraints,
206  ) {
207    assert(rulerManager != null);
208    final ParagraphGeometricStyle style = paragraph._geometricStyle;
209    final ParagraphRuler ruler =
210        TextMeasurementService.rulerManager.findOrCreateRuler(style);
211
212    if (assertionsEnabled) {
213      if (paragraph._plainText == null) {
214        domRenderer.debugRichTextLayout();
215      } else {
216        domRenderer.debugPlainTextLayout();
217      }
218    }
219
220    MeasurementResult result = ruler.cacheLookup(paragraph, constraints);
221    if (result != null) {
222      return result;
223    }
224
225    result = _doMeasure(paragraph, constraints, ruler);
226    ruler.cacheMeasurement(paragraph, result);
227    return result;
228  }
229
230  /// Measures the width of a substring of the given [paragraph] with no
231  /// constraints.
232  double measureSubstringWidth(EngineParagraph paragraph, int start, int end);
233
234  /// Delegates to a [ParagraphRuler] to measure a list of text boxes that
235  /// enclose the given range of text.
236  List<ui.TextBox> measureBoxesForRange(
237    EngineParagraph paragraph,
238    ui.ParagraphConstraints constraints, {
239    int start,
240    int end,
241    double alignOffset,
242    ui.TextDirection textDirection,
243  }) {
244    final ParagraphGeometricStyle style = paragraph._geometricStyle;
245    final ParagraphRuler ruler =
246        TextMeasurementService.rulerManager.findOrCreateRuler(style);
247
248    return ruler.measureBoxesForRange(
249      paragraph._plainText,
250      constraints,
251      start: start,
252      end: end,
253      alignOffset: alignOffset,
254      textDirection: textDirection,
255    );
256  }
257
258  /// Performs the actual measurement of the following values for the given
259  /// paragraph:
260  ///
261  /// * isSingleLine: whether the paragraph can be rendered in a single line.
262  /// * height: constrained measure of the entire paragraph's height.
263  /// * lineHeight: the height of a single line of the paragraph.
264  /// * alphabeticBaseline: single line measure.
265  /// * ideographicBaseline: based on [alphabeticBaseline].
266  /// * maxIntrinsicWidth: the width of the paragraph with no line-wrapping.
267  /// * minIntrinsicWidth: the min width the paragraph fits in without overflowing.
268  ///
269  /// [MeasurementResult.width] is set to the same value of [constraints.width].
270  ///
271  /// It also optionally computes [MeasurementResult.lines] in the given
272  /// paragraph. When that's available, it can be used by a canvas to render
273  /// the text line.
274  MeasurementResult _doMeasure(
275    EngineParagraph paragraph,
276    ui.ParagraphConstraints constraints,
277    ParagraphRuler ruler,
278  );
279}
280
281/// A DOM-based text measurement implementation.
282///
283/// This implementation is slower than [CanvasTextMeasurementService] but it's
284/// needed for some cases that aren't yet supported in the canvas-based
285/// implementation such as letter-spacing, word-spacing, etc.
286class DomTextMeasurementService extends TextMeasurementService {
287  /// The text measurement service singleton.
288  static DomTextMeasurementService get instance =>
289      _instance ??= DomTextMeasurementService();
290
291  static DomTextMeasurementService _instance;
292
293  @override
294  MeasurementResult _doMeasure(
295    EngineParagraph paragraph,
296    ui.ParagraphConstraints constraints,
297    ParagraphRuler ruler,
298  ) {
299    ruler.willMeasure(paragraph);
300    final String plainText = paragraph._plainText;
301
302    ruler.measureAll(constraints);
303
304    MeasurementResult result;
305    // When the text has a new line, we should always use multi-line mode.
306    final bool hasNewline = plainText?.contains('\n') ?? false;
307    if (!hasNewline && ruler.singleLineDimensions.width <= constraints.width) {
308      result = _measureSingleLineParagraph(ruler, paragraph, constraints);
309    } else {
310      // Assert: If text doesn't have new line for infinite constraints we
311      // should have called single line measure paragraph instead.
312      assert(hasNewline || constraints.width != double.infinity);
313      result = _measureMultiLineParagraph(ruler, paragraph, constraints);
314    }
315    ruler.didMeasure();
316    return result;
317  }
318
319  @override
320  double measureSubstringWidth(EngineParagraph paragraph, int start, int end) {
321    final ParagraphGeometricStyle style = paragraph._geometricStyle;
322    final ParagraphRuler ruler =
323        TextMeasurementService.rulerManager.findOrCreateRuler(style);
324
325    final String text = paragraph._plainText.substring(start, end);
326    final ui.Paragraph substringParagraph = paragraph._cloneWithText(text);
327
328    ruler.willMeasure(substringParagraph);
329    ruler.measureAsSingleLine();
330    final TextDimensions dimensions = ruler.singleLineDimensions;
331    ruler.didMeasure();
332    return dimensions.width;
333  }
334
335  /// Called when we have determined that the paragraph fits the [constraints]
336  /// without wrapping.
337  ///
338  /// This means that:
339  /// * `width == maxIntrinsicWidth` - we gave it more horizontal space than
340  ///   it needs and so the paragraph won't expand beyond `maxIntrinsicWidth`.
341  /// * `height` is the height computed by `measureAsSingleLine`; giving the
342  ///    paragraph the width constraint won't change its height as we already
343  ///    determined that it fits within the constraint without wrapping.
344  /// * `alphabeticBaseline` is also final for the same reason as the `height`
345  ///   value.
346  ///
347  /// This method still needs to measure `minIntrinsicWidth`.
348  MeasurementResult _measureSingleLineParagraph(ParagraphRuler ruler,
349      ui.Paragraph paragraph, ui.ParagraphConstraints constraints) {
350    final double width = constraints.width;
351    final double minIntrinsicWidth = ruler.minIntrinsicDimensions.width;
352    double maxIntrinsicWidth = ruler.singleLineDimensions.width;
353    final double alphabeticBaseline = ruler.alphabeticBaseline;
354    final double height = ruler.singleLineDimensions.height;
355
356    maxIntrinsicWidth =
357        _applySubPixelRoundingHack(minIntrinsicWidth, maxIntrinsicWidth);
358    final double ideographicBaseline = alphabeticBaseline * _baselineRatioHack;
359    return MeasurementResult(
360      constraints.width,
361      isSingleLine: true,
362      width: width,
363      height: height,
364      naturalHeight: height,
365      lineHeight: height,
366      minIntrinsicWidth: minIntrinsicWidth,
367      maxIntrinsicWidth: maxIntrinsicWidth,
368      alphabeticBaseline: alphabeticBaseline,
369      ideographicBaseline: ideographicBaseline,
370      lines: null,
371    );
372  }
373
374  /// Called when we have determined that the paragraph needs to wrap into
375  /// multiple lines to fit the [constraints], i.e. its `maxIntrinsicWidth` is
376  /// bigger than the available horizontal space.
377  ///
378  /// While `maxIntrinsicWidth` is still good from the call to
379  /// `measureAsSingleLine`, we need to re-measure with the width constraint
380  /// and get new values for width, height and alphabetic baseline. We also need
381  /// to measure `minIntrinsicWidth`.
382  MeasurementResult _measureMultiLineParagraph(ParagraphRuler ruler,
383      EngineParagraph paragraph, ui.ParagraphConstraints constraints) {
384    // If constraint is infinite, we must use _measureSingleLineParagraph
385    final double width = constraints.width;
386    final double minIntrinsicWidth = ruler.minIntrinsicDimensions.width;
387    double maxIntrinsicWidth = ruler.singleLineDimensions.width;
388    final double alphabeticBaseline = ruler.alphabeticBaseline;
389    // Natural height is the full height of text ignoring height constraints.
390    final double naturalHeight = ruler.constrainedDimensions.height;
391
392    double height;
393    double lineHeight;
394    final int maxLines = paragraph._geometricStyle.maxLines;
395    if (maxLines == null) {
396      height = naturalHeight;
397    } else {
398      // Lazily compute [lineHeight] when [maxLines] is not null.
399      lineHeight = ruler.lineHeightDimensions.height;
400      height = math.min(naturalHeight, maxLines * lineHeight);
401    }
402
403    maxIntrinsicWidth =
404        _applySubPixelRoundingHack(minIntrinsicWidth, maxIntrinsicWidth);
405    assert(minIntrinsicWidth <= maxIntrinsicWidth);
406    final double ideographicBaseline = alphabeticBaseline * _baselineRatioHack;
407    return MeasurementResult(
408      constraints.width,
409      isSingleLine: false,
410      width: width,
411      height: height,
412      lineHeight: lineHeight,
413      naturalHeight: naturalHeight,
414      minIntrinsicWidth: minIntrinsicWidth,
415      maxIntrinsicWidth: maxIntrinsicWidth,
416      alphabeticBaseline: alphabeticBaseline,
417      ideographicBaseline: ideographicBaseline,
418      lines: null,
419    );
420  }
421
422  /// This hack is needed because `offsetWidth` rounds the value to the nearest
423  /// whole number. On a very rare occasion the minimum intrinsic width reported
424  /// by the browser is slightly bigger than the reported maximum intrinsic
425  /// width. If the discrepancy overlaps 0.5 then the rounding happens in
426  /// opposite directions.
427  ///
428  /// For example, if minIntrinsicWidth == 99.5 and maxIntrinsicWidth == 99.48,
429  /// then minIntrinsicWidth is rounded up to 100, and maxIntrinsicWidth is
430  /// rounded down to 99.
431  // TODO(yjbanov): remove the need for this hack.
432  static double _applySubPixelRoundingHack(
433      double minIntrinsicWidth, double maxIntrinsicWidth) {
434    if (minIntrinsicWidth <= maxIntrinsicWidth) {
435      return maxIntrinsicWidth;
436    }
437
438    if (minIntrinsicWidth - maxIntrinsicWidth < 2.0) {
439      return minIntrinsicWidth;
440    }
441
442    throw Exception('minIntrinsicWidth ($minIntrinsicWidth) is greater than '
443        'maxIntrinsicWidth ($maxIntrinsicWidth).');
444  }
445}
446
447/// A canvas-based text measurement implementation.
448///
449/// This is a faster implementation than [DomTextMeasurementService] and
450/// provides line breaks information that can be useful for multi-line text.
451class CanvasTextMeasurementService extends TextMeasurementService {
452  /// The text measurement service singleton.
453  static CanvasTextMeasurementService get instance =>
454      _instance ??= CanvasTextMeasurementService();
455
456  static CanvasTextMeasurementService _instance;
457
458  final html.CanvasRenderingContext2D _canvasContext =
459      html.CanvasElement().context2D;
460
461  @override
462  MeasurementResult _doMeasure(
463    EngineParagraph paragraph,
464    ui.ParagraphConstraints constraints,
465    ParagraphRuler ruler,
466  ) {
467    final String text = paragraph._plainText;
468    final ParagraphGeometricStyle style = paragraph._geometricStyle;
469    assert(text != null);
470
471    // TODO(mdebbar): Check if the whole text can fit in a single-line. Then avoid all this ceremony.
472    _canvasContext.font = style.cssFontString;
473    final LinesCalculator linesCalculator =
474        LinesCalculator(_canvasContext, text, style, constraints.width);
475    final MinIntrinsicCalculator minIntrinsicCalculator =
476        MinIntrinsicCalculator(_canvasContext, text, style);
477    final MaxIntrinsicCalculator maxIntrinsicCalculator =
478        MaxIntrinsicCalculator(_canvasContext, text, style);
479
480    // Indicates whether we've reached the end of text or not. Even if the index
481    // [i] reaches the end of text, we don't want to stop looping until we hit
482    // [LineBreakType.endOfText] because there could be a "\n" at the end of the
483    // string and that would mess things up.
484    bool reachedEndOfText = false;
485
486    // TODO(flutter_web): Chrome & Safari return more info from [canvasContext.measureText].
487    int i = 0;
488    while (!reachedEndOfText) {
489      final LineBreakResult brk = nextLineBreak(text, i);
490
491      linesCalculator.update(brk);
492      minIntrinsicCalculator.update(brk);
493      maxIntrinsicCalculator.update(brk);
494
495      i = brk.index;
496      if (brk.type == LineBreakType.endOfText) {
497        reachedEndOfText = true;
498      }
499    }
500
501    final int lineCount = linesCalculator.lines.length;
502    final double lineHeight = ruler.lineHeightDimensions.height;
503    final double naturalHeight = lineCount * lineHeight;
504
505    final double height = style.maxLines == null
506        ? naturalHeight
507        : math.min(lineCount, style.maxLines) * lineHeight;
508
509    final MeasurementResult result = MeasurementResult(
510      constraints.width,
511      isSingleLine: lineCount == 1,
512      alphabeticBaseline: ruler.alphabeticBaseline,
513      ideographicBaseline: ruler.alphabeticBaseline * _baselineRatioHack,
514      height: height,
515      naturalHeight: naturalHeight,
516      lineHeight: lineHeight,
517      // `minIntrinsicWidth` is the greatest width of text that can't
518      // be broken down into multiple lines.
519      minIntrinsicWidth: minIntrinsicCalculator.value,
520      // `maxIntrinsicWidth` is the width of the widest piece of text
521      // that doesn't contain mandatory line breaks.
522      maxIntrinsicWidth: maxIntrinsicCalculator.value,
523      width: constraints.width,
524      lines: linesCalculator.lines,
525    );
526    return result;
527  }
528
529  @override
530  double measureSubstringWidth(EngineParagraph paragraph, int start, int end) {
531    final String text = paragraph._plainText;
532    final ParagraphGeometricStyle style = paragraph._geometricStyle;
533    _canvasContext.font = style.cssFontString;
534    return _measureSubstring(
535      _canvasContext,
536      paragraph._geometricStyle,
537      text,
538      start,
539      end,
540    );
541  }
542}
543
544// These global variables are used to memoize calls to [_measureSubstring]. They
545// are used to remember the last arguments passed to it, and the last return
546// value.
547// They are being initialized so that the compiler knows they'll never be null.
548int _lastStart = -1;
549int _lastEnd = -1;
550String _lastText = '';
551ParagraphGeometricStyle _lastStyle;
552double _lastWidth = -1;
553
554/// Measures the width of the substring of [text] starting from the index
555/// [start] (inclusive) to [end] (exclusive).
556///
557/// This method assumes that the correct font has already been set on
558/// [_canvasContext].
559double _measureSubstring(
560  html.CanvasRenderingContext2D _canvasContext,
561  ParagraphGeometricStyle style,
562  String text,
563  int start,
564  int end,
565) {
566  assert(0 <= start && start <= end && end <= text.length);
567
568  if (start == end) {
569    return 0;
570  }
571
572  if (start == _lastStart &&
573      end == _lastEnd &&
574      text == _lastText &&
575      _lastStyle == style) {
576    return _lastWidth;
577  }
578  _lastStart = start;
579  _lastEnd = end;
580  _lastText = text;
581  _lastStyle = style;
582
583  final double letterSpacing = style.letterSpacing ?? 0.0;
584  final String sub =
585      start == 0 && end == text.length ? text : text.substring(start, end);
586  final double width =
587      _canvasContext.measureText(sub).width + letterSpacing * sub.length;
588
589  // What we are doing here is we are rounding to the nearest 2nd decimal
590  // point. So 39.999423 becomes 40, and 11.243982 becomes 11.24.
591  // The reason we are doing this is because we noticed that canvas API has a
592  // ±0.001 error margin.
593  return _lastWidth = _roundWidth(width);
594}
595
596double _roundWidth(double width) {
597  return (width * 100).round() / 100;
598}
599
600/// From the substring defined by [text], [start] (inclusive) and [end]
601/// (exclusive), exclude trailing characters that satisfy the given [predicate].
602///
603/// The return value is the new end of the substring after excluding the
604/// trailing characters.
605int _excludeTrailing(String text, int start, int end, CharPredicate predicate) {
606  assert(0 <= start && start <= end && end <= text.length);
607
608  while (start < end && predicate(text.codeUnitAt(end - 1))) {
609    end--;
610  }
611  return end;
612}
613
614/// During the text layout phase, this class splits the lines of text so that it
615/// ends up fitting into the given width constraint.
616///
617/// It mimicks the Flutter engine's behavior when it comes to handling ellipsis
618/// and max lines.
619class LinesCalculator {
620  LinesCalculator(this._canvasContext, this._text, this._style, this._maxWidth);
621
622  final html.CanvasRenderingContext2D _canvasContext;
623  final String _text;
624  final ParagraphGeometricStyle _style;
625  final double _maxWidth;
626
627  /// The lines that have been consumed so far.
628  List<String> lines = <String>[];
629
630  int _lineStart = 0;
631  int _chunkStart = 0;
632  bool _reachedMaxLines = false;
633
634  double _cachedEllipsisWidth;
635  double get _ellipsisWidth => _cachedEllipsisWidth ??=
636      _roundWidth(_canvasContext.measureText(_style.ellipsis).width);
637
638  bool get hasEllipsis => _style.ellipsis != null;
639  bool get unlimitedLines => _style.maxLines == null;
640
641  /// Consumes the next line break opportunity in [_text].
642  ///
643  /// This method should be called for every line break. As soon as it reaches
644  /// the maximum number of lines required
645  void update(LineBreakResult brk) {
646    final bool isHardBreak = brk.type == LineBreakType.mandatory ||
647        brk.type == LineBreakType.endOfText;
648    final int chunkEnd = brk.index;
649    final int chunkEndWithoutSpace =
650        _excludeTrailing(_text, _chunkStart, chunkEnd, _whitespacePredicate);
651
652    // A single chunk of text could be force-broken into multiple lines if it
653    // doesn't fit in a single line. That's why we need a loop.
654    while (!_reachedMaxLines) {
655      final double lineWidth = _measureSubstring(
656        _canvasContext,
657        _style,
658        _text,
659        _lineStart,
660        chunkEndWithoutSpace,
661      );
662
663      // The current chunk doesn't reach the maximum width, so we stop here and
664      // wait for the next line break.
665      if (lineWidth <= _maxWidth) {
666        break;
667      }
668
669      // If the current chunk starts at the beginning of the line and exceeds
670      // [maxWidth], then we will need to force-break it.
671      final bool isChunkTooLong = _chunkStart == _lineStart;
672
673      // When ellipsis is set, and maxLines is null, we stop at the first line
674      // that exceeds [maxWidth].
675      final bool isLastLine = _reachedMaxLines =
676          (hasEllipsis && unlimitedLines) ||
677              lines.length + 1 == _style.maxLines;
678
679      if (isLastLine && hasEllipsis) {
680        // When there's an ellipsis, truncate text to leave enough space for
681        // the ellipsis.
682        final double availableWidth = _maxWidth - _ellipsisWidth;
683        final int breakingPoint = _forceBreak(
684          availableWidth,
685          _text,
686          _lineStart,
687          chunkEndWithoutSpace,
688        );
689        lines.add(_text.substring(_lineStart, breakingPoint) + _style.ellipsis);
690      } else if (isChunkTooLong) {
691        final int breakingPoint =
692            _forceBreak(_maxWidth, _text, _lineStart, chunkEndWithoutSpace);
693        if (breakingPoint == chunkEndWithoutSpace) {
694          // We could force-break the chunk any further which means we reached
695          // the last character and there isn't enough space for it to fit in
696          // its own line. Since this is the last character in the chunk, we
697          // don't do anything here and we rely on the next iteration (or the
698          // [isHardBreak] check below) to break the line.
699          break;
700        }
701        _addLineBreak(lineEnd: breakingPoint);
702        _chunkStart = breakingPoint;
703      } else {
704        // The control case of current line exceeding [_maxWidth], we break the
705        // line.
706        _addLineBreak(lineEnd: _chunkStart);
707      }
708    }
709
710    if (_reachedMaxLines) {
711      return;
712    }
713
714    if (isHardBreak) {
715      _addLineBreak(lineEnd: chunkEnd);
716    }
717    _chunkStart = chunkEnd;
718  }
719
720  void _addLineBreak({@required int lineEnd}) {
721    final int indexWithoutNewlines = _excludeTrailing(
722      _text,
723      _lineStart,
724      lineEnd,
725      _newlinePredicate,
726    );
727    lines.add(_text.substring(_lineStart, indexWithoutNewlines));
728    _lineStart = lineEnd;
729    if (lines.length == _style.maxLines) {
730      _reachedMaxLines = true;
731    }
732  }
733
734  /// In a continuous block of text, finds the point where text can be broken to
735  /// fit in the given constraint [maxWidth].
736  ///
737  /// This always returns at least one character even if there isn't enough
738  /// space for it.
739  int _forceBreak(double maxWidth, String text, int start, int end) {
740    assert(0 <= start && start < end && end <= text.length);
741
742    int low = hasEllipsis ? start : start + 1;
743    int high = end;
744    do {
745      final int mid = (low + high) ~/ 2;
746      final double width =
747          _measureSubstring(_canvasContext, _style, text, start, mid);
748      if (width < maxWidth) {
749        low = mid;
750      } else if (width > maxWidth) {
751        high = mid;
752      } else {
753        low = high = mid;
754      }
755    } while (high - low > 1);
756
757    // The breaking point should be at least one character away from [start].
758    return low;
759  }
760}
761
762/// During the text layout phase, this class takes care of calculating the
763/// minimum intrinsic width of the given text.
764class MinIntrinsicCalculator {
765  MinIntrinsicCalculator(this._canvasContext, this._text, this._style);
766
767  final html.CanvasRenderingContext2D _canvasContext;
768  final String _text;
769  final ParagraphGeometricStyle _style;
770
771  /// The value of minimum intrinsic width calculated so far.
772  double value = 0.0;
773  int _lastChunkEnd = 0;
774
775  /// Consumes the next line break opportunity in [_text].
776  ///
777  /// As this method gets called, it updates the [value] to the minimum
778  /// intrinsic width calculated so far. When the whole text is consumed,
779  /// [value] will contain the final minimum intrinsic width.
780  void update(LineBreakResult brk) {
781    final int chunkEnd = brk.index;
782    final int chunkEndWithoutSpace =
783        _excludeTrailing(_text, _lastChunkEnd, chunkEnd, _whitespacePredicate);
784    final double width = _measureSubstring(
785        _canvasContext, _style, _text, _lastChunkEnd, chunkEndWithoutSpace);
786    if (width > value) {
787      value = width;
788    }
789    _lastChunkEnd = chunkEnd;
790  }
791}
792
793/// During text layout, this class is responsible for calculating the maximum
794/// intrinsic width of the given text.
795class MaxIntrinsicCalculator {
796  MaxIntrinsicCalculator(this._canvasContext, this._text, this._style);
797
798  final html.CanvasRenderingContext2D _canvasContext;
799  final String _text;
800  final ParagraphGeometricStyle _style;
801
802  /// The value of maximum intrinsic width calculated so far.
803  double value = 0.0;
804  int _lastHardLineEnd = 0;
805
806  /// Consumes the next line break opportunity in [_text].
807  ///
808  /// As this method gets called, it updates the [value] to the maximum
809  /// intrinsic width calculated so far. When the whole text is consumed,
810  /// [value] will contain the final maximum intrinsic width.
811  void update(LineBreakResult brk) {
812    if (brk.type == LineBreakType.opportunity) {
813      return;
814    }
815
816    final int hardLineEnd = brk.index;
817    final int hardLineEndWithoutNewlines = _excludeTrailing(
818      _text,
819      _lastHardLineEnd,
820      hardLineEnd,
821      _newlinePredicate,
822    );
823    final double lineWidth = _measureSubstring(
824      _canvasContext,
825      _style,
826      _text,
827      _lastHardLineEnd,
828      hardLineEndWithoutNewlines,
829    );
830    if (lineWidth > value) {
831      value = lineWidth;
832    }
833    _lastHardLineEnd = hardLineEnd;
834  }
835}
836