-
Notifications
You must be signed in to change notification settings - Fork 29.7k
Fix out-of-bounds and reversed TextBox queries in computing caret metrics #122480
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Conversation
…rics Fixes flutter#50563. Fixes flutter#98516. Fixes flutter#118403. Fixes flutter#122477. It turns out that `Paragraph.getBoxesForRange` will not accept a negative index for the start of the range; if you pass one, it will return an empty list. We'd been doing that in `TextPainter._getRectFromUpstream`; this was causing flutter#50563, flutter#98516, and flutter#118403. (Arguably this makes them duplicates of each other, but they looked quite distinct until being debugged: they involve complex emoji, zalgo text, and Indic script respectively.) Fix that by clamping the start to zero, because what we really intended here in that case is the whole text up to the given end offset. Then it turns out that we were sometimes reaching this line with a negative end index too (when the selection is empty on reaching `RenderEditable._updateSelectionExtentsVisibility`.) That was giving an empty list of boxes, but if we clamp the start but not the end, we'd get back boxes for the whole text instead. Avoid that by short-circuiting that case at the top of the function, like we do when the offset is out of bounds in the other direction. Finally, when we get back more than one box from `getBoxesForRange`, we were looking at the wrong end of the list: if we're looking upstream, we want the end of the *last* preceding box, not the first one. Similarly when looking downstream we want the start of the first following box, not the last one. This was causing flutter#122477, which I discovered while studying this code to diagnose flutter#50563. Fix that too.
|
There are also several issues in the tracker that sound potentially related to the issues this fixes but involve RTL text:
or WidgetSpans: I haven't yet investigated whether this fixes any of those issues. |
|
Resolved conflicts after #120834. |
LongCatIsLooong
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks for looking into this! LGTM overall but I think it might be worth looking at the callers of getBoxesForRange and fixing them as well.
On the other hand currently we do expose getBoxesForRange as a public API (also there're some other APIs that provide similar functionalities) but it's not clear to me how they should behave when given an OOB index. I'll create an issue for that.
| _CaretMetrics? _getMetricsFromUpstream(int offset) { | ||
| final int plainTextLength = plainText.length; | ||
| if (plainTextLength == 0 || offset > plainTextLength) { | ||
| if (plainTextLength == 0 || offset > plainTextLength || offset < 0) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think the framework is not supposed to query the caret location at a negative offset (-1 is used to indicates the caret is placed at an invalid location and thus should be invisible, for historical reasons). Maybe this should be an assert, and the caller should sanitize the input before calling getOffsetForCaret?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Sure, I can take a look at doing that. I think it was only after I wrote this PR that I found #95978 (comment) and #79615 and understood the context of that -1 better.
Makes sense. I just took a quick look, and there's only two other call sites in the tree: one a benchmark that never gives it OOB indexes, and one in [TextPainter.getBoxesForSelection]: List<TextBox> getBoxesForSelection(
TextSelection selection, /* … */) {
assert(_debugAssertTextLayoutIsValid);
return _paragraph!.getBoxesForRange(
selection.start,
selection.end,So that looks like it can currently pass OOB indexes, though only in a harmless way: it'll be (-1, -1), and as I found above, a negative start index produces an empty list. Better would be for that to be explicit in a check before calling getBoxesForRange, though: if (!selection.isValid) {
return <TextBox>[];
}
return _paragraph!.getBoxesForRange(
selection.start,
selection.end,I can add that to the PR. |
|
Revised. Thanks for the review! I took the liberty of adding a couple of TODO comments referring to you (@LongCatIsLooong). They come about because I ran into this existing TODO comment: // TODO(LongCatIsLooong): skip painting the caret when the selection is
// (-1, -1).
if (selection == null || !selection.isCollapsed) {
return;From the Git history, I see that's from #79495 , for which a previous fix was reverted because fixing it was a breaking change. |
|
cc: @exaby73 for later reference #120049 (comment) |
LongCatIsLooong
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
LGTM
| bool.fromEnvironment('FLUTTER_WEB_USE_SKIA'); | ||
|
|
||
| void main() { | ||
| void checkCaretOffsetsLtrAt(String text, List<int> boundaries) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: maybe move these helper functions out of the scope of main? Or create a group for the caret tests and the helper functions?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Sure. Moved them out of the scope of main, and then also took the occasion to expand on the docs a bit more.
| ..text = text | ||
| ..layout(maxWidth: 10000); | ||
| final int length = text.toPlainText().length; | ||
| final List<double> result = |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
should be on the same line?
| final List<double> prefixWidths = <double>[]; | ||
| for (final int boundary in boundaries) { | ||
| painter.text = TextSpan(text: text.substring(0, boundary)); | ||
| painter.layout(maxWidth: 10000); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: I think you could just omit maxWidth here? The default value is infinity iirc.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ah indeed, thanks for the catch.
|
Thanks for the review! Pushed a version with those tweaks to the tests. |
| continue; | ||
| } | ||
| final TextBox box = boxes.first; | ||
| final TextBox box = boxes.last; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This makes me wonder: does it work the same way in LTR and RTL? I could image in RTL text boxes come in display order, not logical order.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Interesting question. I'd be surprised if that was the behavior, but I can't immediately rule it out — in particular the dartdoc on getBoxesForRange doesn't say anything about the order of the boxes in the list.
... OK, and I wrote a test for this, and indeed the boxes seem to be in display order! Thanks for the catch.
I'll revise so that the change doesn't break anything that was working before. I expect it still won't work reliably in the presence of bidi, but fixing that may require bigger changes to this code.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Done; PTAL.
I also filed #123424 to track that this logic doesn't seem like it can work in the bidi case. That's an issue this PR doesn't fix, and I think is best left out of scope because it will probably require bigger changes.
tgucio
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
LGTM
justinmc
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
LGTM 👍
I think this can merge, but there's a simple merge conflict in the test.
|
|
||
| /// Returns a list of rects that bound the given selection. | ||
| /// | ||
| /// The [selection] must be a valid range (with [TextSelection.isValid] true.) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nit: Period outside the parenthesis: true).
| /// element should be a valid character range in the concatenated string. | ||
| /// | ||
| /// Consider using [checkCaretOffsetsLtr] instead of this function. If that | ||
| /// doesn't pass, you may have an instance of https://github.com/flutter/flutter/issues/122478 . |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nit: Remove the space between the end of the GitHub link and the period.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I tend to avoid putting periods (or commas, etc.) just after a URL, because then it's ambiguous whether they're part of the URL.
But here in dartdoc, there's a <…> syntax for enclosing URLs; switched to that and cut the space. (That syntax is unambiguous because > cannot appear in a URL.)
| /// | ||
| /// If you have a [String] instead of a nontrivial [TextSpan], | ||
| /// consider using [checkCaretOffsetsLtr] instead. | ||
| List<double> caretOffsetsForTextSpan(TextDirection textDirection, TextSpan text) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nit: Should these functions be private or do you expect them to be used elsewhere?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Because this file has a main function and will never be imported from, the private/public distinction doesn't mean as much as usual. I think the convention I've mostly seen in test files is to leave off the _ prefix, even for test fixtures that are firmly private in intention.
Searching, it's a bit more mixed than my impression had been, but I think still more without the _ prefix than with it: git grep -P '^(?!import\b|library\b|void main\b|//)\w.*' 'test/*_test.dart'
Given that and because these read a bit better to me without the _ prefix, I'm inclined to leave it off.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Sounds good, I've also seen both in other tests.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks for the review! Merged (i.e. resolved conflicts) and fixed nits.
| /// element should be a valid character range in the concatenated string. | ||
| /// | ||
| /// Consider using [checkCaretOffsetsLtr] instead of this function. If that | ||
| /// doesn't pass, you may have an instance of https://github.com/flutter/flutter/issues/122478 . |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I tend to avoid putting periods (or commas, etc.) just after a URL, because then it's ambiguous whether they're part of the URL.
But here in dartdoc, there's a <…> syntax for enclosing URLs; switched to that and cut the space. (That syntax is unambiguous because > cannot appear in a URL.)
| /// | ||
| /// If you have a [String] instead of a nontrivial [TextSpan], | ||
| /// consider using [checkCaretOffsetsLtr] instead. | ||
| List<double> caretOffsetsForTextSpan(TextDirection textDirection, TextSpan text) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Because this file has a main function and will never be imported from, the private/public distinction doesn't mean as much as usual. I think the convention I've mostly seen in test files is to leave off the _ prefix, even for test fixtures that are firmly private in intention.
Searching, it's a bit more mixed than my impression had been, but I think still more without the _ prefix than with it: git grep -P '^(?!import\b|library\b|void main\b|//)\w.*' 'test/*_test.dart'
Given that and because these read a bit better to me without the _ prefix, I'm inclined to leave it off.
|
Re-running customer tests after flutter/tests#239 . |
|
Issues persist when using multiline text TextField( |
|
@RomanIvn Please file a new issue for the problem you're seeing. That will be a more effective place to address it than in a merged PR's thread. Do include all the information in the issue template, including details of what behavior you expected and what behavior you see instead. Since you mention
|
Fixes #50563.
Fixes #98516.
Fixes #118403.
Fixes #122477.
It turns out that
Paragraph.getBoxesForRangewill not accept a negative index for the start of the range; if you pass one, it will return an empty list. We'd been doing that inTextPainter._getRectFromUpstream; this was causing #50563, #98516, and #118403. (Arguably this makes them duplicates of each other, but they looked quite distinct until being debugged: they involve complex emoji, zalgo text, and Indic script respectively.)Fix that by clamping the start to zero, because what we really intended here in that case is the whole text up to the given end offset.
Then it turns out that we were sometimes reaching this line with a negative end index too (when the selection is empty on reaching
RenderEditable._updateSelectionExtentsVisibility.) That was giving an empty list of boxes, but if we clamp the start but not the end, we'd get back boxes for the whole text instead. Avoid that by short-circuiting that case at the top of the function, like we do when the offset is out of bounds in the other direction.Finally, when we get back more than one box from
getBoxesForRange, we were looking at the wrong end of the list: if we're looking upstream, we want the end of the last preceding box, not the first one. Similarly when looking downstream we want the start of the first following box, not the last one. This was causing #122477, which I discovered while studying this code to diagnose #50563. Fix that too.Pre-launch Checklist
///).If you need help, consider asking for advice on the #hackers-new channel on Discord.