LCOV - code coverage report
Current view: top level - base - mongol_paragraph.dart (source / functions) Hit Total Coverage
Test: lcov.info Lines: 361 385 93.8 %
Date: 2021-08-02 17:55:49 Functions: 0 0 -

          Line data    Source code
       1             : // Copyright 2014 The Flutter Authors.
       2             : // Copyright 2021 Suragch.
       3             : // All rights reserved.
       4             : // Use of this source code is governed by a BSD-style license that can be
       5             : // found in the LICENSE file.
       6             : 
       7             : import 'dart:collection';
       8             : import 'dart:math' as math;
       9             : import 'dart:ui' as ui;
      10             : 
      11             : import 'package:flutter/painting.dart';
      12             : import 'package:characters/characters.dart';
      13             : 
      14             : import 'mongol_text_align.dart';
      15             : 
      16             : /// A paragraph of vertical Mongolian layout text.
      17             : ///
      18             : /// This class is a replacement for the Paragraph class. Since Paragraph hands
      19             : /// all it's work down to the Flutter engine, this class also does the work
      20             : /// of line-wrapping and laying out the text.
      21             : ///
      22             : /// The text is divided into a list of [_runs] where each run is a short
      23             : /// substring (usually a word or CJK/emoji character). Sometimes a run includes
      24             : /// multiple styles in which case [_rawStyledTextRuns] are used temorarily
      25             : /// before they can be combined into single [_runs] just based on words. The
      26             : /// [_runs] are then measured and layed out in [_lines] based on the given
      27             : /// constraints.
      28             : class MongolParagraph {
      29             :   /// This class is created by the library, and should not be instantiated
      30             :   /// or extended directly.
      31             :   ///
      32             :   /// To create a [MongolParagraph] object, use a [MongolParagraphBuilder].
      33          12 :   MongolParagraph._(
      34             :     this._runs,
      35             :     this._text,
      36             :     this._maxLines,
      37             :     this._ellipsis,
      38             :     this._textAlign,
      39             :   );
      40             : 
      41             :   final String _text;
      42             :   final List<_TextRun> _runs;
      43             :   final int? _maxLines;
      44             :   final _TextRun? _ellipsis;
      45             :   final MongolTextAlign _textAlign;
      46             : 
      47             :   double? _width;
      48             :   double? _height;
      49             :   double? _minIntrinsicHeight;
      50             :   double? _maxIntrinsicHeight;
      51             : 
      52             :   /// The amount of horizontal space this paragraph occupies.
      53             :   ///
      54             :   /// Valid only after [layout] has been called.
      55          24 :   double get width => _width ?? 0;
      56             : 
      57             :   /// The amount of vertical space this paragraph occupies.
      58             :   ///
      59             :   /// Valid only after [layout] has been called.
      60          22 :   double get height => _height ?? 0;
      61             : 
      62             :   /// The minimum height that this paragraph could be without failing to paint
      63             :   /// its contents within itself.
      64             :   ///
      65             :   /// Valid only after [layout] has been called.
      66          14 :   double get minIntrinsicHeight => _minIntrinsicHeight ?? 0;
      67             : 
      68             :   /// Returns the smallest height beyond which increasing the height never
      69             :   /// decreases the width.
      70             :   ///
      71             :   /// Valid only after [layout] has been called.
      72          20 :   double get maxIntrinsicHeight => _maxIntrinsicHeight ?? double.infinity;
      73             : 
      74             :   /// The distance to the alphabetic baseline the same as for horizontal text.
      75           2 :   double get alphabeticBaseline {
      76           4 :     if (_runs.isEmpty) {
      77             :       return 0.0;
      78             :     }
      79           8 :     return _runs.first.paragraph.alphabeticBaseline;
      80             :   }
      81             : 
      82             :   /// The distance to the ideographic baseline the same as for horizontal text.
      83           0 :   double get ideographicBaseline {
      84           0 :     if (_runs.isEmpty) {
      85             :       return 0.0;
      86             :     }
      87           0 :     return _runs.first.paragraph.ideographicBaseline;
      88             :   }
      89             : 
      90             :   /// True if there is more horizontal content, but the text was truncated, either
      91             :   /// because we reached `maxLines` lines of text or because the `maxLines` was
      92             :   /// null, `ellipsis` was not null, and one of the lines exceeded the height
      93             :   /// constraint.
      94             :   ///
      95             :   /// See the discussion of the `maxLines` and `ellipsis` arguments at
      96             :   /// [ParagraphStyle].
      97           9 :   bool get didExceedMaxLines {
      98           9 :     return _didExceedMaxLines;
      99             :   }
     100             : 
     101             :   bool _didExceedMaxLines = false;
     102             : 
     103             :   /// Computes the size and position of each glyph in the paragraph.
     104             :   ///
     105             :   /// The [MongolParagraphConstraints] control how tall the text is allowed
     106             :   /// to be.
     107          12 :   void layout(MongolParagraphConstraints constraints) =>
     108          24 :       _layout(constraints.height);
     109             : 
     110          12 :   void _layout(double height) {
     111          24 :     if (height == _height) return;
     112          12 :     _calculateLineBreaks(height);
     113          12 :     _calculateWidth();
     114          12 :     _height = height;
     115          12 :     _calculateIntrinsicHeight();
     116             :   }
     117             : 
     118             :   final List<_LineInfo> _lines = [];
     119             : 
     120             :   // Internally this method uses "width" and "height" naming with regard
     121             :   // to a horizontal line of text. Rotation doesn't happen until drawing.
     122          12 :   void _calculateLineBreaks(double maxLineLength) {
     123          24 :     if (_runs.isEmpty) {
     124             :       return;
     125             :     }
     126          24 :     if (_lines.isNotEmpty) {
     127          20 :       _lines.clear();
     128          10 :       _didExceedMaxLines = false;
     129             :     }
     130             : 
     131             :     // add run lengths until exceeds length
     132             :     var start = 0;
     133             :     var end = 0;
     134             :     var lineWidth = 0.0;
     135             :     var lineHeight = 0.0;
     136             :     var runEndsWithNewLine = false;
     137          48 :     for (var i = 0; i < _runs.length; i++) {
     138             :       end = i;
     139          24 :       final run = _runs[i];
     140          12 :       final runWidth = run.width;
     141          12 :       final runHeight = run.height;
     142             : 
     143          24 :       if (lineWidth + runWidth > maxLineLength) {
     144           4 :         _addLine(start, end, lineWidth, lineHeight);
     145             :         lineWidth = runWidth;
     146             :         lineHeight = runHeight;
     147             :         start = end;
     148             :       } else {
     149          12 :         lineWidth += runWidth;
     150          24 :         lineHeight = math.max(lineHeight, run.height);
     151             :       }
     152             : 
     153          12 :       runEndsWithNewLine = _runEndsWithNewLine(run);
     154             :       if (runEndsWithNewLine) {
     155           5 :         end = i + 1;
     156           5 :         _addLine(start, end, lineWidth, lineHeight);
     157             :         lineWidth = 0;
     158             :         lineHeight = 0;
     159             :         start = end;
     160             :       }
     161             : 
     162          12 :       if (_didExceedMaxLines) {
     163             :         break;
     164             :       }
     165             :     }
     166             : 
     167          24 :     end = _runs.length;
     168          12 :     if (start < end) {
     169          12 :       _addLine(start, end, lineWidth, lineHeight);
     170             :     }
     171             : 
     172             :     // add empty line with invalid run indexes for final newline char
     173             :     if (runEndsWithNewLine) {
     174           8 :       final height = _lines.last.bounds.height;
     175           6 :       _addLine(-1, -1, 0, height);
     176             :     }
     177             :   }
     178             : 
     179          12 :   bool _runEndsWithNewLine(_TextRun run) {
     180          24 :     final index = run.end - 1;
     181          36 :     return _text[index] == '\n';
     182             :   }
     183             : 
     184          12 :   void _addLine(int start, int end, double width, double height) {
     185          24 :     if (_maxLines != null && _maxLines! <= _lines.length) {
     186           1 :       _didExceedMaxLines = true;
     187             :       return;
     188             :     }
     189          12 :     _didExceedMaxLines = false;
     190          12 :     final bounds = Rect.fromLTRB(0, 0, width, height);
     191          12 :     final lineInfo = _LineInfo(start, end, bounds);
     192          24 :     _lines.add(lineInfo);
     193             :   }
     194             : 
     195          12 :   void _calculateWidth() {
     196             :     var sum = 0.0;
     197          24 :     for (final line in _lines) {
     198          36 :       sum += line.bounds.height;
     199             :     }
     200          12 :     _width = sum;
     201             :   }
     202             : 
     203             :   // Internally this translates a horizontal run width to the vertical name
     204             :   // that it is known as externally.
     205          12 :   void _calculateIntrinsicHeight() {
     206             :     var sum = 0.0;
     207             :     var maxRunWidth = 0.0;
     208             :     var maxLineLength = 0.0;
     209          24 :     for (final line in _lines) {
     210          48 :       for (var i = line.textRunStart; i < line.textRunEnd; i++) {
     211          36 :         final width = _runs[i].width;
     212          12 :         maxRunWidth = math.max(width, maxRunWidth);
     213          12 :         sum += width;
     214             :       }
     215          12 :       maxLineLength = math.max(maxLineLength, sum);
     216             :       sum = 0;
     217             :     }
     218          12 :     _minIntrinsicHeight = maxRunWidth;
     219          12 :     _maxIntrinsicHeight = maxLineLength;
     220             :   }
     221             : 
     222             :   /// Returns the text position closest to the given offset.
     223           8 :   TextPosition getPositionForOffset(Offset offset) {
     224          24 :     final encoded = _getPositionForOffset(offset.dx, offset.dy);
     225           8 :     return TextPosition(
     226          24 :         offset: encoded[0], affinity: TextAffinity.values[encoded[1]]);
     227             :   }
     228             : 
     229             :   // Both the line info and the text run are in horizontal orientation,
     230             :   // but the [dx] and [dy] offsets are in vertical orientation.
     231           8 :   List<int> _getPositionForOffset(double dx, double dy) {
     232             :     const upstream = 0;
     233             :     const downstream = 1;
     234             : 
     235          16 :     if (_lines.isEmpty) {
     236           3 :       return [0, downstream];
     237             :     }
     238             : 
     239             :     // find the line
     240             :     _LineInfo? matchedLine;
     241             :     var rightEdgeAfterRotation = 0.0;
     242             :     var rotatedRunDx = 0.0;
     243             :     var rotatedRunDy = 0.0;
     244          16 :     for (var line in _lines) {
     245          24 :       rightEdgeAfterRotation += line.bounds.bottom;
     246          16 :       rotatedRunDx = line.bounds.top;
     247           8 :       if (dx <= rightEdgeAfterRotation) {
     248             :         matchedLine = line;
     249             :         break;
     250             :       }
     251             :     }
     252          10 :     matchedLine ??= _lines.last;
     253             : 
     254             :     // find the run in the line
     255             :     _TextRun? matchedRun;
     256             :     var bottomEdgeAfterRotating = 0.0;
     257          31 :     for (var i = matchedLine.textRunStart; i < matchedLine.textRunEnd; i++) {
     258          16 :       final run = _runs[i];
     259             :       rotatedRunDy = bottomEdgeAfterRotating;
     260          16 :       bottomEdgeAfterRotating += run.width;
     261           8 :       if (dy <= bottomEdgeAfterRotating) {
     262             :         matchedRun = run;
     263             :         break;
     264             :       }
     265             :     }
     266             :     if (matchedRun == null) {
     267          12 :       final matchedRunIndex = matchedLine.textRunEnd - 1;
     268           6 :       if (matchedRunIndex.isNegative) {
     269           2 :         matchedRun = _runs.last;
     270             :       } else {
     271          10 :         matchedRun = _runs[matchedRunIndex];
     272             :       }
     273             :     }
     274             : 
     275             :     // find the offset
     276           8 :     final paragraphDx = dy - rotatedRunDy;
     277           8 :     final paragrpahDy = dx - rotatedRunDx;
     278           8 :     final offset = Offset(paragraphDx, paragrpahDy);
     279          16 :     final runPosition = matchedRun.paragraph.getPositionForOffset(offset);
     280          24 :     final textOffset = matchedRun.start + runPosition.offset;
     281             : 
     282             :     // find the afinity
     283           8 :     final lineEndCharOffset = matchedRun.end;
     284             :     final textAfinity =
     285           8 :         (textOffset == lineEndCharOffset) ? upstream : downstream;
     286           8 :     return [textOffset, textAfinity];
     287             :   }
     288             : 
     289             :   /// Draws the precomputed text on a [canvas] one line at a time in vertical
     290             :   /// lines that wrap from left to right.
     291          10 :   void draw(Canvas canvas, Offset offset) {
     292          10 :     final shouldDrawEllipsis = _didExceedMaxLines && _ellipsis != null;
     293             : 
     294             :     // translate for the offset
     295          10 :     canvas.save();
     296          30 :     canvas.translate(offset.dx, offset.dy);
     297             : 
     298             :     // rotate the canvas 90 degrees
     299          20 :     canvas.rotate(math.pi / 2);
     300             : 
     301             :     // loop through every line
     302          40 :     for (var i = 0; i < _lines.length; i++) {
     303          20 :       final line = _lines[i];
     304             : 
     305             :       // translate for the line height
     306          30 :       final dy = -line.bounds.height;
     307          10 :       canvas.translate(0, dy);
     308             : 
     309             :       // draw line
     310          40 :       final isLastLine = i == _lines.length - 1;
     311          10 :       _drawEachRunInCurrentLine(canvas, line, shouldDrawEllipsis, isLastLine);
     312             :     }
     313             : 
     314          10 :     canvas.restore();
     315             :   }
     316             : 
     317          10 :   void _drawEachRunInCurrentLine(
     318             :       Canvas canvas, _LineInfo line, bool shouldDrawEllipsis, bool isLastLine) {
     319          10 :     canvas.save();
     320             : 
     321             :     var runSpacing = 0.0;
     322          10 :     switch (_textAlign) {
     323          10 :       case MongolTextAlign.top:
     324             :         break;
     325           3 :       case MongolTextAlign.center:
     326           0 :         final offset = (_height! - line.bounds.width) / 2;
     327           0 :         canvas.translate(offset, 0);
     328             :         break;
     329           3 :       case MongolTextAlign.bottom:
     330           8 :         final offset = _height! - line.bounds.width;
     331           2 :         canvas.translate(offset, 0);
     332             :         break;
     333           1 :       case MongolTextAlign.justify:
     334             :         if (isLastLine) break;
     335           0 :         final extraSpace = _height! - line.bounds.width;
     336           0 :         final runsInLine = line.textRunEnd - line.textRunStart;
     337           0 :         if (runsInLine <= 1) break;
     338           0 :         runSpacing = extraSpace / (runsInLine - 1);
     339             :         break;
     340             :     }
     341             : 
     342          10 :     final startIndex = line.textRunStart;
     343          20 :     final endIndex = line.textRunEnd - 1;
     344          20 :     for (var j = startIndex; j <= endIndex; j++) {
     345           0 :       if (shouldDrawEllipsis && isLastLine && j == endIndex) {
     346           0 :         if (maxIntrinsicHeight + _ellipsis!.height < height) {
     347           0 :           final run = _runs[j];
     348           0 :           run.draw(canvas, const Offset(0, 0));
     349           0 :           canvas.translate(run.width, 0);
     350             :         }
     351           0 :         _ellipsis!.draw(canvas, const Offset(0, 0));
     352             :       } else {
     353          20 :         final run = _runs[j];
     354          10 :         run.draw(canvas, const Offset(0, 0));
     355          20 :         canvas.translate(run.width, 0);
     356             :       }
     357          10 :       canvas.translate(runSpacing, 0);
     358             :     }
     359             : 
     360          10 :     canvas.restore();
     361             :   }
     362             : 
     363             :   /// Returns a list of rects that enclose the given text range.
     364             :   ///
     365             :   /// Coordinates of the Rect are relative to the upper-left corner of the
     366             :   /// paragraph, where positive y values indicate down. Orientation is as
     367             :   /// vertical Mongolian text with left to right line wrapping.
     368             :   ///
     369             :   /// Note that this method behaves slightly differently than
     370             :   /// Paragraph.getBoxesForRange. The Paragraph version returns List<TextBox>,
     371             :   /// but TextBox doesn't accurately describe vertical text so Rect is used.
     372           5 :   List<Rect> getBoxesForRange(int start, int end) {
     373           5 :     final boxes = <Rect>[];
     374             : 
     375             :     // The [start] index must be within the text range
     376          10 :     final textLength = _text.length;
     377          20 :     if (start < 0 || start > _text.length) {
     378             :       return boxes;
     379             :     }
     380             : 
     381             :     // Allow the [end] index to be larger than the text length but don't use it
     382           5 :     final effectiveEnd = math.min(textLength, end);
     383             : 
     384             :     // Horizontal offset for the left side of the vertical rect
     385             :     var dx = 0.0;
     386             : 
     387             :     // loop through each line
     388          20 :     for (var i = 0; i < _lines.length; i++) {
     389          10 :       final line = _lines[i];
     390          10 :       final lastRunIndex = line.textRunEnd - 1;
     391             : 
     392             :       // return empty line for invalid run indexes
     393             :       // (This happens when text ends with newline char.)
     394           5 :       if (lastRunIndex < 0) {
     395           3 :         if (end > textLength) {
     396           4 :           boxes.add(_lineBoundsAsBox(line, dx));
     397             :         }
     398             :         continue;
     399             :       }
     400             : 
     401          20 :       final lineLastCharIndex = _runs[lastRunIndex].end - 1;
     402             : 
     403             :       // skip empty lines before the selected range
     404           5 :       if (lineLastCharIndex < start) {
     405             :         // The line is horizontal but dx is for vertical orientation
     406          12 :         dx += line.bounds.height;
     407             :         continue;
     408             :       }
     409             : 
     410           5 :       final firstRunIndex = line.textRunStart;
     411          15 :       final lineFirstCharIndex = _runs[firstRunIndex].start;
     412             : 
     413             :       // If this is a full line then skip looping over the runs
     414             :       // because the line size has already been cached.
     415          10 :       if (lineFirstCharIndex >= start && lineLastCharIndex < effectiveEnd) {
     416          10 :         boxes.add(_lineBoundsAsBox(line, dx));
     417             :       } else {
     418             :         // check the runs one at a time
     419           5 :         final lineBox = _getBoxFromLine(line, start, effectiveEnd, dx);
     420             : 
     421             :         // partial selections of grapheme clusters should return no boxes
     422           5 :         if (lineBox != Rect.zero) {
     423           5 :           boxes.add(lineBox);
     424             :         }
     425             : 
     426             :         // If this is the last line there we're finished
     427          10 :         if (lineLastCharIndex >= effectiveEnd - 1) {
     428             :           return boxes;
     429             :         }
     430             :       }
     431          15 :       dx += line.bounds.height;
     432             :     }
     433             :     return boxes;
     434             :   }
     435             : 
     436           5 :   Rect _lineBoundsAsBox(_LineInfo line, double dx) {
     437           5 :     final lineBounds = line.bounds;
     438          15 :     return Rect.fromLTWH(dx, 0, lineBounds.height, lineBounds.width);
     439             :   }
     440             : 
     441             :   // Takes a single line and finds the box that includes the selected range
     442           5 :   Rect _getBoxFromLine(_LineInfo line, int start, int end, double dx) {
     443             :     var boxWidth = 0.0;
     444             :     var boxHeight = 0.0;
     445             : 
     446             :     // This is the vertical offset for the box in vertical line orientation
     447             :     // It will only be non-zero if this is the first box.
     448             :     var dy = 0.0;
     449             : 
     450             :     // loop though every run in the line
     451          20 :     for (var j = line.textRunStart; j < line.textRunEnd; j++) {
     452          10 :       final run = _runs[j];
     453             : 
     454             :       // skips runs that are after selected range
     455          10 :       if (run.start >= end) {
     456             :         break;
     457             :       }
     458             : 
     459             :       // skip runs that are before the selected range
     460          10 :       if (run.end <= start) {
     461          10 :         dy += run.width;
     462             :         continue;
     463             :       }
     464             : 
     465             :       // The size of full intermediate runs has already been cached
     466          20 :       if (run.start >= start && run.end <= end) {
     467          10 :         boxWidth = math.max(boxWidth, run.height);
     468          10 :         boxHeight += run.width;
     469          10 :         if (run.end == end) {
     470             :           break;
     471             :         }
     472             :         continue;
     473             :       }
     474             : 
     475             :       // The range selection is in middle of a run
     476          20 :       final localStart = math.max(start, run.start) - run.start;
     477          20 :       final localEnd = math.min(end, run.end) - run.start;
     478          10 :       final textBoxes = run.paragraph.getBoxesForRange(localStart, localEnd);
     479             : 
     480             :       // empty boxes occur for partial selections of a grapheme cluster
     481           5 :       if (textBoxes.isEmpty) {
     482           8 :         if (end <= run.end) {
     483             :           break;
     484             :         } else {
     485           4 :           dy += run.width;
     486             :           continue;
     487             :         }
     488             :       }
     489             : 
     490             :       // handle orientation differences for emoji and CJK characters
     491           5 :       final box = textBoxes.first;
     492             :       double verticalWidth;
     493             :       double verticalHeight;
     494           5 :       if (run.isRotated) {
     495           1 :         verticalWidth = box.right;
     496           1 :         verticalHeight = box.bottom;
     497             :       } else {
     498          10 :         dy += box.left;
     499           5 :         verticalWidth = box.bottom;
     500          15 :         verticalHeight = box.right - box.left;
     501             :       }
     502             : 
     503             :       // update the rect size
     504           5 :       boxWidth = math.max(boxWidth, verticalWidth);
     505           5 :       boxHeight += verticalHeight;
     506             : 
     507             :       // if this is the last run then we're finished
     508          10 :       if (end <= run.end) {
     509             :         break;
     510             :       }
     511             :     }
     512             : 
     513          10 :     if (boxWidth == 0.0 || boxHeight == 0.0) {
     514             :       return Rect.zero;
     515             :     }
     516           5 :     return Rect.fromLTWH(dx, dy, boxWidth, boxHeight);
     517             :   }
     518             : 
     519             :   /// Returns the [TextRange] of the word at the given [TextPosition].
     520             :   ///
     521             :   /// The current implementation just returns the currect text run, which is
     522             :   /// generally a word.
     523           5 :   TextRange getWordBoundary(TextPosition position) {
     524           5 :     final offset = position.offset;
     525          15 :     if (offset >= _text.length) {
     526          15 :       return TextRange(start: _text.length, end: offset);
     527             :     }
     528           4 :     final run = _getRunFromOffset(offset);
     529             :     if (run == null) {
     530             :       return TextRange.empty;
     531             :     }
     532           4 :     return _splitBreakCharactersFromRun(run, offset);
     533             :   }
     534             : 
     535             :   // runs can include break characters currently so split them from the returned
     536             :   // range
     537           4 :   TextRange _splitBreakCharactersFromRun(_TextRun run, int offset) {
     538           4 :     var start = run.start;
     539           4 :     var end = run.end;
     540          12 :     final finalChar = _text[end - 1];
     541           4 :     if (LineBreaker.isBreakChar(finalChar)) {
     542           8 :       if (offset == end - 1) {
     543           2 :         start = end - 1;
     544             :       } else {
     545           4 :         end = end - 1;
     546             :       }
     547             :     }
     548           4 :     return TextRange(start: start, end: end);
     549             :   }
     550             : 
     551           4 :   _TextRun? _getRunFromOffset(int offset) {
     552          12 :     if (offset >= _text.length) {
     553             :       return null;
     554             :     }
     555             :     var min = 0;
     556          12 :     var max = _runs.length - 1;
     557             :     // do a binary search
     558           4 :     while (min <= max) {
     559           8 :       final guess = (max + min) ~/ 2;
     560          16 :       if (offset >= _runs[guess].end) {
     561           3 :         min = guess + 1;
     562             :         continue;
     563          16 :       } else if (offset < _runs[guess].start) {
     564           3 :         max = guess - 1;
     565             :         continue;
     566             :       } else {
     567           8 :         return _runs[guess];
     568             :       }
     569             :     }
     570             :     return null;
     571             :   }
     572             : 
     573             :   /// Returns the [TextRange] of the line at the given [TextPosition].
     574             :   ///
     575             :   /// The newline (if any) is NOT returned as part of the range.
     576             :   /// https://github.com/flutter/flutter/issues/83392
     577             :   ///
     578             :   /// Not valid until after layout.
     579             :   ///
     580             :   /// This can potentially be expensive, since it needs to compute the line
     581             :   /// metrics, so use it sparingly.
     582           2 :   TextRange getLineBoundary(TextPosition position) {
     583           2 :     final offset = position.offset;
     584           6 :     if (offset > _text.length) {
     585             :       return TextRange.empty;
     586             :     }
     587             :     var min = 0;
     588           6 :     var max = _lines.length - 1;
     589           2 :     var start = -1;
     590           2 :     var end = -1;
     591             :     // do a binary search
     592           2 :     while (min <= max) {
     593           4 :       final guess = (max + min) ~/ 2;
     594           4 :       final line = _lines[guess];
     595           8 :       start = _runs[line.textRunStart].start;
     596          10 :       end = _runs[line.textRunEnd - 1].end;
     597           2 :       if (offset >= end) {
     598           2 :         min = guess + 1;
     599             :         continue;
     600           2 :       } else if (offset < start) {
     601           1 :         max = guess - 1;
     602             :         continue;
     603             :       } else {
     604             :         break;
     605             :       }
     606             :     }
     607             :     // exclude newline character
     608          10 :     if (end > start && _text[end - 1] == '\n') {
     609           2 :       end--;
     610             :     }
     611           2 :     return TextRange(start: start, end: end);
     612             :   }
     613             : }
     614             : 
     615             : /// Layout constraints for [MongolParagraph] objects.
     616             : ///
     617             : /// Instances of this class are typically used with [MongolParagraph.layout].
     618             : ///
     619             : /// The only constraint that can be specified is the [height].
     620             : class MongolParagraphConstraints {
     621          26 :   const MongolParagraphConstraints({
     622             :     required this.height,
     623             :   });
     624             : 
     625             :   /// The height the paragraph should use when computing the positions of glyphs.
     626             :   final double height;
     627             : 
     628           0 :   @override
     629             :   bool operator ==(dynamic other) {
     630           0 :     if (other.runtimeType != runtimeType) return false;
     631           0 :     return other is MongolParagraphConstraints && other.height == height;
     632             :   }
     633             : 
     634           0 :   @override
     635           0 :   int get hashCode => height.hashCode;
     636             : 
     637           0 :   @override
     638           0 :   String toString() => '$runtimeType(height: $height)';
     639             : }
     640             : 
     641             : /// Builds a [MongolParagraph] containing text with the given styling
     642             : /// information.
     643             : ///
     644             : /// To set the paragraph's style, pass an appropriately-configured
     645             : /// [ParagraphStyle] object to the [MongolParagraphBuilder] constructor.
     646             : ///
     647             : /// Then, call combinations of [pushStyle], [addText], and [pop] to add styled
     648             : /// text to the object.
     649             : ///
     650             : /// Finally, call [build] to obtain the constructed [MongolParagraph] object.
     651             : /// After this point, the builder is no longer usable.
     652             : ///
     653             : /// After constructing a [MongolParagraph], call [MongolParagraph.layout] on
     654             : /// it and then paint it with [MongolParagraph.draw].
     655             : class MongolParagraphBuilder {
     656          12 :   MongolParagraphBuilder(
     657             :     ui.ParagraphStyle style, {
     658             :     MongolTextAlign textAlign = MongolTextAlign.top,
     659             :     double textScaleFactor = 1.0,
     660             :     int? maxLines,
     661             :     String? ellipsis,
     662             :   })  : _paragraphStyle = style,
     663             :         _textAlign = textAlign,
     664             :         _textScaleFactor = textScaleFactor,
     665             :         _maxLines = maxLines,
     666             :         _ellipsis = ellipsis;
     667             : 
     668             :   ui.ParagraphStyle? _paragraphStyle;
     669             :   final MongolTextAlign _textAlign;
     670             :   final double _textScaleFactor;
     671             :   final int? _maxLines;
     672             :   final String? _ellipsis;
     673             :   //_TextRun? _ellipsisRun;
     674             :   final _styleStack = _Stack<TextStyle>();
     675             :   final _rawStyledTextRuns = <_RawStyledTextRun>[];
     676             : 
     677           0 :   static final _defaultParagraphStyle = ui.ParagraphStyle(
     678             :     textAlign: TextAlign.start,
     679             :     textDirection: TextDirection.ltr,
     680             :   );
     681             : 
     682          12 :   static final _defaultTextStyle = ui.TextStyle(
     683             :     color: const Color(0xFFFFFFFF),
     684             :     textBaseline: TextBaseline.alphabetic,
     685             :   );
     686             : 
     687             :   /// Applies the given style to the added text until [pop] is called.
     688             :   ///
     689             :   /// See [pop] for details.
     690          11 :   void pushStyle(TextStyle style) {
     691          22 :     if (_styleStack.isEmpty) {
     692          22 :       _styleStack.push(style);
     693             :       return;
     694             :     }
     695           6 :     final lastStyle = _styleStack.top;
     696           9 :     _styleStack.push(lastStyle.merge(style));
     697             :   }
     698             : 
     699             :   /// Ends the effect of the most recent call to [pushStyle].
     700             :   ///
     701             :   /// Internally, the paragraph builder maintains a stack of text styles. Text
     702             :   /// added to the paragraph is affected by all the styles in the stack. Calling
     703             :   /// [pop] removes the topmost style in the stack, leaving the remaining styles
     704             :   /// in effect.
     705          11 :   void pop() {
     706          22 :     _styleStack.pop();
     707             :   }
     708             : 
     709             :   final _plainText = StringBuffer();
     710             : 
     711             :   /// Adds the given text to the paragraph.
     712             :   ///
     713             :   /// The text will be styled according to the current stack of text styles.
     714          12 :   void addText(String text) {
     715          24 :     _plainText.write(text);
     716          46 :     final style = _styleStack.isEmpty ? null : _styleStack.top;
     717          12 :     final breakSegments = BreakSegments(text);
     718          24 :     for (final segment in breakSegments) {
     719          36 :       _rawStyledTextRuns.add(_RawStyledTextRun(style, segment));
     720             :     }
     721             :   }
     722             : 
     723             :   /// Applies the given paragraph style and returns a [MongolParagraph]
     724             :   /// containing the added text and associated styling.
     725             :   ///
     726             :   /// After calling this function, the paragraph builder object is invalid and
     727             :   /// cannot be used further.
     728          12 :   MongolParagraph build() {
     729          12 :     _paragraphStyle ??= _defaultParagraphStyle;
     730          12 :     final runs = <_TextRun>[];
     731             : 
     732          24 :     final length = _rawStyledTextRuns.length;
     733             :     var startIndex = 0;
     734             :     var endIndex = 0;
     735             :     ui.ParagraphBuilder? builder;
     736             :     ui.TextStyle? style;
     737          24 :     for (var i = 0; i < length; i++) {
     738          12 :       style = _uiStyleForRun(i);
     739          36 :       final segment = _rawStyledTextRuns[i].text;
     740          36 :       endIndex += segment.text.length;
     741          24 :       builder ??= ui.ParagraphBuilder(_paragraphStyle!);
     742          12 :       builder.pushStyle(style);
     743          24 :       final text = _stripNewLineChar(segment.text);
     744          12 :       builder.addText(text);
     745          12 :       builder.pop();
     746             : 
     747          12 :       if (_isNonBreakingSegment(i)) {
     748             :         continue;
     749             :       }
     750             : 
     751          12 :       final paragraph = builder.build();
     752          12 :       paragraph.layout(const ui.ParagraphConstraints(width: double.infinity));
     753             :       final run =
     754          24 :           _TextRun(startIndex, endIndex, segment.isRotatable, paragraph);
     755          12 :       runs.add(run);
     756             :       builder = null;
     757             :       startIndex = endIndex;
     758             :     }
     759             : 
     760          12 :     return MongolParagraph._(
     761             :       runs,
     762          24 :       _plainText.toString(),
     763          12 :       _maxLines,
     764          12 :       _ellipsisRun(style),
     765          12 :       _textAlign,
     766             :     );
     767             :   }
     768             : 
     769          12 :   bool _isNonBreakingSegment(int i) {
     770          36 :     final segment = _rawStyledTextRuns[i].text;
     771          12 :     if (segment.isRotatable) return false;
     772          24 :     if (_endsWithBreak(segment.text)) return false;
     773             : 
     774          48 :     if (i >= _rawStyledTextRuns.length - 1) return false;
     775          20 :     final nextSegment = _rawStyledTextRuns[i + 1].text;
     776           5 :     if (nextSegment.isRotatable) return false;
     777           8 :     if (_startsWithBreak(nextSegment.text)) return false;
     778             :     return true;
     779             :   }
     780             : 
     781           4 :   bool _startsWithBreak(String run) {
     782           4 :     if (run.isEmpty) return false;
     783           8 :     return LineBreaker.isBreakChar(run[0]);
     784             :   }
     785             : 
     786          12 :   bool _endsWithBreak(String run) {
     787          12 :     if (run.isEmpty) return false;
     788          48 :     return LineBreaker.isBreakChar(run[run.length - 1]);
     789             :   }
     790             : 
     791          12 :   ui.TextStyle _uiStyleForRun(int index) {
     792          36 :     final style = _rawStyledTextRuns[index].style;
     793          22 :     return style?.getTextStyle(textScaleFactor: _textScaleFactor) ??
     794           4 :         _defaultTextStyle;
     795             :   }
     796             : 
     797          12 :   String _stripNewLineChar(String text) {
     798          12 :     if (!text.endsWith('\n')) return text;
     799           7 :     return text.replaceAll('\n', '');
     800             :   }
     801             : 
     802          12 :   _TextRun? _ellipsisRun(ui.TextStyle? style) {
     803          12 :     if (_ellipsis == null) {
     804             :       return null;
     805             :     }
     806           4 :     final builder = ui.ParagraphBuilder(_paragraphStyle!);
     807             :     if (style != null) {
     808           2 :       builder.pushStyle(style);
     809             :     }
     810           4 :     builder.addText(_ellipsis!);
     811           2 :     final paragraph = builder.build();
     812           2 :     paragraph.layout(const ui.ParagraphConstraints(width: double.infinity));
     813           6 :     return _TextRun(-1, -1, false, paragraph);
     814             :   }
     815             : }
     816             : 
     817             : /// An iterable that iterates over the substrings of [text] between locations
     818             : /// that line breaks are allowed.
     819             : class BreakSegments extends Iterable<RotatableString> {
     820          13 :   BreakSegments(this.text);
     821             :   final String text;
     822             : 
     823          13 :   @override
     824          26 :   Iterator<RotatableString> get iterator => LineBreaker(text);
     825             : }
     826             : 
     827             : class RotatableString {
     828          13 :   const RotatableString(this.text, this.isRotatable);
     829             :   final String text;
     830             :   final bool isRotatable;
     831             : }
     832             : 
     833             : /// Finds all the locations in a string of text where line breaks are allowed.
     834             : ///
     835             : /// LineBreaker gives the strings between the breaks upon iteration.
     836             : class LineBreaker implements Iterator<RotatableString> {
     837          13 :   LineBreaker(this.text) {
     838          52 :     _characterIterator = text.characters.iterator;
     839             :   }
     840             : 
     841             :   final String text;
     842             : 
     843             :   late CharacterRange _characterIterator;
     844             : 
     845             :   RotatableString? _currentTextRun;
     846             : 
     847          13 :   @override
     848             :   RotatableString get current {
     849          13 :     if (_currentTextRun == null) {
     850           0 :       throw StateError(
     851             :           'Current is undefined before moveNext is called or after last element.');
     852             :     }
     853          13 :     return _currentTextRun!;
     854             :   }
     855             : 
     856             :   bool _atEndOfCharacterRange = false;
     857             :   RotatableString? _rotatedCharacterBuffer;
     858             : 
     859          13 :   @override
     860             :   bool moveNext() {
     861          13 :     if (_atEndOfCharacterRange) {
     862          13 :       _currentTextRun = null;
     863             :       return false;
     864             :     }
     865          13 :     if (_rotatedCharacterBuffer != null) {
     866           8 :       _currentTextRun = _rotatedCharacterBuffer;
     867           4 :       _rotatedCharacterBuffer = null;
     868             :       return true;
     869             :     }
     870             : 
     871          13 :     final returnValue = StringBuffer();
     872          26 :     while (_characterIterator.moveNext()) {
     873          26 :       final current = _characterIterator.current;
     874          13 :       if (isBreakChar(current)) {
     875          11 :         returnValue.write(current);
     876          33 :         _currentTextRun = RotatableString(returnValue.toString(), false);
     877             :         return true;
     878          13 :       } else if (_isRotatable(current)) {
     879           6 :         if (returnValue.isEmpty) {
     880          12 :           _currentTextRun = RotatableString(current, true);
     881             :           return true;
     882             :         } else {
     883          12 :           _currentTextRun = RotatableString(returnValue.toString(), false);
     884           8 :           _rotatedCharacterBuffer = RotatableString(current, true);
     885             :           return true;
     886             :         }
     887             :       }
     888          13 :       returnValue.write(current);
     889             :     }
     890          39 :     _currentTextRun = RotatableString(returnValue.toString(), false);
     891          39 :     if (_currentTextRun!.text.isEmpty) {
     892             :       return false;
     893             :     }
     894          13 :     _atEndOfCharacterRange = true;
     895             :     return true;
     896             :   }
     897             : 
     898          13 :   static bool isBreakChar(String character) {
     899          26 :     return (character == ' ' || character == '\n');
     900             :   }
     901             : 
     902             :   // TODO: rename these in the next major version
     903             :   static const MONGOL_QUICKCHECK_START = 0x1800;
     904             :   static const MONGOL_QUICKCHECK_END = 0x2060;
     905             :   static const KOREAN_JAMO_START = 0x1100;
     906             :   static const KOREAN_JAMO_END = 0x11FF;
     907             :   static const CJK_RADICAL_SUPPLEMENT_START = 0x2E80;
     908             :   static const CJK_SYMBOLS_AND_PUNCTUATION_START = 0x3000;
     909             :   static const CJK_SYMBOLS_AND_PUNCTUATION_MENKSOFT_END = 0x301C;
     910             :   static const CIRCLE_NUMBER_21 = 0x3251;
     911             :   static const CIRCLE_NUMBER_35 = 0x325F;
     912             :   static const CIRCLE_NUMBER_36 = 0x32B1;
     913             :   static const CIRCLE_NUMBER_50 = 0x32BF;
     914             :   static const CJK_UNIFIED_IDEOGRAPHS_END = 0x9FFF;
     915             :   static const HANGUL_SYLLABLES_START = 0xAC00;
     916             :   static const HANGUL_JAMO_EXTENDED_B_END = 0xD7FF;
     917             :   static const CJK_COMPATIBILITY_IDEOGRAPHS_START = 0xF900;
     918             :   static const CJK_COMPATIBILITY_IDEOGRAPHS_END = 0xFAFF;
     919             :   static const UNICODE_EMOJI_START = 0x1F000;
     920             : 
     921          13 :   bool _isRotatable(String character) {
     922             :     //if (character.runes.length > 1) return true;
     923             : 
     924          26 :     final codePoint = character.runes.first;
     925             : 
     926             :     // Quick return: most Mongol chars should be in this range
     927          13 :     if (codePoint >= MONGOL_QUICKCHECK_START &&
     928           8 :         codePoint < MONGOL_QUICKCHECK_END) return false;
     929             : 
     930             :     // Korean Jamo
     931          13 :     if (codePoint < KOREAN_JAMO_START) return false; // latin, etc
     932           6 :     if (codePoint <= KOREAN_JAMO_END) return true;
     933             : 
     934             :     // Chinese and Japanese
     935           6 :     if (codePoint >= CJK_RADICAL_SUPPLEMENT_START &&
     936           6 :         codePoint <= CJK_UNIFIED_IDEOGRAPHS_END) {
     937             :       // exceptions for font handled punctuation
     938           3 :       if (codePoint >= CJK_SYMBOLS_AND_PUNCTUATION_START &&
     939           3 :           codePoint <= CJK_SYMBOLS_AND_PUNCTUATION_MENKSOFT_END) return false;
     940           6 :       if (codePoint >= CIRCLE_NUMBER_21 && codePoint <= CIRCLE_NUMBER_35) {
     941             :         return false;
     942             :       }
     943             : 
     944           6 :       if (codePoint >= CIRCLE_NUMBER_36 && codePoint <= CIRCLE_NUMBER_50) {
     945             :         return false;
     946             :       }
     947             :       return true;
     948             :     }
     949             : 
     950             :     // Korean Hangul
     951           6 :     if (codePoint >= HANGUL_SYLLABLES_START &&
     952           6 :         codePoint <= HANGUL_JAMO_EXTENDED_B_END) return true;
     953             : 
     954             :     // More Chinese
     955           6 :     if (codePoint >= CJK_COMPATIBILITY_IDEOGRAPHS_START &&
     956           6 :         codePoint <= CJK_COMPATIBILITY_IDEOGRAPHS_END) return true;
     957             : 
     958             :     // Emoji
     959           6 :     if (_isEmoji(codePoint)) return true;
     960             : 
     961             :     // all other code points
     962             :     return false;
     963             :   }
     964             : 
     965           6 :   bool _isEmoji(int codePoint) {
     966           6 :     return codePoint > UNICODE_EMOJI_START;
     967             :   }
     968             : }
     969             : 
     970             : // A data object to associate a text run with its style
     971             : class _RawStyledTextRun {
     972          12 :   _RawStyledTextRun(this.style, this.text);
     973             :   final TextStyle? style;
     974             :   final RotatableString text;
     975             : }
     976             : 
     977             : /// A [_TextRun] describes the smallest unit of text that is printed on the
     978             : /// canvas. It may be a word, CJK character, emoji or particular style.
     979             : ///
     980             : /// The [start] and [end] values are the indexes of the text range that
     981             : /// forms the run. The [paragraph] is the precomputed Paragraph object that
     982             : /// contains the text run.
     983             : class _TextRun {
     984          12 :   _TextRun(this.start, this.end, this.isRotated, this.paragraph);
     985             : 
     986             :   /// The UTF-16 code unit index where this run starts within the entire text
     987             :   /// range. The value in inclusive (that is, this is the actual start index).
     988             :   final int start;
     989             : 
     990             :   /// The UTF-16 code unit index where this run ends within the entire text
     991             :   /// range. The value is exclusive (that is, one unit beyond the last code
     992             :   /// unit).
     993             :   final int end;
     994             : 
     995             :   /// Whether this text run should be rotated 90 degrees counterclockwise in
     996             :   /// relation to the rest of the text.
     997             :   ///
     998             :   /// This would normally be for emoji and  CJK characters so that they will
     999             :   /// appear in the correct orientation in a vertical line of text.
    1000             :   final bool isRotated;
    1001             : 
    1002             :   /// The pre-computed text layout for this run.
    1003             :   ///
    1004             :   /// It includes the size but should never be more than one line.
    1005             :   final ui.Paragraph paragraph;
    1006             : 
    1007             :   /// Returns the width of the run (in horizontal orientation) taking into account
    1008             :   /// whether it [isRotated].
    1009          12 :   double get width {
    1010          12 :     if (isRotated) {
    1011          10 :       return paragraph.height;
    1012             :     }
    1013          24 :     return paragraph.maxIntrinsicWidth;
    1014             :   }
    1015             : 
    1016             :   /// Returns the height of the run (in horizontal orientation) taking into account
    1017             :   /// whether it [isRotated].
    1018          12 :   double get height {
    1019          12 :     if (isRotated) {
    1020          10 :       return paragraph.maxIntrinsicWidth;
    1021             :     }
    1022          24 :     return paragraph.height;
    1023             :   }
    1024             : 
    1025          10 :   void draw(ui.Canvas canvas, ui.Offset offset) {
    1026          10 :     if (isRotated) {
    1027           3 :       canvas.save();
    1028           9 :       canvas.rotate(-math.pi / 2);
    1029           9 :       canvas.translate(-height, 0);
    1030           6 :       canvas.drawParagraph(paragraph, offset);
    1031           3 :       canvas.restore();
    1032             :     } else {
    1033          20 :       canvas.drawParagraph(paragraph, offset);
    1034             :     }
    1035             :   }
    1036             : }
    1037             : 
    1038             : /// LineInfo stores information about each line in the paragraph.
    1039             : ///
    1040             : /// [textRunStart] is the index of the first text run in the line (out of all the
    1041             : /// text runs in the paragraph). [textRunEnd] is the index of the last run.
    1042             : ///
    1043             : /// The [bounds] is the size of the unrotated text line.
    1044             : class _LineInfo {
    1045          12 :   _LineInfo(this.textRunStart, this.textRunEnd, this.bounds);
    1046             : 
    1047             :   /// The index of the run in [_runs] where this line starts
    1048             :   final int textRunStart;
    1049             : 
    1050             :   /// The index (exclusive) of the run in [_runs] where this line end
    1051             :   final int textRunEnd;
    1052             : 
    1053             :   /// The measured size of this unrotated line (horizontal orientation).
    1054             :   ///
    1055             :   /// There is no offset so [left] and [top] are `0`. Just use [width] and
    1056             :   /// [height].
    1057             :   final Rect bounds;
    1058             : }
    1059             : 
    1060             : // This is for keeping track of the text style stack.
    1061             : class _Stack<T> {
    1062             :   final _stack = Queue<T>();
    1063             : 
    1064          11 :   void push(T element) {
    1065          22 :     _stack.addLast(element);
    1066             :   }
    1067             : 
    1068          11 :   void pop() {
    1069          22 :     _stack.removeLast();
    1070             :   }
    1071             : 
    1072          36 :   bool get isEmpty => _stack.isEmpty;
    1073             : 
    1074          33 :   T get top => _stack.last;
    1075             : }

Generated by: LCOV version 1.15