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 : }
|