diff --git a/lib/web_ui/dev/goldens_lock.yaml b/lib/web_ui/dev/goldens_lock.yaml index 3ddfa4a9ca48c..b13434a53e5b7 100644 --- a/lib/web_ui/dev/goldens_lock.yaml +++ b/lib/web_ui/dev/goldens_lock.yaml @@ -1,2 +1,2 @@ repository: https://github.com/flutter/goldens.git -revision: bb55871d3803337053f7200b8690a4c1322e82ea +revision: 4b4c256d6124a135b70c1a9a7ff10cf2827df31c 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 5a47175da03e0..f264daf912325 100644 --- a/lib/web_ui/lib/src/engine/text/layout_service.dart +++ b/lib/web_ui/lib/src/engine/text/layout_service.dart @@ -78,7 +78,6 @@ class TextLayoutService { final Spanometer spanometer = Spanometer(paragraph, context); int spanIndex = 0; - ParagraphSpan span = paragraph.spans[0]; LineBuilder currentLine = LineBuilder.first(paragraph, spanometer, maxWidth: constraints.width); @@ -86,28 +85,33 @@ class TextLayoutService { // statements (e.g. when we reach `endOfText`, when ellipsis has been // appended). while (true) { - // *********************************************** // - // *** HANDLE HARD LINE BREAKS AND END OF TEXT *** // - // *********************************************** // - - if (currentLine.end.isHard) { - if (currentLine.isNotEmpty) { + // ************************** // + // *** HANDLE END OF TEXT *** // + // ************************** // + + // 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(); lines.add(currentLine.build()); - if (currentLine.end.type != LineBreakType.endOfText) { - currentLine = currentLine.nextLine(); - } - } - - if (currentLine.end.type == LineBreakType.endOfText) { - break; } + break; } // ********************************* // // *** THE MAIN MEASUREMENT PART *** // // ********************************* // - final isLastSpan = spanIndex == spanCount - 1; + final ParagraphSpan span = paragraph.spans[spanIndex]; if (span is PlaceholderSpan) { if (currentLine.widthIncludingSpace + span.width <= constraints.width) { @@ -121,11 +125,7 @@ class TextLayoutService { } currentLine.addPlaceholder(span); } - - if (isLastSpan) { - lines.add(currentLine.build()); - break; - } + spanIndex++; } else if (span is FlatTextSpan) { spanometer.currentSpan = span; final LineBreakResult nextBreak = currentLine.findNextBreak(span.end); @@ -138,6 +138,10 @@ class TextLayoutService { // 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 = @@ -165,6 +169,12 @@ class TextLayoutService { currentLine = currentLine.nextLine(); } } + + // 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}'); } @@ -172,16 +182,6 @@ class TextLayoutService { if (lines.length == maxLines) { break; } - - // ********************************************* // - // *** ADVANCE TO THE NEXT SPAN IF NECESSARY *** // - // ********************************************* // - - // Only go to the next span if we've reached the end of this span. - if (currentLine.end.index >= span.end && spanIndex < spanCount - 1) { - currentLine.createBox(); - span = paragraph.spans[++spanIndex]; - } } // ************************************************** // @@ -205,13 +205,16 @@ class TextLayoutService { // ******************************** // spanIndex = 0; - span = paragraph.spans[0]; currentLine = LineBuilder.first(paragraph, spanometer, maxWidth: constraints.width); - while (currentLine.end.type != LineBreakType.endOfText) { + while (spanIndex < spanCount) { + final ParagraphSpan span = paragraph.spans[spanIndex]; + bool breakToNextLine = false; + if (span is PlaceholderSpan) { currentLine.addPlaceholder(span); + spanIndex++; } else if (span is FlatTextSpan) { spanometer.currentSpan = span; final LineBreakResult nextBreak = currentLine.findNextBreak(span.end); @@ -219,6 +222,16 @@ class TextLayoutService { // 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; @@ -231,19 +244,9 @@ class TextLayoutService { maxIntrinsicWidth = currentLine.widthIncludingSpace; } - if (currentLine.end.isHard) { + if (breakToNextLine) { currentLine = currentLine.nextLine(); } - - // Only go to the next span if we've reached the end of this span. - if (currentLine.end.index >= span.end) { - if (spanIndex < spanCount - 1) { - span = paragraph.spans[++spanIndex]; - } else { - // We reached the end of the last span in the paragraph. - break; - } - } } } @@ -761,12 +764,19 @@ class LineBuilder { return widthOfTrailingSpace + spanometer.measure(end, newEnd); } + bool get _isLastBoxAPlaceholder { + if (_boxes.isEmpty) { + return false; + } + return (_boxes.last is PlaceholderBox); + } + /// Extends the line by setting a [newEnd]. void extendTo(LineBreakResult newEnd) { // If the current end of the line is a hard break, the line shouldn't be // extended any further. assert( - isEmpty || !end.isHard, + isEmpty || !end.isHard || _isLastBoxAPlaceholder, 'Cannot extend a line that ends with a hard break.', ); @@ -776,6 +786,28 @@ class LineBuilder { _addSegment(_createSegment(newEnd)); } + /// 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)); + } else { + end = endOfText; + } + } + void addPlaceholder(PlaceholderSpan placeholder) { // Increase the line's height to fit the placeholder, if necessary. final double ascent, descent; @@ -1024,7 +1056,7 @@ class LineBuilder { 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 == boxEnd) { + if (boxStart.index == boxEnd.index) { return; } @@ -1045,13 +1077,20 @@ class LineBuilder { 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; + } return EngineLineMetrics.rich( lineNumber, ellipsis: ellipsis, startIndex: start.index, endIndex: end.index, - endIndexWithoutNewlines: end.indexWithoutTrailingNewlines, - hardBreak: end.isHard, + endIndexWithoutNewlines: endIndexWithoutNewlines, + hardBreak: hardBreak, width: width + ellipsisWidth, widthWithTrailingSpaces: widthIncludingSpace + ellipsisWidth, left: alignOffset, @@ -1150,6 +1189,9 @@ class Spanometer { } } + /// Whether the spanometer is ready to take measurements. + bool get isReady => _currentSpan != null; + /// The distance from the top of the current span to the alphabetic baseline. double get ascent => _currentRuler!.alphabeticBaseline; diff --git a/lib/web_ui/test/golden_tests/engine/canvas_paragraph/placeholders_test.dart b/lib/web_ui/test/golden_tests/engine/canvas_paragraph/placeholders_test.dart index 45440b03dc3a9..a1764aa33ddd8 100644 --- a/lib/web_ui/test/golden_tests/engine/canvas_paragraph/placeholders_test.dart +++ b/lib/web_ui/test/golden_tests/engine/canvas_paragraph/placeholders_test.dart @@ -50,9 +50,7 @@ void testMain() async { canvas.drawParagraph(paragraph, offset); // Then fill the placeholders. - final TextBox placeholderBox = paragraph.getBoxesForPlaceholders().single; - final SurfacePaint redPaint = Paint()..color = red; - canvas.drawRect(placeholderBox.toRect().shift(offset), redPaint.paintData); + fillPlaceholder(canvas, offset, paragraph); offset = offset.translate(0.0, paragraph.height + 30.0); } @@ -86,9 +84,7 @@ void testMain() async { canvas.drawParagraph(paragraph, offset); // Then fill the placeholders. - final TextBox placeholderBox = paragraph.getBoxesForPlaceholders().single; - final SurfacePaint redPaint = Paint()..color = red; - canvas.drawRect(placeholderBox.toRect().shift(offset), redPaint.paintData); + fillPlaceholder(canvas, offset, paragraph); offset = offset.translate(0.0, paragraph.height + 30.0); } @@ -122,13 +118,89 @@ void testMain() async { canvas.drawParagraph(paragraph, offset); // Then fill the placeholders. - final TextBox placeholderBox = paragraph.getBoxesForPlaceholders().single; - final SurfacePaint redPaint = Paint()..color = red; - canvas.drawRect(placeholderBox.toRect().shift(offset), redPaint.paintData); + fillPlaceholder(canvas, offset, paragraph); offset = offset.translate(0.0, paragraph.height + 30.0); } return takeScreenshot(canvas, bounds, 'canvas_paragraph_placeholders_align_dom'); }); + + test('draws paragraphs starting or ending with a placeholder', () { + const Rect bounds = Rect.fromLTWH(0, 0, 420, 300); + final canvas = BitmapCanvas(bounds, RenderStrategy()); + + Offset offset = Offset(10, 10); + + // First paragraph with a placeholder at the beginning. + final CanvasParagraph paragraph1 = rich( + ParagraphStyle(fontFamily: 'Roboto', fontSize: 24.0, textAlign: TextAlign.center), + (builder) { + builder.addPlaceholder(80.0, 50.0, PlaceholderAlignment.baseline, baseline: TextBaseline.alphabetic); + builder.pushStyle(TextStyle(color: black)); + builder.addText(' Lorem ipsum.'); + }, + )..layout(constrain(400.0)); + + // Draw the paragraph. + canvas.drawParagraph(paragraph1, offset); + fillPlaceholder(canvas, offset, paragraph1); + surroundParagraph(canvas, offset, paragraph1); + + offset = offset.translate(0.0, paragraph1.height + 30.0); + + // Second paragraph with a placeholder at the end. + final CanvasParagraph paragraph2 = rich( + ParagraphStyle(fontFamily: 'Roboto', fontSize: 24.0, textAlign: TextAlign.center), + (builder) { + builder.pushStyle(TextStyle(color: black)); + builder.addText('Lorem ipsum '); + builder.addPlaceholder(80.0, 50.0, PlaceholderAlignment.baseline, baseline: TextBaseline.alphabetic); + }, + )..layout(constrain(400.0)); + + // Draw the paragraph. + canvas.drawParagraph(paragraph2, offset); + fillPlaceholder(canvas, offset, paragraph2); + surroundParagraph(canvas, offset, paragraph2); + + offset = offset.translate(0.0, paragraph2.height + 30.0); + + // Third paragraph with a placeholder alone in the second line. + final CanvasParagraph paragraph3 = rich( + ParagraphStyle(fontFamily: 'Roboto', fontSize: 24.0, textAlign: TextAlign.center), + (builder) { + builder.pushStyle(TextStyle(color: black)); + builder.addText('Lorem ipsum '); + builder.addPlaceholder(80.0, 50.0, PlaceholderAlignment.baseline, baseline: TextBaseline.alphabetic); + }, + )..layout(constrain(200.0)); + + // Draw the paragraph. + canvas.drawParagraph(paragraph3, offset); + fillPlaceholder(canvas, offset, paragraph3); + surroundParagraph(canvas, offset, paragraph3); + + return takeScreenshot(canvas, bounds, 'canvas_paragraph_placeholders_start_and_end'); + }); +} + +void surroundParagraph( + EngineCanvas canvas, + Offset offset, + CanvasParagraph paragraph, +) { + final Rect rect = offset & Size(paragraph.width, paragraph.height); + final SurfacePaint paint = Paint()..color = blue..style = PaintingStyle.stroke; + canvas.drawRect(rect, paint.paintData); +} + +void fillPlaceholder( + EngineCanvas canvas, + Offset offset, + CanvasParagraph paragraph, +) { + final TextBox placeholderBox = paragraph.getBoxesForPlaceholders().single; + final SurfacePaint paint = Paint()..color = red; + canvas.drawRect(placeholderBox.toRect().shift(offset), paint.paintData); } 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 5978658c21390..8ed16095a2692 100644 --- a/lib/web_ui/test/text/layout_service_rich_test.dart +++ b/lib/web_ui/test/text/layout_service_rich_test.dart @@ -164,7 +164,49 @@ void testMain() async { expect(paragraph.minIntrinsicWidth, 300.0); expect(paragraph.height, 50.0); expectLines(paragraph, [ - l('', 0, 0, hardBreak: false, width: 300.0, left: 100.0), + l('', 0, 0, hardBreak: true, width: 300.0, left: 100.0), + ]); + }); + + test('correct maxIntrinsicWidth when paragraph ends with placeholder', () { + final EngineParagraphStyle paragraphStyle = EngineParagraphStyle( + fontFamily: 'ahem', + fontSize: 10, + textAlign: ui.TextAlign.center, + ); + final CanvasParagraph paragraph = rich(paragraphStyle, (builder) { + builder.addText('abcd'); + builder.addPlaceholder(300.0, 50.0, ui.PlaceholderAlignment.bottom); + })..layout(constrain(400.0)); + + expect(paragraph.maxIntrinsicWidth, 340.0); + expect(paragraph.minIntrinsicWidth, 300.0); + expect(paragraph.height, 50.0); + expectLines(paragraph, [ + l('abcd', 0, 4, hardBreak: true, width: 340.0, left: 30.0), + ]); + }); + + test('handles new line followed by a placeholder', () { + final EngineParagraphStyle paragraphStyle = EngineParagraphStyle( + fontFamily: 'ahem', + fontSize: 10, + textAlign: ui.TextAlign.center, + ); + final CanvasParagraph paragraph = rich(paragraphStyle, (builder) { + builder.addText('Lorem\n'); + builder.addPlaceholder(300.0, 40.0, ui.PlaceholderAlignment.bottom); + builder.addText('ipsum'); + })..layout(constrain(300.0)); + + // The placeholder's width + "ipsum" + expect(paragraph.maxIntrinsicWidth, 300.0 + 50.0); + expect(paragraph.minIntrinsicWidth, 300.0); + expect(paragraph.height, 10.0 + 40.0 + 10.0); + expectLines(paragraph, [ + 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), ]); }); }