From d4425b0ef6c93f1d51bac1221f143f0c085f8890 Mon Sep 17 00:00:00 2001 From: Mouad Debbar Date: Tue, 15 Dec 2020 13:42:42 -0800 Subject: [PATCH 1/2] [web] Rich paragraph getPositionForOffset --- .../lib/src/engine/text/canvas_paragraph.dart | 5 +- .../lib/src/engine/text/layout_service.dart | 106 +++++++++ .../test/text/canvas_paragraph_test.dart | 225 ++++++++++++++++++ 3 files changed, 332 insertions(+), 4 deletions(-) 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 58b10a72dd695..ca8c7a9e09260 100644 --- a/lib/web_ui/lib/src/engine/text/canvas_paragraph.dart +++ b/lib/web_ui/lib/src/engine/text/canvas_paragraph.dart @@ -197,10 +197,7 @@ class CanvasParagraph implements EngineParagraph { @override ui.TextPosition getPositionForOffset(ui.Offset offset) { - // TODO(mdebbar): After layout, each paragraph span should have info about - // its position and dimensions. Use that information to find which span the - // offset belongs to, then search within that span for the exact character. - return const ui.TextPosition(offset: 0); + return _layoutService.getPositionForOffset(offset); } @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 53e3c49c1dc57..370c14a4a954c 100644 --- a/lib/web_ui/lib/src/engine/text/layout_service.dart +++ b/lib/web_ui/lib/src/engine/text/layout_service.dart @@ -263,6 +263,57 @@ class TextLayoutService { } return boxes; } + + ui.TextPosition getPositionForOffset(ui.Offset offset) { + // [offset] is above all the lines. + if (offset.dy < 0) { + return ui.TextPosition(offset: 0, affinity: ui.TextAffinity.downstream); + } + + // [offset] is below all the lines. + if (offset.dy >= paragraph.height) { + return ui.TextPosition( + offset: paragraph.toPlainText().length, + affinity: ui.TextAffinity.upstream, + ); + } + + final EngineLineMetrics line = _findLineForY(offset.dy); + // [offset] is to the left of the line. + if (offset.dx <= line.left) { + return ui.TextPosition( + offset: line.startIndex, + affinity: ui.TextAffinity.downstream, + ); + } + + // [offset] is to the right of the line. + if (offset.dx >= line.left + line.widthWithTrailingSpaces) { + return ui.TextPosition( + offset: line.endIndexWithoutNewlines, + 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); + } + } + // Is this ever reachable? + return ui.TextPosition(offset: line.startIndex); + } + + EngineLineMetrics _findLineForY(double y) { + for (EngineLineMetrics line in lines) { + if (y <= line.height) { + return line; + } + y -= line.height; + } + return lines.last; + } } /// Represents a box inside [span] with the range of [start] to [end]. @@ -347,6 +398,61 @@ class RangeBox { direction, ); } + + /// 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) { + spanometer.currentSpan = span as FlatTextSpan; + + // Make `x` relative to this box. + x -= left; + + 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(start.index, cutoff); + final double highWidth = spanometer._measure(start.index, 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, + affinity: ui.TextAffinity.downstream, + ); + } 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. diff --git a/lib/web_ui/test/text/canvas_paragraph_test.dart b/lib/web_ui/test/text/canvas_paragraph_test.dart index db1b2d9110bb4..0bc87992f861f 100644 --- a/lib/web_ui/test/text/canvas_paragraph_test.dart +++ b/lib/web_ui/test/text/canvas_paragraph_test.dart @@ -307,6 +307,226 @@ void testMain() async { ); }); }); + + group('$CanvasParagraph.getPositionForOffset', () { + 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)); + + // Above the line. + expect( + paragraph.getPositionForOffset(ui.Offset(0, -5)), + pos(0, ui.TextAffinity.downstream), + ); + // At the beginning of the line. + expect( + paragraph.getPositionForOffset(ui.Offset(0, 5)), + pos(0, ui.TextAffinity.downstream), + ); + // Below the line. + expect( + paragraph.getPositionForOffset(ui.Offset(0, 12)), + pos(13, ui.TextAffinity.upstream), + ); + // At the end of the line. + expect( + paragraph.getPositionForOffset(ui.Offset(130, 5)), + pos(13, ui.TextAffinity.upstream), + ); + // On the left half of "p" in "ipsum". + expect( + paragraph.getPositionForOffset(ui.Offset(74, 5)), + pos(7, ui.TextAffinity.downstream), + ); + // On the right half of "p" in "ipsum". + expect( + paragraph.getPositionForOffset(ui.Offset(76, 5)), + pos(8, ui.TextAffinity.upstream), + ); + }); + + 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" + + // Above the first line. + expect( + paragraph.getPositionForOffset(ui.Offset(0, -5)), + pos(0, ui.TextAffinity.downstream), + ); + // At the beginning of the first line. + expect( + paragraph.getPositionForOffset(ui.Offset(0, 5)), + pos(0, ui.TextAffinity.downstream), + ); + // At the end of the first line. + expect( + paragraph.getPositionForOffset(ui.Offset(60, 5)), + pos(6, ui.TextAffinity.upstream), + ); + // After the end of the first line to the right. + expect( + paragraph.getPositionForOffset(ui.Offset(70, 5)), + pos(6, ui.TextAffinity.upstream), + ); + // On the left half of " " in "Lorem ". + expect( + paragraph.getPositionForOffset(ui.Offset(54, 5)), + pos(5, ui.TextAffinity.downstream), + ); + // On the right half of " " in "Lorem ". + expect( + paragraph.getPositionForOffset(ui.Offset(56, 5)), + pos(6, ui.TextAffinity.upstream), + ); + + // At the beginning of the second line "ipsum ". + expect( + paragraph.getPositionForOffset(ui.Offset(0, 15)), + pos(6, ui.TextAffinity.downstream), + ); + // At the end of the second line. + expect( + paragraph.getPositionForOffset(ui.Offset(60, 15)), + pos(12, ui.TextAffinity.upstream), + ); + // After the end of the second line to the right. + expect( + paragraph.getPositionForOffset(ui.Offset(70, 15)), + pos(12, ui.TextAffinity.upstream), + ); + + // Below the third line "dolor sit". + expect( + paragraph.getPositionForOffset(ui.Offset(0, 40)), + pos(21, ui.TextAffinity.upstream), + ); + // At the end of the third line. + expect( + paragraph.getPositionForOffset(ui.Offset(90, 25)), + pos(21, ui.TextAffinity.upstream), + ); + // After the end of the third line to the right. + expect( + paragraph.getPositionForOffset(ui.Offset(100, 25)), + pos(21, ui.TextAffinity.upstream), + ); + // On the left half of " " in "dolor sit". + expect( + paragraph.getPositionForOffset(ui.Offset(54, 25)), + pos(17, ui.TextAffinity.downstream), + ); + // On the right half of " " in "dolor sit". + expect( + paragraph.getPositionForOffset(ui.Offset(56, 25)), + pos(18, ui.TextAffinity.upstream), + ); + }); + + 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" + + // Above the first line. + expect( + paragraph.getPositionForOffset(ui.Offset(0, -5)), + pos(0, ui.TextAffinity.downstream), + ); + // At the beginning of the first line. + expect( + paragraph.getPositionForOffset(ui.Offset(0, 5)), + pos(0, ui.TextAffinity.downstream), + ); + // At the end of the first line. + expect( + paragraph.getPositionForOffset(ui.Offset(60, 5)), + pos(6, ui.TextAffinity.upstream), + ); + // After the end of the first line to the right. + expect( + paragraph.getPositionForOffset(ui.Offset(70, 5)), + pos(6, ui.TextAffinity.upstream), + ); + // On the left half of " " in "Lorem ". + expect( + paragraph.getPositionForOffset(ui.Offset(54, 5)), + pos(5, ui.TextAffinity.downstream), + ); + // On the right half of " " in "Lorem ". + expect( + paragraph.getPositionForOffset(ui.Offset(56, 5)), + pos(6, ui.TextAffinity.upstream), + ); + + // At the beginning of the second line "ipsum ". + expect( + paragraph.getPositionForOffset(ui.Offset(0, 15)), + pos(6, ui.TextAffinity.downstream), + ); + // At the end of the second line. + expect( + paragraph.getPositionForOffset(ui.Offset(60, 15)), + pos(12, ui.TextAffinity.upstream), + ); + // After the end of the second line to the right. + expect( + paragraph.getPositionForOffset(ui.Offset(70, 15)), + pos(12, ui.TextAffinity.upstream), + ); + + // Below the third line "dolor sit". + expect( + paragraph.getPositionForOffset(ui.Offset(0, 40)), + pos(21, ui.TextAffinity.upstream), + ); + // At the end of the third line. + expect( + paragraph.getPositionForOffset(ui.Offset(90, 25)), + pos(21, ui.TextAffinity.upstream), + ); + // After the end of the third line to the right. + expect( + paragraph.getPositionForOffset(ui.Offset(100, 25)), + pos(21, ui.TextAffinity.upstream), + ); + // On the left half of " " in "dolor sit". + expect( + paragraph.getPositionForOffset(ui.Offset(54, 25)), + pos(17, ui.TextAffinity.downstream), + ); + // On the right half of " " in "dolor sit". + expect( + paragraph.getPositionForOffset(ui.Offset(56, 25)), + pos(18, ui.TextAffinity.upstream), + ); + }); + }); } /// Shortcut to create a [ui.TextBox] with an optional [ui.TextDirection]. @@ -319,3 +539,8 @@ ui.TextBox box( ]) { return ui.TextBox.fromLTRBD(left, top, right, bottom, direction); } + +/// Shortcut to create a [ui.TextPosition]. +ui.TextPosition pos(int offset, ui.TextAffinity affinity) { + return ui.TextPosition(offset: offset, affinity: affinity); +} From 1e04b935c53db08eb606a492f834145244527767 Mon Sep 17 00:00:00 2001 From: Mouad Debbar Date: Thu, 17 Dec 2020 10:01:55 -0800 Subject: [PATCH 2/2] address nits --- .../lib/src/engine/text/layout_service.dart | 11 ++++++++-- .../test/text/canvas_paragraph_test.dart | 20 +++++++++++++++++++ 2 files changed, 29 insertions(+), 2 deletions(-) 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 370c14a4a954c..4c470bf54d926 100644 --- a/lib/web_ui/lib/src/engine/text/layout_service.dart +++ b/lib/web_ui/lib/src/engine/text/layout_service.dart @@ -265,6 +265,10 @@ class TextLayoutService { } ui.TextPosition getPositionForOffset(ui.Offset offset) { + // After layout, each line has boxes that contain enough information to make + // it possible to do hit testing. Once we find the box, we look inside that + // box to find where exactly the `offset` is located. + // [offset] is above all the lines. if (offset.dy < 0) { return ui.TextPosition(offset: 0, affinity: ui.TextAffinity.downstream); @@ -306,6 +310,9 @@ class TextLayoutService { } EngineLineMetrics _findLineForY(double y) { + // We could do a binary search here but it's not worth it because the number + // of line is typically low, and each iteration is a cheap comparison of + // doubles. for (EngineLineMetrics line in lines) { if (y <= line.height) { return line; @@ -435,8 +442,8 @@ class RangeBox { ); } - final double lowWidth = spanometer._measure(start.index, cutoff); - final double highWidth = spanometer._measure(start.index, cutoff + 1); + 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) { diff --git a/lib/web_ui/test/text/canvas_paragraph_test.dart b/lib/web_ui/test/text/canvas_paragraph_test.dart index 0bc87992f861f..601003fe4b669 100644 --- a/lib/web_ui/test/text/canvas_paragraph_test.dart +++ b/lib/web_ui/test/text/canvas_paragraph_test.dart @@ -325,6 +325,11 @@ void testMain() async { paragraph.getPositionForOffset(ui.Offset(0, -5)), pos(0, ui.TextAffinity.downstream), ); + // At the top left corner of the line. + expect( + paragraph.getPositionForOffset(ui.Offset(0, 0)), + pos(0, ui.TextAffinity.downstream), + ); // At the beginning of the line. expect( paragraph.getPositionForOffset(ui.Offset(0, 5)), @@ -350,6 +355,16 @@ void testMain() async { paragraph.getPositionForOffset(ui.Offset(76, 5)), pos(8, ui.TextAffinity.upstream), ); + // At the top of the line, on the left half of "p" in "ipsum". + expect( + paragraph.getPositionForOffset(ui.Offset(74, 0)), + pos(7, ui.TextAffinity.downstream), + ); + // At the top of the line, on the right half of "p" in "ipsum". + expect( + paragraph.getPositionForOffset(ui.Offset(76, 0)), + pos(8, ui.TextAffinity.upstream), + ); }); test('handles multi-line single-span paragraphs', () { @@ -368,6 +383,11 @@ void testMain() async { paragraph.getPositionForOffset(ui.Offset(0, -5)), pos(0, ui.TextAffinity.downstream), ); + // At the top left corner of the line. + expect( + paragraph.getPositionForOffset(ui.Offset(0, 0)), + pos(0, ui.TextAffinity.downstream), + ); // At the beginning of the first line. expect( paragraph.getPositionForOffset(ui.Offset(0, 5)),