Skip to content
This repository was archived by the owner on Feb 25, 2025. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 1 addition & 4 deletions lib/web_ui/lib/src/engine/text/canvas_paragraph.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
113 changes: 113 additions & 0 deletions lib/web_ui/lib/src/engine/text/layout_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,64 @@ class TextLayoutService {
}
return boxes;
}

ui.TextPosition getPositionForOffset(ui.Offset offset) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add the comment: // 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.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

// 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);
}

// [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) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you agree, add a comment: // Not worth using binary search with a line.yOffset since number of lines is typically low. Or something to that effect

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

// 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;
}
y -= line.height;
}
return lines.last;
}
}

/// Represents a box inside [span] with the range of [start] to [end].
Expand Down Expand Up @@ -347,6 +405,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,
);
}

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

final int startIndex = start.index

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Lol the startIndex variable is already there at the top of the function, and it has the correct value that I need :) Nice catch!

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) {
// 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.
Expand Down
245 changes: 245 additions & 0 deletions lib/web_ui/test/text/canvas_paragraph_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -307,6 +307,246 @@ 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 top left corner of the line.
expect(
paragraph.getPositionForOffset(ui.Offset(0, 0)),
pos(0, ui.TextAffinity.downstream),
);
// At the beginning of the line.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

test y=0 as well ?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

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),
);
// 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', () {
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 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)),
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].
Expand All @@ -319,3 +559,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);
}