[web] Separate text fragmenting from layout (#34085)
diff --git a/ci/licenses_golden/licenses_flutter b/ci/licenses_golden/licenses_flutter
index 3b4555d..627096d 100644
--- a/ci/licenses_golden/licenses_flutter
+++ b/ci/licenses_golden/licenses_flutter
@@ -1926,6 +1926,8 @@
FILE: ../../../flutter/lib/web_ui/lib/src/engine/test_embedding.dart
FILE: ../../../flutter/lib/web_ui/lib/src/engine/text/canvas_paragraph.dart
FILE: ../../../flutter/lib/web_ui/lib/src/engine/text/font_collection.dart
+FILE: ../../../flutter/lib/web_ui/lib/src/engine/text/fragmenter.dart
+FILE: ../../../flutter/lib/web_ui/lib/src/engine/text/layout_fragmenter.dart
FILE: ../../../flutter/lib/web_ui/lib/src/engine/text/layout_service.dart
FILE: ../../../flutter/lib/web_ui/lib/src/engine/text/line_break_properties.dart
FILE: ../../../flutter/lib/web_ui/lib/src/engine/text/line_breaker.dart
diff --git a/lib/web_ui/lib/src/engine.dart b/lib/web_ui/lib/src/engine.dart
index 54464dd..f83e43c 100644
--- a/lib/web_ui/lib/src/engine.dart
+++ b/lib/web_ui/lib/src/engine.dart
@@ -148,6 +148,8 @@
export 'engine/test_embedding.dart';
export 'engine/text/canvas_paragraph.dart';
export 'engine/text/font_collection.dart';
+export 'engine/text/fragmenter.dart';
+export 'engine/text/layout_fragmenter.dart';
export 'engine/text/layout_service.dart';
export 'engine/text/line_break_properties.dart';
export 'engine/text/line_breaker.dart';
diff --git a/lib/web_ui/lib/src/engine/dom.dart b/lib/web_ui/lib/src/engine/dom.dart
index 3ef7e82..ee75499 100644
--- a/lib/web_ui/lib/src/engine/dom.dart
+++ b/lib/web_ui/lib/src/engine/dom.dart
@@ -634,6 +634,8 @@
external set fillStyle(Object? style);
external String get font;
external set font(String value);
+ external String get direction;
+ external set direction(String value);
external set lineWidth(num? value);
external set strokeStyle(Object? value);
external Object? get strokeStyle;
diff --git a/lib/web_ui/lib/src/engine/html/bitmap_canvas.dart b/lib/web_ui/lib/src/engine/html/bitmap_canvas.dart
index e0c70eb..b2b93ab 100644
--- a/lib/web_ui/lib/src/engine/html/bitmap_canvas.dart
+++ b/lib/web_ui/lib/src/engine/html/bitmap_canvas.dart
@@ -913,9 +913,11 @@
_cachedLastCssFont = null;
}
- void setCssFont(String cssFont) {
+ void setCssFont(String cssFont, ui.TextDirection textDirection) {
+ final DomCanvasRenderingContext2D ctx = _canvasPool.context;
+ ctx.direction = textDirection == ui.TextDirection.ltr ? 'ltr' : 'rtl';
+
if (cssFont != _cachedLastCssFont) {
- final DomCanvasRenderingContext2D ctx = _canvasPool.context;
ctx.font = cssFont;
_cachedLastCssFont = cssFont;
}
diff --git a/lib/web_ui/lib/src/engine/text/canvas_paragraph.dart b/lib/web_ui/lib/src/engine/text/canvas_paragraph.dart
index b0de4f8..59cfc57 100644
--- a/lib/web_ui/lib/src/engine/text/canvas_paragraph.dart
+++ b/lib/web_ui/lib/src/engine/text/canvas_paragraph.dart
@@ -9,6 +9,7 @@
import '../html/bitmap_canvas.dart';
import '../profiler.dart';
import '../util.dart';
+import 'layout_fragmenter.dart';
import 'layout_service.dart';
import 'paint_service.dart';
import 'paragraph.dart';
@@ -16,6 +17,8 @@
const ui.Color _defaultTextColor = ui.Color(0xFFFF0000);
+final String _placeholderChar = String.fromCharCode(0xFFFC);
+
/// A paragraph made up of a flat list of text spans and placeholders.
///
/// [CanvasParagraph] doesn't use a DOM element to represent the structure of
@@ -32,7 +35,7 @@
required this.plainText,
required this.placeholderCount,
required this.canDrawOnCanvas,
- });
+ }) : assert(spans.isNotEmpty);
/// The flat list of spans that make up this paragraph.
final List<ParagraphSpan> spans;
@@ -168,38 +171,28 @@
// 2. Append all spans to the paragraph.
- DomHTMLElement? lastSpanElement;
for (int i = 0; i < lines.length; i++) {
final ParagraphLine line = lines[i];
- final List<RangeBox> boxes = line.boxes;
- final StringBuffer buffer = StringBuffer();
-
- int j = 0;
- while (j < boxes.length) {
- final RangeBox box = boxes[j++];
-
- if (box is SpanBox) {
- lastSpanElement = domDocument.createElement('flt-span') as
- DomHTMLElement;
- applyTextStyleToElement(
- element: lastSpanElement,
- style: box.span.style,
- isSpan: true,
- );
- _positionSpanElement(lastSpanElement, line, box);
- lastSpanElement.appendText(box.toText());
- rootElement.append(lastSpanElement);
- buffer.write(box.toText());
- } else if (box is PlaceholderBox) {
- lastSpanElement = null;
- } else {
- throw UnimplementedError('Unknown box type: ${box.runtimeType}');
+ for (final LayoutFragment fragment in line.fragments) {
+ if (fragment.isPlaceholder) {
+ continue;
}
- }
- final String? ellipsis = line.ellipsis;
- if (ellipsis != null) {
- (lastSpanElement ?? rootElement).appendText(ellipsis);
+ final String text = fragment.getText(this);
+ if (text.isEmpty) {
+ continue;
+ }
+
+ final DomHTMLElement spanElement = domDocument.createElement('flt-span') as DomHTMLElement;
+ applyTextStyleToElement(
+ element: spanElement,
+ style: fragment.style,
+ isSpan: true,
+ );
+ _positionSpanElement(spanElement, line, fragment);
+
+ spanElement.appendText(text);
+ rootElement.append(spanElement);
}
}
@@ -283,8 +276,8 @@
}
}
-void _positionSpanElement(DomElement element, ParagraphLine line, RangeBox box) {
- final ui.Rect boxRect = box.toTextBox(line, forPainting: true).toRect();
+void _positionSpanElement(DomElement element, ParagraphLine line, LayoutFragment fragment) {
+ final ui.Rect boxRect = fragment.toPaintingTextBox().toRect();
element.style
..position = 'absolute'
..top = '${boxRect.top}px'
@@ -304,6 +297,9 @@
/// The index of the end of the range of text represented by this span.
int get end;
+
+ /// The resolved style of the span.
+ EngineTextStyle get style;
}
/// Represent a span of text in the paragraph.
@@ -323,7 +319,7 @@
required this.end,
});
- /// The resolved style of the span.
+ @override
final EngineTextStyle style;
@override
@@ -341,14 +337,24 @@
class PlaceholderSpan extends ParagraphPlaceholder implements ParagraphSpan {
PlaceholderSpan(
- int index,
- super.width,
- super.height,
- super.alignment, {
- required super.baselineOffset,
- required super.baseline,
- }) : start = index,
- end = index;
+ this.style,
+ this.start,
+ this.end,
+ double width,
+ double height,
+ ui.PlaceholderAlignment alignment, {
+ required double baselineOffset,
+ required ui.TextBaseline baseline,
+ }) : super(
+ width,
+ height,
+ alignment,
+ baselineOffset: baselineOffset,
+ baseline: baseline,
+ );
+
+ @override
+ final EngineTextStyle style;
@override
final int start;
@@ -624,10 +630,19 @@
alignment == ui.PlaceholderAlignment.belowBaseline ||
alignment == ui.PlaceholderAlignment.baseline) || baseline != null);
+ final int start = _plainTextBuffer.length;
+ _plainTextBuffer.write(_placeholderChar);
+ final int end = _plainTextBuffer.length;
+
+ final EngineTextStyle style = _currentStyleNode.resolveStyle();
+ _updateCanDrawOnCanvas(style);
+
_placeholderCount++;
_placeholderScales.add(scale);
_spans.add(PlaceholderSpan(
- _plainTextBuffer.length,
+ style,
+ start,
+ end,
width * scale,
height * scale,
alignment,
@@ -652,37 +667,54 @@
@override
void addText(String text) {
- final EngineTextStyle style = _currentStyleNode.resolveStyle();
final int start = _plainTextBuffer.length;
_plainTextBuffer.write(text);
final int end = _plainTextBuffer.length;
- if (_canDrawOnCanvas) {
- final ui.TextDecoration? decoration = style.decoration;
- if (decoration != null && decoration != ui.TextDecoration.none) {
- _canDrawOnCanvas = false;
- }
- }
-
- if (_canDrawOnCanvas) {
- final List<ui.FontFeature>? fontFeatures = style.fontFeatures;
- if (fontFeatures != null && fontFeatures.isNotEmpty) {
- _canDrawOnCanvas = false;
- }
- }
-
- if (_canDrawOnCanvas) {
- final List<ui.FontVariation>? fontVariations = style.fontVariations;
- if (fontVariations != null && fontVariations.isNotEmpty) {
- _canDrawOnCanvas = false;
- }
- }
+ final EngineTextStyle style = _currentStyleNode.resolveStyle();
+ _updateCanDrawOnCanvas(style);
_spans.add(FlatTextSpan(style: style, start: start, end: end));
}
+ void _updateCanDrawOnCanvas(EngineTextStyle style) {
+ if (!_canDrawOnCanvas) {
+ return;
+ }
+
+ final ui.TextDecoration? decoration = style.decoration;
+ if (decoration != null && decoration != ui.TextDecoration.none) {
+ _canDrawOnCanvas = false;
+ return;
+ }
+
+ final List<ui.FontFeature>? fontFeatures = style.fontFeatures;
+ if (fontFeatures != null && fontFeatures.isNotEmpty) {
+ _canDrawOnCanvas = false;
+ return;
+ }
+
+ final List<ui.FontVariation>? fontVariations = style.fontVariations;
+ if (fontVariations != null && fontVariations.isNotEmpty) {
+ _canDrawOnCanvas = false;
+ return;
+ }
+ }
+
@override
CanvasParagraph build() {
+ if (_spans.isEmpty) {
+ // In case `addText` and `addPlaceholder` were never called.
+ //
+ // We want the paragraph to always have a non-empty list of spans to match
+ // the expectations of the [LayoutFragmenter].
+ _spans.add(FlatTextSpan(
+ style: _rootStyleNode.resolveStyle(),
+ start: 0,
+ end: 0,
+ ));
+ }
+
return CanvasParagraph(
_spans,
paragraphStyle: _paragraphStyle,
diff --git a/lib/web_ui/lib/src/engine/text/fragmenter.dart b/lib/web_ui/lib/src/engine/text/fragmenter.dart
new file mode 100644
index 0000000..9fdd82e
--- /dev/null
+++ b/lib/web_ui/lib/src/engine/text/fragmenter.dart
@@ -0,0 +1,34 @@
+// Copyright 2013 The Flutter Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+/// Splits [text] into a list of [TextFragment]s.
+///
+/// Various subclasses can perform the fragmenting based on their own criteria.
+///
+/// See:
+///
+/// - [LineBreakFragmenter]: Fragments text based on line break opportunities.
+/// - [BidiFragmenter]: Fragments text based on directionality.
+abstract class TextFragmenter {
+ const TextFragmenter(this.text);
+
+ /// The text to be fragmented.
+ final String text;
+
+ /// Performs the fragmenting of [text] and returns a list of [TextFragment]s.
+ List<TextFragment> fragment();
+}
+
+/// Represents a fragment produced by [TextFragmenter].
+abstract class TextFragment {
+ const TextFragment(this.start, this.end);
+
+ final int start;
+ final int end;
+
+ /// Whether this fragment's range overlaps with the range from [start] to [end].
+ bool overlapsWith(int start, int end) {
+ return start < this.end && this.start < end;
+ }
+}
diff --git a/lib/web_ui/lib/src/engine/text/layout_fragmenter.dart b/lib/web_ui/lib/src/engine/text/layout_fragmenter.dart
new file mode 100644
index 0000000..0c48c0f
--- /dev/null
+++ b/lib/web_ui/lib/src/engine/text/layout_fragmenter.dart
@@ -0,0 +1,628 @@
+// Copyright 2013 The Flutter Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import 'dart:math' as math;
+
+import 'package:ui/ui.dart' as ui;
+
+import '../util.dart';
+import 'canvas_paragraph.dart';
+import 'fragmenter.dart';
+import 'layout_service.dart';
+import 'line_breaker.dart';
+import 'paragraph.dart';
+import 'text_direction.dart';
+
+/// Splits [text] into fragments that are ready to be laid out by
+/// [TextLayoutService].
+///
+/// This fragmenter takes into account line breaks, directionality and styles.
+class LayoutFragmenter extends TextFragmenter {
+ const LayoutFragmenter(super.text, this.paragraphSpans);
+
+ final List<ParagraphSpan> paragraphSpans;
+
+ @override
+ List<LayoutFragment> fragment() {
+ final List<LayoutFragment> fragments = <LayoutFragment>[];
+
+ int fragmentStart = 0;
+
+ final Iterator<LineBreakFragment> lineBreakFragments = LineBreakFragmenter(text).fragment().iterator..moveNext();
+ final Iterator<BidiFragment> bidiFragments = BidiFragmenter(text).fragment().iterator..moveNext();
+ final Iterator<ParagraphSpan> spans = paragraphSpans.iterator..moveNext();
+
+ LineBreakFragment currentLineBreakFragment = lineBreakFragments.current;
+ BidiFragment currentBidiFragment = bidiFragments.current;
+ ParagraphSpan currentSpan = spans.current;
+
+ while (true) {
+ final int fragmentEnd = math.min(
+ currentLineBreakFragment.end,
+ math.min(
+ currentBidiFragment.end,
+ currentSpan.end,
+ ),
+ );
+
+ final int distanceFromLineBreak = currentLineBreakFragment.end - fragmentEnd;
+
+ final LineBreakType lineBreakType = distanceFromLineBreak == 0
+ ? currentLineBreakFragment.type
+ : LineBreakType.prohibited;
+
+ final int trailingNewlines = currentLineBreakFragment.trailingNewlines - distanceFromLineBreak;
+ final int trailingSpaces = currentLineBreakFragment.trailingSpaces - distanceFromLineBreak;
+
+ final int fragmentLength = fragmentEnd - fragmentStart;
+ fragments.add(LayoutFragment(
+ fragmentStart,
+ fragmentEnd,
+ lineBreakType,
+ currentBidiFragment.textDirection,
+ currentBidiFragment.fragmentFlow,
+ currentSpan,
+ trailingNewlines: clampInt(trailingNewlines, 0, fragmentLength),
+ trailingSpaces: clampInt(trailingSpaces, 0, fragmentLength),
+ ));
+
+ fragmentStart = fragmentEnd;
+
+ bool moved = false;
+ if (currentLineBreakFragment.end == fragmentEnd) {
+ if (lineBreakFragments.moveNext()) {
+ moved = true;
+ currentLineBreakFragment = lineBreakFragments.current;
+ }
+ }
+ if (currentBidiFragment.end == fragmentEnd) {
+ if (bidiFragments.moveNext()) {
+ moved = true;
+ currentBidiFragment = bidiFragments.current;
+ }
+ }
+ if (currentSpan.end == fragmentEnd) {
+ if (spans.moveNext()) {
+ moved = true;
+ currentSpan = spans.current;
+ }
+ }
+
+ // Once we reached the end of all fragments, exit the loop.
+ if (!moved) {
+ break;
+ }
+ }
+
+ return fragments;
+ }
+}
+
+abstract class _CombinedFragment extends TextFragment {
+ _CombinedFragment(
+ super.start,
+ super.end,
+ this.type,
+ this._textDirection,
+ this.fragmentFlow,
+ this.span, {
+ required this.trailingNewlines,
+ required this.trailingSpaces,
+ }) : assert(trailingNewlines >= 0),
+ assert(trailingSpaces >= trailingNewlines);
+
+ final LineBreakType type;
+
+ ui.TextDirection? get textDirection => _textDirection;
+ ui.TextDirection? _textDirection;
+
+ final FragmentFlow fragmentFlow;
+
+ final ParagraphSpan span;
+
+ final int trailingNewlines;
+
+ final int trailingSpaces;
+
+ @override
+ int get hashCode => Object.hash(
+ start,
+ end,
+ type,
+ textDirection,
+ fragmentFlow,
+ span,
+ trailingNewlines,
+ trailingSpaces,
+ );
+
+ @override
+ bool operator ==(Object other) {
+ return other is LayoutFragment &&
+ other.start == start &&
+ other.end == end &&
+ other.type == type &&
+ other.textDirection == textDirection &&
+ other.fragmentFlow == fragmentFlow &&
+ other.span == span &&
+ other.trailingNewlines == trailingNewlines &&
+ other.trailingSpaces == trailingSpaces;
+ }
+}
+
+class LayoutFragment extends _CombinedFragment with _FragmentMetrics, _FragmentPosition, _FragmentBox {
+ LayoutFragment(
+ super.start,
+ super.end,
+ super.type,
+ super.textDirection,
+ super.fragmentFlow,
+ super.span, {
+ required super.trailingNewlines,
+ required super.trailingSpaces,
+ });
+
+ int get length => end - start;
+ bool get isSpaceOnly => length == trailingSpaces;
+ bool get isPlaceholder => span is PlaceholderSpan;
+ bool get isBreak => type != LineBreakType.prohibited;
+ bool get isHardBreak => type == LineBreakType.mandatory || type == LineBreakType.endOfText;
+ EngineTextStyle get style => span.style;
+
+ /// Returns the substring from [paragraph] that corresponds to this fragment,
+ /// excluding new line characters.
+ String getText(CanvasParagraph paragraph) {
+ return paragraph.plainText.substring(start, end - trailingNewlines);
+ }
+
+ /// Splits this fragment into two fragments with the split point being the
+ /// given [index].
+ // TODO(mdebbar): If we ever get multiple return values in Dart, we should use it!
+ // See: https://github.com/dart-lang/language/issues/68
+ List<LayoutFragment?> split(int index) {
+ assert(start <= index);
+ assert(index <= end);
+
+ if (start == index) {
+ return <LayoutFragment?>[null, this];
+ }
+
+ if (end == index) {
+ return <LayoutFragment?>[this, null];
+ }
+
+ // The length of the second fragment after the split.
+ final int secondLength = end - index;
+
+ // Trailing spaces/new lines go to the second fragment. Any left over goes
+ // to the first fragment.
+ final int secondTrailingNewlines = math.min(trailingNewlines, secondLength);
+ final int secondTrailingSpaces = math.min(trailingSpaces, secondLength);
+
+ return <LayoutFragment>[
+ LayoutFragment(
+ start,
+ index,
+ LineBreakType.prohibited,
+ textDirection,
+ fragmentFlow,
+ span,
+ trailingNewlines: trailingNewlines - secondTrailingNewlines,
+ trailingSpaces: trailingSpaces - secondTrailingSpaces,
+ ),
+ LayoutFragment(
+ index,
+ end,
+ type,
+ textDirection,
+ fragmentFlow,
+ span,
+ trailingNewlines: secondTrailingNewlines,
+ trailingSpaces: secondTrailingSpaces,
+ ),
+ ];
+ }
+
+ @override
+ String toString() {
+ return '$LayoutFragment($start, $end, $type, $textDirection)';
+ }
+}
+
+mixin _FragmentMetrics on _CombinedFragment {
+ late Spanometer _spanometer;
+
+ /// The rise from the baseline as calculated from the font and style for this text.
+ double get ascent => _ascent;
+ late double _ascent;
+
+ /// The drop from the baseline as calculated from the font and style for this text.
+ double get descent => _descent;
+ late double _descent;
+
+ /// The width of the measured text, not including trailing spaces.
+ double get widthExcludingTrailingSpaces => _widthExcludingTrailingSpaces;
+ late double _widthExcludingTrailingSpaces;
+
+ /// The width of the measured text, including any trailing spaces.
+ double get widthIncludingTrailingSpaces => _widthIncludingTrailingSpaces + _extraWidthForJustification;
+ late double _widthIncludingTrailingSpaces;
+
+ double _extraWidthForJustification = 0.0;
+
+ /// The total height as calculated from the font and style for this text.
+ double get height => ascent + descent;
+
+ double get widthOfTrailingSpaces => widthIncludingTrailingSpaces - widthExcludingTrailingSpaces;
+
+ /// Set measurement values for the fragment.
+ void setMetrics(Spanometer spanometer, {
+ required double ascent,
+ required double descent,
+ required double widthExcludingTrailingSpaces,
+ required double widthIncludingTrailingSpaces,
+ }) {
+ _spanometer = spanometer;
+ _ascent = ascent;
+ _descent = descent;
+ _widthExcludingTrailingSpaces = widthExcludingTrailingSpaces;
+ _widthIncludingTrailingSpaces = widthIncludingTrailingSpaces;
+ }
+}
+
+/// Encapsulates positioning of the fragment relative to the line.
+///
+/// The coordinates are all relative to the line it belongs to. For example,
+/// [left] is the distance from the left edge of the line to the left edge of
+/// the fragment.
+///
+/// This is what the various measurements/coordinates look like for a fragment
+/// in an LTR paragraph:
+///
+/// *------------------------line.width-----------------*
+/// *---width----*
+/// ┌─────────────────┬────────────┬────────────────────┐
+/// │ │--FRAGMENT--│ │
+/// └─────────────────┴────────────┴────────────────────┘
+/// *---startOffset---*
+/// *------left-------*
+/// *--------endOffset-------------*
+/// *----------right---------------*
+///
+///
+/// And in an RTL paragraph, [startOffset] and [endOffset] are flipped because
+/// the line starts from the right. Here's what they look like:
+///
+/// *------------------------line.width-----------------*
+/// *---width----*
+/// ┌─────────────────┬────────────┬────────────────────┐
+/// │ │--FRAGMENT--│ │
+/// └─────────────────┴────────────┴────────────────────┘
+/// *----startOffset-----*
+/// *------left-------*
+/// *-----------endOffset-------------*
+/// *----------right---------------*
+///
+mixin _FragmentPosition on _CombinedFragment, _FragmentMetrics {
+ /// The distance from the beginning of the line to the beginning of the fragment.
+ double get startOffset => _startOffset;
+ late double _startOffset;
+
+ /// The width of the line that contains this fragment.
+ late ParagraphLine line;
+
+ /// The distance from the beginning of the line to the end of the fragment.
+ double get endOffset => startOffset + widthIncludingTrailingSpaces;
+
+ /// The distance from the left edge of the line to the left edge of the fragment.
+ double get left => line.textDirection == ui.TextDirection.ltr
+ ? startOffset
+ : line.width - endOffset;
+
+ /// The distance from the left edge of the line to the right edge of the fragment.
+ double get right => line.textDirection == ui.TextDirection.ltr
+ ? endOffset
+ : line.width - startOffset;
+
+ /// Set the horizontal position of this fragment relative to the [line] that
+ /// contains it.
+ void setPosition({
+ required double startOffset,
+ required ui.TextDirection textDirection,
+ }) {
+ _startOffset = startOffset;
+ _textDirection ??= textDirection;
+ }
+
+ /// Adjust the width of this fragment for paragraph justification.
+ void justifyTo({required double paragraphWidth}) {
+ // Only justify this fragment if it's not a trailing space in the line.
+ if (end > line.endIndex - line.trailingSpaces) {
+ // Don't justify fragments that are part of trailing spaces of the line.
+ return;
+ }
+
+ if (trailingSpaces == 0) {
+ // If this fragment has no spaces, there's nothing to justify.
+ return;
+ }
+
+ final double justificationTotal = paragraphWidth - line.width;
+ final double justificationPerSpace = justificationTotal / line.nonTrailingSpaces;
+ _extraWidthForJustification = justificationPerSpace * trailingSpaces;
+ }
+}
+
+/// Encapsulates calculations related to the bounding box of the fragment
+/// relative to the paragraph.
+mixin _FragmentBox on _CombinedFragment, _FragmentMetrics, _FragmentPosition {
+ double get top => line.baseline - ascent;
+ double get bottom => line.baseline + descent;
+
+ late final ui.TextBox _textBoxIncludingTrailingSpaces = ui.TextBox.fromLTRBD(
+ line.left + left,
+ top,
+ line.left + right,
+ bottom,
+ textDirection!,
+ );
+
+ /// Whether or not the trailing spaces of this fragment are part of trailing
+ /// spaces of the line containing the fragment.
+ bool get _isPartOfTrailingSpacesInLine => end > line.endIndex - line.trailingSpaces;
+
+ /// Returns a [ui.TextBox] for the purpose of painting this fragment.
+ ///
+ /// The coordinates of the resulting [ui.TextBox] are relative to the
+ /// paragraph, not to the line.
+ ///
+ /// Trailing spaces in each line aren't painted on the screen, so they are
+ /// excluded from the resulting text box.
+ ui.TextBox toPaintingTextBox() {
+ if (_isPartOfTrailingSpacesInLine) {
+ // For painting, we exclude the width of trailing spaces from the box.
+ return textDirection! == ui.TextDirection.ltr
+ ? ui.TextBox.fromLTRBD(
+ line.left + left,
+ top,
+ line.left + right - widthOfTrailingSpaces,
+ bottom,
+ textDirection!,
+ )
+ : ui.TextBox.fromLTRBD(
+ line.left + left + widthOfTrailingSpaces,
+ top,
+ line.left + right,
+ bottom,
+ textDirection!,
+ );
+ }
+ return _textBoxIncludingTrailingSpaces;
+ }
+
+ /// Returns a [ui.TextBox] representing this fragment.
+ ///
+ /// The coordinates of the resulting [ui.TextBox] are relative to the
+ /// paragraph, not to the line.
+ ///
+ /// As opposed to [toPaintingTextBox], the resulting text box from this method
+ /// includes trailing spaces of the fragment.
+ ui.TextBox toTextBox({
+ int? start,
+ int? end,
+ }) {
+ start ??= this.start;
+ end ??= this.end;
+
+ if (start <= this.start && end >= this.end - trailingNewlines) {
+ return _textBoxIncludingTrailingSpaces;
+ }
+ return _intersect(start, end);
+ }
+
+ /// Performs the intersection of this fragment with the range given by [start] and
+ /// [end] indices, and returns a [ui.TextBox] representing that intersection.
+ ///
+ /// The coordinates of the resulting [ui.TextBox] are relative to the
+ /// paragraph, not to the line.
+ ui.TextBox _intersect(int start, int end) {
+ // `_intersect` should only be called when there's an actual intersection.
+ assert(start > this.start || end < this.end);
+
+ final double before;
+ if (start <= this.start) {
+ before = 0.0;
+ } else {
+ _spanometer.currentSpan = span;
+ before = _spanometer.measureRange(this.start, start);
+ }
+
+ final double after;
+ if (end >= this.end - trailingNewlines) {
+ after = 0.0;
+ } else {
+ _spanometer.currentSpan = span;
+ after = _spanometer.measureRange(end, this.end - trailingNewlines);
+ }
+
+ final double left, right;
+ if (textDirection! == ui.TextDirection.ltr) {
+ // Example: let's say the text is "Loremipsum" and we want to get the box
+ // for "rem". In this case, `before` is the width of "Lo", and `after`
+ // is the width of "ipsum".
+ //
+ // Here's how the measurements/coordinates look like:
+ //
+ // before after
+ // |----| |----------|
+ // +---------------------+
+ // | L o r e m i p s u m |
+ // +---------------------+
+ // this.left ^ ^ this.right
+ left = this.left + before;
+ right = this.right - after;
+ } else {
+ // Example: let's say the text is "txet_werbeH" ("Hebrew_text" flowing from
+ // right to left). Say we want to get the box for "brew". The `before` is
+ // the width of "He", and `after` is the width of "_text".
+ //
+ // after before
+ // |----------| |----|
+ // +-----------------------+
+ // | t x e t _ w e r b e H |
+ // +-----------------------+
+ // this.left ^ ^ this.right
+ //
+ // Notice how `before` and `after` are reversed in the RTL example. That's
+ // because the text flows from right to left.
+ left = this.left + after;
+ right = this.right - before;
+ }
+
+ // The fragment's left and right edges are relative to the line. In order
+ // to make them relative to the paragraph, we need to add the left edge of
+ // the line.
+ return ui.TextBox.fromLTRBD(
+ line.left + left,
+ top,
+ line.left + right,
+ bottom,
+ textDirection!,
+ );
+ }
+
+ /// Returns the text position within this fragment's range that's closest to
+ /// the given [x] offset.
+ ///
+ /// The [x] offset is expected to be relative to the left edge of the fragment.
+ ui.TextPosition getPositionForX(double x) {
+ x = _makeXDirectionAgnostic(x);
+
+ final int startIndex = start;
+ final int endIndex = end - trailingNewlines;
+
+ // Check some special cases to return the result quicker.
+
+ final int length = endIndex - startIndex;
+ if (length == 0) {
+ return ui.TextPosition(offset: startIndex);
+ }
+ if (length == 1) {
+ // Find out if `x` is closer to `startIndex` or `endIndex`.
+ final double distanceFromStart = x;
+ final double distanceFromEnd = widthIncludingTrailingSpaces - x;
+ return distanceFromStart < distanceFromEnd
+ ? ui.TextPosition(offset: startIndex)
+ : ui.TextPosition(offset: endIndex, affinity: ui.TextAffinity.upstream,);
+ }
+
+ _spanometer.currentSpan = span;
+ // The resulting `cutoff` is the index of the character where the `x` offset
+ // falls. We should return the text position of either `cutoff` or
+ // `cutoff + 1` depending on which one `x` is closer to.
+ //
+ // offset x
+ // ↓
+ // "A B C D E F"
+ // ↑
+ // cutoff
+ final int cutoff = _spanometer.forceBreak(
+ startIndex,
+ endIndex,
+ availableWidth: x,
+ allowEmpty: true,
+ );
+
+ if (cutoff == endIndex) {
+ return ui.TextPosition(
+ offset: cutoff,
+ affinity: ui.TextAffinity.upstream,
+ );
+ }
+
+ final double lowWidth = _spanometer.measureRange(startIndex, cutoff);
+ final double highWidth = _spanometer.measureRange(startIndex, cutoff + 1);
+
+ // See if `x` is closer to `cutoff` or `cutoff + 1`.
+ if (x - lowWidth < highWidth - x) {
+ // The offset is closer to cutoff.
+ return ui.TextPosition(offset: cutoff);
+ } else {
+ // The offset is closer to cutoff + 1.
+ return ui.TextPosition(
+ offset: cutoff + 1,
+ affinity: ui.TextAffinity.upstream,
+ );
+ }
+ }
+
+ /// Transforms the [x] coordinate to be direction-agnostic.
+ ///
+ /// The X (input) is relative to the [left] edge of the fragment, and this
+ /// method returns an X' (output) that's relative to beginning of the text.
+ ///
+ /// Here's how it looks for a fragment with LTR content:
+ ///
+ /// *------------------------line width------------------*
+ /// *-----X (input)
+ /// ┌───────────┬────────────────────────┬───────────────┐
+ /// │ │ ---text-direction----> │ │
+ /// └───────────┴────────────────────────┴───────────────┘
+ /// *-----X' (output)
+ /// *---left----*
+ /// *---------------right----------------*
+ ///
+ ///
+ /// And here's how it looks for a fragment with RTL content:
+ ///
+ /// *------------------------line width------------------*
+ /// *-----X (input)
+ /// ┌───────────┬────────────────────────┬───────────────┐
+ /// │ │ <---text-direction---- │ │
+ /// └───────────┴────────────────────────┴───────────────┘
+ /// (output) X'-----------------*
+ /// *---left----*
+ /// *---------------right----------------*
+ ///
+ double _makeXDirectionAgnostic(double x) {
+ if (textDirection == ui.TextDirection.rtl) {
+ return widthIncludingTrailingSpaces - x;
+ }
+ return x;
+ }
+}
+
+class EllipsisFragment extends LayoutFragment {
+ EllipsisFragment(
+ int index,
+ ParagraphSpan span,
+ ) : super(
+ index,
+ index,
+ LineBreakType.endOfText,
+ null,
+ // The ellipsis is always at the end of the line, so it can't be
+ // sandwiched. This means it'll always follow the paragraph direction.
+ FragmentFlow.sandwich,
+ span,
+ trailingNewlines: 0,
+ trailingSpaces: 0,
+ );
+
+ @override
+ bool get isSpaceOnly => false;
+
+ @override
+ bool get isPlaceholder => false;
+
+ @override
+ String getText(CanvasParagraph paragraph) {
+ return paragraph.paragraphStyle.ellipsis!;
+ }
+
+ @override
+ List<LayoutFragment> split(int index) {
+ throw Exception('Cannot split an EllipsisFragment');
+ }
+}
diff --git a/lib/web_ui/lib/src/engine/text/layout_service.dart b/lib/web_ui/lib/src/engine/text/layout_service.dart
index a5b91bd..b3bdb62 100644
--- a/lib/web_ui/lib/src/engine/text/layout_service.dart
+++ b/lib/web_ui/lib/src/engine/text/layout_service.dart
@@ -9,6 +9,7 @@
import '../dom.dart';
import 'canvas_paragraph.dart';
+import 'layout_fragmenter.dart';
import 'line_breaker.dart';
import 'measurement.dart';
import 'paragraph.dart';
@@ -23,7 +24,8 @@
final CanvasParagraph paragraph;
- final DomCanvasRenderingContext2D context = createDomCanvasElement().context2D;
+ final DomCanvasRenderingContext2D context =
+ createDomCanvasElement().context2D;
// *** Results of layout *** //
@@ -51,13 +53,10 @@
ui.Rect get paintBounds => _paintBounds;
ui.Rect _paintBounds = ui.Rect.zero;
- // *** Convenient shortcuts used during layout *** //
+ late final Spanometer spanometer = Spanometer(paragraph, context);
- int? get maxLines => paragraph.paragraphStyle.maxLines;
- bool get unlimitedLines => maxLines == null;
-
- String? get ellipsis => paragraph.paragraphStyle.ellipsis;
- bool get hasEllipsis => ellipsis != null;
+ late final LayoutFragmenter layoutFragmenter =
+ LayoutFragmenter(paragraph.plainText, paragraph.spans);
/// Performs the layout on a paragraph given the [constraints].
///
@@ -74,8 +73,6 @@
/// 2. Enough lines have been computed to satisfy [maxLines].
/// 3. An ellipsis is appended because of an overflow.
void performLayout(ui.ParagraphConstraints constraints) {
- final int spanCount = paragraph.spans.length;
-
// Reset results from previous layout.
width = constraints.width;
height = 0.0;
@@ -85,129 +82,51 @@
didExceedMaxLines = false;
lines.clear();
- if (spanCount == 0) {
- return;
- }
-
- final Spanometer spanometer = Spanometer(paragraph, context);
-
- int spanIndex = 0;
LineBuilder currentLine =
LineBuilder.first(paragraph, spanometer, maxWidth: constraints.width);
- // The only way to exit this while loop is by hitting one of the `break;`
- // statements (e.g. when we reach `endOfText`, when ellipsis has been
- // appended).
- while (true) {
- // ************************** //
- // *** HANDLE END OF TEXT *** //
- // ************************** //
+ final List<LayoutFragment> fragments =
+ layoutFragmenter.fragment()..forEach(spanometer.measureFragment);
- // All spans have been consumed.
- final bool reachedEnd = spanIndex == spanCount;
- if (reachedEnd) {
- // In some cases, we need to extend the line to the end of text and
- // build it:
- //
- // 1. Line is not empty. This could happen when the last span is a
- // placeholder.
- //
- // 2. We haven't reached `LineBreakType.endOfText` yet. This could
- // happen when the last character is a new line.
- if (currentLine.isNotEmpty || currentLine.end.type != LineBreakType.endOfText) {
- currentLine.extendToEndOfText();
+ outerLoop:
+ for (int i = 0; i < fragments.length; i++) {
+ final LayoutFragment fragment = fragments[i];
+
+ currentLine.addFragment(fragment);
+
+ while (currentLine.isOverflowing) {
+ if (currentLine.canHaveEllipsis) {
+ currentLine.insertEllipsis();
lines.add(currentLine.build());
+ didExceedMaxLines = true;
+ break outerLoop;
}
- break;
- }
- // ********************************* //
- // *** THE MAIN MEASUREMENT PART *** //
- // ********************************* //
-
- ParagraphSpan span = paragraph.spans[spanIndex];
-
- if (span is PlaceholderSpan) {
- if (currentLine.widthIncludingSpace + span.width <= constraints.width) {
- // The placeholder fits on the current line.
- currentLine.addPlaceholder(span);
+ if (currentLine.isBreakable) {
+ currentLine.revertToLastBreakOpportunity();
} else {
- // The placeholder can't fit on the current line.
- if (currentLine.isNotEmpty) {
- lines.add(currentLine.build());
- currentLine = currentLine.nextLine();
- }
- currentLine.addPlaceholder(span);
- }
- spanIndex++;
- } else if (span is FlatTextSpan) {
- spanometer.currentSpan = span;
- final DirectionalPosition nextBreak = currentLine.findNextBreak();
- final double additionalWidth =
- currentLine.getAdditionalWidthTo(nextBreak.lineBreak);
-
- if (currentLine.width + additionalWidth <= constraints.width) {
- // The line can extend to `nextBreak` without overflowing.
- currentLine.extendTo(nextBreak);
- if (nextBreak.type == LineBreakType.mandatory) {
- lines.add(currentLine.build());
- currentLine = currentLine.nextLine();
- }
- } else {
- // The chunk of text can't fit into the current line.
- final bool isLastLine =
- (hasEllipsis && unlimitedLines) || lines.length + 1 == maxLines;
-
- if (isLastLine && hasEllipsis) {
- // We've reached the line that requires an ellipsis to be appended
- // to it.
-
- currentLine.forceBreak(
- nextBreak,
- allowEmpty: true,
- ellipsis: ellipsis,
- );
- lines.add(currentLine.build(ellipsis: ellipsis));
- break;
- } else if (currentLine.isNotBreakable) {
- // The entire line is unbreakable, which means we are dealing
- // with a single block of text that doesn't fit in a single line.
- // We need to force-break it without adding an ellipsis.
-
- currentLine.forceBreak(nextBreak, allowEmpty: false);
- lines.add(currentLine.build());
- currentLine = currentLine.nextLine();
- } else {
- // Normal line break.
- currentLine.revertToLastBreakOpportunity();
- // If a revert had occurred in the line, we need to revert the span
- // index accordingly.
- //
- // If no revert occurred, then `revertedToSpan` will be equal to
- // `span` and the following while loop won't do anything.
- final ParagraphSpan revertedToSpan = currentLine.lastSegment.span;
- while (span != revertedToSpan) {
- span = paragraph.spans[--spanIndex];
- }
- lines.add(currentLine.build());
- currentLine = currentLine.nextLine();
- }
+ // The line can't be legally broken, so the last fragment (that caused
+ // the line to overflow) needs to be force-broken.
+ currentLine.forceBreakLastFragment();
}
- // Only go to the next span if we've reached the end of this span.
- if (currentLine.end.index >= span.end) {
- currentLine.createBox();
- ++spanIndex;
- }
- } else {
- throw UnimplementedError('Unknown span type: ${span.runtimeType}');
+ i += currentLine.appendZeroWidthFragments(fragments, startFrom: i + 1);
+ lines.add(currentLine.build());
+ currentLine = currentLine.nextLine();
}
- if (lines.length == maxLines) {
- break;
+ if (currentLine.isHardBreak) {
+ lines.add(currentLine.build());
+ currentLine = currentLine.nextLine();
}
}
+ final int? maxLines = paragraph.paragraphStyle.maxLines;
+ if (maxLines != null && lines.length > maxLines) {
+ didExceedMaxLines = true;
+ lines.removeRange(maxLines, lines.length);
+ }
+
// ***************************************************************** //
// *** PARAGRAPH BASELINE & HEIGHT & LONGEST LINE & PAINT BOUNDS *** //
// ***************************************************************** //
@@ -241,69 +160,58 @@
height,
);
- // ********************** //
- // *** POSITION BOXES *** //
- // ********************** //
+ // **************************** //
+ // *** FRAGMENT POSITIONING *** //
+ // **************************** //
+ // We have to perform justification alignment first so that we can position
+ // fragments correctly later.
if (lines.isNotEmpty) {
- final ParagraphLine lastLine = lines.last;
- final bool shouldJustifyParagraph =
- width.isFinite &&
- paragraph.paragraphStyle.textAlign == ui.TextAlign.justify;
+ final bool shouldJustifyParagraph = width.isFinite &&
+ paragraph.paragraphStyle.textAlign == ui.TextAlign.justify;
- for (final ParagraphLine line in lines) {
+ if (shouldJustifyParagraph) {
// Don't apply justification to the last line.
- final bool shouldJustifyLine = shouldJustifyParagraph && line != lastLine;
- _positionLineBoxes(line, withJustification: shouldJustifyLine);
+ for (int i = 0; i < lines.length - 1; i++) {
+ for (final LayoutFragment fragment in lines[i].fragments) {
+ fragment.justifyTo(paragraphWidth: width);
+ }
+ }
}
}
+ lines.forEach(_positionLineFragments);
+
// ******************************** //
// *** MAX/MIN INTRINSIC WIDTHS *** //
// ******************************** //
- spanIndex = 0;
- currentLine =
- LineBuilder.first(paragraph, spanometer, maxWidth: constraints.width);
+ // TODO(mdebbar): Handle maxLines https://github.com/flutter/flutter/issues/91254
- while (spanIndex < spanCount) {
- final ParagraphSpan span = paragraph.spans[spanIndex];
- bool breakToNextLine = false;
+ double runningMinIntrinsicWidth = 0;
+ double runningMaxIntrinsicWidth = 0;
- if (span is PlaceholderSpan) {
- currentLine.addPlaceholder(span);
- spanIndex++;
- } else if (span is FlatTextSpan) {
- spanometer.currentSpan = span;
- final DirectionalPosition nextBreak = currentLine.findNextBreak();
-
- // For the purpose of max intrinsic width, we don't care if the line
- // fits within the constraints or not. So we always extend it.
- currentLine.extendTo(nextBreak);
- if (nextBreak.type == LineBreakType.mandatory) {
- // We don't want to break the line now because we want to update
- // min/max intrinsic widths below first.
- breakToNextLine = true;
- }
-
- // Only go to the next span if we've reached the end of this span.
- if (currentLine.end.index >= span.end) {
- spanIndex++;
- }
- }
-
- final double widthOfLastSegment = currentLine.lastSegment.width;
- if (minIntrinsicWidth < widthOfLastSegment) {
- minIntrinsicWidth = widthOfLastSegment;
- }
-
+ for (final LayoutFragment fragment in fragments) {
+ runningMinIntrinsicWidth += fragment.widthExcludingTrailingSpaces;
// Max intrinsic width includes the width of trailing spaces.
- if (maxIntrinsicWidth < currentLine.widthIncludingSpace) {
- maxIntrinsicWidth = currentLine.widthIncludingSpace;
- }
+ runningMaxIntrinsicWidth += fragment.widthIncludingTrailingSpaces;
- if (breakToNextLine) {
- currentLine = currentLine.nextLine();
+ switch (fragment.type) {
+ case LineBreakType.prohibited:
+ break;
+
+ case LineBreakType.opportunity:
+ minIntrinsicWidth = math.max(minIntrinsicWidth, runningMinIntrinsicWidth);
+ runningMinIntrinsicWidth = 0;
+ break;
+
+ case LineBreakType.mandatory:
+ case LineBreakType.endOfText:
+ minIntrinsicWidth = math.max(minIntrinsicWidth, runningMinIntrinsicWidth);
+ maxIntrinsicWidth = math.max(maxIntrinsicWidth, runningMaxIntrinsicWidth);
+ runningMinIntrinsicWidth = 0;
+ runningMaxIntrinsicWidth = 0;
+ break;
}
}
}
@@ -311,143 +219,130 @@
ui.TextDirection get _paragraphDirection =>
paragraph.paragraphStyle.effectiveTextDirection;
- /// Positions the boxes in the given [line] and takes into account their
- /// directions, the paragraph's direction, and alignment justification.
- void _positionLineBoxes(ParagraphLine line, {
- required bool withJustification,
- }) {
- final List<RangeBox> boxes = line.boxes;
- final double justifyPerSpaceBox = withJustification
- ? _calculateJustifyPerSpaceBox(line)
- : 0.0;
+ /// Positions the fragments taking into account their directions and the
+ /// paragraph's direction.
+ void _positionLineFragments(ParagraphLine line) {
+ ui.TextDirection previousDirection = _paragraphDirection;
- int i = 0;
- double cumulativeWidth = 0.0;
- while (i < boxes.length) {
- final RangeBox box = boxes[i];
- if (box.boxDirection == _paragraphDirection) {
- // The box is in the same direction as the paragraph.
- box.startOffset = cumulativeWidth;
- box.lineWidth = line.width;
- if (box is SpanBox && box.isSpaceOnly && !box.isTrailingSpace) {
- box._width += justifyPerSpaceBox;
+ double startOffset = 0.0;
+ int? sandwichStart;
+ int sequenceStart = 0;
+
+ for (int i = 0; i <= line.fragments.length; i++) {
+ if (i < line.fragments.length) {
+ final LayoutFragment fragment = line.fragments[i];
+
+ if (fragment.fragmentFlow == FragmentFlow.previous) {
+ sandwichStart = null;
+ continue;
+ }
+ if (fragment.fragmentFlow == FragmentFlow.sandwich) {
+ sandwichStart ??= i;
+ continue;
}
- cumulativeWidth += box.width;
- i++;
- continue;
- }
- // At this point, we found a box that has the opposite direction to the
- // paragraph. This could be a sequence of one or more boxes.
- //
- // These boxes should flow in the opposite direction. So we need to
- // position them in reverse order.
- //
- // If the last box in the sequence is a space-only box (contains only
- // whitespace characters), it should be excluded from the sequence.
- //
- // Example: an LTR paragraph with the contents:
- //
- // "ABC rtl1 rtl2 rtl3 XYZ"
- // ^ ^ ^ ^
- // SP1 SP2 SP3 SP4
- //
- //
- // box direction: LTR RTL LTR
- // |------>|<-----------------------|------>
- // +----------------------------------------+
- // | ABC | | rtl3 | | rtl2 | | rtl1 | | XYZ |
- // +----------------------------------------+
- // ^ ^ ^ ^
- // SP1 SP3 SP2 SP4
- //
- // Notice how SP2 and SP3 are flowing in the RTL direction because of the
- // surrounding RTL words. SP4 is also preceded by an RTL word, but it marks
- // the end of the RTL sequence, so it goes back to flowing in the paragraph
- // direction (LTR).
+ assert(fragment.fragmentFlow == FragmentFlow.ltr ||
+ fragment.fragmentFlow == FragmentFlow.rtl);
- final int first = i;
- int lastNonSpaceBox = first;
- i++;
- while (i < boxes.length && boxes[i].boxDirection != _paragraphDirection) {
- final RangeBox box = boxes[i];
- if (box is SpanBox && box.isSpaceOnly) {
- // Do nothing.
- } else {
- lastNonSpaceBox = i;
+ final ui.TextDirection currentDirection =
+ fragment.fragmentFlow == FragmentFlow.ltr
+ ? ui.TextDirection.ltr
+ : ui.TextDirection.rtl;
+
+ if (currentDirection == previousDirection) {
+ sandwichStart = null;
+ continue;
}
- i++;
}
- final int last = lastNonSpaceBox;
- i = lastNonSpaceBox + 1;
- // The range (first:last) is the entire sequence of boxes that have the
- // opposite direction to the paragraph.
- final double sequenceWidth = _positionLineBoxesInReverse(
- line,
- first,
- last,
- startOffset: cumulativeWidth,
- justifyPerSpaceBox: justifyPerSpaceBox,
- );
- cumulativeWidth += sequenceWidth;
+ // We've reached a fragment that'll flip the text direction. Let's
+ // position the sequence that we've been traversing.
+
+ if (sandwichStart == null) {
+ // Position fragments in range [sequenceStart:i)
+ startOffset += _positionFragmentRange(
+ line: line,
+ start: sequenceStart,
+ end: i,
+ direction: previousDirection,
+ startOffset: startOffset,
+ );
+ } else {
+ // Position fragments in range [sequenceStart:sandwichStart)
+ startOffset += _positionFragmentRange(
+ line: line,
+ start: sequenceStart,
+ end: sandwichStart,
+ direction: previousDirection,
+ startOffset: startOffset,
+ );
+ // Position fragments in range [sandwichStart:i)
+ startOffset += _positionFragmentRange(
+ line: line,
+ start: sandwichStart,
+ end: i,
+ direction: _paragraphDirection,
+ startOffset: startOffset,
+ );
+ }
+
+ sequenceStart = i;
+ sandwichStart = null;
+
+ if (i < line.fragments.length){
+ previousDirection = line.fragments[i].textDirection!;
+ }
}
}
- /// Positions a sequence of boxes in the direction opposite to the paragraph
- /// text direction.
- ///
- /// This is needed when a right-to-left sequence appears in the middle of a
- /// left-to-right paragraph, or vice versa.
- ///
- /// Returns the total width of all the positioned boxes in the sequence.
- ///
- /// [first] and [last] are expected to be inclusive.
- double _positionLineBoxesInReverse(
- ParagraphLine line,
- int first,
- int last, {
+ double _positionFragmentRange({
+ required ParagraphLine line,
+ required int start,
+ required int end,
+ required ui.TextDirection direction,
required double startOffset,
- required double justifyPerSpaceBox,
}) {
- final List<RangeBox> boxes = line.boxes;
- double cumulativeWidth = 0.0;
- for (int i = last; i >= first; i--) {
- // Update the visual position of each box.
- final RangeBox box = boxes[i];
- assert(box.boxDirection != _paragraphDirection);
- box.startOffset = startOffset + cumulativeWidth;
- box.lineWidth = line.width;
- if (box is SpanBox && box.isSpaceOnly && !box.isTrailingSpace) {
- box._width += justifyPerSpaceBox;
- }
+ assert(start <= end);
- cumulativeWidth += box.width;
+ double cumulativeWidth = 0.0;
+
+ // The bodies of the two for loops below must remain identical. The only
+ // difference is the looping direction. One goes from start to end, while
+ // the other goes from end to start.
+
+ if (direction == _paragraphDirection) {
+ for (int i = start; i < end; i++) {
+ cumulativeWidth +=
+ _positionOneFragment(line, i, startOffset + cumulativeWidth, direction);
+ }
+ } else {
+ for (int i = end - 1; i >= start; i--) {
+ cumulativeWidth +=
+ _positionOneFragment(line, i, startOffset + cumulativeWidth, direction);
+ }
}
+
return cumulativeWidth;
}
- /// Calculates for the given [line], the amount of extra width that needs to be
- /// added to each space box in order to align the line with the rest of the
- /// paragraph.
- double _calculateJustifyPerSpaceBox(ParagraphLine line) {
- final double justifyTotal = width - line.width;
-
- final int spaceBoxesToJustify = line.nonTrailingSpaceBoxCount;
- if (spaceBoxesToJustify > 0) {
- return justifyTotal / spaceBoxesToJustify;
- }
-
- return 0.0;
+ double _positionOneFragment(
+ ParagraphLine line,
+ int i,
+ double startOffset,
+ ui.TextDirection direction,
+ ) {
+ final LayoutFragment fragment = line.fragments[i];
+ fragment.setPosition(startOffset: startOffset, textDirection: direction);
+ return fragment.widthIncludingTrailingSpaces;
}
List<ui.TextBox> getBoxesForPlaceholders() {
final List<ui.TextBox> boxes = <ui.TextBox>[];
for (final ParagraphLine line in lines) {
- for (final RangeBox box in line.boxes) {
- if (box is PlaceholderBox) {
- boxes.add(box.toTextBox(line, forPainting: false));
+ for (final LayoutFragment fragment in line.fragments) {
+ if (fragment.isPlaceholder) {
+ boxes.add(fragment.toTextBox());
}
}
}
@@ -475,9 +370,9 @@
for (final ParagraphLine line in lines) {
if (line.overlapsWith(start, end)) {
- for (final RangeBox box in line.boxes) {
- if (box is SpanBox && box.overlapsWith(start, end)) {
- boxes.add(box.intersect(line, start, end, forPainting: false));
+ for (final LayoutFragment fragment in line.fragments) {
+ if (!fragment.isPlaceholder && fragment.overlapsWith(start, end)) {
+ boxes.add(fragment.toTextBox(start: start, end: end));
}
}
}
@@ -501,15 +396,15 @@
// [offset] is to the right of the line.
if (offset.dx >= line.left + line.widthWithTrailingSpaces) {
return ui.TextPosition(
- offset: line.endIndexWithoutNewlines,
+ offset: line.endIndex - line.trailingNewlines,
affinity: ui.TextAffinity.upstream,
);
}
final double dx = offset.dx - line.left;
- for (final RangeBox box in line.boxes) {
- if (box.left <= dx && dx <= box.right) {
- return box.getPositionForX(dx);
+ for (final LayoutFragment fragment in line.fragments) {
+ if (fragment.left <= dx && dx <= fragment.right) {
+ return fragment.getPositionForX(dx - fragment.left);
}
}
// Is this ever reachable?
@@ -530,496 +425,32 @@
}
}
-/// Represents a box inside a paragraph span with the range of [start] to [end].
-///
-/// The box's coordinates are all relative to the line it belongs to. For
-/// example, [left] is the distance from the left edge of the line to the left
-/// edge of the box.
-///
-/// This is what the various measurements/coordinates look like for a box in an
-/// LTR paragraph:
-///
-/// *------------------------lineWidth------------------*
-/// *--width--*
-/// ┌─────────────────┬─────────┬───────────────────────┐
-/// │ │---BOX---│ │
-/// └─────────────────┴─────────┴───────────────────────┘
-/// *---startOffset---*
-/// *------left-------*
-/// *--------endOffset----------*
-/// *----------right------------*
-///
-///
-/// And in an RTL paragraph, [startOffset] and [endOffset] are flipped because
-/// the line starts from the right. Here's what they look like:
-///
-/// *------------------------lineWidth------------------*
-/// *--width--*
-/// ┌─────────────────┬─────────┬───────────────────────┐
-/// │ │---BOX---│ │
-/// └─────────────────┴─────────┴───────────────────────┘
-/// *------startOffset------*
-/// *------left-------*
-/// *-----------endOffset-------------*
-/// *----------right------------*
-///
-abstract class RangeBox {
- RangeBox(
- this.start,
- this.end,
- this.paragraphDirection,
- this.boxDirection,
- );
-
- final LineBreakResult start;
- final LineBreakResult end;
-
- /// The distance from the beginning of the line to the beginning of the box.
- late final double startOffset;
-
- /// The distance from the beginning of the line to the end of the box.
- double get endOffset => startOffset + width;
-
- /// The distance from the left edge of the line to the left edge of the box.
- double get left => paragraphDirection == ui.TextDirection.ltr
- ? startOffset
- : lineWidth - endOffset;
-
- /// The distance from the left edge of the line to the right edge of the box.
- double get right => paragraphDirection == ui.TextDirection.ltr
- ? endOffset
- : lineWidth - startOffset;
-
- /// The distance from the left edge of the box to the right edge of the box.
- double get width;
-
- /// The width of the line that this box belongs to.
- late final double lineWidth;
-
- /// The text direction of the paragraph that this box belongs to.
- final ui.TextDirection paragraphDirection;
-
- /// Indicates how this box flows among other boxes.
- ///
- /// Example: In an LTR paragraph, the text "ABC hebrew_word 123 DEF" is shown
- /// visually in the following order:
- ///
- /// +-------------------------------+
- /// | ABC | 123 | drow_werbeh | DEF |
- /// +-------------------------------+
- /// box direction: LTR RTL RTL LTR
- /// ----> <---- <------------ ---->
- ///
- /// (In the above example, we are ignoring whitespace to simplify).
- final ui.TextDirection boxDirection;
-
- /// Returns a [ui.TextBox] representing this range box in the given [line].
- ///
- /// The coordinates of the resulting [ui.TextBox] are relative to the
- /// paragraph, not to the line.
- ///
- /// The [forPainting] parameter specifies whether the text box is wanted for
- /// painting purposes or not. The difference is observed in the handling of
- /// trailing spaces. Trailing spaces aren't painted on the screen, but their
- /// dimensions are still useful for other cases like highlighting selection.
- ui.TextBox toTextBox(ParagraphLine line, {required bool forPainting});
-
- /// Returns the text position within this box's range that's closest to the
- /// given [x] offset.
- ///
- /// The [x] offset is expected to be relative to the left edge of the line,
- /// just like the coordinates of this box.
- ui.TextPosition getPositionForX(double x);
-}
-
-/// Represents a box for a [PlaceholderSpan].
-class PlaceholderBox extends RangeBox {
- PlaceholderBox(
- this.placeholder, {
- required LineBreakResult index,
- required ui.TextDirection paragraphDirection,
- required ui.TextDirection boxDirection,
- }) : super(index, index, paragraphDirection, boxDirection);
-
- final PlaceholderSpan placeholder;
-
- @override
- double get width => placeholder.width;
-
- @override
- ui.TextBox toTextBox(ParagraphLine line, {required bool forPainting}) {
- final double left = line.left + this.left;
- final double right = line.left + this.right;
-
- final double lineTop = line.baseline - line.ascent;
-
- final double top;
- switch (placeholder.alignment) {
- case ui.PlaceholderAlignment.top:
- top = lineTop;
- break;
-
- case ui.PlaceholderAlignment.middle:
- top = lineTop + (line.height - placeholder.height) / 2;
- break;
-
- case ui.PlaceholderAlignment.bottom:
- top = lineTop + line.height - placeholder.height;
- break;
-
- case ui.PlaceholderAlignment.aboveBaseline:
- top = line.baseline - placeholder.height;
- break;
-
- case ui.PlaceholderAlignment.belowBaseline:
- top = line.baseline;
- break;
-
- case ui.PlaceholderAlignment.baseline:
- top = line.baseline - placeholder.baselineOffset;
- break;
- }
-
- return ui.TextBox.fromLTRBD(
- left,
- top,
- right,
- top + placeholder.height,
- paragraphDirection,
- );
- }
-
- @override
- ui.TextPosition getPositionForX(double x) {
- // See if `x` is closer to the left edge or the right edge of the box.
- final bool closerToLeft = x - left < right - x;
- return ui.TextPosition(
- offset: start.index,
- affinity: closerToLeft ? ui.TextAffinity.upstream : ui.TextAffinity.downstream,
- );
- }
-}
-
-/// Represents a box in a [FlatTextSpan].
-class SpanBox extends RangeBox {
- SpanBox(
- this.spanometer, {
- required LineBreakResult start,
- required LineBreakResult end,
- required double width,
- required ui.TextDirection paragraphDirection,
- required ui.TextDirection boxDirection,
- required this.contentDirection,
- required this.isSpaceOnly,
- }) : span = spanometer.currentSpan,
- height = spanometer.height,
- baseline = spanometer.ascent,
- _width = width,
- super(start, end, paragraphDirection, boxDirection);
-
-
- final Spanometer spanometer;
- final FlatTextSpan span;
-
- /// The direction of the text inside this box.
- ///
- /// To illustrate the difference between [boxDirection] and [contentDirection]
- /// here's an example:
- ///
- /// In an LTR paragraph, the text "ABC hebrew_word 123 DEF" is rendered as
- /// follows:
- ///
- /// ----> <---- <------------ ---->
- /// box direction: LTR RTL RTL LTR
- /// +-------------------------------+
- /// | ABC | 123 | drow_werbeh | DEF |
- /// +-------------------------------+
- /// content direction: LTR LTR RTL LTR
- /// ----> ----> <------------ ---->
- ///
- /// Notice the box containing "123" flows in the RTL direction (because it
- /// comes after an RTL box), while the content of the box flows in the LTR
- /// direction (i.e. the text is shown as "123" not "321").
- final ui.TextDirection contentDirection;
-
- /// Whether this box is made of only white space.
- final bool isSpaceOnly;
-
- /// Whether this box is a trailing space box at the end of a line.
- bool get isTrailingSpace => _isTrailingSpace;
- bool _isTrailingSpace = false;
-
- /// This is made mutable so it can be updated later in the layout process for
- /// the purpose of aligning the lines of a paragraph with [ui.TextAlign.justify].
- double _width;
-
- @override
- double get width => _width;
-
- /// Whether the contents of this box flow in the left-to-right direction.
- bool get isContentLtr => contentDirection == ui.TextDirection.ltr;
-
- /// Whether the contents of this box flow in the right-to-left direction.
- bool get isContentRtl => !isContentLtr;
-
- /// The distance from the top edge to the bottom edge of the box.
- final double height;
-
- /// The distance from the top edge of the box to the alphabetic baseline of
- /// the box.
- final double baseline;
-
- /// Whether this box's range overlaps with the range from [startIndex] to
- /// [endIndex].
- bool overlapsWith(int startIndex, int endIndex) {
- return startIndex < end.index && start.index < endIndex;
- }
-
- /// Returns the substring of the paragraph that's represented by this box.
- ///
- /// Trailing newlines are omitted, if any.
- String toText() {
- return spanometer.paragraph.toPlainText().substring(start.index, end.indexWithoutTrailingNewlines);
- }
-
- @override
- ui.TextBox toTextBox(ParagraphLine line, {required bool forPainting}) {
- return intersect(line, start.index, end.index, forPainting: forPainting);
- }
-
- /// Performs the intersection of this box with the range given by [start] and
- /// [end] indices, and returns a [ui.TextBox] representing that intersection.
- ///
- /// The coordinates of the resulting [ui.TextBox] are relative to the
- /// paragraph, not to the line.
- ui.TextBox intersect(ParagraphLine line, int start, int end, {required bool forPainting}) {
- final double top = line.baseline - baseline;
-
- final double before;
- if (start <= this.start.index) {
- before = 0.0;
- } else {
- spanometer.currentSpan = span;
- before = spanometer._measure(this.start.index, start);
- }
-
- final double after;
- if (end >= this.end.indexWithoutTrailingNewlines) {
- after = 0.0;
- } else {
- spanometer.currentSpan = span;
- after = spanometer._measure(end, this.end.indexWithoutTrailingNewlines);
- }
-
- double left, right;
- if (isContentLtr) {
- // Example: let's say the text is "Loremipsum" and we want to get the box
- // for "rem". In this case, `before` is the width of "Lo", and `after`
- // is the width of "ipsum".
- //
- // Here's how the measurements/coordinates look like:
- //
- // before after
- // |----| |----------|
- // +---------------------+
- // | L o r e m i p s u m |
- // +---------------------+
- // this.left ^ ^ this.right
- left = this.left + before;
- right = this.right - after;
- } else {
- // Example: let's say the text is "txet_werbeH" ("Hebrew_text" flowing from
- // right to left). Say we want to get the box for "brew". The `before` is
- // the width of "He", and `after` is the width of "_text".
- //
- // after before
- // |----------| |----|
- // +-----------------------+
- // | t x e t _ w e r b e H |
- // +-----------------------+
- // this.left ^ ^ this.right
- //
- // Notice how `before` and `after` are reversed in the RTL example. That's
- // because the text flows from right to left.
- left = this.left + after;
- right = this.right - before;
- }
-
- // When painting a paragraph, trailing spaces should have a zero width.
- final bool isZeroWidth = forPainting && isTrailingSpace;
- if (isZeroWidth) {
- // Collapse the box to the left or to the right depending on the paragraph
- // direction.
- if (paragraphDirection == ui.TextDirection.ltr) {
- right = left;
- } else {
- left = right;
- }
- }
-
- // The [RangeBox]'s left and right edges are relative to the line. In order
- // to make them relative to the paragraph, we need to add the left edge of
- // the line.
- return ui.TextBox.fromLTRBD(
- line.left + left,
- top,
- line.left + right,
- top + height,
- contentDirection,
- );
- }
-
- /// Transforms the [x] coordinate to be relative to this box and matches the
- /// flow of content.
- ///
- /// In LTR paragraphs, the [startOffset] and [endOffset] of an RTL box
- /// indicate the visual beginning and end of the box. But the text inside the
- /// box flows in the opposite direction (from [endOffset] to [startOffset]).
- ///
- /// The X (input) is relative to the line, and always from left-to-right
- /// independent of paragraph and content direction.
- ///
- /// Here's how it looks for a box with LTR content:
- ///
- /// *------------------------lineWidth------------------*
- /// *---------------X (input)
- /// ┌───────────┬────────────────────────┬───────────────┐
- /// │ │ --content-direction--> │ │
- /// └───────────┴────────────────────────┴───────────────┘
- /// *---X' (output)
- /// *---left----*
- /// *---------------right----------------*
- ///
- ///
- /// And here's how it looks for a box with RTL content:
- ///
- /// *------------------------lineWidth------------------*
- /// *----------------X (input)
- /// ┌───────────┬────────────────────────┬───────────────┐
- /// │ │ <--content-direction-- │ │
- /// └───────────┴────────────────────────┴───────────────┘
- /// (output) X'------------------*
- /// *---left----*
- /// *---------------right----------------*
- ///
- double _makeXRelativeToContent(double x) {
- return isContentRtl ? right - x : x - left;
- }
-
- @override
- ui.TextPosition getPositionForX(double x) {
- spanometer.currentSpan = span;
-
- x = _makeXRelativeToContent(x);
-
- final int startIndex = start.index;
- final int endIndex = end.indexWithoutTrailingNewlines;
- // The resulting `cutoff` is the index of the character where the `x` offset
- // falls. We should return the text position of either `cutoff` or
- // `cutoff + 1` depending on which one `x` is closer to.
- //
- // offset x
- // ↓
- // "A B C D E F"
- // ↑
- // cutoff
- final int cutoff = spanometer.forceBreak(
- startIndex,
- endIndex,
- availableWidth: x,
- allowEmpty: true,
- );
-
- if (cutoff == endIndex) {
- return ui.TextPosition(
- offset: cutoff,
- affinity: ui.TextAffinity.upstream,
- );
- }
-
- final double lowWidth = spanometer._measure(startIndex, cutoff);
- final double highWidth = spanometer._measure(startIndex, cutoff + 1);
-
- // See if `x` is closer to `cutoff` or `cutoff + 1`.
- if (x - lowWidth < highWidth - x) {
- // The offset is closer to cutoff.
- return ui.TextPosition(
- offset: cutoff,
- );
- } else {
- // The offset is closer to cutoff + 1.
- return ui.TextPosition(
- offset: cutoff + 1,
- affinity: ui.TextAffinity.upstream,
- );
- }
- }
-}
-
-/// Represents a segment in a line of a paragraph.
-///
-/// For example, this line: "Lorem ipsum dolor sit" is broken up into the
-/// following segments:
-///
-/// - "Lorem "
-/// - "ipsum "
-/// - "dolor "
-/// - "sit"
-class LineSegment {
- LineSegment({
- required this.span,
- required this.start,
- required this.end,
- required this.width,
- required this.widthIncludingSpace,
- });
-
- /// The span that this segment belongs to.
- final ParagraphSpan span;
-
- /// The index of the beginning of the segment in the paragraph.
- final LineBreakResult start;
-
- /// The index of the end of the segment in the paragraph.
- final LineBreakResult end;
-
- /// The width of the segment excluding any trailing white space.
- final double width;
-
- /// The width of the segment including any trailing white space.
- final double widthIncludingSpace;
-
- /// The width of the trailing white space in the segment.
- double get widthOfTrailingSpace => widthIncludingSpace - width;
-
- /// Whether this segment is made of only white space.
- ///
- /// We rely on the [width] to determine this because relying on incides
- /// doesn't work well for placeholders (they are zero-length strings).
- bool get isSpaceOnly => width == 0;
-}
-
/// Builds instances of [ParagraphLine] for the given [paragraph].
///
/// Usage of this class starts by calling [LineBuilder.first] to start building
/// the first line of the paragraph.
///
-/// Then new line breaks can be found by calling [LineBuilder.findNextBreak].
+/// Then fragments can be added by calling [addFragment].
///
-/// The line can be extended one or more times before it's built by calling
-/// [LineBuilder.build] which generates the [ParagraphLine] instance.
+/// After adding a fragment, one can use [isOverflowing] to determine whether
+/// the added fragment caused the line to overflow or not.
///
-/// To start building the next line, simply call [LineBuilder.nextLine] which
-/// creates a new [LineBuilder] that can be extended and built and so on.
+/// Once the line is complete, it can be built by calling [build] to generate
+/// a [ParagraphLine] instance.
+///
+/// To start building the next line, simply call [nextLine] to get a new
+/// [LineBuilder] for the next line.
class LineBuilder {
LineBuilder._(
this.paragraph,
this.spanometer, {
required this.maxWidth,
- required this.start,
required this.lineNumber,
required this.accumulatedHeight,
- }) : _end = start;
+ required List<LayoutFragment> fragments,
+ }) : _fragments = fragments {
+ _recalculateMetrics();
+ }
/// Creates a [LineBuilder] for the first line in a paragraph.
factory LineBuilder.first(
@@ -1032,41 +463,47 @@
spanometer,
maxWidth: maxWidth,
lineNumber: 0,
- start: const LineBreakResult.sameIndex(0, LineBreakType.prohibited),
accumulatedHeight: 0.0,
+ fragments: <LayoutFragment>[],
);
}
- final List<LineSegment> _segments = <LineSegment>[];
- final List<RangeBox> _boxes = <RangeBox>[];
+ final List<LayoutFragment> _fragments;
+ List<LayoutFragment>? _fragmentsForNextLine;
+
+ int get startIndex {
+ assert(_fragments.isNotEmpty || _fragmentsForNextLine!.isNotEmpty);
+
+ return isNotEmpty
+ ? _fragments.first.start
+ : _fragmentsForNextLine!.first.start;
+ }
+
+ int get endIndex {
+ assert(_fragments.isNotEmpty || _fragmentsForNextLine!.isNotEmpty);
+
+ return isNotEmpty
+ ? _fragments.last.end
+ : _fragmentsForNextLine!.first.start;
+ }
final double maxWidth;
final CanvasParagraph paragraph;
final Spanometer spanometer;
- final LineBreakResult start;
final int lineNumber;
/// The accumulated height of all preceding lines, excluding the current line.
final double accumulatedHeight;
- /// The index of the end of the line so far.
- LineBreakResult get end => _end;
- LineBreakResult _end;
- set end(LineBreakResult value) {
- if (value.type != LineBreakType.prohibited) {
- isBreakable = true;
- }
- _end = value;
- }
-
/// The width of the line so far, excluding trailing white space.
double width = 0.0;
/// The width of the line so far, including trailing white space.
double widthIncludingSpace = 0.0;
- /// The width of trailing white space in the line.
- double get widthOfTrailingSpace => widthIncludingSpace - width;
+ double get _widthExcludingLastFragment => _fragments.length > 1
+ ? widthIncludingSpace - _fragments.last.widthIncludingTrailingSpaces
+ : 0;
/// The distance from the top of the line to the alphabetic baseline.
double ascent = 0.0;
@@ -1077,22 +514,31 @@
/// The height of the line so far.
double get height => ascent + descent;
- /// The last segment in this line.
- LineSegment get lastSegment => _segments.last;
+ int _lastBreakableFragment = -1;
+ int _breakCount = 0;
- /// Returns true if there is at least one break opportunity in the line.
- bool isBreakable = false;
+ /// Whether this line can be legally broken into more than one line.
+ bool get isBreakable {
+ if (_fragments.isEmpty) {
+ return false;
+ }
+ if (_fragments.last.isBreak) {
+ // We need one more break other than the last one.
+ return _breakCount > 1;
+ }
+ return _breakCount > 0;
+ }
- /// Returns true if there's no break opportunity in the line.
+ /// Returns true if the line can't be legally broken any further.
bool get isNotBreakable => !isBreakable;
- /// Whether the end of this line is a prohibited break.
- bool get isEndProhibited => end.type == LineBreakType.prohibited;
+ int _spaceCount = 0;
+ int _trailingSpaces = 0;
- int _spaceBoxCount = 0;
+ bool get isEmpty => _fragments.isEmpty;
+ bool get isNotEmpty => _fragments.isNotEmpty;
- bool get isEmpty => _segments.isEmpty;
- bool get isNotEmpty => _segments.isNotEmpty;
+ bool get isHardBreak => _fragments.isNotEmpty && _fragments.last.isHardBreak;
/// The horizontal offset necessary for the line to be correctly aligned.
double get alignOffset {
@@ -1113,81 +559,72 @@
}
}
- /// Measures the width of text between the end of this line and [newEnd].
- double getAdditionalWidthTo(LineBreakResult newEnd) {
- // If the extension is all made of space characters, it shouldn't add
- // anything to the width.
- if (end.index == newEnd.indexWithoutTrailingSpaces) {
- return 0.0;
- }
+ bool get isOverflowing => width > maxWidth;
- return widthOfTrailingSpace + spanometer.measure(end, newEnd);
- }
-
- bool get _isLastBoxAPlaceholder {
- if (_boxes.isEmpty) {
+ bool get canHaveEllipsis {
+ if (paragraph.paragraphStyle.ellipsis == null) {
return false;
}
- return _boxes.last is PlaceholderBox;
+
+ final int? maxLines = paragraph.paragraphStyle.maxLines;
+ return (maxLines == null) || (maxLines == lineNumber + 1);
+ }
+
+ bool get _canAppendEmptyFragments {
+ if (isHardBreak) {
+ // Can't append more fragments to this line if it has a hard break.
+ return false;
+ }
+
+ if (_fragmentsForNextLine?.isNotEmpty ?? false) {
+ // If we already have fragments prepared for the next line, then we can't
+ // append more fragments to this line.
+ return false;
+ }
+
+ return true;
}
ui.TextDirection get _paragraphDirection =>
paragraph.paragraphStyle.effectiveTextDirection;
- late ui.TextDirection _currentBoxDirection = _paragraphDirection;
+ void addFragment(LayoutFragment fragment) {
+ _updateMetrics(fragment);
- late ui.TextDirection _currentContentDirection = _paragraphDirection;
+ if (fragment.isBreak) {
+ _lastBreakableFragment = _fragments.length;
+ }
- bool _shouldCreateBoxBeforeExtendingTo(DirectionalPosition newEnd) {
- // When the direction changes, we need to make sure to put them in separate
- // boxes.
- return newEnd.isSpaceOnly || _currentBoxDirection != newEnd.textDirection || _currentContentDirection != newEnd.textDirection;
+ _fragments.add(fragment);
}
- /// Extends the line by setting a [newEnd].
- void extendTo(DirectionalPosition newEnd) {
- ascent = math.max(ascent, spanometer.ascent);
- descent = math.max(descent, spanometer.descent);
+ /// Updates the [LineBuilder]'s metrics to take into account the new [fragment].
+ void _updateMetrics(LayoutFragment fragment) {
+ _spaceCount += fragment.trailingSpaces;
- // When the direction changes, we need to make sure to put them in separate
- // boxes.
- if (_shouldCreateBoxBeforeExtendingTo(newEnd)) {
- createBox();
- }
- _currentBoxDirection = newEnd.textDirection ?? _currentBoxDirection;
- _currentContentDirection = newEnd.textDirection ?? ui.TextDirection.ltr;
-
- _addSegment(_createSegment(newEnd.lineBreak));
- if (newEnd.isSpaceOnly) {
- // Whitespace sequences go in their own boxes.
- createBox(isSpaceOnly: true);
- }
- }
-
- /// Extends the line to the end of the paragraph.
- void extendToEndOfText() {
- if (end.type == LineBreakType.endOfText) {
- return;
- }
-
- final LineBreakResult endOfText = LineBreakResult.sameIndex(
- paragraph.toPlainText().length,
- LineBreakType.endOfText,
- );
-
- // The spanometer may not be ready in some cases. E.g. when the paragraph
- // is made up of only placeholders and no text.
- if (spanometer.isReady) {
- ascent = math.max(ascent, spanometer.ascent);
- descent = math.max(descent, spanometer.descent);
- _addSegment(_createSegment(endOfText));
+ if (fragment.isSpaceOnly) {
+ _trailingSpaces += fragment.trailingSpaces;
} else {
- end = endOfText;
+ _trailingSpaces = fragment.trailingSpaces;
+ width = widthIncludingSpace + fragment.widthExcludingTrailingSpaces;
}
+ widthIncludingSpace += fragment.widthIncludingTrailingSpaces;
+
+ if (fragment.isPlaceholder) {
+ _adjustPlaceholderAscentDescent(fragment);
+ }
+
+ if (fragment.isBreak) {
+ _breakCount++;
+ }
+
+ ascent = math.max(ascent, fragment.ascent);
+ descent = math.max(descent, fragment.descent);
}
- void addPlaceholder(PlaceholderSpan placeholder) {
- // Increase the line's height to fit the placeholder, if necessary.
+ void _adjustPlaceholderAscentDescent(LayoutFragment fragment) {
+ final PlaceholderSpan placeholder = fragment.span as PlaceholderSpan;
+
final double ascent, descent;
switch (placeholder.alignment) {
case ui.PlaceholderAlignment.top:
@@ -1229,330 +666,198 @@
break;
}
- this.ascent = math.max(this.ascent, ascent);
- this.descent = math.max(this.descent, descent);
-
- _addSegment(LineSegment(
- span: placeholder,
- start: end,
- end: end,
- width: placeholder.width,
- widthIncludingSpace: placeholder.width,
- ));
-
- // Add the placeholder box.
- _boxes.add(PlaceholderBox(
- placeholder,
- index: _currentBoxStart,
- paragraphDirection: _paragraphDirection,
- boxDirection: _currentBoxDirection,
- ));
- _currentBoxStartOffset = widthIncludingSpace;
- // Breaking is always allowed after a placeholder.
- isBreakable = true;
- }
-
- /// Creates a new segment to be appended to the end of this line.
- LineSegment _createSegment(LineBreakResult segmentEnd) {
- // The segment starts at the end of the line.
- final LineBreakResult segmentStart = end;
- return LineSegment(
- span: spanometer.currentSpan,
- start: segmentStart,
- end: segmentEnd,
- width: spanometer.measure(segmentStart, segmentEnd),
- widthIncludingSpace:
- spanometer.measureIncludingSpace(segmentStart, segmentEnd),
+ // Update the metrics of the fragment to reflect the calculated ascent and
+ // descent.
+ fragment.setMetrics(spanometer,
+ ascent: ascent,
+ descent: descent,
+ widthExcludingTrailingSpaces: fragment.widthExcludingTrailingSpaces,
+ widthIncludingTrailingSpaces: fragment.widthIncludingTrailingSpaces,
);
}
- /// Adds a segment to this line.
- ///
- /// It adjusts the width properties to accommodate the new segment. It also
- /// sets the line end to the end of the segment.
- void _addSegment(LineSegment segment) {
- _segments.add(segment);
+ void _recalculateMetrics() {
+ width = 0;
+ widthIncludingSpace = 0;
+ ascent = 0;
+ descent = 0;
+ _spaceCount = 0;
+ _trailingSpaces = 0;
+ _breakCount = 0;
+ _lastBreakableFragment = -1;
- // Adding a space-only segment has no effect on `width` because it doesn't
- // include trailing white space.
- if (!segment.isSpaceOnly) {
- // Add the width of previous trailing space.
- width += widthOfTrailingSpace + segment.width;
- }
- widthIncludingSpace += segment.widthIncludingSpace;
- end = segment.end;
- }
-
- /// Removes the latest [LineSegment] added by [_addSegment].
- ///
- /// It re-adjusts the width properties and the end of the line.
- LineSegment _popSegment() {
- final LineSegment poppedSegment = _segments.removeLast();
-
- if (_segments.isEmpty) {
- width = 0.0;
- widthIncludingSpace = 0.0;
- end = start;
- } else {
- widthIncludingSpace -= poppedSegment.widthIncludingSpace;
- end = lastSegment.end;
-
- // Now, let's figure out what to do with `width`.
-
- // Popping a space-only segment has no effect on `width`.
- if (!poppedSegment.isSpaceOnly) {
- // First, we subtract the width of the popped segment.
- width -= poppedSegment.width;
-
- // Second, we subtract all trailing spaces from `width`. There could be
- // multiple trailing segments that are space-only.
- double widthOfTrailingSpace = 0.0;
- int i = _segments.length - 1;
- while (i >= 0 && _segments[i].isSpaceOnly) {
- // Since the segment is space-only, `widthIncludingSpace` contains
- // the width of the space and nothing else.
- widthOfTrailingSpace += _segments[i].widthIncludingSpace;
- i--;
- }
- if (i >= 0) {
- // Having `i >= 0` means in the above loop we stopped at a
- // non-space-only segment. We should also subtract its trailing spaces.
- widthOfTrailingSpace += _segments[i].widthOfTrailingSpace;
- }
- width -= widthOfTrailingSpace;
+ for (int i = 0; i < _fragments.length; i++) {
+ _updateMetrics(_fragments[i]);
+ if (_fragments[i].isBreak) {
+ _lastBreakableFragment = i;
}
}
-
- // Now let's fixes boxes if they need fixing.
- //
- // If we popped a segment of an already created box, we should pop the box
- // too.
- if (_currentBoxStart.index > poppedSegment.start.index) {
- final RangeBox poppedBox = _boxes.removeLast();
- _currentBoxStartOffset -= poppedBox.width;
- if (poppedBox is SpanBox && poppedBox.isSpaceOnly) {
- _spaceBoxCount--;
- }
- }
-
- return poppedSegment;
}
- /// Force-breaks the line in order to fit in [maxWidth] while trying to extend
- /// to [nextBreak].
- ///
- /// This should only be called when there isn't enough width to extend to
- /// [nextBreak], and either of the following is true:
- ///
- /// 1. An ellipsis is being appended to this line, OR
- /// 2. The line doesn't have any line break opportunities and has to be
- /// force-broken.
- void forceBreak(
- DirectionalPosition nextBreak, {
- required bool allowEmpty,
- String? ellipsis,
- }) {
- if (ellipsis == null) {
- final double availableWidth = maxWidth - widthIncludingSpace;
- final int breakingPoint = spanometer.forceBreak(
- end.index,
- nextBreak.lineBreak.indexWithoutTrailingSpaces,
- availableWidth: availableWidth,
- allowEmpty: allowEmpty,
- );
+ void forceBreakLastFragment({ double? availableWidth, bool allowEmptyLine = false }) {
+ assert(isNotEmpty);
- // This condition can be true in the following case:
- // 1. Next break is only one character away, with zero or many spaces. AND
- // 2. There isn't enough width to fit the single character. AND
- // 3. `allowEmpty` is false.
- if (breakingPoint == nextBreak.lineBreak.indexWithoutTrailingSpaces) {
- // In this case, we just extend to `nextBreak` instead of creating a new
- // artificial break. It's safe (and better) to do so, because we don't
- // want the trailing white space to go to the next line.
- extendTo(nextBreak);
- } else {
- extendTo(nextBreak.copyWithIndex(breakingPoint));
+ availableWidth ??= maxWidth;
+ assert(widthIncludingSpace > availableWidth);
+
+ _fragmentsForNextLine ??= <LayoutFragment>[];
+
+ // When the line has fragments other than the last one, we can always allow
+ // the last fragment to be empty (i.e. completely removed from the line).
+ final bool hasOtherFragments = _fragments.length > 1;
+ final bool allowLastFragmentToBeEmpty = hasOtherFragments || allowEmptyLine;
+
+ final LayoutFragment lastFragment = _fragments.last;
+
+ if (lastFragment.isPlaceholder) {
+ // Placeholder can't be force-broken. Either keep all of it in the line or
+ // move it to the next line.
+ if (allowLastFragmentToBeEmpty) {
+ _fragmentsForNextLine!.insert(0, _fragments.removeLast());
+ _recalculateMetrics();
}
return;
}
- // For example: "foo bar baz". Let's say all characters have the same width, and
- // the constraint width can only fit 9 characters "foo bar b". So if the
- // paragraph has an ellipsis, we can't just remove the last segment "baz"
- // and replace it with "..." because that would overflow.
- //
- // We need to keep popping segments until we are able to fit the "..."
- // without overflowing. In this example, that would be: "foo ba..."
+ spanometer.currentSpan = lastFragment.span;
+ final double lineWidthWithoutLastFragment = widthIncludingSpace - lastFragment.widthIncludingTrailingSpaces;
+ final double availableWidthForFragment = availableWidth - lineWidthWithoutLastFragment;
+ final int forceBreakEnd = lastFragment.end - lastFragment.trailingNewlines;
- final double ellipsisWidth = spanometer.measureText(ellipsis);
- final double availableWidth = maxWidth - ellipsisWidth;
-
- // First, we create the new segment until `nextBreak`.
- LineSegment segmentToBreak = _createSegment(nextBreak.lineBreak);
-
- // Then, we keep popping until we find the segment that has to be broken.
- // After the loop ends, two things are correct:
- // 1. All remaining segments in `_segments` can fit within constraints.
- // 2. Adding `segmentToBreak` causes the line to overflow.
- while (_segments.isNotEmpty && widthIncludingSpace > availableWidth) {
- segmentToBreak = _popSegment();
- }
-
- spanometer.currentSpan = segmentToBreak.span as FlatTextSpan;
- final double availableWidthForSegment =
- availableWidth - widthIncludingSpace;
final int breakingPoint = spanometer.forceBreak(
- segmentToBreak.start.index,
- segmentToBreak.end.index,
- availableWidth: availableWidthForSegment,
- allowEmpty: allowEmpty,
+ lastFragment.start,
+ forceBreakEnd,
+ availableWidth: availableWidthForFragment,
+ allowEmpty: allowLastFragmentToBeEmpty,
);
- // There's a possibility that the end of line has moved backwards, so we
- // need to remove some boxes in that case.
- while (_boxes.isNotEmpty && _boxes.last.end.index > breakingPoint) {
- _boxes.removeLast();
+ if (breakingPoint == forceBreakEnd) {
+ // The entire fragment remained intact. Let's keep everything as is.
+ return;
}
- _currentBoxStartOffset = widthIncludingSpace;
- extendTo(nextBreak.copyWithIndex(breakingPoint));
+ _fragments.removeLast();
+ _recalculateMetrics();
+
+ final List<LayoutFragment?> split = lastFragment.split(breakingPoint);
+
+ final LayoutFragment? first = split.first;
+ if (first != null) {
+ spanometer.measureFragment(first);
+ addFragment(first);
+ }
+
+ final LayoutFragment? second = split.last;
+ if (second != null) {
+ spanometer.measureFragment(second);
+ _fragmentsForNextLine!.insert(0, second);
+ }
}
- /// Looks for the last break opportunity in the line and reverts the line to
- /// that point.
- ///
- /// If the line already ends with a break opportunity, this method does
- /// nothing.
+ void insertEllipsis() {
+ assert(canHaveEllipsis);
+ assert(isOverflowing);
+
+ final String ellipsisText = paragraph.paragraphStyle.ellipsis!;
+
+ _fragmentsForNextLine = <LayoutFragment>[];
+
+ spanometer.currentSpan = _fragments.last.span;
+ double ellipsisWidth = spanometer.measureText(ellipsisText);
+ double availableWidth = math.max(0, maxWidth - ellipsisWidth);
+
+ while (_widthExcludingLastFragment > availableWidth) {
+ _fragmentsForNextLine!.insert(0, _fragments.removeLast());
+ _recalculateMetrics();
+
+ spanometer.currentSpan = _fragments.last.span;
+ ellipsisWidth = spanometer.measureText(ellipsisText);
+ availableWidth = maxWidth - ellipsisWidth;
+ }
+
+ final LayoutFragment lastFragment = _fragments.last;
+ forceBreakLastFragment(availableWidth: availableWidth, allowEmptyLine: true);
+
+ final EllipsisFragment ellipsisFragment = EllipsisFragment(
+ endIndex,
+ lastFragment.span,
+ );
+ ellipsisFragment.setMetrics(spanometer,
+ ascent: lastFragment.ascent,
+ descent: lastFragment.descent,
+ widthExcludingTrailingSpaces: ellipsisWidth,
+ widthIncludingTrailingSpaces: ellipsisWidth,
+ );
+ addFragment(ellipsisFragment);
+ }
+
void revertToLastBreakOpportunity() {
assert(isBreakable);
- while (isEndProhibited) {
- _popSegment();
+
+ // The last fragment in the line may or may not be breakable. Regardless,
+ // it needs to be removed.
+ //
+ // We need to find the latest breakable fragment in the line (other than the
+ // last fragment). Such breakable fragment is guaranteed to be found because
+ // the line `isBreakable`.
+
+ // Start from the end and skip the last fragment.
+ int i = _fragments.length - 2;
+ while (!_fragments[i].isBreak) {
+ i--;
}
- // Make sure the line is not empty and still breakable after popping a few
- // segments.
- assert(isNotEmpty);
- assert(isBreakable);
+
+ _fragmentsForNextLine = _fragments.getRange(i + 1, _fragments.length).toList();
+ _fragments.removeRange(i + 1, _fragments.length);
+ _recalculateMetrics();
}
- LineBreakResult get _currentBoxStart {
- if (_boxes.isEmpty) {
- return start;
- }
- // The end of the last box is the start of the new box.
- return _boxes.last.end;
- }
-
- double _currentBoxStartOffset = 0.0;
-
- double get _currentBoxWidth => widthIncludingSpace - _currentBoxStartOffset;
-
- /// Cuts a new box in the line.
+ /// Appends as many zero-width fragments as this line allows.
///
- /// If this is the first box in the line, it'll start at the beginning of the
- /// line. Else, it'll start at the end of the last box.
- ///
- /// A box should be cut whenever the end of line is reached, when switching
- /// from one span to another, or when switching text direction.
- ///
- /// [isSpaceOnly] indicates that the box contains nothing but whitespace
- /// characters.
- void createBox({bool isSpaceOnly = false}) {
- final LineBreakResult boxStart = _currentBoxStart;
- final LineBreakResult boxEnd = end;
- // Avoid creating empty boxes. This could happen when the end of a span
- // coincides with the end of a line. In this case, `createBox` is called twice.
- if (boxStart.index == boxEnd.index) {
- return;
+ /// Returns the number of fragments that were appended.
+ int appendZeroWidthFragments(List<LayoutFragment> fragments, {required int startFrom}) {
+ int i = startFrom;
+ while (_canAppendEmptyFragments &&
+ i < fragments.length &&
+ fragments[i].widthExcludingTrailingSpaces == 0) {
+ addFragment(fragments[i]);
+ i++;
}
-
- _boxes.add(SpanBox(
- spanometer,
- start: boxStart,
- end: boxEnd,
- width: _currentBoxWidth,
- paragraphDirection: _paragraphDirection,
- boxDirection: _currentBoxDirection,
- contentDirection: _currentContentDirection,
- isSpaceOnly: isSpaceOnly,
- ));
-
- if (isSpaceOnly) {
- _spaceBoxCount++;
- }
-
- _currentBoxStartOffset = widthIncludingSpace;
+ return i - startFrom;
}
/// Builds the [ParagraphLine] instance that represents this line.
- ParagraphLine build({String? ellipsis}) {
- // At the end of each line, we cut the last box of the line.
- createBox();
-
- final double ellipsisWidth =
- ellipsis == null ? 0.0 : spanometer.measureText(ellipsis);
-
- final int endIndexWithoutNewlines = math.max(start.index, end.indexWithoutTrailingNewlines);
- final bool hardBreak;
- if (end.type != LineBreakType.endOfText && _isLastBoxAPlaceholder) {
- hardBreak = false;
- } else {
- hardBreak = end.isHard;
+ ParagraphLine build() {
+ if (_fragmentsForNextLine == null) {
+ _fragmentsForNextLine = _fragments.getRange(_lastBreakableFragment + 1, _fragments.length).toList();
+ _fragments.removeRange(_lastBreakableFragment + 1, _fragments.length);
}
- _processTrailingSpaces();
-
- return ParagraphLine(
+ final int trailingNewlines = isEmpty ? 0 : _fragments.last.trailingNewlines;
+ final ParagraphLine line = ParagraphLine(
lineNumber: lineNumber,
- ellipsis: ellipsis,
- startIndex: start.index,
- endIndex: end.index,
- endIndexWithoutNewlines: endIndexWithoutNewlines,
- hardBreak: hardBreak,
- width: width + ellipsisWidth,
- widthWithTrailingSpaces: widthIncludingSpace + ellipsisWidth,
+ startIndex: startIndex,
+ endIndex: endIndex,
+ trailingNewlines: trailingNewlines,
+ trailingSpaces: _trailingSpaces,
+ spaceCount: _spaceCount,
+ hardBreak: isHardBreak,
+ width: width,
+ widthWithTrailingSpaces: widthIncludingSpace,
left: alignOffset,
height: height,
baseline: accumulatedHeight + ascent,
ascent: ascent,
descent: descent,
- boxes: _boxes,
- spaceBoxCount: _spaceBoxCount,
- trailingSpaceBoxCount: _trailingSpaceBoxCount,
+ fragments: _fragments,
+ textDirection: _paragraphDirection,
);
- }
- int _trailingSpaceBoxCount = 0;
-
- void _processTrailingSpaces() {
- _trailingSpaceBoxCount = 0;
- for (int i = _boxes.length - 1; i >= 0; i--) {
- final RangeBox box = _boxes[i];
- final bool isSpaceBox = box is SpanBox && box.isSpaceOnly;
- if (!isSpaceBox) {
- // We traversed all trailing space boxes.
- break;
- }
-
- box._isTrailingSpace = true;
- _trailingSpaceBoxCount++;
+ for (final LayoutFragment fragment in _fragments) {
+ fragment.line = line;
}
- }
- LineBreakResult? _cachedNextBreak;
-
- /// Finds the next line break after the end of this line.
- DirectionalPosition findNextBreak() {
- LineBreakResult? nextBreak = _cachedNextBreak;
- final String text = paragraph.toPlainText();
- // Don't recompute the `nextBreak` until the line has reached the previously
- // computed `nextBreak`.
- if (nextBreak == null || end.index >= nextBreak.index) {
- final int maxEnd = spanometer.currentSpan.end;
- nextBreak = nextLineBreak(text, end.index, maxEnd: maxEnd);
- _cachedNextBreak = nextBreak;
- }
- // The current end of the line is the beginning of the next block.
- return getDirectionalBlockEnd(text, end, nextBreak);
+ return line;
}
/// Creates a new [LineBuilder] to build the next line in the paragraph.
@@ -1561,9 +866,9 @@
paragraph,
spanometer,
maxWidth: maxWidth,
- start: end,
lineNumber: lineNumber + 1,
accumulatedHeight: accumulatedHeight + height,
+ fragments: _fragmentsForNextLine ?? <LayoutFragment>[],
);
}
}
@@ -1604,10 +909,10 @@
double? get letterSpacing => currentSpan.style.letterSpacing;
TextHeightRuler? _currentRuler;
- FlatTextSpan? _currentSpan;
+ ParagraphSpan? _currentSpan;
- FlatTextSpan get currentSpan => _currentSpan!;
- set currentSpan(FlatTextSpan? span) {
+ ParagraphSpan get currentSpan => _currentSpan!;
+ set currentSpan(ParagraphSpan? span) {
if (span == _currentSpan) {
return;
}
@@ -1649,24 +954,44 @@
/// The line height of the current span.
double get height => _currentRuler!.height;
- /// Measures the width of text between two line breaks.
- ///
- /// Doesn't include the width of any trailing white space.
- double measure(LineBreakResult start, LineBreakResult end) {
- return _measure(start.index, end.indexWithoutTrailingSpaces);
- }
-
- /// Measures the width of text between two line breaks.
- ///
- /// Includes the width of trailing white space, if any.
- double measureIncludingSpace(LineBreakResult start, LineBreakResult end) {
- return _measure(start.index, end.indexWithoutTrailingNewlines);
- }
-
double measureText(String text) {
return measureSubstring(context, text, 0, text.length);
}
+ double measureRange(int start, int end) {
+ assert(_currentSpan != null);
+
+ // Make sure the range is within the current span.
+ assert(start >= currentSpan.start && start <= currentSpan.end);
+ assert(end >= currentSpan.start && end <= currentSpan.end);
+
+ return _measure(start, end);
+ }
+
+ void measureFragment(LayoutFragment fragment) {
+ if (fragment.isPlaceholder) {
+ final PlaceholderSpan placeholder = fragment.span as PlaceholderSpan;
+ // The ascent/descent values of the placeholder fragment will be finalized
+ // later when the line is built.
+ fragment.setMetrics(this,
+ ascent: placeholder.height,
+ descent: 0,
+ widthExcludingTrailingSpaces: placeholder.width,
+ widthIncludingTrailingSpaces: placeholder.width,
+ );
+ } else {
+ currentSpan = fragment.span;
+ final double widthExcludingTrailingSpaces = _measure(fragment.start, fragment.end - fragment.trailingSpaces);
+ final double widthIncludingTrailingSpaces = _measure(fragment.start, fragment.end - fragment.trailingNewlines);
+ fragment.setMetrics(this,
+ ascent: ascent,
+ descent: descent,
+ widthExcludingTrailingSpaces: widthExcludingTrailingSpaces,
+ widthIncludingTrailingSpaces: widthIncludingTrailingSpaces,
+ );
+ }
+ }
+
/// In a continuous, unbreakable block of text from [start] to [end], finds
/// the point where text should be broken to fit in the given [availableWidth].
///
@@ -1687,11 +1012,9 @@
}) {
assert(_currentSpan != null);
- final FlatTextSpan span = currentSpan;
-
// Make sure the range is within the current span.
- assert(start >= span.start && start <= span.end);
- assert(end >= span.start && end <= span.end);
+ assert(start >= currentSpan.start && start <= currentSpan.end);
+ assert(end >= currentSpan.start && end <= currentSpan.end);
if (availableWidth <= 0.0) {
return allowEmpty ? start : start + 1;
@@ -1699,7 +1022,7 @@
int low = start;
int high = end;
- do {
+ while (high - low > 1) {
final int mid = (low + high) ~/ 2;
final double width = _measure(start, mid);
if (width < availableWidth) {
@@ -1709,7 +1032,7 @@
} else {
low = high = mid;
}
- } while (high - low > 1);
+ }
if (low == start && !allowEmpty) {
low++;
@@ -1719,11 +1042,9 @@
double _measure(int start, int end) {
assert(_currentSpan != null);
- final FlatTextSpan span = currentSpan;
-
// Make sure the range is within the current span.
- assert(start >= span.start && start <= span.end);
- assert(end >= span.start && end <= span.end);
+ assert(start >= currentSpan.start && start <= currentSpan.end);
+ assert(end >= currentSpan.start && end <= currentSpan.end);
final String text = paragraph.toPlainText();
return measureSubstring(
diff --git a/lib/web_ui/lib/src/engine/text/line_breaker.dart b/lib/web_ui/lib/src/engine/text/line_breaker.dart
index 9068980..fa2adb8 100644
--- a/lib/web_ui/lib/src/engine/text/line_breaker.dart
+++ b/lib/web_ui/lib/src/engine/text/line_breaker.dart
@@ -2,9 +2,7 @@
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
-import 'dart:math' as math;
-
-import '../util.dart';
+import 'fragmenter.dart';
import 'line_break_properties.dart';
import 'unicode_range.dart';
@@ -26,96 +24,42 @@
endOfText,
}
-/// Acts as a tuple that encapsulates information about a line break.
-///
-/// It contains multiple indices that are helpful when it comes to measuring the
-/// width of a line of text.
-///
-/// [indexWithoutTrailingSpaces] <= [indexWithoutTrailingNewlines] <= [index]
-///
-/// Example: for the string "foo \nbar " here are the indices:
-/// ```
-/// f o o \n b a r
-/// ^ ^ ^ ^ ^ ^ ^ ^ ^ ^
-/// 0 1 2 3 4 5 6 7 8 9
-/// ```
-/// It contains two line breaks:
-/// ```
-/// // The first line break:
-/// LineBreakResult(5, 4, 3, LineBreakType.mandatory)
-///
-/// // Second line break:
-/// LineBreakResult(9, 9, 8, LineBreakType.mandatory)
-/// ```
-class LineBreakResult {
- const LineBreakResult(
- this.index,
- this.indexWithoutTrailingNewlines,
- this.indexWithoutTrailingSpaces,
- this.type,
- ): assert(indexWithoutTrailingSpaces <= indexWithoutTrailingNewlines),
- assert(indexWithoutTrailingNewlines <= index);
-
- /// Creates a [LineBreakResult] where all indices are the same (i.e. there are
- /// no trailing spaces or new lines).
- const LineBreakResult.sameIndex(this.index, this.type)
- : indexWithoutTrailingNewlines = index,
- indexWithoutTrailingSpaces = index;
-
- /// The true index at which the line break should occur, including all spaces
- /// and new lines.
- final int index;
-
- /// The index of the line break excluding any trailing new lines.
- final int indexWithoutTrailingNewlines;
-
- /// The index of the line break excluding any trailing spaces.
- final int indexWithoutTrailingSpaces;
-
- /// The type of line break is useful to determine the behavior in text
- /// measurement.
- ///
- /// For example, a mandatory line break always causes a line break regardless
- /// of width constraints. But a line break opportunity requires further checks
- /// to decide whether to take the line break or not.
- final LineBreakType type;
-
- bool get isHard =>
- type == LineBreakType.mandatory || type == LineBreakType.endOfText;
+/// Splits [text] into fragments based on line breaks.
+class LineBreakFragmenter extends TextFragmenter {
+ const LineBreakFragmenter(super.text);
@override
- int get hashCode => Object.hash(
- index,
- indexWithoutTrailingNewlines,
- indexWithoutTrailingSpaces,
- type,
- );
+ List<LineBreakFragment> fragment() {
+ return _computeLineBreakFragments(text);
+ }
+}
+
+class LineBreakFragment extends TextFragment {
+ const LineBreakFragment(super.start, super.end, this.type, {
+ required this.trailingNewlines,
+ required this.trailingSpaces,
+ });
+
+ final LineBreakType type;
+ final int trailingNewlines;
+ final int trailingSpaces;
+
+ @override
+ int get hashCode => Object.hash(start, end, type, trailingNewlines, trailingSpaces);
@override
bool operator ==(Object other) {
- if (identical(this, other)) {
- return true;
- }
- if (other.runtimeType != runtimeType) {
- return false;
- }
- return other is LineBreakResult &&
- other.index == index &&
- other.indexWithoutTrailingNewlines == indexWithoutTrailingNewlines &&
- other.indexWithoutTrailingSpaces == indexWithoutTrailingSpaces &&
- other.type == type;
+ return other is LineBreakFragment &&
+ other.start == start &&
+ other.end == end &&
+ other.type == type &&
+ other.trailingNewlines == trailingNewlines &&
+ other.trailingSpaces == trailingSpaces;
}
@override
String toString() {
- if (assertionsEnabled) {
- return 'LineBreakResult(index: $index, '
- 'without new lines: $indexWithoutTrailingNewlines, '
- 'without spaces: $indexWithoutTrailingSpaces, '
- 'type: $type)';
- } else {
- return super.toString();
- }
+ return 'LineBreakFragment($start, $end, $type)';
}
}
@@ -151,6 +95,10 @@
(charCode >= 0xFE17 && charCode <= 0xFF62);
}
+bool _isSurrogatePair(int? codePoint) {
+ return codePoint != null && codePoint > 0xFFFF;
+}
+
/// Finds the next line break in the given [text] starting from [index].
///
/// We think about indices as pointing between characters, and they go all the
@@ -163,100 +111,105 @@
/// 0 1 2 3 4 5 6 7
/// ```
///
-/// This way the indices work well with [String.substring()].
+/// This way the indices work well with [String.substring].
///
/// Useful resources:
///
/// * https://www.unicode.org/reports/tr14/tr14-45.html#Algorithm
/// * https://www.unicode.org/Public/11.0.0/ucd/LineBreak.txt
-LineBreakResult nextLineBreak(String text, int index, {int? maxEnd}) {
- final LineBreakResult unsafeResult = _unsafeNextLineBreak(text, index, maxEnd: maxEnd);
- if (maxEnd != null && unsafeResult.index > maxEnd) {
- return LineBreakResult(
- maxEnd,
- math.min(maxEnd, unsafeResult.indexWithoutTrailingNewlines),
- math.min(maxEnd, unsafeResult.indexWithoutTrailingSpaces),
- LineBreakType.prohibited,
- );
- }
- return unsafeResult;
-}
-
-LineBreakResult _unsafeNextLineBreak(String text, int index, {int? maxEnd}) {
- int? codePoint = getCodePoint(text, index);
- LineCharProperty curr = lineLookup.findForChar(codePoint);
-
- LineCharProperty? prev1;
+List<LineBreakFragment> _computeLineBreakFragments(String text) {
+ final List<LineBreakFragment> fragments = <LineBreakFragment>[];
// Keeps track of the character two positions behind.
LineCharProperty? prev2;
+ LineCharProperty? prev1;
- // When there's a sequence of spaces or combining marks, this variable
- // contains the base property i.e. the property of the character before the
- // sequence.
- LineCharProperty? baseOfSpaceSequence;
+ int? codePoint = getCodePoint(text, 0);
+ LineCharProperty? curr = lineLookup.findForChar(codePoint);
- /// The index of the last character that wasn't a space.
- int lastNonSpaceIndex = index;
+ // When there's a sequence of spaces, this variable contains the base property
+ // i.e. the property of the character preceding the sequence.
+ LineCharProperty baseOfSpaceSequence = LineCharProperty.WJ;
- /// The index of the last character that wasn't a new line.
- int lastNonNewlineIndex = index;
+ // When there's a sequence of combining marks, this variable contains the base
+ // property i.e. the property of the character preceding the sequence.
+ LineCharProperty baseOfCombiningMarks = LineCharProperty.AL;
- // When the text/line starts with SP, we should treat the beginning of text/line
- // as if it were a WJ (word joiner).
- if (curr == LineCharProperty.SP) {
- baseOfSpaceSequence = LineCharProperty.WJ;
+ int index = 0;
+ int trailingNewlines = 0;
+ int trailingSpaces = 0;
+
+ int fragmentStart = 0;
+
+ void setBreak(LineBreakType type, int debugRuleNumber) {
+ final int fragmentEnd =
+ type == LineBreakType.endOfText ? text.length : index;
+ assert(fragmentEnd >= fragmentStart);
+
+ if (prev1 == LineCharProperty.SP) {
+ trailingSpaces++;
+ } else if (_isHardBreak(prev1) || prev1 == LineCharProperty.CR) {
+ trailingNewlines++;
+ trailingSpaces++;
+ }
+
+ if (type == LineBreakType.prohibited) {
+ // Don't create a fragment.
+ return;
+ }
+
+ fragments.add(LineBreakFragment(
+ fragmentStart,
+ fragmentEnd,
+ type,
+ trailingNewlines: trailingNewlines,
+ trailingSpaces: trailingSpaces,
+ ));
+
+ fragmentStart = index;
+
+ // Reset trailing spaces/newlines counter after a new fragment.
+ trailingNewlines = 0;
+ trailingSpaces = 0;
+
+ prev1 = prev2 = null;
}
- bool isCurrZWJ = curr == LineCharProperty.ZWJ;
+ // Never break at the start of text.
+ // LB2: sot ×
+ setBreak(LineBreakType.prohibited, 2);
- // LB10: Treat any remaining combining mark or ZWJ as AL.
- // This catches the case where a CM is the first character on the line.
- if (curr == LineCharProperty.CM || curr == LineCharProperty.ZWJ) {
- curr = LineCharProperty.AL;
- }
+ // Never break at the start of text.
+ // LB2: sot ×
+ //
+ // Skip index 0 because a line break can't exist at the start of text.
+ index++;
int regionalIndicatorCount = 0;
- // Always break at the end of text.
- // LB3: ! eot
- while (index < text.length) {
- if (maxEnd != null && index > maxEnd) {
- return LineBreakResult(
- maxEnd,
- math.min(maxEnd, lastNonNewlineIndex),
- math.min(maxEnd, lastNonSpaceIndex),
- LineBreakType.prohibited,
- );
- }
-
- // Keep count of the RI (regional indicator) sequence.
- if (curr == LineCharProperty.RI) {
- regionalIndicatorCount++;
- } else {
- regionalIndicatorCount = 0;
- }
-
- if (codePoint != null && codePoint > 0xFFFF) {
- // Advance `index` one extra step when handling a surrogate pair in the
- // string.
- index++;
- }
- index++;
+ // We need to go until `text.length` in order to handle the case where the
+ // paragraph ends with a hard break. In this case, there will be an empty line
+ // at the end.
+ for (; index <= text.length; index++) {
prev2 = prev1;
prev1 = curr;
- final bool isPrevZWJ = isCurrZWJ;
-
- // Reset the base when we are past the space sequence.
- if (prev1 != LineCharProperty.SP) {
- baseOfSpaceSequence = null;
+ if (_isSurrogatePair(codePoint)) {
+ // Can't break in the middle of a surrogate pair.
+ setBreak(LineBreakType.prohibited, -1);
+ // Advance `index` one extra step to skip the tail of the surrogate pair.
+ index++;
}
codePoint = getCodePoint(text, index);
curr = lineLookup.findForChar(codePoint);
- isCurrZWJ = curr == LineCharProperty.ZWJ;
+ // Keep count of the RI (regional indicator) sequence.
+ if (prev1 == LineCharProperty.RI) {
+ regionalIndicatorCount++;
+ } else {
+ regionalIndicatorCount = 0;
+ }
// Always break after hard line breaks.
// LB4: BK !
@@ -265,69 +218,44 @@
// LB5: LF !
// NL !
if (_isHardBreak(prev1)) {
- return LineBreakResult(
- index,
- lastNonNewlineIndex,
- lastNonSpaceIndex,
- LineBreakType.mandatory,
- );
+ setBreak(LineBreakType.mandatory, 5);
+ continue;
}
if (prev1 == LineCharProperty.CR) {
if (curr == LineCharProperty.LF) {
// LB5: CR × LF
- continue;
+ setBreak(LineBreakType.prohibited, 5);
} else {
// LB5: CR !
- return LineBreakResult(
- index,
- lastNonNewlineIndex,
- lastNonSpaceIndex,
- LineBreakType.mandatory,
- );
+ setBreak(LineBreakType.mandatory, 5);
}
- }
-
- // At this point, we know for sure the prev character wasn't a new line.
- lastNonNewlineIndex = index;
- if (prev1 != LineCharProperty.SP) {
- lastNonSpaceIndex = index;
+ continue;
}
// Do not break before hard line breaks.
// LB6: × ( BK | CR | LF | NL )
if (_isHardBreak(curr) || curr == LineCharProperty.CR) {
+ setBreak(LineBreakType.prohibited, 6);
continue;
}
- // Always break at the end of text.
- // LB3: ! eot
if (index >= text.length) {
- return LineBreakResult(
- text.length,
- lastNonNewlineIndex,
- lastNonSpaceIndex,
- LineBreakType.endOfText,
- );
+ break;
+ }
+
+ // Establish the base for the space sequence.
+ if (prev1 != LineCharProperty.SP) {
+ // When the text/line starts with SP, we should treat the beginning of text/line
+ // as if it were a WJ (word joiner).
+ baseOfSpaceSequence = prev1 ?? LineCharProperty.WJ;
}
// Do not break before spaces or zero width space.
// LB7: × SP
- if (curr == LineCharProperty.SP) {
- // When we encounter SP, we preserve the property of the previous
- // character so we can later apply the indirect breaking rules.
- if (prev1 == LineCharProperty.SP) {
- // If we are in the middle of a space sequence, a base should've
- // already been set.
- assert(baseOfSpaceSequence != null);
- } else {
- // We are at the beginning of a space sequence, establish the base.
- baseOfSpaceSequence = prev1;
- }
- continue;
- }
- // LB7: × ZW
- if (curr == LineCharProperty.ZW) {
+ // × ZW
+ if (curr == LineCharProperty.SP || curr == LineCharProperty.ZW) {
+ setBreak(LineBreakType.prohibited, 7);
continue;
}
@@ -336,53 +264,61 @@
// LB8: ZW SP* ÷
if (prev1 == LineCharProperty.ZW ||
baseOfSpaceSequence == LineCharProperty.ZW) {
- return LineBreakResult(
- index,
- lastNonNewlineIndex,
- lastNonSpaceIndex,
- LineBreakType.opportunity,
- );
+ setBreak(LineBreakType.opportunity, 8);
+ continue;
+ }
+
+ // Do not break after a zero width joiner.
+ // LB8a: ZWJ ×
+ if (prev1 == LineCharProperty.ZWJ) {
+ setBreak(LineBreakType.prohibited, 8);
+ continue;
+ }
+
+ // Establish the base for the sequences of combining marks.
+ if (prev1 != LineCharProperty.CM && prev1 != LineCharProperty.ZWJ) {
+ baseOfCombiningMarks = prev1 ?? LineCharProperty.AL;
}
// Do not break a combining character sequence; treat it as if it has the
// line breaking class of the base character in all of the following rules.
// Treat ZWJ as if it were CM.
- // LB9: Treat X (CM | ZWJ)* as if it were X
- // where X is any line break class except BK, NL, LF, CR, SP, or ZW.
if (curr == LineCharProperty.CM || curr == LineCharProperty.ZWJ) {
- // Other properties: BK, NL, LF, CR, ZW would've already generated a line
- // break, so we won't find them in `prev`.
- if (prev1 == LineCharProperty.SP) {
+ if (baseOfCombiningMarks == LineCharProperty.SP) {
// LB10: Treat any remaining combining mark or ZWJ as AL.
curr = LineCharProperty.AL;
} else {
- if (prev1 == LineCharProperty.RI) {
+ // LB9: Treat X (CM | ZWJ)* as if it were X
+ // where X is any line break class except BK, NL, LF, CR, SP, or ZW.
+ curr = baseOfCombiningMarks;
+ if (curr == LineCharProperty.RI) {
// Prevent the previous RI from being double-counted.
regionalIndicatorCount--;
}
- // Preserve the property of the previous character to treat the sequence
- // as if it were X.
- curr = prev1;
+ setBreak(LineBreakType.prohibited, 9);
continue;
}
}
-
- // Do not break after a zero width joiner.
- // LB8a: ZWJ ×
- if (isPrevZWJ) {
- continue;
+ // In certain situations (e.g. CM immediately following a hard break), we
+ // need to also check if the previous character was CM/ZWJ. That's because
+ // hard breaks caused the previous iteration to short-circuit, which leads
+ // to `baseOfCombiningMarks` not being updated properly.
+ if (prev1 == LineCharProperty.CM || prev1 == LineCharProperty.ZWJ) {
+ prev1 = baseOfCombiningMarks;
}
// Do not break before or after Word joiner and related characters.
// LB11: × WJ
// WJ ×
if (curr == LineCharProperty.WJ || prev1 == LineCharProperty.WJ) {
+ setBreak(LineBreakType.prohibited, 11);
continue;
}
// Do not break after NBSP and related characters.
// LB12: GL ×
if (prev1 == LineCharProperty.GL) {
+ setBreak(LineBreakType.prohibited, 12);
continue;
}
@@ -393,6 +329,7 @@
prev1 == LineCharProperty.BA ||
prev1 == LineCharProperty.HY) &&
curr == LineCharProperty.GL) {
+ setBreak(LineBreakType.prohibited, 12);
continue;
}
@@ -412,6 +349,7 @@
curr == LineCharProperty.EX ||
curr == LineCharProperty.IS ||
curr == LineCharProperty.SY)) {
+ setBreak(LineBreakType.prohibited, 13);
continue;
}
@@ -421,6 +359,7 @@
// The above is a quote from unicode.org. In our implementation, we did the
// following modification: Allow breaks when there are spaces.
if (prev1 == LineCharProperty.OP) {
+ setBreak(LineBreakType.prohibited, 14);
continue;
}
@@ -430,6 +369,7 @@
// The above is a quote from unicode.org. In our implementation, we did the
// following modification: Allow breaks when there are spaces.
if (prev1 == LineCharProperty.QU && curr == LineCharProperty.OP) {
+ setBreak(LineBreakType.prohibited, 15);
continue;
}
@@ -441,6 +381,7 @@
prev1 == LineCharProperty.CP ||
baseOfSpaceSequence == LineCharProperty.CP) &&
curr == LineCharProperty.NS) {
+ setBreak(LineBreakType.prohibited, 16);
continue;
}
@@ -449,37 +390,34 @@
if ((prev1 == LineCharProperty.B2 ||
baseOfSpaceSequence == LineCharProperty.B2) &&
curr == LineCharProperty.B2) {
+ setBreak(LineBreakType.prohibited, 17);
continue;
}
// Break after spaces.
// LB18: SP ÷
if (prev1 == LineCharProperty.SP) {
- return LineBreakResult(
- index,
- lastNonNewlineIndex,
- lastNonSpaceIndex,
- LineBreakType.opportunity,
- );
+ setBreak(LineBreakType.opportunity, 18);
+ continue;
}
// Do not break before or after quotation marks, such as ‘”’.
// LB19: × QU
// QU ×
if (prev1 == LineCharProperty.QU || curr == LineCharProperty.QU) {
+ setBreak(LineBreakType.prohibited, 19);
continue;
}
// Break before and after unresolved CB.
// LB20: ÷ CB
// CB ÷
+ //
+ // In flutter web, we use this as an object-replacement character for
+ // placeholders.
if (prev1 == LineCharProperty.CB || curr == LineCharProperty.CB) {
- return LineBreakResult(
- index,
- lastNonNewlineIndex,
- lastNonSpaceIndex,
- LineBreakType.opportunity,
- );
+ setBreak(LineBreakType.opportunity, 20);
+ continue;
}
// Do not break before hyphen-minus, other hyphens, fixed-width spaces,
@@ -492,6 +430,7 @@
curr == LineCharProperty.HY ||
curr == LineCharProperty.NS ||
prev1 == LineCharProperty.BB) {
+ setBreak(LineBreakType.prohibited, 21);
continue;
}
@@ -499,18 +438,21 @@
// LB21a: HL (HY | BA) ×
if (prev2 == LineCharProperty.HL &&
(prev1 == LineCharProperty.HY || prev1 == LineCharProperty.BA)) {
+ setBreak(LineBreakType.prohibited, 21);
continue;
}
// Don’t break between Solidus and Hebrew letters.
// LB21b: SY × HL
if (prev1 == LineCharProperty.SY && curr == LineCharProperty.HL) {
+ setBreak(LineBreakType.prohibited, 21);
continue;
}
// Do not break before ellipses.
// LB22: × IN
if (curr == LineCharProperty.IN) {
+ setBreak(LineBreakType.prohibited, 22);
continue;
}
@@ -519,6 +461,7 @@
// NU × (AL | HL)
if ((_isALorHL(prev1) && curr == LineCharProperty.NU) ||
(prev1 == LineCharProperty.NU && _isALorHL(curr))) {
+ setBreak(LineBreakType.prohibited, 23);
continue;
}
@@ -529,6 +472,7 @@
(curr == LineCharProperty.ID ||
curr == LineCharProperty.EB ||
curr == LineCharProperty.EM)) {
+ setBreak(LineBreakType.prohibited, 23);
continue;
}
// LB23a: (ID | EB | EM) × PO
@@ -536,6 +480,7 @@
prev1 == LineCharProperty.EB ||
prev1 == LineCharProperty.EM) &&
curr == LineCharProperty.PO) {
+ setBreak(LineBreakType.prohibited, 23);
continue;
}
@@ -544,11 +489,13 @@
// LB24: (PR | PO) × (AL | HL)
if ((prev1 == LineCharProperty.PR || prev1 == LineCharProperty.PO) &&
_isALorHL(curr)) {
+ setBreak(LineBreakType.prohibited, 24);
continue;
}
// LB24: (AL | HL) × (PR | PO)
if (_isALorHL(prev1) &&
(curr == LineCharProperty.PR || curr == LineCharProperty.PO)) {
+ setBreak(LineBreakType.prohibited, 24);
continue;
}
@@ -558,11 +505,13 @@
prev1 == LineCharProperty.CP ||
prev1 == LineCharProperty.NU) &&
(curr == LineCharProperty.PO || curr == LineCharProperty.PR)) {
+ setBreak(LineBreakType.prohibited, 25);
continue;
}
// LB25: (PO | PR) × OP
if ((prev1 == LineCharProperty.PO || prev1 == LineCharProperty.PR) &&
curr == LineCharProperty.OP) {
+ setBreak(LineBreakType.prohibited, 25);
continue;
}
// LB25: (PO | PR | HY | IS | NU | SY) × NU
@@ -573,6 +522,7 @@
prev1 == LineCharProperty.NU ||
prev1 == LineCharProperty.SY) &&
curr == LineCharProperty.NU) {
+ setBreak(LineBreakType.prohibited, 25);
continue;
}
@@ -583,38 +533,45 @@
curr == LineCharProperty.JV ||
curr == LineCharProperty.H2 ||
curr == LineCharProperty.H3)) {
+ setBreak(LineBreakType.prohibited, 26);
continue;
}
// LB26: (JV | H2) × (JV | JT)
if ((prev1 == LineCharProperty.JV || prev1 == LineCharProperty.H2) &&
(curr == LineCharProperty.JV || curr == LineCharProperty.JT)) {
+ setBreak(LineBreakType.prohibited, 26);
continue;
}
// LB26: (JT | H3) × JT
if ((prev1 == LineCharProperty.JT || prev1 == LineCharProperty.H3) &&
curr == LineCharProperty.JT) {
+ setBreak(LineBreakType.prohibited, 26);
continue;
}
// Treat a Korean Syllable Block the same as ID.
// LB27: (JL | JV | JT | H2 | H3) × PO
if (_isKoreanSyllable(prev1) && curr == LineCharProperty.PO) {
+ setBreak(LineBreakType.prohibited, 27);
continue;
}
// LB27: PR × (JL | JV | JT | H2 | H3)
if (prev1 == LineCharProperty.PR && _isKoreanSyllable(curr)) {
+ setBreak(LineBreakType.prohibited, 27);
continue;
}
// Do not break between alphabetics.
// LB28: (AL | HL) × (AL | HL)
if (_isALorHL(prev1) && _isALorHL(curr)) {
+ setBreak(LineBreakType.prohibited, 28);
continue;
}
// Do not break between numeric punctuation and alphabetics (“e.g.”).
// LB29: IS × (AL | HL)
if (prev1 == LineCharProperty.IS && _isALorHL(curr)) {
+ setBreak(LineBreakType.prohibited, 29);
continue;
}
@@ -627,12 +584,14 @@
if ((_isALorHL(prev1) || prev1 == LineCharProperty.NU) &&
curr == LineCharProperty.OP &&
!_hasEastAsianWidthFWH(text.codeUnitAt(index))) {
+ setBreak(LineBreakType.prohibited, 30);
continue;
}
// LB30: CP × (AL | HL | NU)
if (prev1 == LineCharProperty.CP &&
!_hasEastAsianWidthFWH(text.codeUnitAt(index - 1)) &&
(_isALorHL(curr) || curr == LineCharProperty.NU)) {
+ setBreak(LineBreakType.prohibited, 30);
continue;
}
@@ -642,37 +601,29 @@
// [^RI] (RI RI)* RI × RI
if (curr == LineCharProperty.RI) {
if (regionalIndicatorCount.isOdd) {
- continue;
+ setBreak(LineBreakType.prohibited, 30);
} else {
- return LineBreakResult(
- index,
- lastNonNewlineIndex,
- lastNonSpaceIndex,
- LineBreakType.opportunity,
- );
+ setBreak(LineBreakType.opportunity, 30);
}
+ continue;
}
// Do not break between an emoji base and an emoji modifier.
// LB30b: EB × EM
if (prev1 == LineCharProperty.EB && curr == LineCharProperty.EM) {
+ setBreak(LineBreakType.prohibited, 30);
continue;
}
// Break everywhere else.
// LB31: ALL ÷
// ÷ ALL
- return LineBreakResult(
- index,
- lastNonNewlineIndex,
- lastNonSpaceIndex,
- LineBreakType.opportunity,
- );
+ setBreak(LineBreakType.opportunity, 31);
}
- return LineBreakResult(
- text.length,
- lastNonNewlineIndex,
- lastNonSpaceIndex,
- LineBreakType.endOfText,
- );
+
+ // Always break at the end of text.
+ // LB3: ! eot
+ setBreak(LineBreakType.endOfText, 3);
+
+ return fragments;
}
diff --git a/lib/web_ui/lib/src/engine/text/paint_service.dart b/lib/web_ui/lib/src/engine/text/paint_service.dart
index 1183a66..857d55a 100644
--- a/lib/web_ui/lib/src/engine/text/paint_service.dart
+++ b/lib/web_ui/lib/src/engine/text/paint_service.dart
@@ -8,7 +8,7 @@
import '../html/bitmap_canvas.dart';
import '../html/painting.dart';
import 'canvas_paragraph.dart';
-import 'layout_service.dart';
+import 'layout_fragmenter.dart';
import 'paragraph.dart';
/// Responsible for painting a [CanvasParagraph] on a [BitmapCanvas].
@@ -18,31 +18,15 @@
final CanvasParagraph paragraph;
void paint(BitmapCanvas canvas, ui.Offset offset) {
- // Loop through all the lines, for each line, loop through all the boxes and
- // paint them. The boxes have enough information so they can be painted
+ // Loop through all the lines, for each line, loop through all fragments and
+ // paint them. The fragment objects have enough information to be painted
// individually.
final List<ParagraphLine> lines = paragraph.lines;
- if (lines.isEmpty) {
- return;
- }
-
for (final ParagraphLine line in lines) {
- if (line.boxes.isEmpty) {
- continue;
- }
-
- final RangeBox lastBox = line.boxes.last;
-
- for (final RangeBox box in line.boxes) {
- final bool isTrailingSpaceBox =
- box == lastBox && box is SpanBox && box.isSpaceOnly;
-
- // Don't paint background for the trailing space in the line.
- if (!isTrailingSpaceBox) {
- _paintBackground(canvas, offset, line, box);
- }
- _paintText(canvas, offset, line, box);
+ for (final LayoutFragment fragment in line.fragments) {
+ _paintBackground(canvas, offset, fragment);
+ _paintText(canvas, offset, line, fragment);
}
}
}
@@ -50,17 +34,17 @@
void _paintBackground(
BitmapCanvas canvas,
ui.Offset offset,
- ParagraphLine line,
- RangeBox box,
+ LayoutFragment fragment,
) {
- if (box is SpanBox) {
- final FlatTextSpan span = box.span;
-
+ final ParagraphSpan span = fragment.span;
+ if (span is FlatTextSpan) {
// Paint the background of the box, if the span has a background.
final SurfacePaint? background = span.style.background as SurfacePaint?;
if (background != null) {
- final ui.Rect rect = box.toTextBox(line, forPainting: true).toRect().shift(offset);
- canvas.drawRect(rect, background.paintData);
+ final ui.Rect rect = fragment.toPaintingTextBox().toRect();
+ if (!rect.isEmpty) {
+ canvas.drawRect(rect.shift(offset), background.paintData);
+ }
}
}
}
@@ -69,63 +53,63 @@
BitmapCanvas canvas,
ui.Offset offset,
ParagraphLine line,
- RangeBox box,
+ LayoutFragment fragment,
) {
// There's no text to paint in placeholder spans.
- if (box is SpanBox) {
- final FlatTextSpan span = box.span;
-
- _applySpanStyleToCanvas(span, canvas);
- final double x = offset.dx + line.left + box.left;
- final double y = offset.dy + line.baseline;
-
- // Don't paint the text for space-only boxes. This is just an
- // optimization, it doesn't have any effect on the output.
- if (!box.isSpaceOnly) {
- final String text = paragraph.toPlainText().substring(
- box.start.index,
- box.end.indexWithoutTrailingNewlines,
- );
- final double? letterSpacing = span.style.letterSpacing;
- if (letterSpacing == null || letterSpacing == 0.0) {
- canvas.drawText(text, x, y,
- style: span.style.foreground?.style, shadows: span.style.shadows);
- } else {
- // TODO(mdebbar): Implement letter-spacing on canvas more efficiently:
- // https://github.com/flutter/flutter/issues/51234
- double charX = x;
- final int len = text.length;
- for (int i = 0; i < len; i++) {
- final String char = text[i];
- canvas.drawText(char, charX.roundToDouble(), y,
- style: span.style.foreground?.style,
- shadows: span.style.shadows);
- charX += letterSpacing + canvas.measureText(char).width!;
- }
- }
- }
-
- // Paint the ellipsis using the same span styles.
- final String? ellipsis = line.ellipsis;
- if (ellipsis != null && box == line.boxes.last) {
- final double x = offset.dx + line.left + box.right;
- canvas.drawText(ellipsis, x, y, style: span.style.foreground?.style);
- }
-
- canvas.tearDownPaint();
+ if (fragment.isPlaceholder) {
+ return;
}
+
+ // Don't paint the text for space-only boxes. This is just an
+ // optimization, it doesn't have any effect on the output.
+ if (fragment.isSpaceOnly) {
+ return;
+ }
+
+ _prepareCanvasForFragment(canvas, fragment);
+ final double fragmentX = fragment.textDirection! == ui.TextDirection.ltr
+ ? fragment.left
+ : fragment.right;
+
+ final double x = offset.dx + line.left + fragmentX;
+ final double y = offset.dy + line.baseline;
+
+ final EngineTextStyle style = fragment.style;
+
+ final String text = fragment.getText(paragraph);
+ final double? letterSpacing = style.letterSpacing;
+ if (letterSpacing == null || letterSpacing == 0.0) {
+ canvas.drawText(text, x, y,
+ style: style.foreground?.style, shadows: style.shadows);
+ } else {
+ // TODO(mdebbar): Implement letter-spacing on canvas more efficiently:
+ // https://github.com/flutter/flutter/issues/51234
+ double charX = x;
+ final int len = text.length;
+ for (int i = 0; i < len; i++) {
+ final String char = text[i];
+ canvas.drawText(char, charX.roundToDouble(), y,
+ style: style.foreground?.style,
+ shadows: style.shadows);
+ charX += letterSpacing + canvas.measureText(char).width!;
+ }
+ }
+
+ canvas.tearDownPaint();
}
- void _applySpanStyleToCanvas(FlatTextSpan span, BitmapCanvas canvas) {
+ void _prepareCanvasForFragment(BitmapCanvas canvas, LayoutFragment fragment) {
+ final EngineTextStyle style = fragment.style;
+
final SurfacePaint? paint;
- final ui.Paint? foreground = span.style.foreground;
+ final ui.Paint? foreground = style.foreground;
if (foreground != null) {
paint = foreground as SurfacePaint;
} else {
- paint = (ui.Paint()..color = span.style.color!) as SurfacePaint;
+ paint = (ui.Paint()..color = style.color!) as SurfacePaint;
}
- canvas.setCssFont(span.style.cssFontString);
+ canvas.setCssFont(style.cssFontString, fragment.textDirection!);
canvas.setUpPaint(paint.paintData, null);
}
}
diff --git a/lib/web_ui/lib/src/engine/text/paragraph.dart b/lib/web_ui/lib/src/engine/text/paragraph.dart
index 7011038..ef0d559 100644
--- a/lib/web_ui/lib/src/engine/text/paragraph.dart
+++ b/lib/web_ui/lib/src/engine/text/paragraph.dart
@@ -10,7 +10,8 @@
import '../dom.dart';
import '../embedder.dart';
import '../util.dart';
-import 'layout_service.dart';
+import 'canvas_paragraph.dart';
+import 'layout_fragmenter.dart';
import 'ruler.dart';
class EngineLineMetrics implements ui.LineMetrics {
@@ -114,16 +115,17 @@
required double left,
required double baseline,
required int lineNumber,
- required this.ellipsis,
required this.startIndex,
required this.endIndex,
- required this.endIndexWithoutNewlines,
+ required this.trailingNewlines,
+ required this.trailingSpaces,
+ required this.spaceCount,
required this.widthWithTrailingSpaces,
- required this.boxes,
- required this.spaceBoxCount,
- required this.trailingSpaceBoxCount,
+ required this.fragments,
+ required this.textDirection,
this.displayText,
- }) : lineMetrics = EngineLineMetrics(
+ }) : assert(trailingNewlines <= endIndex - startIndex),
+ lineMetrics = EngineLineMetrics(
hardBreak: hardBreak,
ascent: ascent,
descent: descent,
@@ -138,12 +140,6 @@
/// Metrics for this line of the paragraph.
final EngineLineMetrics lineMetrics;
- /// The string to be displayed as an overflow indicator.
- ///
- /// When the value is non-null, it means this line is overflowing and the
- /// [ellipsis] needs to be displayed at the end of it.
- final String? ellipsis;
-
/// The index (inclusive) in the text where this line begins.
final int startIndex;
@@ -153,9 +149,14 @@
/// the text and doesn't stop at the overflow cutoff.
final int endIndex;
- /// The index (exclusive) in the text where this line ends, ignoring newline
- /// characters.
- final int endIndexWithoutNewlines;
+ /// The number of new line characters at the end of the line.
+ final int trailingNewlines;
+
+ /// The number of spaces at the end of the line.
+ final int trailingSpaces;
+
+ /// The number of space characters in the entire line.
+ final int spaceCount;
/// The full width of the line including all trailing space but not new lines.
///
@@ -169,21 +170,17 @@
/// spaces so [widthWithTrailingSpaces] is more suitable.
final double widthWithTrailingSpaces;
- /// The list of boxes representing the entire line, possibly across multiple
- /// spans.
- final List<RangeBox> boxes;
+ /// The fragments that make up this line.
+ final List<LayoutFragment> fragments;
- /// The number of boxes that are space-only.
- final int spaceBoxCount;
-
- /// The number of trailing boxes that are space-only.
- final int trailingSpaceBoxCount;
+ /// The text direction of this line, which is the same as the paragraph's.
+ final ui.TextDirection textDirection;
/// The text to be rendered on the screen representing this line.
final String? displayText;
- /// The number of space-only boxes excluding trailing spaces.
- int get nonTrailingSpaceBoxCount => spaceBoxCount - trailingSpaceBoxCount;
+ /// The number of space characters in the line excluding trailing spaces.
+ int get nonTrailingSpaces => spaceCount - trailingSpaces;
// Convenient getters for line metrics properties.
@@ -201,17 +198,25 @@
return startIndex < this.endIndex && this.startIndex < endIndex;
}
+ String getText(CanvasParagraph paragraph) {
+ final StringBuffer buffer = StringBuffer();
+ for (final LayoutFragment fragment in fragments) {
+ buffer.write(fragment.getText(paragraph));
+ }
+ return buffer.toString();
+ }
+
@override
int get hashCode => Object.hash(
lineMetrics,
- ellipsis,
startIndex,
endIndex,
- endIndexWithoutNewlines,
+ trailingNewlines,
+ trailingSpaces,
+ spaceCount,
widthWithTrailingSpaces,
- boxes,
- spaceBoxCount,
- trailingSpaceBoxCount,
+ fragments,
+ textDirection,
displayText,
);
@@ -225,16 +230,21 @@
}
return other is ParagraphLine &&
other.lineMetrics == lineMetrics &&
- other.ellipsis == ellipsis &&
other.startIndex == startIndex &&
other.endIndex == endIndex &&
- other.endIndexWithoutNewlines == endIndexWithoutNewlines &&
+ other.trailingNewlines == trailingNewlines &&
+ other.trailingSpaces == trailingSpaces &&
+ other.spaceCount == spaceCount &&
other.widthWithTrailingSpaces == widthWithTrailingSpaces &&
- other.boxes == boxes &&
- other.spaceBoxCount == spaceBoxCount &&
- other.trailingSpaceBoxCount == trailingSpaceBoxCount &&
+ other.fragments == fragments &&
+ other.textDirection == textDirection &&
other.displayText == displayText;
}
+
+ @override
+ String toString() {
+ return '$ParagraphLine($startIndex, $endIndex, $lineMetrics)';
+ }
}
/// The web implementation of [ui.ParagraphStyle].
@@ -496,6 +506,52 @@
);
}
+ EngineTextStyle copyWith({
+ ui.Color? color,
+ ui.TextDecoration? decoration,
+ ui.Color? decorationColor,
+ ui.TextDecorationStyle? decorationStyle,
+ double? decorationThickness,
+ ui.FontWeight? fontWeight,
+ ui.FontStyle? fontStyle,
+ ui.TextBaseline? textBaseline,
+ String? fontFamily,
+ List<String>? fontFamilyFallback,
+ double? fontSize,
+ double? letterSpacing,
+ double? wordSpacing,
+ double? height,
+ ui.Locale? locale,
+ ui.Paint? background,
+ ui.Paint? foreground,
+ List<ui.Shadow>? shadows,
+ List<ui.FontFeature>? fontFeatures,
+ List<ui.FontVariation>? fontVariations,
+ }) {
+ return EngineTextStyle(
+ color: color ?? this.color,
+ decoration: decoration ?? this.decoration,
+ decorationColor: decorationColor ?? this.decorationColor,
+ decorationStyle: decorationStyle ?? this.decorationStyle,
+ decorationThickness: decorationThickness ?? this.decorationThickness,
+ fontWeight: fontWeight ?? this.fontWeight,
+ fontStyle: fontStyle ?? this.fontStyle,
+ textBaseline: textBaseline ?? this.textBaseline,
+ fontFamily: fontFamily ?? this.fontFamily,
+ fontFamilyFallback: fontFamilyFallback ?? this.fontFamilyFallback,
+ fontSize: fontSize ?? this.fontSize,
+ letterSpacing: letterSpacing ?? this.letterSpacing,
+ wordSpacing: wordSpacing ?? this.wordSpacing,
+ height: height ?? this.height,
+ locale: locale ?? this.locale,
+ background: background ?? this.background,
+ foreground: foreground ?? this.foreground,
+ shadows: shadows ?? this.shadows,
+ fontFeatures: fontFeatures ?? this.fontFeatures,
+ fontVariations: fontVariations ?? this.fontVariations,
+ );
+ }
+
@override
bool operator ==(Object other) {
if (identical(this, other)) {
diff --git a/lib/web_ui/lib/src/engine/text/text_direction.dart b/lib/web_ui/lib/src/engine/text/text_direction.dart
index 782b0b6..5fe0f33 100644
--- a/lib/web_ui/lib/src/engine/text/text_direction.dart
+++ b/lib/web_ui/lib/src/engine/text/text_direction.dart
@@ -4,9 +4,63 @@
import 'package:ui/ui.dart' as ui;
-import 'line_breaker.dart';
+import 'fragmenter.dart';
import 'unicode_range.dart';
+enum FragmentFlow {
+ /// The fragment flows from left to right regardless of its surroundings.
+ ltr,
+ /// The fragment flows from right to left regardless of its surroundings.
+ rtl,
+ /// The fragment flows the same as the previous fragment.
+ ///
+ /// If it's the first fragment in a line, then it flows the same as the
+ /// paragraph direction.
+ ///
+ /// E.g. digits.
+ previous,
+ /// If the previous and next fragments flow in the same direction, then this
+ /// fragment flows in that same direction. Otherwise, it flows the same as the
+ /// paragraph direction.
+ ///
+ /// E.g. spaces, symbols.
+ sandwich,
+}
+
+/// Splits [text] into fragments based on directionality.
+class BidiFragmenter extends TextFragmenter {
+ const BidiFragmenter(super.text);
+
+ @override
+ List<BidiFragment> fragment() {
+ return _computeBidiFragments(text);
+ }
+}
+
+class BidiFragment extends TextFragment {
+ const BidiFragment(super.start, super.end, this.textDirection, this.fragmentFlow);
+
+ final ui.TextDirection? textDirection;
+ final FragmentFlow fragmentFlow;
+
+ @override
+ int get hashCode => Object.hash(start, end, textDirection, fragmentFlow);
+
+ @override
+ bool operator ==(Object other) {
+ return other is BidiFragment &&
+ other.start == start &&
+ other.end == end &&
+ other.textDirection == textDirection &&
+ other.fragmentFlow == fragmentFlow;
+ }
+
+ @override
+ String toString() {
+ return 'BidiFragment($start, $end, $textDirection)';
+ }
+}
+
// This data was taken from the source code of the Closure library:
//
// - https://github.com/google/closure-library/blob/9d24a6c1809a671c2e54c328897ebeae15a6d172/closure/goog/i18n/bidi.js#L203-L234
@@ -50,69 +104,83 @@
null,
);
-/// Represents a block of text with a certain [ui.TextDirection].
-class DirectionalPosition {
- const DirectionalPosition(this.lineBreak, this.textDirection, this.isSpaceOnly);
+List<BidiFragment> _computeBidiFragments(String text) {
+ final List<BidiFragment> fragments = <BidiFragment>[];
- final LineBreakResult lineBreak;
+ if (text.isEmpty) {
+ fragments.add(const BidiFragment(0, 0, null, FragmentFlow.previous));
+ return fragments;
+ }
- final ui.TextDirection? textDirection;
+ int fragmentStart = 0;
+ ui.TextDirection? textDirection = _getTextDirection(text, 0);
+ FragmentFlow fragmentFlow = _getFragmentFlow(text, 0);
- final bool isSpaceOnly;
+ for (int i = 1; i < text.length; i++) {
+ final ui.TextDirection? charTextDirection = _getTextDirection(text, i);
- LineBreakType get type => lineBreak.type;
+ if (charTextDirection != textDirection) {
+ // We've reached the end of a text direction fragment.
+ fragments.add(BidiFragment(fragmentStart, i, textDirection, fragmentFlow));
+ fragmentStart = i;
+ textDirection = charTextDirection;
- /// Creates a copy of this [DirectionalPosition] with a different [index].
- ///
- /// The type of the returned [DirectionalPosition] is set to
- /// [LineBreakType.prohibited].
- DirectionalPosition copyWithIndex(int index) {
- return DirectionalPosition(
- LineBreakResult.sameIndex(index, LineBreakType.prohibited),
- textDirection,
- isSpaceOnly,
- );
+ fragmentFlow = _getFragmentFlow(text, i);
+ } else {
+ // This code handles the case of a sequence of digits followed by a sequence
+ // of LTR characters with no space in between.
+ if (fragmentFlow == FragmentFlow.previous) {
+ fragmentFlow = _getFragmentFlow(text, i);
+ }
+ }
+ }
+
+ fragments.add(BidiFragment(fragmentStart, text.length, textDirection, fragmentFlow));
+ return fragments;
+}
+
+ui.TextDirection? _getTextDirection(String text, int i) {
+ final int codePoint = getCodePoint(text, i)!;
+ if (_isDigit(codePoint) || _isMashriqiDigit(codePoint)) {
+ // A sequence of regular digits or Mashriqi digits always goes from left to
+ // regardless of their fragment flow direction.
+ return ui.TextDirection.ltr;
+ }
+
+ final ui.TextDirection? textDirection = _textDirectionLookup.findForChar(codePoint);
+ if (textDirection != null) {
+ return textDirection;
+ }
+
+ return null;
+}
+
+FragmentFlow _getFragmentFlow(String text, int i) {
+ final int codePoint = getCodePoint(text, i)!;
+ if (_isDigit(codePoint)) {
+ return FragmentFlow.previous;
+ }
+ if (_isMashriqiDigit(codePoint)) {
+ return FragmentFlow.rtl;
+ }
+
+ final ui.TextDirection? textDirection = _textDirectionLookup.findForChar(codePoint);
+ switch (textDirection) {
+ case ui.TextDirection.ltr:
+ return FragmentFlow.ltr;
+
+ case ui.TextDirection.rtl:
+ return FragmentFlow.rtl;
+
+ case null:
+ return FragmentFlow.sandwich;
}
}
-/// Finds the end of the directional block of text that starts at [start] up
-/// until [end].
-///
-/// If the block goes beyond [end], the part after [end] is ignored.
-DirectionalPosition getDirectionalBlockEnd(
- String text,
- LineBreakResult start,
- LineBreakResult end,
-) {
- if (start.index == end.index) {
- return DirectionalPosition(end, null, false);
- }
+bool _isDigit(int codePoint) {
+ return codePoint >= kChar_0 && codePoint <= kChar_9;
+}
- // Check if we are in a space-only block.
- if (start.index == end.indexWithoutTrailingSpaces) {
- return DirectionalPosition(end, null, true);
- }
-
- final ui.TextDirection? blockDirection = _textDirectionLookup.find(text, start.index);
- int i = start.index + 1;
-
- while (i < end.indexWithoutTrailingSpaces) {
- final ui.TextDirection? direction = _textDirectionLookup.find(text, i);
- if (direction != blockDirection) {
- // Reached the next block.
- break;
- }
- i++;
- }
-
- if (i == end.indexWithoutTrailingNewlines) {
- // If all that remains before [end] is new lines, let's include them in the
- // block.
- return DirectionalPosition(end, blockDirection, false);
- }
- return DirectionalPosition(
- LineBreakResult.sameIndex(i, LineBreakType.prohibited),
- blockDirection,
- false,
- );
+bool _isMashriqiDigit(int codePoint) {
+ return codePoint >= kMashriqi_0 && codePoint <= kMashriqi_9;
}
diff --git a/lib/web_ui/lib/src/engine/text/unicode_range.dart b/lib/web_ui/lib/src/engine/text/unicode_range.dart
index 81d14ed..0e280cc 100644
--- a/lib/web_ui/lib/src/engine/text/unicode_range.dart
+++ b/lib/web_ui/lib/src/engine/text/unicode_range.dart
@@ -3,12 +3,14 @@
// found in the LICENSE file.
const int kChar_0 = 48;
-const int kChar_9 = 57;
+const int kChar_9 = kChar_0 + 9;
const int kChar_A = 65;
const int kChar_Z = 90;
const int kChar_a = 97;
const int kChar_z = 122;
const int kCharBang = 33;
+const int kMashriqi_0 = 0x660;
+const int kMashriqi_9 = kMashriqi_0 + 9;
enum _ComparisonResult {
inside,
diff --git a/lib/web_ui/test/html/bitmap_canvas_golden_test.dart b/lib/web_ui/test/html/bitmap_canvas_golden_test.dart
index 211bf44..656bd30 100644
--- a/lib/web_ui/test/html/bitmap_canvas_golden_test.dart
+++ b/lib/web_ui/test/html/bitmap_canvas_golden_test.dart
@@ -10,6 +10,7 @@
import 'package:ui/ui.dart';
import 'package:web_engine_tester/golden_tester.dart';
+import 'paragraph/helper.dart';
import 'screenshot.dart';
void main() {
@@ -225,8 +226,13 @@
..lineTo(-r, 0)
..close()).shift(const Offset(250, 250));
+ final SurfacePaintData borderPaint = SurfacePaintData()
+ ..color = black
+ ..style = PaintingStyle.stroke;
+
canvas.drawPath(path, pathPaint);
canvas.drawParagraph(paragraph, const Offset(180, 50));
+ canvas.drawRect(Rect.fromLTWH(180, 50, paragraph.width, paragraph.height), borderPaint);
expect(
canvas.rootElement.querySelectorAll('flt-paragraph').map<String?>((DomElement e) => e.text).toList(),
diff --git a/lib/web_ui/test/html/paragraph/helper.dart b/lib/web_ui/test/html/paragraph/helper.dart
index 05f85e6..fde2dd6 100644
--- a/lib/web_ui/test/html/paragraph/helper.dart
+++ b/lib/web_ui/test/html/paragraph/helper.dart
@@ -6,6 +6,22 @@
import 'package:ui/ui.dart';
import 'package:web_engine_tester/golden_tester.dart';
+const LineBreakType prohibited = LineBreakType.prohibited;
+const LineBreakType opportunity = LineBreakType.opportunity;
+const LineBreakType mandatory = LineBreakType.mandatory;
+const LineBreakType endOfText = LineBreakType.endOfText;
+
+const TextDirection ltr = TextDirection.ltr;
+const TextDirection rtl = TextDirection.rtl;
+
+const FragmentFlow ffLtr = FragmentFlow.ltr;
+const FragmentFlow ffRtl = FragmentFlow.rtl;
+const FragmentFlow ffPrevious = FragmentFlow.previous;
+const FragmentFlow ffSandwich = FragmentFlow.sandwich;
+
+const String rtlWord1 = 'واحدة';
+const String rtlWord2 = 'ثنتان';
+
const Color white = Color(0xFFFFFFFF);
const Color black = Color(0xFF000000);
const Color red = Color(0xFFFF0000);
diff --git a/lib/web_ui/test/html/paragraph/overflow_golden_test.dart b/lib/web_ui/test/html/paragraph/overflow_golden_test.dart
index ff39d66..9f1e8bc 100644
--- a/lib/web_ui/test/html/paragraph/overflow_golden_test.dart
+++ b/lib/web_ui/test/html/paragraph/overflow_golden_test.dart
@@ -28,6 +28,9 @@
const double fontSize = 22.0;
const double width = 126.0;
const double padding = 20.0;
+ final SurfacePaintData borderPaint = SurfacePaintData()
+ ..color = black
+ ..style = PaintingStyle.stroke;
paragraph = rich(
EngineParagraphStyle(fontFamily: 'Roboto', fontSize: fontSize, ellipsis: '...'),
@@ -39,6 +42,7 @@
},
)..layout(constrain(width));
canvas.drawParagraph(paragraph, offset);
+ canvas.drawRect(Rect.fromLTWH(offset.dx, offset.dy, width, paragraph.height), borderPaint);
offset = offset.translate(0, paragraph.height + padding);
paragraph = rich(
@@ -53,6 +57,7 @@
},
)..layout(constrain(width));
canvas.drawParagraph(paragraph, offset);
+ canvas.drawRect(Rect.fromLTWH(offset.dx, offset.dy, width, paragraph.height), borderPaint);
offset = offset.translate(0, paragraph.height + padding);
paragraph = rich(
@@ -83,6 +88,7 @@
},
)..layout(constrain(width));
canvas.drawParagraph(paragraph, offset);
+ canvas.drawRect(Rect.fromLTWH(offset.dx, offset.dy, width, paragraph.height), borderPaint);
offset = offset.translate(0, paragraph.height + padding);
paragraph = rich(
@@ -91,9 +97,9 @@
builder.pushStyle(EngineTextStyle.only(color: blue));
builder.addText('Lorem');
builder.pushStyle(EngineTextStyle.only(color: green));
- builder.addText('ipsum');
+ builder.addText('ipsu');
builder.pushStyle(EngineTextStyle.only(color: red));
- builder.addText('dolor');
+ builder.addText('mdolor');
builder.pushStyle(EngineTextStyle.only(color: black));
builder.addText('sit');
builder.pushStyle(EngineTextStyle.only(color: blue));
@@ -103,6 +109,7 @@
},
)..layout(constrain(width));
canvas.drawParagraph(paragraph, offset);
+ canvas.drawRect(Rect.fromLTWH(offset.dx, offset.dy, width, paragraph.height), borderPaint);
offset = offset.translate(0, paragraph.height + padding);
}
diff --git a/lib/web_ui/test/text/canvas_paragraph_builder_test.dart b/lib/web_ui/test/text/canvas_paragraph_builder_test.dart
index b898f5a..2a7040f 100644
--- a/lib/web_ui/test/text/canvas_paragraph_builder_test.dart
+++ b/lib/web_ui/test/text/canvas_paragraph_builder_test.dart
@@ -7,6 +7,8 @@
import 'package:ui/src/engine.dart';
import 'package:ui/ui.dart';
+import '../html/paragraph/helper.dart';
+
bool get isIosSafari =>
browserEngine == BrowserEngine.webkit &&
operatingSystem == OperatingSystem.iOs;
@@ -35,6 +37,28 @@
Future<void> testMain() async {
await initializeTestFlutterViewEmbedder();
+ test('empty paragraph', () {
+ final CanvasParagraph paragraph1 = rich(
+ EngineParagraphStyle(),
+ (CanvasParagraphBuilder builder) {},
+ );
+ expect(paragraph1.plainText, '');
+ expect(paragraph1.spans, hasLength(1));
+ expect(paragraph1.spans.single.start, 0);
+ expect(paragraph1.spans.single.end, 0);
+
+ final CanvasParagraph paragraph2 = rich(
+ EngineParagraphStyle(),
+ (CanvasParagraphBuilder builder) {
+ builder.addText('');
+ },
+ );
+ expect(paragraph2.plainText, '');
+ expect(paragraph2.spans, hasLength(1));
+ expect(paragraph2.spans.single.start, 0);
+ expect(paragraph2.spans.single.end, 0);
+ });
+
test('Builds a text-only canvas paragraph', () {
final EngineParagraphStyle style = EngineParagraphStyle(fontSize: 13.0);
final CanvasParagraphBuilder builder = CanvasParagraphBuilder(style);
@@ -142,7 +166,10 @@
paragraph,
'<flt-paragraph style="${paragraphStyle()}">'
'<flt-span style="${spanStyle(top: 0, left: 0, width: 14*4)}">'
- 'Hell...'
+ 'Hell'
+ '</flt-span>'
+ '<flt-span style="${spanStyle(top: 0, left: 14*4, width: 14*3)}">'
+ '...'
'</flt-span>'
'</flt-paragraph>',
ignorePositions: !isBlink,
@@ -356,10 +383,10 @@
'<flt-span style="${spanStyle(top: 13, left: 0, width: 13*6, fontSize: 13)}">'
'Second'
'</flt-span>'
- '<flt-span style="${spanStyle(top: 13, left: 78, width: 13*1, fontSize: 13)}">'
+ '<flt-span style="${spanStyle(top: 13, left: 13*6, width: 13*1, fontSize: 13)}">'
' '
'</flt-span>'
- '<flt-span style="${spanStyle(top: 13, left: 91, width: 13*13, fontSize: 13, fontStyle: 'italic')}">'
+ '<flt-span style="${spanStyle(top: 13, left: 13*7, width: 13*13, fontSize: 13, fontStyle: 'italic')}">'
'ThirdLongLine'
'</flt-span>'
'</flt-paragraph>',
@@ -377,8 +404,7 @@
'<flt-span style="${spanStyle(top: 13, left: 0, width: 13*6, fontSize: 13)}">'
'Second'
'</flt-span>'
- // Trailing space.
- '<flt-span style="${spanStyle(top: 13, left: 78, width: 0, fontSize: 13)}">'
+ '<flt-span style="${spanStyle(top: 13, left: 13*6, width: 0, fontSize: 13)}">'
' '
'</flt-span>'
'<flt-span style="${spanStyle(top: 26, left: 0, width: 13*13, fontSize: 13, fontStyle: 'italic')}">'
diff --git a/lib/web_ui/test/text/canvas_paragraph_test.dart b/lib/web_ui/test/text/canvas_paragraph_test.dart
index 6a7caaa..c661016 100644
--- a/lib/web_ui/test/text/canvas_paragraph_test.dart
+++ b/lib/web_ui/test/text/canvas_paragraph_test.dart
@@ -370,7 +370,7 @@
);
});
- test('pops boxes when segments are popped', () {
+ test('reverts to last line break opportunity', () {
final CanvasParagraph paragraph = rich(ahemStyle, (ui.ParagraphBuilder builder) {
// Lines:
// "AAA "
@@ -383,6 +383,10 @@
builder.addText('DD');
});
+ String getTextForFragment(LayoutFragment fragment) {
+ return paragraph.plainText.substring(fragment.start, fragment.end);
+ }
+
// The layout algorithm will keep appending segments to the line builder
// until it reaches: "AAA B_". At that point, it'll try to add the "C" but
// realizes there isn't enough width in the line. Since the line already
@@ -398,29 +402,32 @@
final ParagraphLine firstLine = paragraph.lines[0];
final ParagraphLine secondLine = paragraph.lines[1];
- // There should be no "B" in the first line's boxes.
- expect(firstLine.boxes, hasLength(2));
+ // There should be no "B" in the first line's fragments.
+ expect(firstLine.fragments, hasLength(2));
- expect((firstLine.boxes[0] as SpanBox).toText(), 'AAA');
- expect((firstLine.boxes[0] as SpanBox).left, 0.0);
+ expect(getTextForFragment(firstLine.fragments[0]), 'AAA');
+ expect(firstLine.fragments[0].left, 0.0);
- expect((firstLine.boxes[1] as SpanBox).toText(), ' ');
- expect((firstLine.boxes[1] as SpanBox).left, 30.0);
+ expect(getTextForFragment(firstLine.fragments[1]), ' ');
+ expect(firstLine.fragments[1].left, 30.0);
- // Make sure the second line isn't missing any boxes.
- expect(secondLine.boxes, hasLength(4));
+ // Make sure the second line isn't missing any fragments.
+ expect(secondLine.fragments, hasLength(5));
- expect((secondLine.boxes[0] as SpanBox).toText(), 'B');
- expect((secondLine.boxes[0] as SpanBox).left, 0.0);
+ expect(getTextForFragment(secondLine.fragments[0]), 'B');
+ expect(secondLine.fragments[0].left, 0.0);
- expect((secondLine.boxes[1] as SpanBox).toText(), '_C');
- expect((secondLine.boxes[1] as SpanBox).left, 10.0);
+ expect(getTextForFragment(secondLine.fragments[1]), '_');
+ expect(secondLine.fragments[1].left, 10.0);
- expect((secondLine.boxes[2] as SpanBox).toText(), ' ');
- expect((secondLine.boxes[2] as SpanBox).left, 30.0);
+ expect(getTextForFragment(secondLine.fragments[2]), 'C');
+ expect(secondLine.fragments[2].left, 20.0);
- expect((secondLine.boxes[3] as SpanBox).toText(), 'DD');
- expect((secondLine.boxes[3] as SpanBox).left, 40.0);
+ expect(getTextForFragment(secondLine.fragments[3]), ' ');
+ expect(secondLine.fragments[3].left, 30.0);
+
+ expect(getTextForFragment(secondLine.fragments[4]), 'DD');
+ expect(secondLine.fragments[4].left, 40.0);
});
});
diff --git a/lib/web_ui/test/text/layout_fragmenter_test.dart b/lib/web_ui/test/text/layout_fragmenter_test.dart
new file mode 100644
index 0000000..0c315ce
--- /dev/null
+++ b/lib/web_ui/test/text/layout_fragmenter_test.dart
@@ -0,0 +1,293 @@
+// Copyright 2013 The Flutter Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import 'package:test/bootstrap/browser.dart';
+import 'package:test/test.dart';
+import 'package:ui/src/engine.dart';
+import 'package:ui/ui.dart';
+
+import '../html/paragraph/helper.dart';
+
+final EngineTextStyle defaultStyle = EngineTextStyle.only(
+ color: const Color(0xFFFF0000),
+ fontFamily: FlutterViewEmbedder.defaultFontFamily,
+ fontSize: FlutterViewEmbedder.defaultFontSize,
+);
+final EngineTextStyle style1 = defaultStyle.copyWith(fontSize: 20);
+final EngineTextStyle style2 = defaultStyle.copyWith(color: blue);
+final EngineTextStyle style3 = defaultStyle.copyWith(fontFamily: 'Roboto');
+
+void main() {
+ internalBootstrapBrowserTest(() => testMain);
+}
+
+Future<void> testMain() async {
+ group('$LayoutFragmenter', () {
+ test('empty paragraph', () {
+ final CanvasParagraph paragraph1 = rich(
+ EngineParagraphStyle(),
+ (CanvasParagraphBuilder builder) {},
+ );
+ expect(split(paragraph1), <_Fragment>[
+ _Fragment('', endOfText, null, ffPrevious, defaultStyle),
+ ]);
+
+ final CanvasParagraph paragraph2 = rich(
+ EngineParagraphStyle(),
+ (CanvasParagraphBuilder builder) {
+ builder.addText('');
+ },
+ );
+ expect(split(paragraph2), <_Fragment>[
+ _Fragment('', endOfText, null, ffPrevious, defaultStyle),
+ ]);
+
+ final CanvasParagraph paragraph3 = rich(
+ EngineParagraphStyle(),
+ (CanvasParagraphBuilder builder) {
+ builder.pushStyle(style1);
+ builder.addText('');
+ },
+ );
+ expect(split(paragraph3), <_Fragment>[
+ _Fragment('', endOfText, null, ffPrevious, style1),
+ ]);
+ });
+
+ test('single span', () {
+ final CanvasParagraph paragraph =
+ plain(EngineParagraphStyle(), 'Lorem 12 $rtlWord1 ipsum34');
+ expect(split(paragraph), <_Fragment>[
+ _Fragment('Lorem', prohibited, ltr, ffLtr, defaultStyle),
+ _Fragment(' ', opportunity, null, ffSandwich, defaultStyle, sp: 1),
+ _Fragment('12', prohibited, ltr, ffPrevious, defaultStyle),
+ _Fragment(' ', opportunity, null, ffSandwich, defaultStyle, sp: 1),
+ _Fragment(rtlWord1, prohibited, rtl, ffRtl, defaultStyle),
+ _Fragment(' ', opportunity, null, ffSandwich, defaultStyle, sp: 3),
+ _Fragment('ipsum34', endOfText, ltr, ffLtr, defaultStyle),
+ ]);
+ });
+
+ test('multi span', () {
+ final CanvasParagraph paragraph = rich(
+ EngineParagraphStyle(),
+ (CanvasParagraphBuilder builder) {
+ builder.pushStyle(style1);
+ builder.addText('Lorem');
+ builder.pop();
+ builder.pushStyle(style2);
+ builder.addText(' ipsum 12 ');
+ builder.pop();
+ builder.pushStyle(style3);
+ builder.addText(' $rtlWord1 foo.');
+ builder.pop();
+ },
+ );
+
+ expect(split(paragraph), <_Fragment>[
+ _Fragment('Lorem', prohibited, ltr, ffLtr, style1),
+ _Fragment(' ', opportunity, null, ffSandwich, style2, sp: 1),
+ _Fragment('ipsum', prohibited, ltr, ffLtr, style2),
+ _Fragment(' ', opportunity, null, ffSandwich, style2, sp: 1),
+ _Fragment('12', prohibited, ltr, ffPrevious, style2),
+ _Fragment(' ', prohibited, null, ffSandwich, style2, sp: 1),
+ _Fragment(' ', opportunity, null, ffSandwich, style3, sp: 1),
+ _Fragment(rtlWord1, prohibited, rtl, ffRtl, style3),
+ _Fragment(' ', opportunity, null, ffSandwich, style3, sp: 1),
+ _Fragment('foo', prohibited, ltr, ffLtr, style3),
+ _Fragment('.', endOfText, null, ffSandwich, style3),
+ ]);
+ });
+
+ test('new lines', () {
+ final CanvasParagraph paragraph = rich(
+ EngineParagraphStyle(),
+ (CanvasParagraphBuilder builder) {
+ builder.pushStyle(style1);
+ builder.addText('Lor\nem \n');
+ builder.pop();
+ builder.pushStyle(style2);
+ builder.addText(' \n ipsum 12 ');
+ builder.pop();
+ builder.pushStyle(style3);
+ builder.addText(' $rtlWord1 fo');
+ builder.pop();
+ builder.pushStyle(style1);
+ builder.addText('o.');
+ builder.pop();
+ },
+ );
+
+ expect(split(paragraph), <_Fragment>[
+ _Fragment('Lor', prohibited, ltr, ffLtr, style1),
+ _Fragment('\n', mandatory, null, ffSandwich, style1, nl: 1, sp: 1),
+ _Fragment('em', prohibited, ltr, ffLtr, style1),
+ _Fragment(' \n', mandatory, null, ffSandwich, style1, nl: 1, sp: 2),
+ _Fragment(' \n', mandatory, null, ffSandwich, style2, nl: 1, sp: 2),
+ _Fragment(' ', opportunity, null, ffSandwich, style2, sp: 2),
+ _Fragment('ipsum', prohibited, ltr, ffLtr, style2),
+ _Fragment(' ', opportunity, null, ffSandwich, style2, sp: 1),
+ _Fragment('12', prohibited, ltr, ffPrevious, style2),
+ _Fragment(' ', prohibited, null, ffSandwich, style2, sp: 1),
+ _Fragment(' ', opportunity, null, ffSandwich, style3, sp: 1),
+ _Fragment(rtlWord1, prohibited, rtl, ffRtl, style3),
+ _Fragment(' ', opportunity, null, ffSandwich, style3, sp: 1),
+ _Fragment('fo', prohibited, ltr, ffLtr, style3),
+ _Fragment('o', prohibited, ltr, ffLtr, style1),
+ _Fragment('.', endOfText, null, ffSandwich, style1),
+ ]);
+ });
+
+ test('last line is empty', () {
+ final CanvasParagraph paragraph = rich(
+ EngineParagraphStyle(),
+ (CanvasParagraphBuilder builder) {
+ builder.pushStyle(style1);
+ builder.addText('Lorem \n');
+ builder.pop();
+ builder.pushStyle(style2);
+ builder.addText(' \n ipsum \n');
+ builder.pop();
+ },
+ );
+
+ expect(split(paragraph), <_Fragment>[
+ _Fragment('Lorem', prohibited, ltr, ffLtr, style1),
+ _Fragment(' \n', mandatory, null, ffSandwich, style1, nl: 1, sp: 2),
+ _Fragment(' \n', mandatory, null, ffSandwich, style2, nl: 1, sp: 2),
+ _Fragment(' ', opportunity, null, ffSandwich, style2, sp: 2),
+ _Fragment('ipsum', prohibited, ltr, ffLtr, style2),
+ _Fragment(' \n', mandatory, null, ffSandwich, style2, nl: 1, sp: 2),
+ _Fragment('', endOfText, null, ffSandwich, style2),
+ ]);
+ });
+
+ test('space-only spans', () {
+ final CanvasParagraph paragraph = rich(
+ EngineParagraphStyle(),
+ (CanvasParagraphBuilder builder) {
+ builder.addText('Lorem ');
+ builder.pushStyle(style1);
+ builder.addText(' ');
+ builder.pop();
+ builder.pushStyle(style2);
+ builder.addText(' ');
+ builder.pop();
+ builder.addText('ipsum');
+ },
+ );
+
+ expect(split(paragraph), <_Fragment>[
+ _Fragment('Lorem', prohibited, ltr, ffLtr, defaultStyle),
+ _Fragment(' ', prohibited, null, ffSandwich, defaultStyle, sp: 1),
+ _Fragment(' ', prohibited, null, ffSandwich, style1, sp: 3),
+ _Fragment(' ', opportunity, null, ffSandwich, style2, sp: 2),
+ _Fragment('ipsum', endOfText, ltr, ffLtr, defaultStyle),
+ ]);
+ });
+
+ test('placeholders', () {
+ final CanvasParagraph paragraph = rich(
+ EngineParagraphStyle(),
+ (CanvasParagraphBuilder builder) {
+ builder.pushStyle(style1);
+ builder.addPlaceholder(100, 100, PlaceholderAlignment.top);
+ builder.addText('Lorem');
+ builder.addPlaceholder(100, 100, PlaceholderAlignment.top);
+ builder.addText('ipsum\n');
+ builder.addPlaceholder(100, 100, PlaceholderAlignment.top);
+ builder.pop();
+ builder.pushStyle(style2);
+ builder.addText('$rtlWord1 ');
+ builder.addPlaceholder(100, 100, PlaceholderAlignment.top);
+ builder.addText('\nsit');
+ builder.pop();
+ builder.addPlaceholder(100, 100, PlaceholderAlignment.top);
+ },
+ );
+
+ final String placeholderChar = String.fromCharCode(0xFFFC);
+
+ expect(split(paragraph), <_Fragment>[
+ _Fragment(placeholderChar, opportunity, ltr, ffLtr, null),
+ _Fragment('Lorem', opportunity, ltr, ffLtr, style1),
+ _Fragment(placeholderChar, opportunity, ltr, ffLtr, null),
+ _Fragment('ipsum', prohibited, ltr, ffLtr, style1),
+ _Fragment('\n', mandatory, null, ffSandwich, style1, nl: 1, sp: 1),
+ _Fragment(placeholderChar, opportunity, ltr, ffLtr, null),
+ _Fragment(rtlWord1, prohibited, rtl, ffRtl, style2),
+ _Fragment(' ', opportunity, null, ffSandwich, style2, sp: 1),
+ _Fragment(placeholderChar, prohibited, ltr, ffLtr, null),
+ _Fragment('\n', mandatory, null, ffSandwich, style2, nl: 1, sp: 1),
+ _Fragment('sit', opportunity, ltr, ffLtr, style2),
+ _Fragment(placeholderChar, endOfText, ltr, ffLtr, null),
+ ]);
+ });
+ });
+}
+
+/// Holds information about how a fragment.
+class _Fragment {
+ _Fragment(this.text, this.type, this.textDirection, this.fragmentFlow, this.style, {
+ this.nl = 0,
+ this.sp = 0,
+ });
+
+ factory _Fragment._fromLayoutFragment(String text, LayoutFragment layoutFragment) {
+ final ParagraphSpan span = layoutFragment.span;
+ return _Fragment(
+ text.substring(layoutFragment.start, layoutFragment.end),
+ layoutFragment.type,
+ layoutFragment.textDirection,
+ layoutFragment.fragmentFlow,
+ span is FlatTextSpan ? span.style : null,
+ nl: layoutFragment.trailingNewlines,
+ sp: layoutFragment.trailingSpaces,
+ );
+ }
+
+ final String text;
+ final LineBreakType type;
+ final TextDirection? textDirection;
+ final FragmentFlow fragmentFlow;
+ final EngineTextStyle? style;
+
+ /// The number of trailing new line characters.
+ final int nl;
+
+ /// The number of trailing spaces.
+ final int sp;
+
+ @override
+ int get hashCode => Object.hash(text, type, textDirection, fragmentFlow, style, nl, sp);
+
+ @override
+ bool operator ==(Object other) {
+ return other is _Fragment &&
+ other.text == text &&
+ other.type == type &&
+ other.textDirection == textDirection &&
+ other.fragmentFlow == fragmentFlow &&
+ other.style == style &&
+ other.nl == nl &&
+ other.sp == sp;
+ }
+
+ @override
+ String toString() {
+ return '"$text" ($type, $textDirection, $fragmentFlow, nl: $nl, sp: $sp)';
+ }
+}
+
+List<_Fragment> split(CanvasParagraph paragraph) {
+ return <_Fragment>[
+ for (final LayoutFragment layoutFragment
+ in computeLayoutFragments(paragraph))
+ _Fragment._fromLayoutFragment(paragraph.plainText, layoutFragment)
+ ];
+}
+
+List<LayoutFragment> computeLayoutFragments(CanvasParagraph paragraph) {
+ return LayoutFragmenter(paragraph.plainText, paragraph.spans).fragment();
+}
diff --git a/lib/web_ui/test/text/layout_service_helper.dart b/lib/web_ui/test/text/layout_service_helper.dart
index 9fef792..e516d19 100644
--- a/lib/web_ui/test/text/layout_service_helper.dart
+++ b/lib/web_ui/test/text/layout_service_helper.dart
@@ -10,7 +10,6 @@
String? displayText,
int? startIndex,
int? endIndex, {
- int? endIndexWithoutNewlines,
bool? hardBreak,
double? height,
double? width,
@@ -22,7 +21,6 @@
displayText: displayText,
startIndex: startIndex,
endIndex: endIndex,
- endIndexWithoutNewlines: endIndexWithoutNewlines,
hardBreak: hardBreak,
height: height,
width: width,
@@ -33,7 +31,6 @@
}
void expectLines(CanvasParagraph paragraph, List<TestLine> expectedLines) {
- final String text = paragraph.toPlainText();
final List<ParagraphLine> lines = paragraph.lines;
expect(lines, hasLength(expectedLines.length));
for (int i = 0; i < lines.length; i++) {
@@ -46,11 +43,7 @@
reason: 'line #$i had the wrong `lineNumber`. Expected: $i. Actual: ${line.lineNumber}',
);
if (expectedLine.displayText != null) {
- String displayText =
- text.substring(line.startIndex, line.endIndexWithoutNewlines);
- if (line.ellipsis != null) {
- displayText += line.ellipsis!;
- }
+ final String displayText = line.getText(paragraph);
expect(
displayText,
expectedLine.displayText,
@@ -74,14 +67,6 @@
'line #$i had a different `endIndex` value: "${line.endIndex}" vs. "${expectedLine.endIndex}"',
);
}
- if (expectedLine.endIndexWithoutNewlines != null) {
- expect(
- line.endIndexWithoutNewlines,
- expectedLine.endIndexWithoutNewlines,
- reason:
- 'line #$i had a different `endIndexWithoutNewlines` value: "${line.endIndexWithoutNewlines}" vs. "${expectedLine.endIndexWithoutNewlines}"',
- );
- }
if (expectedLine.hardBreak != null) {
expect(
line.hardBreak,
@@ -130,7 +115,6 @@
this.displayText,
this.startIndex,
this.endIndex,
- this.endIndexWithoutNewlines,
this.hardBreak,
this.height,
this.width,
@@ -142,7 +126,6 @@
final String? displayText;
final int? startIndex;
final int? endIndex;
- final int? endIndexWithoutNewlines;
final bool? hardBreak;
final double? height;
final double? width;
diff --git a/lib/web_ui/test/text/layout_service_plain_test.dart b/lib/web_ui/test/text/layout_service_plain_test.dart
index 9cdb9a5..777c80c 100644
--- a/lib/web_ui/test/text/layout_service_plain_test.dart
+++ b/lib/web_ui/test/text/layout_service_plain_test.dart
@@ -46,8 +46,10 @@
expect(paragraph.maxIntrinsicWidth, 0);
expect(paragraph.minIntrinsicWidth, 0);
- expect(paragraph.height, 0);
- expect(paragraph.computeLineMetrics(), isEmpty);
+ expect(paragraph.height, 10);
+ expectLines(paragraph, <TestLine>[
+ l('', 0, 0, width: 0.0, height: 10.0, baseline: 8.0),
+ ]);
});
test('preserves whitespace when measuring', () {
@@ -422,7 +424,7 @@
expect(longText.maxIntrinsicWidth, 480);
expect(longText.height, 10);
expectLines(longText, <TestLine>[
- l('AA...', 0, 2, hardBreak: false, width: 50.0, left: 0.0),
+ l('AA...', 0, 2, hardBreak: true, width: 50.0, left: 0.0),
]);
// The short prefix should make the text break into two lines, but the
@@ -436,7 +438,7 @@
expect(longTextShortPrefix.height, 20);
expectLines(longTextShortPrefix, <TestLine>[
l('AAA', 0, 4, hardBreak: true, width: 30.0, left: 0.0),
- l('AA...', 4, 6, hardBreak: false, width: 50.0, left: 0.0),
+ l('AA...', 4, 6, hardBreak: true, width: 50.0, left: 0.0),
]);
// Constraints only enough to fit "AA" with the ellipsis, but not the
@@ -447,7 +449,7 @@
expect(trailingSpace.maxIntrinsicWidth, 60);
expect(trailingSpace.height, 10);
expectLines(trailingSpace, <TestLine>[
- l('AA...', 0, 2, hardBreak: false, width: 50.0, left: 0.0),
+ l('AA...', 0, 2, hardBreak: true, width: 50.0, left: 0.0),
]);
// Tiny constraints.
@@ -457,7 +459,7 @@
expect(paragraph.maxIntrinsicWidth, 40);
expect(paragraph.height, 10);
expectLines(paragraph, <TestLine>[
- l('...', 0, 0, hardBreak: false, width: 30.0, left: 0.0),
+ l('...', 0, 0, hardBreak: true, width: 30.0, left: 0.0),
]);
// Tinier constraints (not enough for the ellipsis).
@@ -471,7 +473,7 @@
// l('.', 0, 0, hardBreak: false, width: 10.0, left: 0.0),
// ]);
expectLines(paragraph, <TestLine>[
- l('...', 0, 0, hardBreak: false, width: 30.0, left: 0.0),
+ l('...', 0, 0, hardBreak: true, width: 30.0, left: 0.0),
]);
});
@@ -550,14 +552,14 @@
paragraph = plain(onelineStyle, 'abcd efg')..layout(constrain(60.0));
expect(paragraph.height, 10);
expectLines(paragraph, <TestLine>[
- l('abc...', 0, 3, hardBreak: false, width: 60.0, left: 0.0),
+ l('abc...', 0, 3, hardBreak: true, width: 60.0, left: 0.0),
]);
// Another simple overflow case.
paragraph = plain(onelineStyle, 'a bcde fgh')..layout(constrain(60.0));
expect(paragraph.height, 10);
expectLines(paragraph, <TestLine>[
- l('a b...', 0, 3, hardBreak: false, width: 60.0, left: 0.0),
+ l('a b...', 0, 3, hardBreak: true, width: 60.0, left: 0.0),
]);
// The ellipsis is supposed to go on the second line, but because the
@@ -574,7 +576,7 @@
expect(paragraph.height, 20);
expectLines(paragraph, <TestLine>[
l('abcd ', 0, 5, hardBreak: false, width: 40.0, left: 0.0),
- l('efg...', 5, 8, hardBreak: false, width: 60.0, left: 0.0),
+ l('efg...', 5, 8, hardBreak: true, width: 60.0, left: 0.0),
]);
// Even if the second line can be broken, we don't break it, we just
@@ -584,7 +586,7 @@
expect(paragraph.height, 20);
expectLines(paragraph, <TestLine>[
l('abcde ', 0, 6, hardBreak: false, width: 50.0, left: 0.0),
- l('f g...', 6, 9, hardBreak: false, width: 60.0, left: 0.0),
+ l('f g...', 6, 9, hardBreak: true, width: 60.0, left: 0.0),
]);
// First line overflows but second line doesn't.
@@ -601,7 +603,7 @@
expect(paragraph.height, 20);
expectLines(paragraph, <TestLine>[
l('abcdef', 0, 6, hardBreak: false, width: 60.0, left: 0.0),
- l('g h...', 6, 9, hardBreak: false, width: 60.0, left: 0.0),
+ l('g h...', 6, 9, hardBreak: true, width: 60.0, left: 0.0),
]);
});
diff --git a/lib/web_ui/test/text/layout_service_rich_test.dart b/lib/web_ui/test/text/layout_service_rich_test.dart
index 28d50bc..f1d3816 100644
--- a/lib/web_ui/test/text/layout_service_rich_test.dart
+++ b/lib/web_ui/test/text/layout_service_rich_test.dart
@@ -11,6 +11,8 @@
import '../html/paragraph/helper.dart';
import 'layout_service_helper.dart';
+final String placeholderChar = String.fromCharCode(0xFFFC);
+
const ui.Color white = ui.Color(0xFFFFFFFF);
const ui.Color black = ui.Color(0xFF000000);
const ui.Color red = ui.Color(0xFFFF0000);
@@ -166,7 +168,7 @@
expect(paragraph.minIntrinsicWidth, 300.0);
expect(paragraph.height, 50.0);
expectLines(paragraph, <TestLine>[
- l('', 0, 0, hardBreak: true, width: 300.0, left: 100.0),
+ l(placeholderChar, 0, 1, hardBreak: true, width: 300.0, left: 100.0),
]);
});
@@ -185,7 +187,7 @@
expect(paragraph.minIntrinsicWidth, 300.0);
expect(paragraph.height, 50.0);
expectLines(paragraph, <TestLine>[
- l('abcd', 0, 4, hardBreak: true, width: 340.0, left: 30.0),
+ l('abcd$placeholderChar', 0, 5, hardBreak: true, width: 340.0, left: 30.0),
]);
});
@@ -207,8 +209,8 @@
expect(paragraph.height, 10.0 + 40.0 + 10.0);
expectLines(paragraph, <TestLine>[
l('Lorem', 0, 6, hardBreak: true, width: 50.0, height: 10.0, left: 125.0),
- l('', 6, 6, hardBreak: false, width: 300.0, height: 40.0, left: 0.0),
- l('ipsum', 6, 11, hardBreak: true, width: 50.0, height: 10.0, left: 125.0),
+ l(placeholderChar, 6, 7, hardBreak: false, width: 300.0, height: 40.0, left: 0.0),
+ l('ipsum', 7, 12, hardBreak: true, width: 50.0, height: 10.0, left: 125.0),
]);
});
diff --git a/lib/web_ui/test/text/line_breaker_test.dart b/lib/web_ui/test/text/line_breaker_test.dart
index 3b21855..b536bd4 100644
--- a/lib/web_ui/test/text/line_breaker_test.dart
+++ b/lib/web_ui/test/text/line_breaker_test.dart
@@ -6,53 +6,53 @@
import 'package:test/test.dart';
import 'package:ui/src/engine.dart';
+import 'package:ui/ui.dart';
+import '../html/paragraph/helper.dart';
import 'line_breaker_test_helper.dart';
import 'line_breaker_test_raw_data.dart';
+final String placeholderChar = String.fromCharCode(0xFFFC);
+
void main() {
internalBootstrapBrowserTest(() => testMain);
}
void testMain() {
- group('nextLineBreak', () {
- test('Does not go beyond the ends of a string', () {
- expect(split('foo'), <Line>[
- Line('foo', LineBreakType.endOfText),
+ group('$LineBreakFragmenter', () {
+ test('empty string', () {
+ expect(split(''), <Line>[
+ Line('', endOfText),
]);
-
- final LineBreakResult result = nextLineBreak('foo', 'foo'.length);
- expect(result.index, 'foo'.length);
- expect(result.type, LineBreakType.endOfText);
});
test('whitespace', () {
expect(split('foo bar'), <Line>[
- Line('foo ', LineBreakType.opportunity),
- Line('bar', LineBreakType.endOfText),
+ Line('foo ', opportunity, sp: 1),
+ Line('bar', endOfText),
]);
expect(split(' foo bar '), <Line>[
- Line(' ', LineBreakType.opportunity),
- Line('foo ', LineBreakType.opportunity),
- Line('bar ', LineBreakType.endOfText),
+ Line(' ', opportunity, sp: 2),
+ Line('foo ', opportunity, sp: 4),
+ Line('bar ', endOfText, sp: 2),
]);
});
test('single-letter lines', () {
expect(split('foo a bar'), <Line>[
- Line('foo ', LineBreakType.opportunity),
- Line('a ', LineBreakType.opportunity),
- Line('bar', LineBreakType.endOfText),
+ Line('foo ', opportunity, sp: 1),
+ Line('a ', opportunity, sp: 1),
+ Line('bar', endOfText),
]);
expect(split('a b c'), <Line>[
- Line('a ', LineBreakType.opportunity),
- Line('b ', LineBreakType.opportunity),
- Line('c', LineBreakType.endOfText),
+ Line('a ', opportunity, sp: 1),
+ Line('b ', opportunity, sp: 1),
+ Line('c', endOfText),
]);
expect(split(' a b '), <Line>[
- Line(' ', LineBreakType.opportunity),
- Line('a ', LineBreakType.opportunity),
- Line('b ', LineBreakType.endOfText),
+ Line(' ', opportunity, sp: 1),
+ Line('a ', opportunity, sp: 1),
+ Line('b ', endOfText, sp: 1),
]);
});
@@ -60,242 +60,344 @@
final String bk = String.fromCharCode(0x000B);
// Can't have a line break between CR×LF.
expect(split('foo\r\nbar'), <Line>[
- Line('foo\r\n', LineBreakType.mandatory),
- Line('bar', LineBreakType.endOfText),
+ Line('foo\r\n', mandatory, nl: 2, sp: 2),
+ Line('bar', endOfText),
]);
// Any other new line is considered a line break on its own.
expect(split('foo\n\nbar'), <Line>[
- Line('foo\n', LineBreakType.mandatory),
- Line('\n', LineBreakType.mandatory),
- Line('bar', LineBreakType.endOfText),
+ Line('foo\n', mandatory, nl: 1, sp: 1),
+ Line('\n', mandatory, nl: 1, sp: 1),
+ Line('bar', endOfText),
]);
expect(split('foo\r\rbar'), <Line>[
- Line('foo\r', LineBreakType.mandatory),
- Line('\r', LineBreakType.mandatory),
- Line('bar', LineBreakType.endOfText),
+ Line('foo\r', mandatory, nl: 1, sp: 1),
+ Line('\r', mandatory, nl: 1, sp: 1),
+ Line('bar', endOfText),
]);
expect(split('foo$bk${bk}bar'), <Line>[
- Line('foo$bk', LineBreakType.mandatory),
- Line(bk, LineBreakType.mandatory),
- Line('bar', LineBreakType.endOfText),
+ Line('foo$bk', mandatory, nl: 1, sp: 1),
+ Line(bk, mandatory, nl: 1, sp: 1),
+ Line('bar', endOfText),
]);
expect(split('foo\n\rbar'), <Line>[
- Line('foo\n', LineBreakType.mandatory),
- Line('\r', LineBreakType.mandatory),
- Line('bar', LineBreakType.endOfText),
+ Line('foo\n', mandatory, nl: 1, sp: 1),
+ Line('\r', mandatory, nl: 1, sp: 1),
+ Line('bar', endOfText),
]);
expect(split('foo$bk\rbar'), <Line>[
- Line('foo$bk', LineBreakType.mandatory),
- Line('\r', LineBreakType.mandatory),
- Line('bar', LineBreakType.endOfText),
+ Line('foo$bk', mandatory, nl: 1, sp: 1),
+ Line('\r', mandatory, nl: 1, sp: 1),
+ Line('bar', endOfText),
]);
expect(split('foo\r${bk}bar'), <Line>[
- Line('foo\r', LineBreakType.mandatory),
- Line(bk, LineBreakType.mandatory),
- Line('bar', LineBreakType.endOfText),
+ Line('foo\r', mandatory, nl: 1, sp: 1),
+ Line(bk, mandatory, nl: 1, sp: 1),
+ Line('bar', endOfText),
]);
expect(split('foo$bk\nbar'), <Line>[
- Line('foo$bk', LineBreakType.mandatory),
- Line('\n', LineBreakType.mandatory),
- Line('bar', LineBreakType.endOfText),
+ Line('foo$bk', mandatory, nl: 1, sp: 1),
+ Line('\n', mandatory, nl: 1, sp: 1),
+ Line('bar', endOfText),
]);
expect(split('foo\n${bk}bar'), <Line>[
- Line('foo\n', LineBreakType.mandatory),
- Line(bk, LineBreakType.mandatory),
- Line('bar', LineBreakType.endOfText),
+ Line('foo\n', mandatory, nl: 1, sp: 1),
+ Line(bk, mandatory, nl: 1, sp: 1),
+ Line('bar', endOfText),
]);
// New lines at the beginning and end.
expect(split('foo\n'), <Line>[
- Line('foo\n', LineBreakType.mandatory),
- Line('', LineBreakType.endOfText),
+ Line('foo\n', mandatory, nl: 1, sp: 1),
+ Line('', endOfText),
]);
expect(split('foo\r'), <Line>[
- Line('foo\r', LineBreakType.mandatory),
- Line('', LineBreakType.endOfText),
+ Line('foo\r', mandatory, nl: 1, sp: 1),
+ Line('', endOfText),
]);
expect(split('foo$bk'), <Line>[
- Line('foo$bk', LineBreakType.mandatory),
- Line('', LineBreakType.endOfText),
+ Line('foo$bk', mandatory, nl: 1, sp: 1),
+ Line('', endOfText),
]);
expect(split('\nfoo'), <Line>[
- Line('\n', LineBreakType.mandatory),
- Line('foo', LineBreakType.endOfText),
+ Line('\n', mandatory, nl: 1, sp: 1),
+ Line('foo', endOfText),
]);
expect(split('\rfoo'), <Line>[
- Line('\r', LineBreakType.mandatory),
- Line('foo', LineBreakType.endOfText),
+ Line('\r', mandatory, nl: 1, sp: 1),
+ Line('foo', endOfText),
]);
expect(split('${bk}foo'), <Line>[
- Line(bk, LineBreakType.mandatory),
- Line('foo', LineBreakType.endOfText),
+ Line(bk, mandatory, nl: 1, sp: 1),
+ Line('foo', endOfText),
]);
// Whitespace with new lines.
expect(split('foo \n'), <Line>[
- Line('foo \n', LineBreakType.mandatory),
- Line('', LineBreakType.endOfText),
+ Line('foo \n', mandatory, nl: 1, sp: 3),
+ Line('', endOfText),
]);
expect(split('foo \n '), <Line>[
- Line('foo \n', LineBreakType.mandatory),
- Line(' ', LineBreakType.endOfText),
+ Line('foo \n', mandatory, nl: 1, sp: 3),
+ Line(' ', endOfText, sp: 3),
]);
expect(split('foo \n bar'), <Line>[
- Line('foo \n', LineBreakType.mandatory),
- Line(' ', LineBreakType.opportunity),
- Line('bar', LineBreakType.endOfText),
+ Line('foo \n', mandatory, nl: 1, sp: 3),
+ Line(' ', opportunity, sp: 3),
+ Line('bar', endOfText),
]);
expect(split('\n foo'), <Line>[
- Line('\n', LineBreakType.mandatory),
- Line(' ', LineBreakType.opportunity),
- Line('foo', LineBreakType.endOfText),
+ Line('\n', mandatory, nl: 1, sp: 1),
+ Line(' ', opportunity, sp: 2),
+ Line('foo', endOfText),
]);
expect(split(' \n foo'), <Line>[
- Line(' \n', LineBreakType.mandatory),
- Line(' ', LineBreakType.opportunity),
- Line('foo', LineBreakType.endOfText),
+ Line(' \n', mandatory, nl: 1, sp: 4),
+ Line(' ', opportunity, sp: 2),
+ Line('foo', endOfText),
]);
});
test('trailing spaces and new lines', () {
- expect(
- findBreaks('foo bar '),
- const <LineBreakResult>[
- LineBreakResult(4, 4, 3, LineBreakType.opportunity),
- LineBreakResult(9, 9, 7, LineBreakType.endOfText),
+ expect(split('foo bar '), <Line>[
+ Line('foo ', opportunity, sp: 1),
+ Line('bar ', endOfText, sp: 2),
],
);
- expect(
- findBreaks('foo \nbar\nbaz \n'),
- const <LineBreakResult>[
- LineBreakResult(6, 5, 3, LineBreakType.mandatory),
- LineBreakResult(10, 9, 9, LineBreakType.mandatory),
- LineBreakResult(17, 16, 13, LineBreakType.mandatory),
- LineBreakResult(17, 17, 17, LineBreakType.endOfText),
+ expect(split('foo \nbar\nbaz \n'), <Line>[
+ Line('foo \n', mandatory, nl: 1, sp: 3),
+ Line('bar\n', mandatory, nl: 1, sp: 1),
+ Line('baz \n', mandatory, nl: 1, sp: 4),
+ Line('', endOfText),
],
);
});
test('leading spaces', () {
- expect(
- findBreaks(' foo'),
- const <LineBreakResult>[
- LineBreakResult(1, 1, 0, LineBreakType.opportunity),
- LineBreakResult(4, 4, 4, LineBreakType.endOfText),
+ expect(split(' foo'), <Line>[
+ Line(' ', opportunity, sp: 1),
+ Line('foo', endOfText),
],
);
- expect(
- findBreaks(' foo'),
- const <LineBreakResult>[
- LineBreakResult(3, 3, 0, LineBreakType.opportunity),
- LineBreakResult(6, 6, 6, LineBreakType.endOfText),
+ expect(split(' foo'), <Line>[
+ Line(' ', opportunity, sp: 3),
+ Line('foo', endOfText),
],
);
- expect(
- findBreaks(' foo bar'),
- const <LineBreakResult>[
- LineBreakResult(2, 2, 0, LineBreakType.opportunity),
- LineBreakResult(8, 8, 5, LineBreakType.opportunity),
- LineBreakResult(11, 11, 11, LineBreakType.endOfText),
+ expect(split(' foo bar'), <Line>[
+ Line(' ', opportunity, sp: 2),
+ Line('foo ', opportunity, sp: 3),
+ Line('bar', endOfText),
],
);
- expect(
- findBreaks(' \n foo'),
- const <LineBreakResult>[
- LineBreakResult(3, 2, 0, LineBreakType.mandatory),
- LineBreakResult(6, 6, 3, LineBreakType.opportunity),
- LineBreakResult(9, 9, 9, LineBreakType.endOfText),
+ expect(split(' \n foo'), <Line>[
+ Line(' \n', mandatory, nl: 1, sp: 3),
+ Line(' ', opportunity, sp: 3),
+ Line('foo', endOfText),
],
);
});
test('whitespace before the last character', () {
- const String text = 'Lorem sit .';
- const LineBreakResult expectedResult =
- LineBreakResult(10, 10, 9, LineBreakType.opportunity);
+ expect(split('Lorem sit .'), <Line>[
+ Line('Lorem ', opportunity, sp: 1),
+ Line('sit ', opportunity, sp: 1),
+ Line('.', endOfText),
+ ],
+ );
+ });
- LineBreakResult result;
+ test('placeholders', () {
+ final CanvasParagraph paragraph = rich(
+ EngineParagraphStyle(),
+ (CanvasParagraphBuilder builder) {
+ builder.addPlaceholder(100, 100, PlaceholderAlignment.top);
+ builder.addText('Lorem');
+ builder.addPlaceholder(100, 100, PlaceholderAlignment.top);
+ builder.addText('ipsum\n');
+ builder.addPlaceholder(100, 100, PlaceholderAlignment.top);
+ builder.addText('dolor');
+ builder.addPlaceholder(100, 100, PlaceholderAlignment.top);
+ builder.addText('\nsit');
+ builder.addPlaceholder(100, 100, PlaceholderAlignment.top);
+ },
+ );
- result = nextLineBreak(text, 6);
- expect(result, expectedResult);
+ final String placeholderChar = String.fromCharCode(0xFFFC);
- result = nextLineBreak(text, 9);
- expect(result, expectedResult);
+ expect(splitParagraph(paragraph), <Line>[
+ Line(placeholderChar, opportunity),
+ Line('Lorem', opportunity),
+ Line(placeholderChar, opportunity),
+ Line('ipsum\n', mandatory, nl: 1, sp: 1),
+ Line(placeholderChar, opportunity),
+ Line('dolor', opportunity),
+ Line('$placeholderChar\n', mandatory, nl: 1, sp: 1),
+ Line('sit', opportunity),
+ Line(placeholderChar, endOfText),
+ ]);
+ });
- result = nextLineBreak(text, 9, maxEnd: 10);
- expect(result, expectedResult);
+ test('single placeholder', () {
+ final CanvasParagraph paragraph = rich(
+ EngineParagraphStyle(),
+ (CanvasParagraphBuilder builder) {
+ builder.addPlaceholder(100, 100, PlaceholderAlignment.top);
+ },
+ );
+
+ final String placeholderChar = String.fromCharCode(0xFFFC);
+
+ expect(splitParagraph(paragraph), <Line>[
+ Line(placeholderChar, endOfText),
+ ]);
+ });
+
+ test('placeholders surrounded by spaces and new lines', () {
+ final CanvasParagraph paragraph = rich(
+ EngineParagraphStyle(),
+ (CanvasParagraphBuilder builder) {
+ builder.addPlaceholder(100, 100, PlaceholderAlignment.top);
+ builder.addText(' Lorem ');
+ builder.addPlaceholder(100, 100, PlaceholderAlignment.top);
+ builder.addText(' \nipsum \n');
+ builder.addPlaceholder(100, 100, PlaceholderAlignment.top);
+ builder.addText('\n');
+ builder.addPlaceholder(100, 100, PlaceholderAlignment.top);
+ },
+ );
+
+ expect(splitParagraph(paragraph), <Line>[
+ Line('$placeholderChar ', opportunity, sp: 2),
+ Line('Lorem ', opportunity, sp: 2),
+ Line('$placeholderChar \n', mandatory, nl: 1, sp: 3),
+ Line('ipsum \n', mandatory, nl: 1, sp: 2),
+ Line('$placeholderChar\n', mandatory, nl: 1, sp: 1),
+ Line(placeholderChar, endOfText),
+ ],
+ );
+ });
+
+ test('surrogates', () {
+ expect(split('A\u{1F600}'), <Line>[
+ Line('A', opportunity),
+ Line('\u{1F600}', endOfText),
+ ],
+ );
+
+ expect(split('\u{1F600}A'), <Line>[
+ Line('\u{1F600}', opportunity),
+ Line('A', endOfText),
+ ],
+ );
+
+ expect(split('\u{1F600}\u{1F600}'), <Line>[
+ Line('\u{1F600}', opportunity),
+ Line('\u{1F600}', endOfText),
+ ],
+ );
+
+ expect(split('A \u{1F600} \u{1F600}'), <Line>[
+ Line('A ', opportunity, sp: 1),
+ Line('\u{1F600} ', opportunity, sp: 1),
+ Line('\u{1F600}', endOfText),
+ ],
+ );
});
test('comprehensive test', () {
- final List<TestCase> testCollection = parseRawTestData(rawLineBreakTestData);
+ final List<TestCase> testCollection =
+ parseRawTestData(rawLineBreakTestData);
for (int t = 0; t < testCollection.length; t++) {
final TestCase testCase = testCollection[t];
- final String text = testCase.toText();
- int lastLineBreak = 0;
+ final String text = testCase.toText();
+ final List<LineBreakFragment> fragments = LineBreakFragmenter(text).fragment();
+
+ // `f` is the index in the `fragments` list.
+ int f = 0;
+ LineBreakFragment currentFragment = fragments[f];
+
int surrogateCount = 0;
// `s` is the index in the `testCase.signs` list.
- for (int s = 0; s < testCase.signs.length; s++) {
+ for (int s = 0; s < testCase.signs.length - 1; s++) {
// `i` is the index in the `text`.
final int i = s + surrogateCount;
- if (s < testCase.chars.length && testCase.chars[s].isSurrogatePair) {
- surrogateCount++;
- }
-
final Sign sign = testCase.signs[s];
- final LineBreakResult result = nextLineBreak(text, lastLineBreak);
+
if (sign.isBreakOpportunity) {
- // The line break should've been found at index `i`.
expect(
- result.index,
+ currentFragment.end,
i,
reason: 'Failed at test case number $t:\n'
'$testCase\n'
'"$text"\n'
- '\nExpected line break at {$lastLineBreak - $i} but found line break at {$lastLineBreak - ${result.index}}.',
+ '\nExpected fragment to end at {$i} but ended at {${currentFragment.end}}.',
);
-
- // Since this is a line break, passing a `maxEnd` that's greater
- // should return the same line break.
- final LineBreakResult maxEndResult =
- nextLineBreak(text, lastLineBreak, maxEnd: i + 1);
- expect(maxEndResult.index, i);
- expect(maxEndResult.type, isNot(LineBreakType.prohibited));
-
- lastLineBreak = i;
+ currentFragment = fragments[++f];
} else {
- // This isn't a line break opportunity so the line break should be
- // somewhere after index `i`.
expect(
- result.index,
+ currentFragment.end,
greaterThan(i),
reason: 'Failed at test case number $t:\n'
'$testCase\n'
'"$text"\n'
- '\nUnexpected line break found at {$lastLineBreak - ${result.index}}.',
+ '\nFragment ended in early at {${currentFragment.end}}.',
);
+ }
- // Since this isn't a line break, passing it as a `maxEnd` should
- // return `maxEnd` as a prohibited line break type.
- final LineBreakResult maxEndResult =
- nextLineBreak(text, lastLineBreak, maxEnd: i);
- expect(maxEndResult.index, i);
- expect(maxEndResult.type, LineBreakType.prohibited);
+ if (s < testCase.chars.length && testCase.chars[s].isSurrogatePair) {
+ surrogateCount++;
}
}
+
+ // Now let's look at the last sign, which requires different handling.
+
+ // The last line break is an endOfText (or a hard break followed by
+ // endOfText if the last character is a hard line break).
+ if (currentFragment.type == mandatory) {
+ // When last character is a hard line break, there should be an
+ // extra fragment to represent the empty line at the end.
+ expect(
+ fragments,
+ hasLength(f + 2),
+ reason: 'Failed at test case number $t:\n'
+ '$testCase\n'
+ '"$text"\n'
+ "\nExpected an extra fragment for endOfText but there wasn't one.",
+ );
+
+ currentFragment = fragments[++f];
+ }
+
+ expect(
+ currentFragment.type,
+ endOfText,
+ reason: 'Failed at test case number $t:\n'
+ '$testCase\n'
+ '"$text"\n\n'
+ 'Expected an endOfText fragment but found: $currentFragment',
+ );
+ expect(
+ currentFragment.end,
+ text.length,
+ reason: 'Failed at test case number $t:\n'
+ '$testCase\n'
+ '"$text"\n\n'
+ 'Expected an endOfText fragment ending at {${text.length}} but found: $currentFragment',
+ );
}
});
});
@@ -303,17 +405,32 @@
/// Holds information about how a line was split from a string.
class Line {
- Line(this.text, this.breakType);
+ Line(this.text, this.breakType, {this.nl = 0, this.sp = 0});
+
+ factory Line.fromLineBreakFragment(String text, LineBreakFragment fragment) {
+ return Line(
+ text.substring(fragment.start, fragment.end),
+ fragment.type,
+ nl: fragment.trailingNewlines,
+ sp: fragment.trailingSpaces,
+ );
+ }
final String text;
final LineBreakType breakType;
+ final int nl;
+ final int sp;
@override
- int get hashCode => Object.hash(text, breakType);
+ int get hashCode => Object.hash(text, breakType, nl, sp);
@override
bool operator ==(Object other) {
- return other is Line && other.text == text && other.breakType == breakType;
+ return other is Line &&
+ other.text == text &&
+ other.breakType == breakType &&
+ other.nl == nl &&
+ other.sp == sp;
}
String get escapedText {
@@ -329,29 +446,20 @@
@override
String toString() {
- return '"$escapedText" ($breakType)';
+ return '"$escapedText" ($breakType, nl: $nl, sp: $sp)';
}
}
List<Line> split(String text) {
- final List<Line> lines = <Line>[];
-
- int lastIndex = 0;
- for (final LineBreakResult brk in findBreaks(text)) {
- lines.add(Line(text.substring(lastIndex, brk.index), brk.type));
- lastIndex = brk.index;
- }
- return lines;
+ return <Line>[
+ for (final LineBreakFragment fragment in LineBreakFragmenter(text).fragment())
+ Line.fromLineBreakFragment(text, fragment)
+ ];
}
-List<LineBreakResult> findBreaks(String text) {
- final List<LineBreakResult> breaks = <LineBreakResult>[];
-
- LineBreakResult brk = nextLineBreak(text, 0);
- breaks.add(brk);
- while (brk.type != LineBreakType.endOfText) {
- brk = nextLineBreak(text, brk.index);
- breaks.add(brk);
- }
- return breaks;
+List<Line> splitParagraph(CanvasParagraph paragraph) {
+ return <Line>[
+ for (final LineBreakFragment fragment in LineBreakFragmenter(paragraph.plainText).fragment())
+ Line.fromLineBreakFragment(paragraph.toPlainText(), fragment)
+ ];
}
diff --git a/lib/web_ui/test/text/text_direction_test.dart b/lib/web_ui/test/text/text_direction_test.dart
index 61b7b44..3c39220 100644
--- a/lib/web_ui/test/text/text_direction_test.dart
+++ b/lib/web_ui/test/text/text_direction_test.dart
@@ -2,125 +2,197 @@
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
-
import 'package:test/bootstrap/browser.dart';
import 'package:test/test.dart';
import 'package:ui/src/engine.dart';
import 'package:ui/ui.dart';
-// Two RTL strings, 5 characters each, to match the length of "$rtl1" and "$rtl2".
-const String rtl1 = 'واحدة';
-const String rtl2 = 'ثنتان';
+import '../html/paragraph/helper.dart';
void main() {
internalBootstrapBrowserTest(() => testMain);
}
Future<void> testMain() async {
- group('$getDirectionalBlockEnd', () {
+ group('$BidiFragmenter', () {
+ test('empty string', () {
+ expect(split(''), <_Bidi>[
+ _Bidi('', null, ffPrevious),
+ ]);
+ });
test('basic cases', () {
- const String text = 'Lorem 12 $rtl1 ipsum34';
- const LineBreakResult start = LineBreakResult.sameIndex(0, LineBreakType.prohibited);
- const LineBreakResult end = LineBreakResult.sameIndex(text.length, LineBreakType.endOfText);
- const LineBreakResult loremMiddle = LineBreakResult.sameIndex(3, LineBreakType.prohibited);
- const LineBreakResult loremEnd = LineBreakResult.sameIndex(5, LineBreakType.prohibited);
- const LineBreakResult twelveStart = LineBreakResult(6, 6, 5, LineBreakType.opportunity);
- const LineBreakResult twelveEnd = LineBreakResult.sameIndex(8, LineBreakType.prohibited);
- const LineBreakResult rtl1Start = LineBreakResult(9, 9, 8, LineBreakType.opportunity);
- const LineBreakResult rtl1End = LineBreakResult.sameIndex(14, LineBreakType.prohibited);
- const LineBreakResult ipsumStart = LineBreakResult(17, 17, 15, LineBreakType.opportunity);
- const LineBreakResult ipsumEnd = LineBreakResult.sameIndex(22, LineBreakType.prohibited);
+ expect(split('Lorem 11 $rtlWord1 22 ipsum'), <_Bidi>[
+ _Bidi('Lorem', ltr, ffLtr),
+ _Bidi(' ', null, ffSandwich),
+ _Bidi('11', ltr, ffPrevious),
+ _Bidi(' ', null, ffSandwich),
+ _Bidi(rtlWord1, rtl, ffRtl),
+ _Bidi(' ', null, ffSandwich),
+ _Bidi('22', ltr, ffPrevious),
+ _Bidi(' ', null, ffSandwich),
+ _Bidi('ipsum', ltr, ffLtr),
+ ]);
+ });
- DirectionalPosition blockEnd;
+ test('text and digits', () {
+ expect(split('Lorem11 ${rtlWord1}22 33ipsum44dolor ${rtlWord2}55$rtlWord1'), <_Bidi>[
+ _Bidi('Lorem11', ltr, ffLtr),
+ _Bidi(' ', null, ffSandwich),
+ _Bidi(rtlWord1, rtl, ffRtl),
+ _Bidi('22', ltr, ffPrevious),
+ _Bidi(' ', null, ffSandwich),
+ _Bidi('33ipsum44dolor', ltr, ffLtr),
+ _Bidi(' ', null, ffSandwich),
+ _Bidi(rtlWord2, rtl, ffRtl),
+ _Bidi('55', ltr, ffPrevious),
+ _Bidi(rtlWord1, rtl, ffRtl),
+ ]);
+ });
- blockEnd = getDirectionalBlockEnd(text, start, end);
- expect(blockEnd.isSpaceOnly, isFalse);
- expect(blockEnd.textDirection, TextDirection.ltr);
- expect(blockEnd.lineBreak, loremEnd);
+ test('Mashriqi digits', () {
+ expect(split('foo ١١ ٢٢ bar'), <_Bidi>[
+ _Bidi('foo', ltr, ffLtr),
+ _Bidi(' ', null, ffSandwich),
+ _Bidi('١١', ltr, ffRtl),
+ _Bidi(' ', null, ffSandwich),
+ _Bidi('٢٢', ltr, ffRtl),
+ _Bidi(' ', null, ffSandwich),
+ _Bidi('bar', ltr, ffLtr),
+ ]);
- blockEnd = getDirectionalBlockEnd(text, start, loremMiddle);
- expect(blockEnd.isSpaceOnly, isFalse);
- expect(blockEnd.textDirection, TextDirection.ltr);
- expect(blockEnd.lineBreak, loremMiddle);
+ expect(split('$rtlWord1 ١١ ٢٢ $rtlWord2'), <_Bidi>[
+ _Bidi(rtlWord1, rtl, ffRtl),
+ _Bidi(' ', null, ffSandwich),
+ _Bidi('١١', ltr, ffRtl),
+ _Bidi(' ', null, ffSandwich),
+ _Bidi('٢٢', ltr, ffRtl),
+ _Bidi(' ', null, ffSandwich),
+ _Bidi(rtlWord2, rtl, ffRtl),
+ ]);
+ });
- blockEnd = getDirectionalBlockEnd(text, loremMiddle, loremEnd);
- expect(blockEnd.isSpaceOnly, isFalse);
- expect(blockEnd.textDirection, TextDirection.ltr);
- expect(blockEnd.lineBreak, loremEnd);
+ test('spaces', () {
+ expect(split(' '), <_Bidi>[
+ _Bidi(' ', null, ffSandwich),
+ ]);
+ });
- blockEnd = getDirectionalBlockEnd(text, loremEnd, twelveStart);
- expect(blockEnd.isSpaceOnly, isTrue);
- expect(blockEnd.textDirection, isNull);
- expect(blockEnd.lineBreak, twelveStart);
+ test('symbols', () {
+ expect(split('Calculate 2.2 + 4.5 and write the result'), <_Bidi>[
+ _Bidi('Calculate', ltr, ffLtr),
+ _Bidi(' ', null, ffSandwich),
+ _Bidi('2', ltr, ffPrevious),
+ _Bidi('.', null, ffSandwich),
+ _Bidi('2', ltr, ffPrevious),
+ _Bidi(' + ', null, ffSandwich),
+ _Bidi('4', ltr, ffPrevious),
+ _Bidi('.', null, ffSandwich),
+ _Bidi('5', ltr, ffPrevious),
+ _Bidi(' ', null, ffSandwich),
+ _Bidi('and', ltr, ffLtr),
+ _Bidi(' ', null, ffSandwich),
+ _Bidi('write', ltr, ffLtr),
+ _Bidi(' ', null, ffSandwich),
+ _Bidi('the', ltr, ffLtr),
+ _Bidi(' ', null, ffSandwich),
+ _Bidi('result', ltr, ffLtr),
+ ]);
- blockEnd = getDirectionalBlockEnd(text, twelveStart, rtl1Start);
- expect(blockEnd.isSpaceOnly, isFalse);
- expect(blockEnd.textDirection, isNull);
- expect(blockEnd.lineBreak, twelveEnd);
+ expect(split('Calculate $rtlWord1 2.2 + 4.5 and write the result'), <_Bidi>[
+ _Bidi('Calculate', ltr, ffLtr),
+ _Bidi(' ', null, ffSandwich),
+ _Bidi(rtlWord1, rtl, ffRtl),
+ _Bidi(' ', null, ffSandwich),
+ _Bidi('2', ltr, ffPrevious),
+ _Bidi('.', null, ffSandwich),
+ _Bidi('2', ltr, ffPrevious),
+ _Bidi(' + ', null, ffSandwich),
+ _Bidi('4', ltr, ffPrevious),
+ _Bidi('.', null, ffSandwich),
+ _Bidi('5', ltr, ffPrevious),
+ _Bidi(' ', null, ffSandwich),
+ _Bidi('and', ltr, ffLtr),
+ _Bidi(' ', null, ffSandwich),
+ _Bidi('write', ltr, ffLtr),
+ _Bidi(' ', null, ffSandwich),
+ _Bidi('the', ltr, ffLtr),
+ _Bidi(' ', null, ffSandwich),
+ _Bidi('result', ltr, ffLtr),
+ ]);
- blockEnd = getDirectionalBlockEnd(text, rtl1Start, end);
- expect(blockEnd.isSpaceOnly, isFalse);
- expect(blockEnd.textDirection, TextDirection.rtl);
- expect(blockEnd.lineBreak, rtl1End);
-
- blockEnd = getDirectionalBlockEnd(text, ipsumStart, end);
- expect(blockEnd.isSpaceOnly, isFalse);
- expect(blockEnd.textDirection, TextDirection.ltr);
- expect(blockEnd.lineBreak, ipsumEnd);
-
- blockEnd = getDirectionalBlockEnd(text, ipsumEnd, end);
- expect(blockEnd.isSpaceOnly, isFalse);
- expect(blockEnd.textDirection, isNull);
- expect(blockEnd.lineBreak, end);
+ expect(split('12 + 24 = 36'), <_Bidi>[
+ _Bidi('12', ltr, ffPrevious),
+ _Bidi(' + ', null, ffSandwich),
+ _Bidi('24', ltr, ffPrevious),
+ _Bidi(' = ', null, ffSandwich),
+ _Bidi('36', ltr, ffPrevious),
+ ]);
});
test('handles new lines', () {
- const String text = 'Lorem\n12\nipsum \n';
- const LineBreakResult start = LineBreakResult.sameIndex(0, LineBreakType.prohibited);
- const LineBreakResult end = LineBreakResult(
- text.length,
- text.length - 1,
- text.length - 3,
- LineBreakType.mandatory,
- );
- const LineBreakResult loremEnd = LineBreakResult.sameIndex(5, LineBreakType.prohibited);
- const LineBreakResult twelveStart = LineBreakResult(6, 5, 5, LineBreakType.mandatory);
- const LineBreakResult twelveEnd = LineBreakResult.sameIndex(8, LineBreakType.prohibited);
- const LineBreakResult ipsumStart = LineBreakResult(9, 8, 8, LineBreakType.mandatory);
- const LineBreakResult ipsumEnd = LineBreakResult.sameIndex(14, LineBreakType.prohibited);
+ expect(split('Lorem\n12\nipsum \n'), <_Bidi>[
+ _Bidi('Lorem', ltr, ffLtr),
+ _Bidi('\n', null, ffSandwich),
+ _Bidi('12', ltr, ffPrevious),
+ _Bidi('\n', null, ffSandwich),
+ _Bidi('ipsum', ltr, ffLtr),
+ _Bidi(' \n', null, ffSandwich),
+ ]);
- DirectionalPosition blockEnd;
+ expect(split('$rtlWord1\n $rtlWord2 \n'), <_Bidi>[
+ _Bidi(rtlWord1, rtl, ffRtl),
+ _Bidi('\n ', null, ffSandwich),
+ _Bidi(rtlWord2, rtl, ffRtl),
+ _Bidi(' \n', null, ffSandwich),
+ ]);
+ });
- blockEnd = getDirectionalBlockEnd(text, start, twelveStart);
- expect(blockEnd.isSpaceOnly, isFalse);
- expect(blockEnd.textDirection, TextDirection.ltr);
- expect(blockEnd.lineBreak, twelveStart);
-
- blockEnd = getDirectionalBlockEnd(text, loremEnd, twelveStart);
- expect(blockEnd.isSpaceOnly, isTrue);
- expect(blockEnd.textDirection, isNull);
- expect(blockEnd.lineBreak, twelveStart);
-
- blockEnd = getDirectionalBlockEnd(text, twelveStart, ipsumStart);
- expect(blockEnd.isSpaceOnly, isFalse);
- expect(blockEnd.textDirection, isNull);
- expect(blockEnd.lineBreak, ipsumStart);
-
- blockEnd = getDirectionalBlockEnd(text, twelveEnd, ipsumStart);
- expect(blockEnd.isSpaceOnly, isTrue);
- expect(blockEnd.textDirection, isNull);
- expect(blockEnd.lineBreak, ipsumStart);
-
- blockEnd = getDirectionalBlockEnd(text, ipsumStart, end);
- expect(blockEnd.isSpaceOnly, isFalse);
- expect(blockEnd.textDirection, TextDirection.ltr);
- expect(blockEnd.lineBreak, ipsumEnd);
-
- blockEnd = getDirectionalBlockEnd(text, ipsumEnd, end);
- expect(blockEnd.isSpaceOnly, isTrue);
- expect(blockEnd.textDirection, isNull);
- expect(blockEnd.lineBreak, end);
+ test('surrogates', () {
+ expect(split('A\u{1F600}'), <_Bidi>[
+ _Bidi('A', ltr, ffLtr),
+ _Bidi('\u{1F600}', null, ffSandwich),
+ ]);
});
});
}
+
+/// Holds information about how a bidi region was split from a string.
+class _Bidi {
+ _Bidi(this.text, this.textDirection, this.fragmentFlow);
+
+ factory _Bidi.fromBidiFragment(String text, BidiFragment bidiFragment) {
+ return _Bidi(
+ text.substring(bidiFragment.start, bidiFragment.end),
+ bidiFragment.textDirection,
+ bidiFragment.fragmentFlow,
+ );
+ }
+
+ final String text;
+ final TextDirection? textDirection;
+ final FragmentFlow fragmentFlow;
+
+ @override
+ int get hashCode => Object.hash(text, textDirection);
+
+ @override
+ bool operator ==(Object other) {
+ return other is _Bidi &&
+ other.text == text &&
+ other.textDirection == textDirection &&
+ other.fragmentFlow == fragmentFlow;
+ }
+
+ @override
+ String toString() {
+ return '"$text" ($textDirection | $fragmentFlow)';
+ }
+}
+
+List<_Bidi> split(String text) {
+ return <_Bidi>[
+ for (final BidiFragment bidiFragment in BidiFragmenter(text).fragment())
+ _Bidi.fromBidiFragment(text, bidiFragment)
+ ];
+}