diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/text/layout_service.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/text/layout_service.dart index e50dd7fb2ff..663969832fc 100644 --- a/engine/src/flutter/lib/web_ui/lib/src/engine/text/layout_service.dart +++ b/engine/src/flutter/lib/web_ui/lib/src/engine/text/layout_service.dart @@ -41,7 +41,9 @@ class TextLayoutService { int? get maxLines => paragraph.paragraphStyle._maxLines; bool get unlimitedLines => maxLines == null; - bool get hasEllipsis => paragraph.paragraphStyle._ellipsis != null; + + String? get ellipsis => paragraph.paragraphStyle._ellipsis; + bool get hasEllipsis => ellipsis != null; /// Performs the layout on a paragraph given the [constraints]. /// @@ -73,13 +75,13 @@ class TextLayoutService { int spanIndex = 0; ParagraphSpan span = paragraph.spans[0]; - LineBuilder currentLine = LineBuilder.first(paragraph, spanometer); - LineBuilder maxIntrinsicLine = LineBuilder.first(paragraph, spanometer); + LineBuilder currentLine = + LineBuilder.first(paragraph, spanometer, maxWidth: constraints.width); - // The only way to exit this while loop is by hitting the `break;` statement - // when we reach the `endOfText` line break. + // 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 HARD LINE BREAKS AND END OF TEXT *** // // *********************************************** // @@ -103,7 +105,6 @@ class TextLayoutService { if (span is PlaceholderSpan) { spanometer.currentSpan = null; final double lineWidth = currentLine.width + span.width; - // TODO(mdebbar): Consider how placeholders affect min/max intrinsics. if (lineWidth <= constraints.width) { // The placeholder fits on the current line. // TODO(mdebbar): @@ -122,12 +123,6 @@ class TextLayoutService { final double additionalWidth = currentLine.getAdditionalWidthTo(nextBreak); - // 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. - if (maxIntrinsicLine.end != nextBreak) { - maxIntrinsicLine.extendTo(nextBreak); - } - if (currentLine.width + additionalWidth <= constraints.width) { // TODO(mdebbar): Handle the case when `nextBreak` is just a span end // that shouldn't extend the line yet. @@ -138,19 +133,23 @@ class TextLayoutService { // 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. - // TODO(mdebbar): Remove this line and implement overflow ellipsis. - currentLine.extendTo(nextBreak); + currentLine.forceBreak(nextBreak, + allowEmpty: true, ellipsis: ellipsis); + lines.add(currentLine.build(ellipsis: ellipsis)); + break; } else if (currentLine.isEmpty) { // The current line is still empty, 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. + // We need to force-break it without adding an ellipsis. - // TODO(mdebbar): Remove this line and implement force-breaking. - currentLine.extendTo(nextBreak); + currentLine.forceBreak(nextBreak, allowEmpty: false); + lines.add(currentLine.build()); + currentLine = currentLine.nextLine(); } else { // Normal line break. lines.add(currentLine.build()); @@ -161,24 +160,8 @@ class TextLayoutService { throw UnimplementedError('Unknown span type: ${span.runtimeType}'); } - // ************************************************ // - // *** LONGEST LINE && MAX/MIN INTRINSIC WIDTHS *** // - // ************************************************ // - - if (longestLine < currentLine.width) { - longestLine = currentLine.width; - } - - if (minIntrinsicWidth < currentLine.widthOfLastExtension) { - minIntrinsicWidth = currentLine.widthOfLastExtension; - } - - if (maxIntrinsicLine.end.isHard) { - // Max intrinsic width includes the width of trailing spaces. - if (maxIntrinsicWidth < maxIntrinsicLine.widthIncludingSpace) { - maxIntrinsicWidth = maxIntrinsicLine.widthIncludingSpace; - } - maxIntrinsicLine = maxIntrinsicLine.nextLine(); + if (lines.length == maxLines) { + break; } // ********************************************* // @@ -190,9 +173,85 @@ class TextLayoutService { span = paragraph.spans[++spanIndex]; } } + + // ******************************** // + // *** MAX/MIN INTRINSIC WIDTHS *** // + // ******************************** // + + spanIndex = 0; + span = paragraph.spans[0]; + currentLine = + LineBuilder.first(paragraph, spanometer, maxWidth: constraints.width); + + while (currentLine.end.type != LineBreakType.endOfText) { + if (span is PlaceholderSpan) { + // TODO(mdebbar): Do placeholders affect min/max intrinsic width? + } else if (span is FlatTextSpan) { + final LineBreakResult nextBreak = currentLine.findNextBreak(span.end); + + // 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); + + final double widthOfLastSegment = currentLine.lastSegment.width; + if (minIntrinsicWidth < widthOfLastSegment) { + minIntrinsicWidth = widthOfLastSegment; + } + + if (currentLine.end.isHard) { + // Max intrinsic width includes the width of trailing spaces. + if (maxIntrinsicWidth < currentLine.widthIncludingSpace) { + maxIntrinsicWidth = currentLine.widthIncludingSpace; + } + currentLine = currentLine.nextLine(); + } + + // Only go to the next span if we've reached the end of this span. + if (currentLine.end.index >= span.end && spanIndex < spanCount - 1) { + span = paragraph.spans[++spanIndex]; + } + } + } } } +/// 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; +} + /// Builds instances of [EngineLineMetrics] for the given [paragraph]. /// /// Usage of this class starts by calling [LineBuilder.first] to start building @@ -209,20 +268,29 @@ class LineBuilder { LineBuilder._( this.paragraph, this.spanometer, { + required this.maxWidth, required this.start, required this.lineNumber, }) : end = start; /// Creates a [LineBuilder] for the first line in a paragraph. - factory LineBuilder.first(CanvasParagraph paragraph, Spanometer spanometer) { + factory LineBuilder.first( + CanvasParagraph paragraph, + Spanometer spanometer, { + required double maxWidth, + }) { return LineBuilder._( paragraph, spanometer, + maxWidth: maxWidth, lineNumber: 0, start: LineBreakResult.sameIndex(0, LineBreakType.prohibited), ); } + final List _segments = []; + + final double maxWidth; final CanvasParagraph paragraph; final Spanometer spanometer; final LineBreakResult start; @@ -233,17 +301,17 @@ class LineBuilder { /// The width of the line so far, excluding trailing white space. double width = 0.0; - /// The width of trailing white space in the line. - double widthOfTrailingSpace = 0.0; - /// The width of the line so far, including trailing white space. - double get widthIncludingSpace => width + widthOfTrailingSpace; + double widthIncludingSpace = 0.0; - /// The width of the last extension to the line made via [extendTo]. - double widthOfLastExtension = 0.0; + /// The width of trailing white space in the line. + double get widthOfTrailingSpace => widthIncludingSpace - width; - bool get isEmpty => start == end; - bool get isNotEmpty => !isEmpty; + /// The last segment in this line. + LineSegment get lastSegment => _segments.last; + + bool get isEmpty => _segments.isEmpty; + bool get isNotEmpty => _segments.isNotEmpty; /// Measures the width of text between the end of this line and [newEnd]. double getAdditionalWidthTo(LineBreakResult newEnd) { @@ -265,28 +333,137 @@ class LineBuilder { 'Cannot extend a line that ends with a hard break.', ); - // TODO(mdebbar): Handle the case where the entire extension is made of spaces. - widthOfLastExtension = spanometer.measure(end, newEnd); - final double additionalWidthIncludingSpace = - spanometer.measureIncludingSpace(end, newEnd); + _addSegment(_createSegment(newEnd)); + } + + /// 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), + ); + } + + /// 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); // Add the width of previous trailing space. - width += widthOfTrailingSpace + widthOfLastExtension; - widthOfTrailingSpace = additionalWidthIncludingSpace - widthOfLastExtension; - end = newEnd; + 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(); + + double widthOfPrevTrailingSpace; + if (_segments.isEmpty) { + widthOfPrevTrailingSpace = 0.0; + end = start; + } else { + widthOfPrevTrailingSpace = lastSegment.widthOfTrailingSpace; + end = lastSegment.end; + } + + width = width - poppedSegment.width - widthOfPrevTrailingSpace; + widthIncludingSpace -= poppedSegment.widthIncludingSpace; + + 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( + LineBreakResult nextBreak, { + required bool allowEmpty, + String? ellipsis, + }) { + if (ellipsis == null) { + final double availableWidth = maxWidth - widthIncludingSpace; + final LineBreakResult breakingPoint = spanometer.forceBreak( + end.index, + nextBreak.indexWithoutTrailingSpaces, + availableWidth: availableWidth, + allowEmpty: allowEmpty, + ); + extendTo(breakingPoint); + 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..." + + final double ellipsisWidth = spanometer.measureText(ellipsis); + final double availableWidth = maxWidth - ellipsisWidth; + + // First, we create the new segment until `nextBreak`. + LineSegment segmentToBreak = _createSegment(nextBreak); + + // 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 && width > availableWidth) { + segmentToBreak = _popSegment(); + } + + spanometer.currentSpan = segmentToBreak.span as FlatTextSpan; + final double availableWidthForSegment = + availableWidth - widthIncludingSpace; + final LineBreakResult breakingPoint = spanometer.forceBreak( + segmentToBreak.start.index, + segmentToBreak.end.indexWithoutTrailingSpaces, + availableWidth: availableWidthForSegment, + allowEmpty: allowEmpty, + ); + extendTo(breakingPoint); } /// Builds the [EngineLineMetrics] instance that represents this line. - EngineLineMetrics build() { - final String text = paragraph.toPlainText(); + EngineLineMetrics build({String? ellipsis}) { + double ellipsisWidth = 0.0; + String text = paragraph + .toPlainText() + .substring(start.index, end.indexWithoutTrailingNewlines); + + if (ellipsis != null) { + ellipsisWidth = spanometer.measureText(ellipsis); + text += ellipsis; + } + return EngineLineMetrics.withText( - text.substring(start.index, end.indexWithoutTrailingNewlines), + text, startIndex: start.index, endIndex: end.index, endIndexWithoutNewlines: end.indexWithoutTrailingNewlines, hardBreak: end.isHard, - width: width, - widthWithTrailingSpaces: width + widthOfTrailingSpace, + width: width + ellipsisWidth, + widthWithTrailingSpaces: widthIncludingSpace + ellipsisWidth, // TODO(mdebbar): Calculate actual align offset. left: 0.0, lineNumber: lineNumber, @@ -303,6 +480,7 @@ class LineBuilder { return LineBuilder._( paragraph, spanometer, + maxWidth: maxWidth, start: end, lineNumber: lineNumber + 1, ); @@ -328,6 +506,7 @@ class Spanometer { double? get letterSpacing => _currentSpan!.style._letterSpacing; FlatTextSpan? _currentSpan; + FlatTextSpan? get currentSpan => _currentSpan; set currentSpan(FlatTextSpan? span) { if (span == _currentSpan) { return; @@ -361,6 +540,49 @@ class Spanometer { return _measure(start.index, end.indexWithoutTrailingNewlines); } + double measureText(String text) { + return _measureSubstring(context, text, 0, text.length); + } + + LineBreakResult forceBreak( + int start, + int end, { + required double availableWidth, + required bool allowEmpty, + }) { + 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); + + if (availableWidth <= 0.0) { + return LineBreakResult.sameIndex( + allowEmpty ? start : start + 1, LineBreakType.prohibited); + } + + int low = start; + int high = end; + do { + final int mid = (low + high) ~/ 2; + final double width = _measure(start, mid); + if (width < availableWidth) { + low = mid; + } else if (width > availableWidth) { + high = mid; + } else { + low = high = mid; + } + } while (high - low > 1); + + if (low == start && !allowEmpty) { + low++; + } + return LineBreakResult.sameIndex(low, LineBreakType.prohibited); + } + double _measure(int start, int end) { assert(_currentSpan != null); final FlatTextSpan span = _currentSpan!; diff --git a/engine/src/flutter/lib/web_ui/test/text/layout_service_plain_test.dart b/engine/src/flutter/lib/web_ui/test/text/layout_service_plain_test.dart index 4f4760738c2..83138259bc5 100644 --- a/engine/src/flutter/lib/web_ui/test/text/layout_service_plain_test.dart +++ b/engine/src/flutter/lib/web_ui/test/text/layout_service_plain_test.dart @@ -12,8 +12,6 @@ import 'package:ui/ui.dart' as ui; import 'layout_service_helper.dart'; const bool skipForceBreak = true; -const bool skipOverflow = true; -const bool skipMaxLines = true; const bool skipTextAlign = true; const bool skipWordSpacing = true; @@ -443,7 +441,7 @@ void testMain() async { expectLines(paragraph, [ l('...', 0, 0, hardBreak: false, width: 30.0, left: 0.0), ]); - }, skip: skipOverflow); + }); test('respects max lines', () { final EngineParagraphStyle maxlinesStyle = EngineParagraphStyle( @@ -491,7 +489,7 @@ void testMain() async { l('AAA ', 0, 4, hardBreak: false, width: 30.0, left: 0.0), l('AAAAA', 4, 9, hardBreak: false, width: 50.0, left: 0.0), ]); - }, skip: skipMaxLines); + }); test('respects text overflow and max lines combined', () { final EngineParagraphStyle onelineStyle = EngineParagraphStyle( @@ -573,7 +571,7 @@ void testMain() async { 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), ]); - }, skip: skipOverflow || skipMaxLines); + }); test('handles textAlign', () { CanvasParagraph paragraph;