Unverified Commit 1cad96a6 authored by stuartmorgan's avatar stuartmorgan Committed by GitHub

Handle surrogate pairs in RenderEditable (#55246)

The arrow key and delete handling in RenderEditable was using single
index values, which made it possible to move the cursor into the middle
of a surrogate pair (allowing things like adding text at that insertion
point), or to delete half of a surrogate pair. Since unpaired surrogate
pairs aren't valid UTF-16, doing so would cause assertions in the text
field.

This makes the arrow key and delete key handling surrogate-aware
(although not grapheme-cluster-aware, which is a larger fix that is out
of scope here).

Part of #55014
parent 5f147b57
......@@ -138,6 +138,18 @@ bool _isWhitespace(int codeUnit) {
return true;
}
/// Returns true if [codeUnit] is a leading (high) surrogate for a surrogate
/// pair.
bool _isLeadingSurrogate(int codeUnit) {
return codeUnit & 0xFC00 == 0xD800;
}
/// Returns true if [codeUnit] is a trailing (low) surrogate for a surrogate
/// pair.
bool _isTrailingSurrogate(int codeUnit) {
return codeUnit & 0xFC00 == 0xDC00;
}
/// Displays some text in a scrollable container with a potentially blinking
/// cursor and with gesture recognizers.
///
......@@ -597,12 +609,14 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin {
}
} else {
if (rightArrow && newSelection.extentOffset < _plainText.length) {
newSelection = newSelection.copyWith(extentOffset: newSelection.extentOffset + 1);
final int delta = _isLeadingSurrogate(text.codeUnitAt(newSelection.extentOffset)) ? 2 : 1;
newSelection = newSelection.copyWith(extentOffset: newSelection.extentOffset + delta);
if (shift) {
_cursorResetLocation += 1;
}
} else if (leftArrow && newSelection.extentOffset > 0) {
newSelection = newSelection.copyWith(extentOffset: newSelection.extentOffset - 1);
final int delta = _isTrailingSurrogate(text.codeUnitAt(newSelection.extentOffset - 1)) ? 2 : 1;
newSelection = newSelection.copyWith(extentOffset: newSelection.extentOffset - delta);
if (shift) {
_cursorResetLocation -= 1;
}
......@@ -720,10 +734,12 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin {
}
void _handleDelete() {
if (selection.textAfter(_plainText).isNotEmpty) {
final String textAfter = selection.textAfter(_plainText);
if (textAfter.isNotEmpty) {
final int deleteCount = _isLeadingSurrogate(textAfter.codeUnitAt(0)) ? 2 : 1;
textSelectionDelegate.textEditingValue = TextEditingValue(
text: selection.textBefore(_plainText)
+ selection.textAfter(_plainText).substring(1),
+ selection.textAfter(_plainText).substring(deleteCount),
selection: TextSelection.collapsed(offset: selection.start),
);
} else {
......
......@@ -14,10 +14,7 @@ import 'rendering_tester.dart';
class FakeEditableTextState with TextSelectionDelegate {
@override
TextEditingValue get textEditingValue { return const TextEditingValue(); }
@override
set textEditingValue(TextEditingValue value) { }
TextEditingValue textEditingValue = const TextEditingValue();
@override
void hideToolbar() { }
......@@ -701,4 +698,102 @@ void main() {
editable.layout(BoxConstraints.loose(const Size(1000.0, 1000.0)));
expect(editable.maxScrollExtent, equals(10));
}, skip: isBrowser); // TODO(yjbanov): https://github.com/flutter/flutter/issues/42772
test('arrow keys and delete handle simple text correctly', () async {
final TextSelectionDelegate delegate = FakeEditableTextState();
final ViewportOffset viewportOffset = ViewportOffset.zero();
TextSelection currentSelection;
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) {
currentSelection = selection;
},
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;
editable.selectPositionAt(from: const Offset(0, 0), cause: SelectionChangedCause.tap);
editable.selection = const TextSelection.collapsed(offset: 0);
pumpFrame();
await simulateKeyDownEvent(LogicalKeyboardKey.arrowRight, platform: 'android');
await simulateKeyUpEvent(LogicalKeyboardKey.arrowRight, platform: 'android');
expect(currentSelection.isCollapsed, true);
expect(currentSelection.baseOffset, 1);
await simulateKeyDownEvent(LogicalKeyboardKey.arrowLeft, platform: 'android');
await simulateKeyUpEvent(LogicalKeyboardKey.arrowLeft, platform: 'android');
expect(currentSelection.isCollapsed, true);
expect(currentSelection.baseOffset, 0);
await simulateKeyDownEvent(LogicalKeyboardKey.delete, platform: 'android');
await simulateKeyUpEvent(LogicalKeyboardKey.delete, platform: 'android');
expect(delegate.textEditingValue.text, 'est');
}, skip: kIsWeb);
test('arrow keys and delete handle surrogate pairs correctly', () async {
final TextSelectionDelegate delegate = FakeEditableTextState();
final ViewportOffset viewportOffset = ViewportOffset.zero();
TextSelection currentSelection;
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) {
currentSelection = selection;
},
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: 0,
),
);
layout(editable);
editable.hasFocus = true;
editable.selectPositionAt(from: const Offset(0, 0), cause: SelectionChangedCause.tap);
editable.selection = const TextSelection.collapsed(offset: 0);
pumpFrame();
await simulateKeyDownEvent(LogicalKeyboardKey.arrowRight, platform: 'android');
await simulateKeyUpEvent(LogicalKeyboardKey.arrowRight, platform: 'android');
expect(currentSelection.isCollapsed, true);
expect(currentSelection.baseOffset, 2);
await simulateKeyDownEvent(LogicalKeyboardKey.arrowLeft, platform: 'android');
await simulateKeyUpEvent(LogicalKeyboardKey.arrowLeft, platform: 'android');
expect(currentSelection.isCollapsed, true);
expect(currentSelection.baseOffset, 0);
await simulateKeyDownEvent(LogicalKeyboardKey.delete, platform: 'android');
await simulateKeyUpEvent(LogicalKeyboardKey.delete, platform: 'android');
expect(delegate.textEditingValue.text, '');
}, skip: kIsWeb); // Key simulation doesn't work on web.
}
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