Skip to content

Commit

Permalink
Send caret rect to embedder on selection update (flutter#137863)
Browse files Browse the repository at this point in the history
Background: In the framework, the position of the caret rect is updated on each cursor position change such that if the user initiates composing input, the current cursor position can be used for the first character until the composing rect can be sent.

Previously, no update was sent on selection changes, on the assumption that the most recent cursor position will remain the correct position for the duration of the selection. While this is the case for forward selections, it is an incorrect assumption for reversed selections, where selection.base > selection.extent.

We now update the cursor position during selection changes such that the cursor position sent to the embedder is always the position at which next text input would occur. This is the start position of the selection or min(selection.baseOffset, selection.extentOffset).

Issue: flutter#137677
  • Loading branch information
cbracken authored and pull[bot] committed Nov 22, 2024
1 parent b5722b9 commit 8276375
Show file tree
Hide file tree
Showing 2 changed files with 56 additions and 10 deletions.
28 changes: 21 additions & 7 deletions packages/flutter/lib/src/widgets/editable_text.dart
Original file line number Diff line number Diff line change
Expand Up @@ -4115,11 +4115,13 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
_textInputConnection!.setSelectionRects(rects);
}

// Sends the current composing rect to the iOS text input plugin via the text
// input channel. We need to keep sending the information even if no text is
// currently marked, as the information usually lags behind. The text input
// plugin needs to estimate the composing rect based on the latest caret rect,
// when the composing rect info didn't arrive in time.
// Sends the current composing rect to the embedder's text input plugin.
//
// In cases where the composing rect hasn't been updated in the embedder due
// to the lag of asynchronous messages over the channel, the position of the
// current caret rect is used instead.
//
// See: [_updateCaretRectIfNeeded]
void _updateComposingRectIfNeeded() {
final TextRange composingRange = _value.composing;
assert(mounted);
Expand All @@ -4133,12 +4135,24 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
_textInputConnection!.setComposingRect(composingRect);
}

// Sends the current caret rect to the embedder's text input plugin.
//
// The position of the caret rect is updated periodically such that if the
// user initiates composing input, the current cursor rect can be used for
// the first character until the composing rect can be sent.
//
// On selection changes, the start of the selection is used. This ensures
// that regardless of the direction the selection was created, the cursor is
// set to the position where next text input occurs. This position is used to
// position the IME's candidate selection menu.
//
// See: [_updateComposingRectIfNeeded]
void _updateCaretRectIfNeeded() {
final TextSelection? selection = renderEditable.selection;
if (selection == null || !selection.isValid || !selection.isCollapsed) {
if (selection == null || !selection.isValid) {
return;
}
final TextPosition currentTextPosition = TextPosition(offset: selection.baseOffset);
final TextPosition currentTextPosition = TextPosition(offset: selection.start);
final Rect caretRect = renderEditable.getLocalRectForCaret(currentTextPosition);
_textInputConnection!.setCaretRect(caretRect);
}
Expand Down
38 changes: 35 additions & 3 deletions packages/flutter/test/widgets/editable_text_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -5875,16 +5875,48 @@ void main() {
);

testWidgetsWithLeakTracking(
'not sent with selection',
'set to selection start on forward selection',
(WidgetTester tester) async {
controller.value = TextEditingValue(
text: 'a' * 100,
selection: const TextSelection(baseOffset: 0, extentOffset: 10),
selection: const TextSelection(baseOffset: 10, extentOffset: 30),
);
await tester.pumpWidget(builder());
await tester.showKeyboard(find.byType(EditableText));

expect(tester.testTextInput.log, isNot(contains(matchesMethodCall('TextInput.setCaretRect'))));
expect(tester.testTextInput.log, contains(
matchesMethodCall(
'TextInput.setCaretRect',
// Now the composing range is not empty.
args: allOf(
containsPair('x', equals(140)),
containsPair('y', equals(0)),
),
),
));
},
);

testWidgetsWithLeakTracking(
'set to selection start on reversed selection',
(WidgetTester tester) async {
controller.value = TextEditingValue(
text: 'a' * 100,
selection: const TextSelection(baseOffset: 30, extentOffset: 10),
);
await tester.pumpWidget(builder());
await tester.showKeyboard(find.byType(EditableText));

expect(tester.testTextInput.log, contains(
matchesMethodCall(
'TextInput.setCaretRect',
// Now the composing range is not empty.
args: allOf(
containsPair('x', equals(140)),
containsPair('y', equals(0)),
),
),
));
},
);
});
Expand Down

0 comments on commit 8276375

Please sign in to comment.