diff --git a/lib/web_ui/dev/goldens_lock.yaml b/lib/web_ui/dev/goldens_lock.yaml index 67ef9b56c5e11..cff7a5f43af5b 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: 4946ab2de031c14d30502efcaf51220e0be4d1f1 +revision: 7529e9018b11c79334b99d1e7343fcd500c77b08 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 5284d63ed5f6a..f61ca0c01f08f 100644 --- a/lib/web_ui/lib/src/engine/text/canvas_paragraph.dart +++ b/lib/web_ui/lib/src/engine/text/canvas_paragraph.dart @@ -170,9 +170,7 @@ class CanvasParagraph implements EngineParagraph { @override List getBoxesForPlaceholders() { - // TODO(mdebbar): After layout, placeholders positions should've been - // determined and can be used to compute their boxes. - return []; + return _layoutService.getBoxesForPlaceholders(); } // TODO(mdebbar): Check for child spans if any has styles that can't be drawn 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 cbb927b4bd81f..30688013acb70 100644 --- a/lib/web_ui/lib/src/engine/text/layout_service.dart +++ b/lib/web_ui/lib/src/engine/text/layout_service.dart @@ -104,21 +104,16 @@ class TextLayoutService { // ********************************* // if (span is PlaceholderSpan) { - spanometer.currentSpan = null; - final double lineWidth = currentLine.width + span.width; - if (lineWidth <= constraints.width) { + if (currentLine.widthIncludingSpace + span.width <= constraints.width) { // The placeholder fits on the current line. - // TODO(mdebbar): - // (1) adjust the current line's height to fit the placeholder. - // (2) update accumulated line width. - // (3) add placeholder box to line. + currentLine.addPlaceholder(span); } else { // The placeholder can't fit on the current line. - // TODO(mdebbar): - // (1) create a line. - // (2) adjust the new line's height to fit the placeholder. - // (3) update `lineStart`, etc. - // (4) add placeholder box to line. + if (currentLine.isNotEmpty) { + lines.add(currentLine.build()); + currentLine = currentLine.nextLine(); + } + currentLine.addPlaceholder(span); } } else if (span is FlatTextSpan) { spanometer.currentSpan = span; @@ -203,7 +198,7 @@ class TextLayoutService { while (currentLine.end.type != LineBreakType.endOfText) { if (span is PlaceholderSpan) { - // TODO(mdebbar): Do placeholders affect min/max intrinsic width? + currentLine.addPlaceholder(span); } else if (span is FlatTextSpan) { spanometer.currentSpan = span; final LineBreakResult nextBreak = currentLine.findNextBreak(span.end); @@ -211,26 +206,38 @@ 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); + } - final double widthOfLastSegment = currentLine.lastSegment.width; - if (minIntrinsicWidth < widthOfLastSegment) { - minIntrinsicWidth = widthOfLastSegment; - } + 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(); + 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]; + // 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]; + } + } + } + + List getBoxesForPlaceholders() { + final List boxes = []; + for (final EngineLineMetrics line in lines) { + for (final RangeBox box in line.boxes!) { + if (box is PlaceholderBox) { + boxes.add(box.toTextBox(line)); } } } + return boxes; } List getBoxesForRange( @@ -255,7 +262,7 @@ class TextLayoutService { for (final EngineLineMetrics line in lines) { if (line.overlapsWith(start, end)) { for (final RangeBox box in line.boxes!) { - if (box.overlapsWith(start, end)) { + if (box is SpanBox && box.overlapsWith(start, end)) { boxes.add(box.intersect(line, start, end)); } } @@ -328,23 +335,133 @@ class TextLayoutService { /// 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. -class RangeBox { - RangeBox.fromSpanometer( - this.spanometer, { +abstract class RangeBox { + LineBreakResult get start; + LineBreakResult get end; + + /// The distance from the left edge of the line to the left edge of the box. + double get left; + + /// The distance from the left edge of the line to the right edge of the box. + double get right; + + /// The direction in which text inside this box flows. + ui.TextDirection get direction; + + /// 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. + ui.TextBox toTextBox(EngineLineMetrics line); + + /// 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 this.left, + required this.direction, + }) : start = index, end = index; + + final PlaceholderSpan placeholder; + + @override + final LineBreakResult start; + + @override + final LineBreakResult end; + + @override + final double left; + + @override + double get right => left + placeholder.width; + + @override + final ui.TextDirection direction; + + ui.TextBox toTextBox(EngineLineMetrics line) { + 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, + direction, + ); + } + + @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( + Spanometer spanometer, { required this.start, required this.end, required this.left, - }) : span = spanometer.currentSpan, + required this.direction, + }) : this.spanometer = spanometer, + span = spanometer.currentSpan, height = spanometer.height, - baseline = spanometer.alphabeticBaseline, + baseline = spanometer.ascent, width = spanometer.measureIncludingSpace(start, end); + final Spanometer spanometer; - final ParagraphSpan span; + final FlatTextSpan span; final LineBreakResult start; final LineBreakResult end; - /// The distance from the left edge of the line to the left edge of the box. + @override final double left; /// The distance from the left edge to the right edge of the box. @@ -357,11 +474,10 @@ class RangeBox { /// the box. final double baseline; - /// The direction in which text inside this box flows. - ui.TextDirection get direction => - spanometer.paragraph.paragraphStyle._effectiveTextDirection; + @override + final ui.TextDirection direction; - /// The distance from the left edge of the line to the right edge of the box. + @override double get right => left + width; /// Whether this box's range overlaps with the range from [startIndex] to @@ -390,14 +506,14 @@ class RangeBox { if (start <= this.start.index) { left = this.left; } else { - spanometer.currentSpan = span as FlatTextSpan; + spanometer.currentSpan = span; left = this.left + spanometer._measure(this.start.index, start); } if (end >= this.end.indexWithoutTrailingNewlines) { right = this.right; } else { - spanometer.currentSpan = span as FlatTextSpan; + spanometer.currentSpan = span; right = this.right - spanometer._measure(end, this.end.indexWithoutTrailingNewlines); } @@ -414,13 +530,9 @@ class RangeBox { ); } - /// 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. + @override ui.TextPosition getPositionForX(double x) { - spanometer.currentSpan = span as FlatTextSpan; + spanometer.currentSpan = span; // Make `x` relative to this box. x -= left; @@ -572,11 +684,14 @@ class LineBuilder { /// The width of trailing white space in the line. double get widthOfTrailingSpace => widthIncludingSpace - width; - /// The alphabetic baseline of the line so far. - double alphabeticBaseline = 0.0; + /// The distance from the top of the line to the alphabetic baseline. + double ascent = 0.0; + + /// The distance from the bottom of the line to the alphabetic baseline. + double descent = 0.0; /// The height of the line so far. - double height = 0.0; + double get height => ascent + descent; /// The last segment in this line. LineSegment get lastSegment => _segments.last; @@ -626,13 +741,75 @@ class LineBuilder { 'Cannot extend a line that ends with a hard break.', ); - alphabeticBaseline = - math.max(alphabeticBaseline, spanometer.alphabeticBaseline); - height = math.max(height, spanometer.height); + ascent = math.max(ascent, spanometer.ascent); + descent = math.max(descent, spanometer.descent); _addSegment(_createSegment(newEnd)); } + void addPlaceholder(PlaceholderSpan placeholder) { + // Increase the line's height to fit the placeholder, if necessary. + final double ascent, descent; + switch (placeholder.alignment) { + case ui.PlaceholderAlignment.top: + // The placeholder is aligned to the top of text, which means it has the + // same `ascent` as the remaining text. We only need to extend the + // `descent` enough to fit the placeholder. + ascent = this.ascent; + descent = placeholder.height - this.ascent; + break; + + case ui.PlaceholderAlignment.bottom: + // The opposite of `top`. The `descent` is the same, but we extend the + // `ascent`. + ascent = placeholder.height - this.descent; + descent = this.descent; + break; + + case ui.PlaceholderAlignment.middle: + final double textMidPoint = this.height / 2; + final double placeholderMidPoint = placeholder.height / 2; + final double diff = placeholderMidPoint - textMidPoint; + ascent = this.ascent + diff; + descent = this.descent + diff; + break; + + case ui.PlaceholderAlignment.aboveBaseline: + ascent = placeholder.height; + descent = 0.0; + break; + + case ui.PlaceholderAlignment.belowBaseline: + ascent = 0.0; + descent = placeholder.height; + break; + + case ui.PlaceholderAlignment.baseline: + ascent = placeholder.baselineOffset; + descent = placeholder.height - ascent; + 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: _boxStart, + left: _boxLeft, + direction: paragraph.paragraphStyle._effectiveTextDirection, + )); + } + /// 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. @@ -822,11 +999,12 @@ class LineBuilder { return; } - _boxes.add(RangeBox.fromSpanometer( + _boxes.add(SpanBox( spanometer, start: boxStart, end: boxEnd, left: _boxLeft, + direction: paragraph.paragraphStyle._effectiveTextDirection, )); } @@ -849,7 +1027,9 @@ class LineBuilder { widthWithTrailingSpaces: widthIncludingSpace + ellipsisWidth, left: alignOffset, height: height, - baseline: accumulatedHeight + alphabeticBaseline, + baseline: accumulatedHeight + ascent, + ascent: ascent, + descent: descent, boxes: _boxes, ); } @@ -929,8 +1109,11 @@ class Spanometer { } } - /// The alphabetic baseline for the current span. - double get alphabeticBaseline => _currentRuler!.alphabeticBaseline; + /// The distance from the top of the current span to the alphabetic baseline. + double get ascent => _currentRuler!.alphabeticBaseline; + + /// The distance from the bottom of the current span to the alphabetic baseline. + double get descent => height - ascent; /// The line height of the current span. double get height => _currentRuler!.height; 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 d861257e5182b..f5d537cd47a5a 100644 --- a/lib/web_ui/lib/src/engine/text/paint_service.dart +++ b/lib/web_ui/lib/src/engine/text/paint_service.dart @@ -30,11 +30,11 @@ class TextPaintService { EngineLineMetrics line, RangeBox box, ) { - final ParagraphSpan span = box.span; - // Placeholder spans don't need any painting. Their boxes should remain // empty so that their underlying widgets do their own painting. - if (span is FlatTextSpan) { + if (box is SpanBox) { + final FlatTextSpan span = box.span; + // Paint the background of the box, if the span has a background. final SurfacePaint? background = span.style._background as SurfacePaint?; if (background != null) { diff --git a/lib/web_ui/lib/src/engine/text/paragraph.dart b/lib/web_ui/lib/src/engine/text/paragraph.dart index 614709ad890e9..67fec31b85de6 100644 --- a/lib/web_ui/lib/src/engine/text/paragraph.dart +++ b/lib/web_ui/lib/src/engine/text/paragraph.dart @@ -66,12 +66,12 @@ class EngineLineMetrics implements ui.LineMetrics { required this.left, required this.height, required this.baseline, + required this.ascent, + required this.descent, // Didn't use `this.boxes` because we want it to be non-null in this // constructor. required List boxes, }) : displayText = null, - ascent = double.infinity, - descent = double.infinity, unscaledAscent = double.infinity, this.boxes = boxes; 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 new file mode 100644 index 0000000000000..3db635d91eacb --- /dev/null +++ b/lib/web_ui/test/golden_tests/engine/canvas_paragraph/placeholders_test.dart @@ -0,0 +1,117 @@ +// 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. + +// @dart = 2.6 +import 'dart:async'; + +import 'package:test/bootstrap/browser.dart'; +import 'package:test/test.dart'; +import 'package:ui/ui.dart' hide window; +import 'package:ui/src/engine.dart'; + +import '../scuba.dart'; +import 'helper.dart'; + +typedef CanvasTest = FutureOr Function(EngineCanvas canvas); + +const Rect bounds = Rect.fromLTWH(0, 0, 800, 600); + +const Color white = Color(0xFFFFFFFF); +const Color black = Color(0xFF000000); +const Color red = Color(0xFFFF0000); +const Color green = Color(0xFF00FF00); +const Color blue = Color(0xFF0000FF); + +ParagraphConstraints constrain(double width) { + return ParagraphConstraints(width: width); +} + +CanvasParagraph rich( + EngineParagraphStyle style, + void Function(CanvasParagraphBuilder) callback, +) { + final CanvasParagraphBuilder builder = CanvasParagraphBuilder(style); + callback(builder); + return builder.build(); +} + +void main() { + internalBootstrapBrowserTest(() => testMain); +} + +void testMain() async { + setUpStableTestFonts(); + + test('draws paragraphs with placeholders', () { + final canvas = BitmapCanvas(bounds, RenderStrategy()); + + Offset offset = Offset.zero; + for (PlaceholderAlignment placeholderAlignment in PlaceholderAlignment.values) { + final CanvasParagraph paragraph = rich( + ParagraphStyle(fontFamily: 'Roboto', fontSize: 14.0), + (builder) { + builder.pushStyle(TextStyle(color: black)); + builder.addText('Lorem ipsum'); + builder.addPlaceholder( + 80.0, + 50.0, + placeholderAlignment, + baselineOffset: 40.0, + baseline: TextBaseline.alphabetic, + ); + builder.pushStyle(TextStyle(color: blue)); + builder.addText('dolor sit amet, consecteur.'); + }, + )..layout(constrain(200.0)); + + // Draw the paragraph. + 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); + + offset = offset.translate(0.0, paragraph.height + 30.0); + } + + return takeScreenshot(canvas, bounds, 'canvas_paragraph_placeholders'); + }); + + test('draws paragraphs with placeholders and text align', () { + final canvas = BitmapCanvas(bounds, RenderStrategy()); + + const List aligns = [ + TextAlign.left, + TextAlign.center, + TextAlign.right, + ]; + + Offset offset = Offset.zero; + for (TextAlign align in aligns) { + final CanvasParagraph paragraph = rich( + ParagraphStyle(fontFamily: 'Roboto', fontSize: 14.0, textAlign: align), + (builder) { + builder.pushStyle(TextStyle(color: black)); + builder.addText('Lorem'); + builder.addPlaceholder(80.0, 50.0, PlaceholderAlignment.bottom); + builder.pushStyle(TextStyle(color: blue)); + builder.addText('ipsum.'); + }, + )..layout(constrain(200.0)); + + // Draw the paragraph. + 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); + + offset = offset.translate(0.0, paragraph.height + 30.0); + } + + return takeScreenshot(canvas, bounds, 'canvas_paragraph_placeholders_align'); + }); +}