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 03d8175bee1de..8024981a0aa3f 100644 --- a/lib/web_ui/lib/src/engine/text/canvas_paragraph.dart +++ b/lib/web_ui/lib/src/engine/text/canvas_paragraph.dart @@ -191,15 +191,7 @@ class CanvasParagraph implements EngineParagraph { ui.BoxHeightStyle boxHeightStyle = ui.BoxHeightStyle.tight, ui.BoxWidthStyle boxWidthStyle = ui.BoxWidthStyle.tight, }) { - // TODO(mdebbar): After layout, each paragraph span should have info about - // its position and dimensions. - // - // 1. Find the spans where the `start` and `end` indices fall. - // 2. If it's the same span, find the sub-box from `start` to `end`. - // 3. Else, find the trailing box(es) of the `start` span, and the `leading` - // box(es) of the `end` span. - // 4. Include the boxes of all the spans in between. - return []; + return _layoutService.getBoxesForRange(start, end, boxHeightStyle, boxWidthStyle); } @override 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 39ecfa494672f..53e3c49c1dc57 100644 --- a/lib/web_ui/lib/src/engine/text/layout_service.dart +++ b/lib/web_ui/lib/src/engine/text/layout_service.dart @@ -111,12 +111,14 @@ class TextLayoutService { // TODO(mdebbar): // (1) adjust the current line's height to fit the placeholder. // (2) update accumulated line width. + // (3) add placeholder box to line. } 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. } } else if (span is FlatTextSpan) { spanometer.currentSpan = span; @@ -171,6 +173,7 @@ class TextLayoutService { // 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]; } } @@ -229,6 +232,121 @@ class TextLayoutService { } } } + + List getBoxesForRange( + int start, + int end, + ui.BoxHeightStyle boxHeightStyle, + ui.BoxWidthStyle boxWidthStyle, + ) { + // Zero-length ranges and invalid ranges return an empty list. + if (start >= end || start < 0 || end < 0) { + return []; + } + + final int length = paragraph.toPlainText().length; + // Ranges that are out of bounds should return an empty list. + if (start > length || end > length) { + return []; + } + + final List boxes = []; + + for (final EngineLineMetrics line in lines) { + if (line.overlapsWith(start, end)) { + for (final RangeBox box in line.boxes!) { + if (box.overlapsWith(start, end)) { + boxes.add(box.intersect(line, start, end)); + } + } + } + } + return boxes; + } +} + +/// Represents a box inside [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. +class RangeBox { + RangeBox.fromSpanometer( + this.spanometer, { + required this.start, + required this.end, + required this.left, + }) : span = spanometer.currentSpan, + height = spanometer.height, + baseline = spanometer.alphabeticBaseline, + width = spanometer.measureIncludingSpace(start, end); + + final Spanometer spanometer; + final ParagraphSpan span; + final LineBreakResult start; + final LineBreakResult end; + + /// The distance from the left edge of the line to the left edge of the box. + final double left; + + /// The distance from the left edge to the right edge of the box. + final double width; + + /// 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; + + /// The direction in which text inside this box flows. + ui.TextDirection get direction => + spanometer.paragraph.paragraphStyle._effectiveTextDirection; + + /// The distance from the left edge of the line to the right edge of the box. + double get right => left + width; + + /// Whether this box's range overlaps with the range from [startIndex] to + /// [endIndex]. + bool overlapsWith(int startIndex, int endIndex) { + return startIndex < this.end.index && this.start.index < endIndex; + } + + /// 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(EngineLineMetrics line, int start, int end) { + final double top = line.baseline - baseline; + final double left, right; + + if (start <= this.start.index) { + left = this.left; + } else { + spanometer.currentSpan = span as FlatTextSpan; + left = this.left + spanometer._measure(this.start.index, start); + } + + if (end >= this.end.indexWithoutTrailingNewlines) { + right = this.right; + } else { + spanometer.currentSpan = span as FlatTextSpan; + right = this.right - + spanometer._measure(end, this.end.indexWithoutTrailingNewlines); + } + + // 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( + left + line.left, + top, + right + line.left, + top + height, + direction, + ); + } } /// Represents a segment in a line of a paragraph. @@ -310,6 +428,7 @@ class LineBuilder { } final List _segments = []; + final List _boxes = []; final double maxWidth; final CanvasParagraph paragraph; @@ -398,7 +517,7 @@ class LineBuilder { // The segment starts at the end of the line. final LineBreakResult segmentStart = end; return LineSegment( - span: spanometer.currentSpan!, + span: spanometer.currentSpan, start: segmentStart, end: segmentEnd, width: spanometer.measure(segmentStart, segmentEnd), @@ -542,8 +661,53 @@ class LineBuilder { LineBreakResult.sameIndex(breakingPoint, LineBreakType.prohibited)); } + LineBreakResult get _boxStart { + if (_boxes.isEmpty) { + return start; + } + // The end of the last box is the start of the new box. + return _boxes.last.end; + } + + double get _boxLeft { + if (_boxes.isEmpty) { + return 0.0; + } + return _boxes.last.right; + } + + ui.TextDirection get direction => + paragraph.paragraphStyle._effectiveTextDirection; + + /// Cuts a new box in the line. + /// + /// 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, or when switching + /// from one span to another. + void createBox() { + final LineBreakResult boxStart = _boxStart; + 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) { + return; + } + + _boxes.add(RangeBox.fromSpanometer( + spanometer, + start: boxStart, + end: boxEnd, + left: _boxLeft, + )); + } + /// Builds the [EngineLineMetrics] instance that represents this line. EngineLineMetrics 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); @@ -559,6 +723,7 @@ class LineBuilder { left: alignOffset, height: height, baseline: accumulatedHeight + alphabeticBaseline, + boxes: _boxes, ); } @@ -601,12 +766,12 @@ class Spanometer { String _cssFontString = ''; - double? get letterSpacing => _currentSpan!.style._letterSpacing; + double? get letterSpacing => currentSpan.style._letterSpacing; TextHeightRuler? _currentRuler; FlatTextSpan? _currentSpan; - FlatTextSpan? get currentSpan => _currentSpan; + FlatTextSpan get currentSpan => _currentSpan!; set currentSpan(FlatTextSpan? span) { if (span == _currentSpan) { return; @@ -681,7 +846,7 @@ class Spanometer { }) { assert(_currentSpan != null); - final FlatTextSpan span = _currentSpan!; + final FlatTextSpan span = currentSpan; // Make sure the range is within the current span. assert(start >= span.start && start <= span.end); @@ -713,7 +878,7 @@ class Spanometer { double _measure(int start, int end) { assert(_currentSpan != null); - final FlatTextSpan span = _currentSpan!; + final FlatTextSpan span = currentSpan; // Make sure the range is within the current span. assert(start >= span.start && start <= span.end); diff --git a/lib/web_ui/lib/src/engine/text/paragraph.dart b/lib/web_ui/lib/src/engine/text/paragraph.dart index 34dde2c94e748..eb8b828864487 100644 --- a/lib/web_ui/lib/src/engine/text/paragraph.dart +++ b/lib/web_ui/lib/src/engine/text/paragraph.dart @@ -25,7 +25,8 @@ class EngineLineMetrics implements ui.LineMetrics { startIndex = -1, endIndex = -1, endIndexWithoutNewlines = -1, - widthWithTrailingSpaces = width; + widthWithTrailingSpaces = width, + boxes = null; EngineLineMetrics.withText( String this.displayText, { @@ -50,7 +51,8 @@ class EngineLineMetrics implements ui.LineMetrics { descent = double.infinity, unscaledAscent = double.infinity, height = double.infinity, - baseline = double.infinity; + baseline = double.infinity, + boxes = null; EngineLineMetrics.rich( this.lineNumber, { @@ -64,10 +66,14 @@ class EngineLineMetrics implements ui.LineMetrics { required this.left, required this.height, required this.baseline, + // 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; + unscaledAscent = double.infinity, + this.boxes = boxes; /// The text to be rendered on the screen representing this line. final String? displayText; @@ -91,6 +97,10 @@ class EngineLineMetrics implements ui.LineMetrics { /// characters. final int endIndexWithoutNewlines; + /// The list of boxes representing the entire line, possibly across multiple + /// spans. + final List? boxes; + @override final bool hardBreak; @@ -130,6 +140,10 @@ class EngineLineMetrics implements ui.LineMetrics { @override final int lineNumber; + bool overlapsWith(int startIndex, int endIndex) { + return startIndex < this.endIndex && this.startIndex < endIndex; + } + @override int get hashCode => ui.hashValues( displayText, diff --git a/lib/web_ui/test/text/canvas_paragraph_test.dart b/lib/web_ui/test/text/canvas_paragraph_test.dart new file mode 100644 index 0000000000000..db1b2d9110bb4 --- /dev/null +++ b/lib/web_ui/test/text/canvas_paragraph_test.dart @@ -0,0 +1,321 @@ +// 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.12 +import 'package:test/bootstrap/browser.dart'; +import 'package:test/test.dart'; +import 'package:ui/src/engine.dart'; +import 'package:ui/ui.dart' as ui; + +const ui.Color white = ui.Color(0xFFFFFFFF); +const ui.Color black = ui.Color(0xFF000000); +const ui.Color red = ui.Color(0xFFFF0000); +const ui.Color green = ui.Color(0xFF00FF00); +const ui.Color blue = ui.Color(0xFF0000FF); + +final EngineParagraphStyle ahemStyle = EngineParagraphStyle( + fontFamily: 'ahem', + fontSize: 10, +); + +ui.ParagraphConstraints constrain(double width) { + return ui.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 { + await ui.webOnlyInitializeTestDomRenderer(); + + group('$CanvasParagraph.getBoxesForRange', () { + test('return empty list for invalid ranges', () { + final CanvasParagraph paragraph = rich(ahemStyle, (builder) { + builder.addText('Lorem ipsum'); + }) + ..layout(constrain(double.infinity)); + + expect(paragraph.getBoxesForRange(-1, 0), []); + expect(paragraph.getBoxesForRange(0, 0), []); + expect(paragraph.getBoxesForRange(11, 11), []); + expect(paragraph.getBoxesForRange(11, 12), []); + expect(paragraph.getBoxesForRange(4, 3), []); + }); + + test('handles single-line multi-span paragraphs', () { + final CanvasParagraph paragraph = rich(ahemStyle, (builder) { + builder.pushStyle(EngineTextStyle.only(color: blue)); + builder.addText('Lorem '); + builder.pushStyle(EngineTextStyle.only(color: green)); + builder.addText('ipsum '); + builder.pop(); + builder.addText('.'); + }) + ..layout(constrain(double.infinity)); + + // Within the first span "Lorem ". + + expect( + // "or" + paragraph.getBoxesForRange(1, 3), + [ + box(10, 0, 30, 10), + ], + ); + expect( + // "Lorem" + paragraph.getBoxesForRange(0, 5), + [ + box(0, 0, 50, 10), + ], + ); + // Make sure the trailing space is also included in the box. + expect( + // "Lorem " + paragraph.getBoxesForRange(0, 6), + [ + box(0, 0, 60, 10), + ], + ); + + // Within the second span "ipsum ". + + expect( + // "psum" + paragraph.getBoxesForRange(7, 11), + [ + box(70, 0, 110, 10), + ], + ); + expect( + // "um " + paragraph.getBoxesForRange(9, 12), + [ + box(90, 0, 120, 10), + ], + ); + + // Across the two spans "Lorem " and "ipsum ". + + expect( + // "rem ipsum" + paragraph.getBoxesForRange(2, 11), + [ + box(20, 0, 60, 10), + box(60, 0, 110, 10), + ], + ); + + // Across all spans "Lorem ", "ipsum ", ".". + + expect( + // "Lorem ipsum." + paragraph.getBoxesForRange(0, 13), + [ + box(0, 0, 60, 10), + box(60, 0, 120, 10), + box(120, 0, 130, 10), + ], + ); + }); + + test('handles multi-line single-span paragraphs', () { + final CanvasParagraph paragraph = rich(ahemStyle, (builder) { + builder.addText('Lorem ipsum dolor sit'); + }) + ..layout(constrain(90.0)); + + // Lines: + // "Lorem " + // "ipsum " + // "dolor sit" + + // Within the first line "Lorem ". + + expect( + // "or" + paragraph.getBoxesForRange(1, 3), + [ + box(10, 0, 30, 10), + ], + ); + // Make sure the trailing space at the end of line is also included in the + // box. + expect( + // "Lorem " + paragraph.getBoxesForRange(0, 6), + [ + box(0, 0, 60, 10), + ], + ); + + // Within the second line "ipsum ". + + expect( + // "psum " + paragraph.getBoxesForRange(7, 12), + [ + box(10, 10, 60, 20), + ], + ); + + // Across all lines. + + expect( + // "em " + // "ipsum " + // "dolor s" + paragraph.getBoxesForRange(3, 19), + [ + box(30, 0, 60, 10), + box(0, 10, 60, 20), + box(0, 20, 70, 30), + ], + ); + }); + + test('handles multi-line multi-span paragraphs', () { + final CanvasParagraph paragraph = rich(ahemStyle, (builder) { + builder.pushStyle(EngineTextStyle.only(color: blue)); + builder.addText('Lorem ipsum '); + builder.pushStyle(EngineTextStyle.only(color: green)); + builder.addText('dolor '); + builder.pop(); + builder.addText('sit'); + }) + ..layout(constrain(90.0)); + + // Lines: + // "Lorem " + // "ipsum " + // "dolor sit" + + // Within the first line "Lorem ". + + expect( + // "ore" + paragraph.getBoxesForRange(1, 4), + [ + box(10, 0, 40, 10), + ], + ); + expect( + // "Lorem " + paragraph.getBoxesForRange(0, 6), + [ + box(0, 0, 60, 10), + ], + ); + + // Within the second line "ipsum ". + + expect( + // "psum " + paragraph.getBoxesForRange(7, 12), + [ + box(10, 10, 60, 20), + ], + ); + + // Within the third line "dolor sit" which is made of 2 spans. + + expect( + // "lor sit" + paragraph.getBoxesForRange(14, 21), + [ + box(20, 20, 60, 30), + box(60, 20, 90, 30), + ], + ); + + // Across all lines. + + expect( + // "em " + // "ipsum " + // "dolor s" + paragraph.getBoxesForRange(3, 19), + [ + box(30, 0, 60, 10), + box(0, 10, 60, 20), + box(0, 20, 60, 30), + box(60, 20, 70, 30), + ], + ); + }); + + test('handles spans with varying heights/baselines', () { + final CanvasParagraph paragraph = rich(ahemStyle, (builder) { + builder.pushStyle(EngineTextStyle.only(fontSize: 20.0)); + // width = 20.0 * 6 = 120.0 + // baseline = 20.0 * 80% = 16.0 + builder.addText('Lorem '); + builder.pushStyle(EngineTextStyle.only(fontSize: 40.0)); + // width = 40.0 * 6 = 240.0 + // baseline = 40.0 * 80% = 32.0 + builder.addText('ipsum '); + builder.pushStyle(EngineTextStyle.only(fontSize: 10.0)); + // width = 10.0 * 6 = 60.0 + // baseline = 10.0 * 80% = 8.0 + builder.addText('dolor '); + builder.pushStyle(EngineTextStyle.only(fontSize: 30.0)); + // width = 30.0 * 4 = 120.0 + // baseline = 30.0 * 80% = 24.0 + builder.addText('sit '); + builder.pushStyle(EngineTextStyle.only(fontSize: 20.0)); + // width = 20.0 * 4 = 80.0 + // baseline = 20.0 * 80% = 16.0 + builder.addText('amet'); + }) + ..layout(constrain(420.0)); + + // Lines: + // "Lorem ipsum dolor " (width: 420, height: 40, baseline: 32) + // "sit amet" (width: 200, height: 30, baseline: 24) + + expect( + // "em ipsum dol" + paragraph.getBoxesForRange(3, 15), + [ + box(60, 16, 120, 36), + box(120, 0, 360, 40), + box(360, 24, 390, 34), + ], + ); + + expect( + // "sum dolor " + // "sit amet" + paragraph.getBoxesForRange(8, 26), + [ + box(200, 0, 360, 40), + box(360, 24, 420, 34), + box(0, 40, 120, 70), + box(120, 48, 200, 68), + ], + ); + }); + }); +} + +/// Shortcut to create a [ui.TextBox] with an optional [ui.TextDirection]. +ui.TextBox box( + double left, + double top, + double right, + double bottom, [ + ui.TextDirection direction = ui.TextDirection.ltr, +]) { + return ui.TextBox.fromLTRBD(left, top, right, bottom, direction); +}