Skip to content

Conversation

@gnprice
Copy link
Member

@gnprice gnprice commented Mar 12, 2023

Fixes #50563.
Fixes #98516.
Fixes #118403.
Fixes #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 #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

  • I read the Contributor Guide and followed the process outlined there for submitting PRs.
  • I read the Tree Hygiene wiki page, which explains my responsibilities.
  • I read and followed the Flutter Style Guide, including Features we expect every widget to implement.
  • I signed the CLA.
  • I listed at least one issue that this PR fixes in the description above.
  • I updated/added relevant documentation (doc comments with ///).
  • I added new tests to check the change I am making, or this PR is test-exempt.
  • All existing and new tests are passing.

If you need help, consider asking for advice on the #hackers-new channel on Discord.

…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.
@flutter-dashboard flutter-dashboard bot added a: text input Entering text in a text field or keyboard related problems framework flutter/packages/flutter repository. See also f: labels. labels Mar 12, 2023
@gnprice
Copy link
Member Author

gnprice commented Mar 12, 2023

@gnprice
Copy link
Member Author

gnprice commented Mar 19, 2023

Resolved conflicts after #120834.

Copy link
Contributor

@LongCatIsLooong LongCatIsLooong left a 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) {
Copy link
Contributor

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?

Copy link
Member Author

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.

@gnprice
Copy link
Member Author

gnprice commented Mar 20, 2023

LGTM overall but I think it might be worth looking at the callers of getBoxesForRange and fixing them as well.

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.

@gnprice
Copy link
Member Author

gnprice commented Mar 21, 2023

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.

@exaby73
Copy link
Member

exaby73 commented Mar 21, 2023

cc: @exaby73 for later reference #120049 (comment)

Copy link
Contributor

@LongCatIsLooong LongCatIsLooong left a 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) {
Copy link
Contributor

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?

Copy link
Member Author

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 =
Copy link
Contributor

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);
Copy link
Contributor

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.

Copy link
Member Author

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.

@gnprice
Copy link
Member Author

gnprice commented Mar 24, 2023

Thanks for the review! Pushed a version with those tweaks to the tests.

continue;
}
final TextBox box = boxes.first;
final TextBox box = boxes.last;
Copy link
Contributor

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.

Copy link
Member Author

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.

Copy link
Member Author

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.

Copy link
Contributor

@tgucio tgucio left a comment

Choose a reason for hiding this comment

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

LGTM

Copy link
Contributor

@justinmc justinmc left a 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.)
Copy link
Contributor

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 .
Copy link
Contributor

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.

Copy link
Member Author

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

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?

Copy link
Member Author

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.

Copy link
Contributor

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.

Copy link
Member Author

@gnprice gnprice left a 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 .
Copy link
Member Author

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) {
Copy link
Member Author

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.

@gnprice
Copy link
Member Author

gnprice commented Mar 28, 2023

Re-running customer tests after flutter/tests#239 .

@gnprice gnprice added the autosubmit Merge PR when tree becomes green via auto submit App label Mar 28, 2023
@auto-submit auto-submit bot merged commit 4811119 into flutter:master Mar 28, 2023
@gnprice gnprice deleted the pr-caret-offset branch March 28, 2023 04:06
engine-flutter-autoroll added a commit to engine-flutter-autoroll/packages that referenced this pull request Mar 28, 2023
engine-flutter-autoroll added a commit to engine-flutter-autoroll/packages that referenced this pull request Mar 28, 2023
engine-flutter-autoroll added a commit to engine-flutter-autoroll/packages that referenced this pull request Mar 28, 2023
engine-flutter-autoroll added a commit to engine-flutter-autoroll/packages that referenced this pull request Mar 28, 2023
@RomanIvn
Copy link

RomanIvn commented Apr 3, 2023

Issues persist when using multiline text

TextField(
keyboardType: TextInputType.multiline,
maxLines: null,
textDirection: TextDirection.rtl,
textAlign: TextAlign.right,
)

@gnprice
Copy link
Member Author

gnprice commented Apr 3, 2023

@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 TextDirection.rtl, take a look also at these still-open issues and see if one of them describes what you're seeing:

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

a: text input Entering text in a text field or keyboard related problems autosubmit Merge PR when tree becomes green via auto submit App framework flutter/packages/flutter repository. See also f: labels.

Projects

None yet

6 participants