Unverified Commit aa6a0a9d authored by Bruno Leroux's avatar Bruno Leroux Committed by GitHub

Fix EditableText misplaces caret when selection is invalid (#123777)

Fix EditableText misplaces caret when selection is invalid
parent 1ea013dc
......@@ -3706,6 +3706,15 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
}
void _didChangeTextEditingValue() {
if (_hasFocus && !_value.selection.isValid) {
// If this field is focused and the selection is invalid, place the cursor at
// the end. Does not rely on _handleFocusChanged because it makes selection
// handles visible on Android.
// Unregister as a listener to the text controller while making the change.
widget.controller.removeListener(_didChangeTextEditingValue);
widget.controller.selection = _adjustedSelectionWhenFocused()!;
widget.controller.addListener(_didChangeTextEditingValue);
}
_updateRemoteEditingValueIfNeeded();
_startOrStopCursorTimerIfNeeded();
_updateOrDisposeSelectionOverlayIfNeeded();
......@@ -3726,21 +3735,9 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
if (!widget.readOnly) {
_scheduleShowCaretOnScreen(withAnimation: true);
}
final bool shouldSelectAll = widget.selectionEnabled && kIsWeb
&& !_isMultiline && !_nextFocusChangeIsInternal;
if (shouldSelectAll) {
// On native web, single line <input> tags select all when receiving
// focus.
_handleSelectionChanged(
TextSelection(
baseOffset: 0,
extentOffset: _value.text.length,
),
null,
);
} else if (!_value.selection.isValid) {
// Place cursor at the end if the selection is invalid when we receive focus.
_handleSelectionChanged(TextSelection.collapsed(offset: _value.text.length), null);
final TextSelection? updatedSelection = _adjustedSelectionWhenFocused();
if (updatedSelection != null) {
_handleSelectionChanged(updatedSelection, null);
}
} else {
WidgetsBinding.instance.removeObserver(this);
......@@ -3749,6 +3746,24 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
updateKeepAlive();
}
TextSelection? _adjustedSelectionWhenFocused() {
TextSelection? selection;
final bool shouldSelectAll = widget.selectionEnabled && kIsWeb
&& !_isMultiline && !_nextFocusChangeIsInternal;
if (shouldSelectAll) {
// On native web, single line <input> tags select all when receiving
// focus.
selection = TextSelection(
baseOffset: 0,
extentOffset: _value.text.length,
);
} else if (!_value.selection.isValid) {
// Place cursor at the end if the selection is invalid when we receive focus.
selection = TextSelection.collapsed(offset: _value.text.length);
}
return selection;
}
void _compositeCallback(Layer layer) {
// The callback can be invoked when the layer is detached.
// The input connection can be closed by the platform in which case this
......
......@@ -6744,8 +6744,8 @@ void main() {
variant: KeySimulatorTransitModeVariant.all()
);
// Regressing test for https://github.com/flutter/flutter/issues/78219
testWidgets('Paste does not crash when the section is inValid', (WidgetTester tester) async {
// Regression test for https://github.com/flutter/flutter/issues/78219
testWidgets('Paste does not crash after calling TextController.text setter', (WidgetTester tester) async {
final FocusNode focusNode = FocusNode();
final TextEditingController controller = TextEditingController();
final TextField textField = TextField(
......@@ -6778,7 +6778,7 @@ void main() {
await tester.tap(find.byType(TextField));
await tester.pumpAndSettle();
// This setter will set `selection` invalid.
// Clear the text.
controller.text = '';
// Paste clipboardContent to the text field.
......@@ -6790,10 +6790,12 @@ void main() {
await tester.sendKeyUpEvent(LogicalKeyboardKey.controlRight);
await tester.pumpAndSettle();
// Do nothing.
expect(find.text(clipboardContent), findsNothing);
expect(controller.selection, const TextSelection.collapsed(offset: -1));
}, variant: KeySimulatorTransitModeVariant.all());
// Clipboard content is correctly pasted.
expect(find.text(clipboardContent), findsOneWidget);
},
skip: areKeyEventsHandledByPlatform, // [intended] only applies to platforms where we handle key events.
variant: KeySimulatorTransitModeVariant.all(),
);
testWidgets('Cut test', (WidgetTester tester) async {
final FocusNode focusNode = FocusNode();
......
......@@ -90,38 +90,6 @@ void main() {
);
}
testWidgets(
'Movement/Deletion shortcuts do nothing when the selection is invalid',
(WidgetTester tester) async {
await tester.pumpWidget(buildEditableText());
controller.text = testText;
controller.selection = const TextSelection.collapsed(offset: -1);
await tester.pump();
const List<LogicalKeyboardKey> triggers = <LogicalKeyboardKey>[
LogicalKeyboardKey.backspace,
LogicalKeyboardKey.delete,
LogicalKeyboardKey.arrowLeft,
LogicalKeyboardKey.arrowRight,
LogicalKeyboardKey.arrowUp,
LogicalKeyboardKey.arrowDown,
LogicalKeyboardKey.pageUp,
LogicalKeyboardKey.pageDown,
LogicalKeyboardKey.home,
LogicalKeyboardKey.end,
];
for (final SingleActivator activator in triggers.expand(allModifierVariants)) {
await sendKeyCombination(tester, activator);
await tester.pump();
expect(controller.text, testText, reason: activator.toString());
expect(controller.selection, const TextSelection.collapsed(offset: -1), reason: activator.toString());
}
},
skip: kIsWeb, // [intended] on web these keys are handled by the browser.
variant: TargetPlatformVariant.all(),
);
group('Common text editing shortcuts: ',
() {
final TargetPlatformVariant allExceptApple = TargetPlatformVariant.all(excluding: <TargetPlatform>{TargetPlatform.macOS, TargetPlatform.iOS});
......
......@@ -55,6 +55,13 @@ enum HandlePositionInViewport {
typedef _VoidFutureCallback = Future<void> Function();
TextEditingValue collapsedAtEnd(String text) {
return TextEditingValue(
text: text,
selection: TextSelection.collapsed(offset: text.length),
);
}
void main() {
final MockClipboard mockClipboard = MockClipboard();
TestWidgetsFlutterBinding.ensureInitialized()
......@@ -5641,7 +5648,7 @@ void main() {
tester.testTextInput.log.clear();
controller.value = TextEditingValue(text: 'a' * 100, composing: const TextRange(start: 0, end: 10));
controller.value = collapsedAtEnd('a' * 100).copyWith(composing: const TextRange(start: 0, end: 10));
await tester.pump();
expect(tester.testTextInput.log, contains(
......@@ -9447,86 +9454,95 @@ void main() {
});
group('EditableText does not send editing values more than once', () {
final TextEditingController controller = TextEditingController(text: testText);
final EditableText editableText = EditableText(
showSelectionHandles: true,
maxLines: 2,
controller: controller,
focusNode: FocusNode(),
cursorColor: Colors.red,
backgroundCursorColor: Colors.blue,
style: Typography.material2018().black.titleMedium!.copyWith(fontFamily: 'Roboto'),
keyboardType: TextInputType.text,
inputFormatters: <TextInputFormatter>[LengthLimitingTextInputFormatter(6)],
onChanged: (String s) => controller.text += ' onChanged',
);
Widget boilerplate(TextEditingController controller) {
final EditableText editableText = EditableText(
showSelectionHandles: true,
maxLines: 2,
controller: controller,
focusNode: FocusNode(),
cursorColor: Colors.red,
backgroundCursorColor: Colors.blue,
style: Typography.material2018().black.titleMedium!.copyWith(fontFamily: 'Roboto'),
keyboardType: TextInputType.text,
inputFormatters: <TextInputFormatter>[LengthLimitingTextInputFormatter(6)],
onChanged: (String s) {
controller.value = collapsedAtEnd('${controller.text} onChanged');
},
);
final Widget widget = MediaQuery(
data: const MediaQueryData(),
child: Directionality(
textDirection: TextDirection.ltr,
child: editableText,
),
);
controller.addListener(() {
if (!controller.text.endsWith('listener')) {
controller.value = collapsedAtEnd('${controller.text} listener');
}
});
controller.addListener(() {
if (!controller.text.endsWith('listener')) {
controller.text += ' listener';
}
});
return MediaQuery(
data: const MediaQueryData(),
child: Directionality(
textDirection: TextDirection.ltr,
child: editableText,
),
);
}
testWidgets('input from text input plugin', (WidgetTester tester) async {
await tester.pumpWidget(widget);
final TextEditingController controller = TextEditingController(text: testText);
await tester.pumpWidget(boilerplate(controller));
// Connect.
await tester.showKeyboard(find.byType(EditableText));
tester.testTextInput.log.clear();
final EditableTextState state = tester.state<EditableTextState>(find.byWidget(editableText));
state.updateEditingValue(const TextEditingValue(text: 'remoteremoteremote'));
final EditableTextState state = tester.state<EditableTextState>(find.byType(EditableText));
state.updateEditingValue(collapsedAtEnd('remoteremoteremote'));
// Apply in order: length formatter -> listener -> onChanged -> listener.
expect(controller.text, 'remote listener onChanged listener');
const String expectedText = 'remote listener onChanged listener';
expect(controller.text, expectedText);
final List<TextEditingValue> updates = tester.testTextInput.log
.where((MethodCall call) => call.method == 'TextInput.setEditingState')
.map((MethodCall call) => TextEditingValue.fromJSON(call.arguments as Map<String, dynamic>))
.toList(growable: false);
expect(updates, const <TextEditingValue>[TextEditingValue(text: 'remote listener onChanged listener')]);
expect(updates, <TextEditingValue>[collapsedAtEnd(expectedText)]);
tester.testTextInput.log.clear();
// If by coincidence the text input plugin sends the same value back,
// do nothing.
state.updateEditingValue(const TextEditingValue(text: 'remote listener onChanged listener'));
state.updateEditingValue(collapsedAtEnd(expectedText));
expect(controller.text, 'remote listener onChanged listener');
expect(tester.testTextInput.log, isEmpty);
});
testWidgets('input from text selection menu', (WidgetTester tester) async {
await tester.pumpWidget(widget);
final TextEditingController controller = TextEditingController(text: testText);
await tester.pumpWidget(boilerplate(controller));
// Connect.
await tester.showKeyboard(find.byType(EditableText));
tester.testTextInput.log.clear();
final EditableTextState state = tester.state<EditableTextState>(find.byWidget(editableText));
state.userUpdateTextEditingValue(const TextEditingValue(text: 'remoteremoteremote'), SelectionChangedCause.keyboard);
final EditableTextState state = tester.state<EditableTextState>(find.byType(EditableText));
state.userUpdateTextEditingValue(
collapsedAtEnd('remoteremoteremote'),
SelectionChangedCause.keyboard,
);
// Apply in order: length formatter -> listener -> onChanged -> listener.
expect(controller.text, 'remote listener onChanged listener');
final List<TextEditingValue> updates = tester.testTextInput.log
.where((MethodCall call) => call.method == 'TextInput.setEditingState')
.map((MethodCall call) => TextEditingValue.fromJSON(call.arguments as Map<String, dynamic>))
.toList(growable: false);
expect(updates, const <TextEditingValue>[TextEditingValue(text: 'remote listener onChanged listener')]);
const String expectedText = 'remote listener onChanged listener';
expect(updates, <TextEditingValue>[collapsedAtEnd(expectedText)]);
tester.testTextInput.log.clear();
});
testWidgets('input from controller', (WidgetTester tester) async {
await tester.pumpWidget(widget);
final TextEditingController controller = TextEditingController(text: testText);
await tester.pumpWidget(boilerplate(controller));
// Connect.
await tester.showKeyboard(find.byType(EditableText));
......@@ -9538,7 +9554,7 @@ void main() {
.map((MethodCall call) => TextEditingValue.fromJSON(call.arguments as Map<String, dynamic>))
.toList(growable: false);
expect(updates, const <TextEditingValue>[TextEditingValue(text: 'remoteremoteremote listener')]);
expect(updates, <TextEditingValue>[collapsedAtEnd('remoteremoteremote listener')]);
});
testWidgets('input from changing controller', (WidgetTester tester) async {
......@@ -9825,7 +9841,7 @@ void main() {
));
await tester.showKeyboard(find.byType(EditableText));
controller.text += '...';
controller.value = collapsedAtEnd('${controller.text}...');
await tester.idle();
final List<String> logOrder = <String>[
......@@ -9858,7 +9874,7 @@ void main() {
});
final TextInputFormatter formatter = TextInputFormatter.withFunction((TextEditingValue oldValue, TextEditingValue newValue) {
if (newValue.text == 'I will be modified by the formatter.') {
newValue = const TextEditingValue(text: 'Flutter is the best!');
newValue = collapsedAtEnd('Flutter is the best!');
}
return newValue;
});
......@@ -9925,8 +9941,8 @@ void main() {
methodCall,
isMethodCall('TextInput.setEditingState', arguments: <String, dynamic>{
'text': 'Flutter is the best!',
'selectionBase': -1,
'selectionExtent': -1,
'selectionBase': 20,
'selectionExtent': 20,
'selectionAffinity': 'TextAffinity.downstream',
'selectionIsDirectional': false,
'composingBase': -1,
......@@ -9937,8 +9953,9 @@ void main() {
log.clear();
// setEditingState is called when the [controller.value] is modified by local.
String text = 'I love flutter!';
setState(() {
controller.text = 'I love flutter!';
controller.value = collapsedAtEnd(text);
});
expect(log.length, 1);
methodCall = log[0];
......@@ -9946,8 +9963,8 @@ void main() {
methodCall,
isMethodCall('TextInput.setEditingState', arguments: <String, dynamic>{
'text': 'I love flutter!',
'selectionBase': -1,
'selectionExtent': -1,
'selectionBase': text.length,
'selectionExtent': text.length,
'selectionAffinity': 'TextAffinity.downstream',
'selectionIsDirectional': false,
'composingBase': -1,
......@@ -9959,8 +9976,9 @@ void main() {
// Currently `_receivedRemoteTextEditingValue` equals 'I will be modified by the formatter.',
// setEditingState will be called when set the [controller.value] to `_receivedRemoteTextEditingValue` by local.
text = 'I will be modified by the formatter.';
setState(() {
controller.text = 'I will be modified by the formatter.';
controller.value = collapsedAtEnd(text);
});
expect(log.length, 1);
methodCall = log[0];
......@@ -9968,8 +9986,8 @@ void main() {
methodCall,
isMethodCall('TextInput.setEditingState', arguments: <String, dynamic>{
'text': 'I will be modified by the formatter.',
'selectionBase': -1,
'selectionExtent': -1,
'selectionBase': text.length,
'selectionExtent': text.length,
'selectionAffinity': 'TextAffinity.downstream',
'selectionIsDirectional': false,
'composingBase': -1,
......@@ -9986,7 +10004,7 @@ void main() {
return null;
});
final TextInputFormatter formatter = TextInputFormatter.withFunction((TextEditingValue oldValue, TextEditingValue newValue) {
return const TextEditingValue(text: 'Flutter is the best!');
return collapsedAtEnd('Flutter is the best!');
});
final TextEditingController controller = TextEditingController();
......@@ -10032,11 +10050,8 @@ void main() {
final EditableTextState state = tester.firstState(find.byType(EditableText));
// setEditingState is called when remote value modified by the formatter.
state.updateEditingValue(TextEditingValue(
text: 'I will be modified by the formatter.',
selection: controller.selection,
));
expect(log.length, 1);
state.updateEditingValue(collapsedAtEnd('I will be modified by the formatter.'));
expect(log.length, 2);
expect(log, contains(matchesMethodCall(
'TextInput.setEditingState',
args: allOf(
......@@ -10046,10 +10061,8 @@ void main() {
log.clear();
state.updateEditingValue(const TextEditingValue(
text: 'I will be modified by the formatter.',
));
expect(log.length, 1);
state.updateEditingValue(collapsedAtEnd('I will be modified by the formatter.'));
expect(log.length, 2);
expect(log, contains(matchesMethodCall(
'TextInput.setEditingState',
args: allOf(
......@@ -10531,9 +10544,10 @@ void main() {
expect(state.wantKeepAlive, true);
expect(formatter.formatCallCount, 0);
state.updateEditingValue(const TextEditingValue(text: 'test'));
state.updateEditingValue(const TextEditingValue(text: 'test', composing: TextRange(start: 1, end: 2)));
state.updateEditingValue(const TextEditingValue(text: '0')); // pass to formatter once to check the values.
state.updateEditingValue(collapsedAtEnd('test'));
state.updateEditingValue(collapsedAtEnd('test').copyWith(composing: const TextRange(start: 1, end: 2)));
// Pass to formatter once to check the values.
state.updateEditingValue(collapsedAtEnd('test'));
expect(formatter.lastOldValue.composing, const TextRange(start: 1, end: 2));
expect(formatter.lastOldValue.text, 'test');
});
......@@ -15528,6 +15542,65 @@ testWidgets('Floating cursor ending with selection', (WidgetTester tester) async
);
});
testWidgets('Selection is updated when the field has focus and the new selection is invalid', (WidgetTester tester) async {
// Regression test for https://github.com/flutter/flutter/issues/120631.
final TextEditingController controller = TextEditingController();
controller.text = 'Text';
final FocusNode focusNode = FocusNode();
await tester.pumpWidget(
MaterialApp(
home: EditableText(
key: ValueKey<String>(controller.text),
controller: controller,
focusNode: focusNode,
style: Typography.material2018().black.titleMedium!,
cursorColor: Colors.blue,
backgroundCursorColor: Colors.grey,
),
),
);
expect(focusNode.hasFocus, isFalse);
expect(
controller.selection,
const TextSelection.collapsed(offset: -1),
);
// Tab to focus the field.
await tester.sendKeyEvent(LogicalKeyboardKey.tab);
await tester.pumpAndSettle();
expect(focusNode.hasFocus, isTrue);
expect(
controller.selection,
kIsWeb
? TextSelection(
baseOffset: 0,
extentOffset: controller.text.length,
)
: TextSelection.collapsed(
offset: controller.text.length,
),
);
// Update text without specifying the selection.
controller.text = 'Updated';
// As the TextField is focused the selection should be automatically adjusted.
expect(focusNode.hasFocus, isTrue);
expect(
controller.selection,
kIsWeb
? TextSelection(
baseOffset: 0,
extentOffset: controller.text.length,
)
: TextSelection.collapsed(
offset: controller.text.length,
),
);
});
testWidgets('when having focus stolen between frames on web', (WidgetTester tester) async {
final TextEditingController controller1 = TextEditingController();
controller1.text = 'Text1';
......
......@@ -621,11 +621,13 @@ void main() {
expect(paragraph1.selections.isEmpty, isTrue);
expect(paragraph2.selections.isEmpty, isTrue);
// Reset selection and focus selectable region.
controller.selection = const TextSelection.collapsed(offset: -1);
// Focus selectable region.
selectableRegionFocus.requestFocus();
await tester.pump();
// Reset controller selection once the TextField is unfocused.
controller.selection = const TextSelection.collapsed(offset: -1);
// Make sure keyboard select all will be handled by selectable region now.
await sendKeyCombination(tester, const SingleActivator(LogicalKeyboardKey.keyA, control: true));
expect(controller.selection, const TextSelection.collapsed(offset: -1));
......@@ -672,11 +674,13 @@ void main() {
expect(paragraph1.selections.isEmpty, isTrue);
expect(paragraph2.selections.isEmpty, isTrue);
// Reset selection and focus selectable region.
controller.selection = const TextSelection.collapsed(offset: -1);
// Focus selectable region.
selectableRegionFocus.requestFocus();
await tester.pump();
// Reset controller selection once the TextField is unfocused.
controller.selection = const TextSelection.collapsed(offset: -1);
// Make sure keyboard select all will be handled by selectable region now.
await sendKeyCombination(tester, const SingleActivator(LogicalKeyboardKey.keyA, meta: true));
expect(controller.selection, const TextSelection.collapsed(offset: -1));
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment