Unverified Commit 3da995ad authored by stuartmorgan's avatar stuartmorgan Committed by GitHub

Handle backspace in text fields (#68812)

Currently the framework handles delete, but not backspace, so embeddings
all have to implement backspace handling themselves. This eliminates
that inconsistency and allows simplified code in embeddings by adding
backspace handling.

It also fixes a bug uncovered in the delete handling where deleting a
selection would also delete the next character after the selection.
parent f63d56e4
......@@ -484,6 +484,7 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin {
LogicalKeyboardKey.keyV,
LogicalKeyboardKey.keyX,
LogicalKeyboardKey.delete,
LogicalKeyboardKey.backspace,
};
static final Set<LogicalKeyboardKey> _nonModifierKeys = <LogicalKeyboardKey>{
......@@ -544,7 +545,9 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin {
// as the _handleKeyEvent method
_handleShortcuts(key);
} else if (key == LogicalKeyboardKey.delete) {
_handleDelete();
_handleDelete(forward: true);
} else if (key == LogicalKeyboardKey.backspace) {
_handleDelete(forward: false);
}
}
......@@ -813,23 +816,28 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin {
}
}
void _handleDelete() {
void _handleDelete({ required bool forward }) {
assert(_selection != null);
final String textAfter = selection!.textAfter(_plainText);
if (textAfter.isNotEmpty) {
String textBefore = selection!.textBefore(_plainText);
String textAfter = selection!.textAfter(_plainText);
int cursorPosition = selection!.start;
// If not deleting a selection, delete the next/previous character.
if (selection!.isCollapsed) {
if (!forward && textBefore.isNotEmpty) {
final int characterBoundary = previousCharacter(textBefore.length, textBefore);
textBefore = textBefore.substring(0, characterBoundary);
cursorPosition = characterBoundary;
}
if (forward && textAfter.isNotEmpty) {
final int deleteCount = nextCharacter(0, textAfter);
textAfter = textAfter.substring(deleteCount);
}
}
textSelectionDelegate.textEditingValue = TextEditingValue(
text: selection!.textBefore(_plainText)
+ selection!.textAfter(_plainText).substring(deleteCount),
selection: TextSelection.collapsed(offset: selection!.start),
);
} else {
textSelectionDelegate.textEditingValue = TextEditingValue(
text: selection!.textBefore(_plainText),
selection: TextSelection.collapsed(offset: selection!.start),
text: textBefore + textAfter,
selection: TextSelection.collapsed(offset: cursorPosition),
);
}
}
/// Marks the render object as needing to be laid out again and have its text
/// metrics recomputed.
......
......@@ -997,6 +997,241 @@ void main() {
}, skip: isBrowser); // https://github.com/flutter/flutter/issues/58068
group('delete', () {
test('handles selection', () async {
final TextSelectionDelegate delegate = FakeEditableTextState();
final ViewportOffset viewportOffset = ViewportOffset.zero();
final RenderEditable editable = RenderEditable(
backgroundCursorColor: Colors.grey,
selectionColor: Colors.black,
textDirection: TextDirection.ltr,
cursorColor: Colors.red,
offset: viewportOffset,
textSelectionDelegate: delegate,
onSelectionChanged: (TextSelection selection, RenderEditable renderObject, SelectionChangedCause cause) {},
startHandleLayerLink: LayerLink(),
endHandleLayerLink: LayerLink(),
text: const TextSpan(
text: 'test',
style: TextStyle(
height: 1.0, fontSize: 10.0, fontFamily: 'Ahem',
),
),
selection: const TextSelection(baseOffset: 1, extentOffset: 3),
);
layout(editable);
editable.hasFocus = true;
pumpFrame();
await simulateKeyDownEvent(LogicalKeyboardKey.delete, platform: 'android');
await simulateKeyUpEvent(LogicalKeyboardKey.delete, platform: 'android');
expect(delegate.textEditingValue.text, 'tt');
expect(delegate.textEditingValue.selection.isCollapsed, true);
expect(delegate.textEditingValue.selection.baseOffset, 1);
}, skip: isBrowser); // https://github.com/flutter/flutter/issues/61021
test('is a no-op at the end of the text', () async {
final TextSelectionDelegate delegate = FakeEditableTextState();
final ViewportOffset viewportOffset = ViewportOffset.zero();
final RenderEditable editable = RenderEditable(
backgroundCursorColor: Colors.grey,
selectionColor: Colors.black,
textDirection: TextDirection.ltr,
cursorColor: Colors.red,
offset: viewportOffset,
textSelectionDelegate: delegate,
onSelectionChanged: (TextSelection selection, RenderEditable renderObject, SelectionChangedCause cause) {},
startHandleLayerLink: LayerLink(),
endHandleLayerLink: LayerLink(),
text: const TextSpan(
text: 'test',
style: TextStyle(
height: 1.0, fontSize: 10.0, fontFamily: 'Ahem',
),
),
selection: const TextSelection.collapsed(offset: 4),
);
layout(editable);
editable.hasFocus = true;
pumpFrame();
await simulateKeyDownEvent(LogicalKeyboardKey.delete, platform: 'android');
await simulateKeyUpEvent(LogicalKeyboardKey.delete, platform: 'android');
expect(delegate.textEditingValue.text, 'test');
expect(delegate.textEditingValue.selection.isCollapsed, true);
expect(delegate.textEditingValue.selection.baseOffset, 4);
}, skip: isBrowser); // https://github.com/flutter/flutter/issues/61021
});
group('backspace', () {
test('handles selection', () async {
final TextSelectionDelegate delegate = FakeEditableTextState();
final ViewportOffset viewportOffset = ViewportOffset.zero();
final RenderEditable editable = RenderEditable(
backgroundCursorColor: Colors.grey,
selectionColor: Colors.black,
textDirection: TextDirection.ltr,
cursorColor: Colors.red,
offset: viewportOffset,
textSelectionDelegate: delegate,
onSelectionChanged: (TextSelection selection, RenderEditable renderObject, SelectionChangedCause cause) {},
startHandleLayerLink: LayerLink(),
endHandleLayerLink: LayerLink(),
text: const TextSpan(
text: 'test',
style: TextStyle(
height: 1.0, fontSize: 10.0, fontFamily: 'Ahem',
),
),
selection: const TextSelection(baseOffset: 1, extentOffset: 3),
);
layout(editable);
editable.hasFocus = true;
pumpFrame();
await simulateKeyDownEvent(LogicalKeyboardKey.backspace, platform: 'android');
await simulateKeyUpEvent(LogicalKeyboardKey.backspace, platform: 'android');
expect(delegate.textEditingValue.text, 'tt');
expect(delegate.textEditingValue.selection.isCollapsed, true);
expect(delegate.textEditingValue.selection.baseOffset, 1);
}, skip: isBrowser); // https://github.com/flutter/flutter/issues/61021
test('handles simple text', () async {
final TextSelectionDelegate delegate = FakeEditableTextState();
final ViewportOffset viewportOffset = ViewportOffset.zero();
final RenderEditable editable = RenderEditable(
backgroundCursorColor: Colors.grey,
selectionColor: Colors.black,
textDirection: TextDirection.ltr,
cursorColor: Colors.red,
offset: viewportOffset,
textSelectionDelegate: delegate,
onSelectionChanged: (TextSelection selection, RenderEditable renderObject, SelectionChangedCause cause) {},
startHandleLayerLink: LayerLink(),
endHandleLayerLink: LayerLink(),
text: const TextSpan(
text: 'test',
style: TextStyle(
height: 1.0, fontSize: 10.0, fontFamily: 'Ahem',
),
),
selection: const TextSelection.collapsed(offset: 3),
);
layout(editable);
editable.hasFocus = true;
pumpFrame();
await simulateKeyDownEvent(LogicalKeyboardKey.backspace, platform: 'android');
await simulateKeyUpEvent(LogicalKeyboardKey.backspace, platform: 'android');
expect(delegate.textEditingValue.text, 'tet');
expect(delegate.textEditingValue.selection.isCollapsed, true);
expect(delegate.textEditingValue.selection.baseOffset, 2);
}, skip: isBrowser); // https://github.com/flutter/flutter/issues/61021
test('handles surrogate pairs', () async {
final TextSelectionDelegate delegate = FakeEditableTextState();
final ViewportOffset viewportOffset = ViewportOffset.zero();
final RenderEditable editable = RenderEditable(
backgroundCursorColor: Colors.grey,
selectionColor: Colors.black,
textDirection: TextDirection.ltr,
cursorColor: Colors.red,
offset: viewportOffset,
textSelectionDelegate: delegate,
onSelectionChanged: (TextSelection selection, RenderEditable renderObject, SelectionChangedCause cause) {},
startHandleLayerLink: LayerLink(),
endHandleLayerLink: LayerLink(),
text: const TextSpan(
text: '\u{1F44D}', // Thumbs up
style: TextStyle(
height: 1.0, fontSize: 10.0, fontFamily: 'Ahem',
),
),
selection: const TextSelection.collapsed(offset: 2),
);
layout(editable);
editable.hasFocus = true;
pumpFrame();
await simulateKeyDownEvent(LogicalKeyboardKey.backspace, platform: 'android');
await simulateKeyUpEvent(LogicalKeyboardKey.backspace, platform: 'android');
expect(delegate.textEditingValue.text, '');
expect(delegate.textEditingValue.selection.isCollapsed, true);
expect(delegate.textEditingValue.selection.baseOffset, 0);
}, skip: isBrowser); // https://github.com/flutter/flutter/issues/61021
test('handles grapheme clusters', () async {
final TextSelectionDelegate delegate = FakeEditableTextState();
final ViewportOffset viewportOffset = ViewportOffset.zero();
final RenderEditable editable = RenderEditable(
backgroundCursorColor: Colors.grey,
selectionColor: Colors.black,
textDirection: TextDirection.ltr,
cursorColor: Colors.red,
offset: viewportOffset,
textSelectionDelegate: delegate,
onSelectionChanged: (TextSelection selection, RenderEditable renderObject, SelectionChangedCause cause) {},
startHandleLayerLink: LayerLink(),
endHandleLayerLink: LayerLink(),
text: const TextSpan(
text: '0123👨‍👩‍👦2345',
style: TextStyle(
height: 1.0, fontSize: 10.0, fontFamily: 'Ahem',
),
),
selection: const TextSelection.collapsed(offset: 12),
);
layout(editable);
editable.hasFocus = true;
pumpFrame();
await simulateKeyDownEvent(LogicalKeyboardKey.backspace, platform: 'android');
await simulateKeyUpEvent(LogicalKeyboardKey.backspace, platform: 'android');
expect(delegate.textEditingValue.text, '01232345');
expect(delegate.textEditingValue.selection.isCollapsed, true);
expect(delegate.textEditingValue.selection.baseOffset, 4);
}, skip: isBrowser); // https://github.com/flutter/flutter/issues/61021
test('is a no-op at the start of the text', () async {
final TextSelectionDelegate delegate = FakeEditableTextState();
final ViewportOffset viewportOffset = ViewportOffset.zero();
final RenderEditable editable = RenderEditable(
backgroundCursorColor: Colors.grey,
selectionColor: Colors.black,
textDirection: TextDirection.ltr,
cursorColor: Colors.red,
offset: viewportOffset,
textSelectionDelegate: delegate,
onSelectionChanged: (TextSelection selection, RenderEditable renderObject, SelectionChangedCause cause) {},
startHandleLayerLink: LayerLink(),
endHandleLayerLink: LayerLink(),
text: const TextSpan(
text: 'test',
style: TextStyle(
height: 1.0, fontSize: 10.0, fontFamily: 'Ahem',
),
),
selection: const TextSelection.collapsed(offset: 0),
);
layout(editable);
editable.hasFocus = true;
pumpFrame();
await simulateKeyDownEvent(LogicalKeyboardKey.backspace, platform: 'android');
await simulateKeyUpEvent(LogicalKeyboardKey.backspace, platform: 'android');
expect(delegate.textEditingValue.text, 'test');
expect(delegate.textEditingValue.selection.isCollapsed, true);
expect(delegate.textEditingValue.selection.baseOffset, 0);
}, skip: isBrowser); // https://github.com/flutter/flutter/issues/61021
});
test('getEndpointsForSelection handles empty characters', () {
final TextSelectionDelegate delegate = FakeEditableTextState();
final RenderEditable editable = RenderEditable(
......
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