Skip to content

Conversation

@Renzo-Olivares
Copy link
Contributor

Relands #108913

Fixes #111660

Was failing due to a call to EditableText.toggleToolbar which does a forced null dereference on SelectionOverlay. There is an assert that checks if the SelectionOverlay exists, but this does not work on production. This change creates a SelectionOverlay if it does not exist when toggleToolbar is called and adds a regression test.

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.

@flutter-dashboard flutter-dashboard bot added a: text input Entering text in a text field or keyboard related problems f: cupertino flutter/packages/flutter/cupertino repository f: material design flutter/packages/flutter/material repository. framework flutter/packages/flutter repository. See also f: labels. labels Sep 20, 2022
position,
);

return renderEditable.selection!.start < textPosition.offset
Copy link
Contributor

Choose a reason for hiding this comment

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

when offset == start why is it not considered not on selection?

Copy link
Contributor

Choose a reason for hiding this comment

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

Ah I saw the comment from the previous pull request. But you may still want to check the affinity of the incoming position?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

What should the TextAffinity be checked for?

Copy link
Contributor

Choose a reason for hiding this comment

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

Oh nvm you don't have to since the logic will be identical.

}

bool _tapWasOnSelection(Offset position) {
if (renderEditable.selection == null) {
Copy link
Contributor

Choose a reason for hiding this comment

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

consider creating a local variable for selection so you can promote it to non null.

void toggleToolbar([bool hideHandles = true]) {
if (_selectionOverlay == null) {
_createSelectionOverlay();
}
Copy link
Contributor

Choose a reason for hiding this comment

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

final selectionOverlay = _selectionOverlay ??= createSelectionOverlay() to avoid the force cast?

renderEditable.selectWordEdge(cause: SelectionChangedCause.tap);
}
if (previousSelection == editableText.textEditingValue.selection && renderEditable.hasFocus) {
editableText.toggleToolbar(false);
Copy link
Contributor

Choose a reason for hiding this comment

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

Could this be combined with the previous if clause?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Not sure if it can, but I'll try it out.

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 👍


/// Toggles the visibility of the toolbar.
void toggleToolbar() {
assert(_selectionOverlay != null);
Copy link
Contributor

Choose a reason for hiding this comment

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

I don't know why we didn't create the selection overlay here in the first place. Seems fine to me.


void _createSelectionOverlay() {
_selectionOverlay = TextSelectionOverlay(
TextSelectionOverlay _createSelectionOverlay() {
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 I like this pattern better 👍

);

return selection.start < textPosition.offset
&& selection.end > textPosition.offset;
Copy link
Contributor

Choose a reason for hiding this comment

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

Not inclusive?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I updated the logic.

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.

Renewing my LGTM after the previous changes 👍

await tester.tapAt(textfieldStart + const Offset(150.0, 9.0));
await tester.pump(const Duration(milliseconds: 50));
// First tap moved the cursor.
// First tap moved the cursor and hides the toolbar.
Copy link
Contributor

Choose a reason for hiding this comment

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

"hides" => "hid"

// If the tap was neither on the selection exclusively or inclusively then we select the word edge. After
// selecting the word edge, if our selection remains the same, then we toggle the toolbar. If the selection changes
// then we hide the toolbar.
if (((_tapWasOnSelectionExclusive(details.globalPosition) && !previousSelection.isCollapsed)
Copy link
Contributor

Choose a reason for hiding this comment

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

This is a bit hard to read. Could you simplify this a bit?

Copy link
Contributor

Choose a reason for hiding this comment

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

Also you probably don't need _tapWasOnSelectionInclusive? if previousSelection == editableText.textEditingValue.selection then we'll toggle the toolbar anyway?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I think there is still the case where the cursor is in the middle of a word wor|d, and we tap it.

If we remove the inclusive check, then the exclusive one will not catch it, and the cursor will move to the word edge.

The expected behavior would be to toggle the toolbar when we tap the cursor.

Copy link
Contributor Author

@Renzo-Olivares Renzo-Olivares Oct 4, 2022

Choose a reason for hiding this comment

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

if () {
   // If the tap was on the selection (different meaning for collapsed vs uncollapsed selection) 
   // and the editable was focused, then toggle the toolbar.
} else if () {
   // Tap was not on the selection at all.
   // We should still toggle the toolbar, if the selection remains the same after the word edge is selected.
   // If the selection changes then we hide the toolbar.
}

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I updated the logic a little, getting rid of the else if.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yeah I’ll keep a lookout of affinity issues.

On native if the renderEditable doesn’t have focus we always select the closest word edge. And the toolbar is not toggled unless the editable has focus.

Copy link
Contributor

Choose a reason for hiding this comment

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

Could you check if you tap the latter half of the character before the caret? I think that will give you (x-1, upstream) instead of (x, downstream) so wasTapOnSelection will return false? But that should also toggle the toolbar right?

Copy link
Contributor Author

@Renzo-Olivares Renzo-Olivares Oct 5, 2022

Choose a reason for hiding this comment

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

Good point, If i do the later half of the character before the character the selection is upstream. If I then follow that up with a tap on the former half of the character after the caret, then that selection is downstream. Then because of previousSelection == editableText.textEditingValue.selection, the toolbar is not toggled in this case. On native it looks like it toggles the toolbar regardless of the affinity.

In the case where the text is wrapping, then if we place the cursor at the end of the first line, and then try to tap at the beginning of the second line, then the toolbar will be toggled because wasTapOnSelectionInclusive returns true.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I think in the case where we select the word edge the affinity does not matter since a word will not be wrapped between two lines (will either fall on first or second line).

In the case where we check for wasTapOnSelectionInclusive we do care about the affinity because we want to be able to select the text at beginning of the next line. So we want it to return false when the affinity is different.

Copy link
Contributor Author

@Renzo-Olivares Renzo-Olivares Oct 6, 2022

Choose a reason for hiding this comment

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

There might be a trade off we have to make here since we cannot differentiate why the TextAffinity changed.

We want to toggle the toolbar when

  • We call selectWordEdge and the selection after the call is the same as the previous selection (including TextAffinity).
  • We call selectWordEdge and the selection after the call is the same as the previous selection (excluding TextAffinity), this can happen when we tap

We want to hide the toolbar when

  • We call selectWordEdge and the selection after the call is the same as the previous selection (excluding TextAffinity). This can happen when there is a multi-line text field, there is word wrap and we want to tap on the boundary. Let's say the cursor is at the end of the first line. Tapping on the first line boundary should toggle the toolbar, tapping at the beginning of the second line should hide the toolbar.

We can't differentiate whether TextAffinity changed due to tapping on a new line but same selection offset, or if it occurred from tapping on the same line with the same selection offset.

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.

👍 I'm on board with the compromise made here, it seems like the best option in the circumstances. But I want to explore this a little farther just to make sure.

You probably already saw this but the logic that gives the affinity based on the side of a character is all the way down in paragraph_txt.cc:

  double glyph_center = (gp->x_pos.start + gp->x_pos.end) / 2;
  if ((direction == TextDirection::ltr && dx < glyph_center) ||
      (direction == TextDirection::rtl && dx >= glyph_center)) {
    return PositionWithAffinity(gp->code_units.start, DOWNSTREAM);
  } else {
    return PositionWithAffinity(gp->code_units.end, UPSTREAM);
  }

So that probably shouldn't change. Instead, the ideal logic is that on tap, we only care about affinity for toggling the toolbar if the tap is at a soft wordwrap.

Can we know about the location of soft wordwraps at this point in the pipeline? The text has already been laid out since this is a tap callback, and getPositionForPoint (called already on line 1948) requires the text to be laid out too. So the information we need is there, but if I'm not mistaken, there is no good way to get this from RenderEditable.

Tl;dr: The alternative to this compromise

You could add a method to RenderEditable that tells you one way or another if a given TextPosition is at a soft wordwrap or not. I think you can figure this out using LineMetrics. If the tap is at a soft wordwrap, only toggle the toolbar if the TextAffinity matches. If it's not at a soft wordwrap, toggle it regardless of the affinity.

Curious about your thoughts on if this is a correct analysis, and if so if it's worth it or not! But I'm on board with this PR either way. Maybe this could even be done in a follow-up PR.

// toggle the toolbar. If the selection changes then we hide the toolbar.
final TextSelection previousSelection = renderEditable.selection ?? editableText.textEditingValue.selection;
final bool isAffinityTheSame = renderEditable.getPositionForPoint(details.globalPosition).affinity == previousSelection.affinity;
if (((_tapWasOnSelectionExclusive(details.globalPosition) && !previousSelection.isCollapsed)
Copy link
Contributor

Choose a reason for hiding this comment

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

nit: You're potentially calling getPositionForPoint 3 times. The call is via FFI so could be a bit costly than usual dart calls. So maybe store that in a local variable and that might help simplify the code too.

@Renzo-Olivares
Copy link
Contributor Author

👍 I'm on board with the compromise made here, it seems like the best option in the circumstances. But I want to explore this a little farther just to make sure.

You probably already saw this but the logic that gives the affinity based on the side of a character is all the way down in paragraph_txt.cc:

  double glyph_center = (gp->x_pos.start + gp->x_pos.end) / 2;
  if ((direction == TextDirection::ltr && dx < glyph_center) ||
      (direction == TextDirection::rtl && dx >= glyph_center)) {
    return PositionWithAffinity(gp->code_units.start, DOWNSTREAM);
  } else {
    return PositionWithAffinity(gp->code_units.end, UPSTREAM);
  }

So that probably shouldn't change. Instead, the ideal logic is that on tap, we only care about affinity for toggling the toolbar if the tap is at a soft wordwrap.

Can we know about the location of soft wordwraps at this point in the pipeline? The text has already been laid out since this is a tap callback, and getPositionForPoint (called already on line 1948) requires the text to be laid out too. So the information we need is there, but if I'm not mistaken, there is no good way to get this from RenderEditable.

Tl;dr: The alternative to this compromise

You could add a method to RenderEditable that tells you one way or another if a given TextPosition is at a soft wordwrap or not. I think you can figure this out using LineMetrics. If the tap is at a soft wordwrap, only toggle the toolbar if the TextAffinity matches. If it's not at a soft wordwrap, toggle it regardless of the affinity.

Curious about your thoughts on if this is a correct analysis, and if so if it's worth it or not! But I'm on board with this PR either way. Maybe this could even be done in a follow-up PR.

I 100% agree with your analysis. I will investigate further on how feasible adding such an API to renderEditable is, but it definitely sounds worth it to add if its possible. That will be in a separate PR. I created an issue for this #113228.

@Renzo-Olivares Renzo-Olivares added the autosubmit Merge PR when tree becomes green via auto submit App label Oct 17, 2022
@auto-submit auto-submit bot merged commit 4abe6fd into flutter:master Oct 17, 2022
engine-flutter-autoroll added a commit to engine-flutter-autoroll/plugins that referenced this pull request Oct 18, 2022
engine-flutter-autoroll added a commit to engine-flutter-autoroll/plugins that referenced this pull request Oct 18, 2022
engine-flutter-autoroll added a commit to engine-flutter-autoroll/plugins that referenced this pull request Oct 18, 2022
engine-flutter-autoroll added a commit to engine-flutter-autoroll/packages that referenced this pull request Oct 19, 2022
Renzo-Olivares pushed a commit to Renzo-Olivares/flutter that referenced this pull request Nov 28, 2022
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 f: cupertino flutter/packages/flutter/cupertino repository f: material design flutter/packages/flutter/material repository. framework flutter/packages/flutter repository. See also f: labels.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Forced Null Dereference causing crash in editableTextState

3 participants