Unverified Commit 2a573a32 authored by Justin McCandless's avatar Justin McCandless Committed by GitHub

Reland Characters Usage (#59778)

Use Dart's characters package to fix user-facing grapheme cluster bugs.
parent f61a4e71
......@@ -6,6 +6,7 @@
import 'dart:ui' as ui show BoxHeightStyle, BoxWidthStyle;
import 'package:characters/characters.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart';
......@@ -818,9 +819,9 @@ class _TextFieldState extends State<TextField> implements TextSelectionGestureDe
bool get _isEnabled => widget.enabled ?? widget.decoration?.enabled ?? true;
int get _currentLength => _effectiveController.value.text.runes.length;
int get _currentLength => _effectiveController.value.text.characters.length;
bool get _hasIntrinsicError => widget.maxLength != null && widget.maxLength > 0 && _effectiveController.value.text.runes.length > widget.maxLength;
bool get _hasIntrinsicError => widget.maxLength != null && widget.maxLength > 0 && _effectiveController.value.text.characters.length > widget.maxLength;
bool get _hasError => widget.decoration?.errorText != null || _hasIntrinsicError;
......
......@@ -603,7 +603,7 @@ class TextPainter {
// Complex glyphs can be represented by two or more UTF16 codepoints. This
// checks if the value represents a UTF16 glyph by itself or is a 'surrogate'.
bool _isUtf16Surrogate(int value) {
static bool _isUtf16Surrogate(int value) {
return value & 0xF800 == 0xD800;
}
......@@ -611,7 +611,7 @@ class TextPainter {
// up zero space and do not have valid bounding boxes around them.
//
// We do not directly use the [Unicode] constants since they are strings.
bool _isUnicodeDirectionality(int value) {
static bool _isUnicodeDirectionality(int value) {
return value == 0x200F || value == 0x200E;
}
......@@ -640,15 +640,13 @@ class TextPainter {
// Get the Rect of the cursor (in logical pixels) based off the near edge
// of the character upstream from the given string offset.
// TODO(garyq): Use actual extended grapheme cluster length instead of
// an increasing cluster length amount to achieve deterministic performance.
Rect _getRectFromUpstream(int offset, Rect caretPrototype) {
final String flattenedText = _text.toPlainText(includePlaceholders: false);
final int prevCodeUnit = _text.codeUnitAt(max(0, offset - 1));
if (prevCodeUnit == null)
return null;
// Check for multi-code-unit glyphs such as emojis or zero width joiner
// Check for multi-code-unit glyphs such as emojis or zero width joiner.
final bool needsSearch = _isUtf16Surrogate(prevCodeUnit) || _text.codeUnitAt(offset) == _zwjUtf16 || _isUnicodeDirectionality(prevCodeUnit);
int graphemeClusterLength = needsSearch ? 2 : 1;
List<TextBox> boxes = <TextBox>[];
......@@ -691,8 +689,6 @@ class TextPainter {
// Get the Rect of the cursor (in logical pixels) based off the near edge
// of the character downstream from the given string offset.
// TODO(garyq): Use actual extended grapheme cluster length instead of
// an increasing cluster length amount to achieve deterministic performance.
Rect _getRectFromDownstream(int offset, Rect caretPrototype) {
final String flattenedText = _text.toPlainText(includePlaceholders: false);
// We cap the offset at the final index of the _text.
......
......@@ -7,6 +7,7 @@
import 'dart:math' as math;
import 'dart:ui' as ui show TextBox, lerpDouble, BoxHeightStyle, BoxWidthStyle;
import 'package:characters/characters.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/semantics.dart';
......@@ -140,18 +141,6 @@ 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.
///
......@@ -252,7 +241,7 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin {
assert(ignorePointer != null),
assert(textWidthBasis != null),
assert(paintCursorAboveText != null),
assert(obscuringCharacter != null && obscuringCharacter.length == 1),
assert(obscuringCharacter != null && obscuringCharacter.characters.length == 1),
assert(obscureText != null),
assert(textSelectionDelegate != null),
assert(cursorWidth != null && cursorWidth >= 0.0),
......@@ -369,7 +358,7 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin {
if (_obscuringCharacter == value) {
return;
}
assert(value != null && value.length == 1);
assert(value != null && value.characters.length == 1);
_obscuringCharacter = value;
markNeedsLayout();
}
......@@ -521,10 +510,6 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin {
..._nonModifierKeys,
};
// TODO(goderbauer): doesn't handle extended grapheme clusters with more than one Unicode scalar value (https://github.com/flutter/flutter/issues/13404).
// This is because some of this code depends upon counting the length of the
// string using Unicode scalar values, rather than using the number of
// extended grapheme clusters (a.k.a. "characters" in the end user's mind).
void _handleKeyEvent(RawKeyEvent keyEvent) {
if(kIsWeb) {
// On web platform, we should ignore the key because it's processed already.
......@@ -560,6 +545,71 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin {
}
}
/// Returns the index into the string of the next character boundary after the
/// given index.
///
/// The character boundary is determined by the characters package, so
/// surrogate pairs and extended grapheme clusters are considered.
///
/// The index must be between 0 and string.length, inclusive. If given
/// string.length, string.length is returned.
///
/// Setting includeWhitespace to false will only return the index of non-space
/// characters.
@visibleForTesting
static int nextCharacter(int index, String string, [bool includeWhitespace = true]) {
assert(index >= 0 && index <= string.length);
if (index == string.length) {
return string.length;
}
int count = 0;
final Characters remaining = string.characters.skipWhile((String currentString) {
if (count <= index) {
count += currentString.length;
return true;
}
if (includeWhitespace) {
return false;
}
return _isWhitespace(currentString.codeUnitAt(0));
});
return string.length - remaining.toString().length;
}
/// Returns the index into the string of the previous character boundary
/// before the given index.
///
/// The character boundary is determined by the characters package, so
/// surrogate pairs and extended grapheme clusters are considered.
///
/// The index must be between 0 and string.length, inclusive. If index is 0,
/// 0 will be returned.
///
/// Setting includeWhitespace to false will only return the index of non-space
/// characters.
@visibleForTesting
static int previousCharacter(int index, String string, [bool includeWhitespace = true]) {
assert(index >= 0 && index <= string.length);
if (index == 0) {
return 0;
}
int count = 0;
int lastNonWhitespace;
for (final String currentString in string.characters) {
if (!includeWhitespace &&
!_isWhitespace(currentString.characters.first.toString().codeUnitAt(0))) {
lastNonWhitespace = count;
}
if (count + currentString.length >= index) {
return includeWhitespace ? count : lastNonWhitespace ?? 0;
}
count += currentString.length;
}
return 0;
}
void _handleMovement(
LogicalKeyboardKey key, {
@required bool wordModifier,
......@@ -578,23 +628,6 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin {
final bool upArrow = key == LogicalKeyboardKey.arrowUp;
final bool downArrow = key == LogicalKeyboardKey.arrowDown;
// Find the previous non-whitespace character
int previousNonWhitespace(int extent) {
int result = math.max(extent - 1, 0);
while (result > 0 && _isWhitespace(_plainText.codeUnitAt(result))) {
result -= 1;
}
return result;
}
int nextNonWhitespace(int extent) {
int result = math.min(extent + 1, _plainText.length);
while (result < _plainText.length && _isWhitespace(_plainText.codeUnitAt(result))) {
result += 1;
}
return result;
}
if ((rightArrow || leftArrow) && !(rightArrow && leftArrow)) {
// Jump to begin/end of word.
if (wordModifier) {
......@@ -605,7 +638,7 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin {
// so we go back to the first non-whitespace before asking for the word
// boundary, since _selectWordAtOffset finds the word boundaries without
// including whitespace.
final int startPoint = previousNonWhitespace(newSelection.extentOffset);
final int startPoint = previousCharacter(newSelection.extentOffset, _plainText, false);
final TextSelection textSelection = _selectWordAtOffset(TextPosition(offset: startPoint));
newSelection = newSelection.copyWith(extentOffset: textSelection.baseOffset);
} else {
......@@ -613,7 +646,7 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin {
// so we go forward to the first non-whitespace character before asking
// for the word bounds, since _selectWordAtOffset finds the word
// boundaries without including whitespace.
final int startPoint = nextNonWhitespace(newSelection.extentOffset);
final int startPoint = nextCharacter(newSelection.extentOffset, _plainText, false);
final TextSelection textSelection = _selectWordAtOffset(TextPosition(offset: startPoint));
newSelection = newSelection.copyWith(extentOffset: textSelection.extentOffset);
}
......@@ -625,7 +658,7 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin {
// so we go back to the first non-whitespace before asking for the line
// bounds, since _selectLineAtOffset finds the line boundaries without
// including whitespace (like the newline).
final int startPoint = previousNonWhitespace(newSelection.extentOffset);
final int startPoint = previousCharacter(newSelection.extentOffset, _plainText, false);
final TextSelection textSelection = _selectLineAtOffset(TextPosition(offset: startPoint));
newSelection = newSelection.copyWith(extentOffset: textSelection.baseOffset);
} else {
......@@ -633,22 +666,24 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin {
// so we go forward to the first non-whitespace character before asking
// for the line bounds, since _selectLineAtOffset finds the line
// boundaries without including whitespace (like the newline).
final int startPoint = nextNonWhitespace(newSelection.extentOffset);
final int startPoint = nextCharacter(newSelection.extentOffset, _plainText, false);
final TextSelection textSelection = _selectLineAtOffset(TextPosition(offset: startPoint));
newSelection = newSelection.copyWith(extentOffset: textSelection.extentOffset);
}
} else {
if (rightArrow && newSelection.extentOffset < _plainText.length) {
final int delta = _isLeadingSurrogate(text.codeUnitAt(newSelection.extentOffset)) ? 2 : 1;
newSelection = newSelection.copyWith(extentOffset: newSelection.extentOffset + delta);
final int nextExtent = nextCharacter(newSelection.extentOffset, _plainText);
final int distance = nextExtent - newSelection.extentOffset;
newSelection = newSelection.copyWith(extentOffset: nextExtent);
if (shift) {
_cursorResetLocation += 1;
_cursorResetLocation += distance;
}
} else if (leftArrow && newSelection.extentOffset > 0) {
final int delta = _isTrailingSurrogate(text.codeUnitAt(newSelection.extentOffset - 1)) ? 2 : 1;
newSelection = newSelection.copyWith(extentOffset: newSelection.extentOffset - delta);
final int previousExtent = previousCharacter(newSelection.extentOffset, _plainText);
final int distance = newSelection.extentOffset - previousExtent;
newSelection = newSelection.copyWith(extentOffset: previousExtent);
if (shift) {
_cursorResetLocation -= 1;
_cursorResetLocation -= distance;
}
}
}
......@@ -766,7 +801,7 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin {
void _handleDelete() {
final String textAfter = selection.textAfter(_plainText);
if (textAfter.isNotEmpty) {
final int deleteCount = _isLeadingSurrogate(textAfter.codeUnitAt(0)) ? 2 : 1;
final int deleteCount = nextCharacter(0, textAfter);
textSelectionDelegate.textEditingValue = TextEditingValue(
text: selection.textBefore(_plainText)
+ selection.textAfter(_plainText).substring(deleteCount),
......
......@@ -6,6 +6,7 @@
import 'dart:math' as math;
import 'package:characters/characters.dart';
import 'package:flutter/foundation.dart';
import 'text_editing.dart';
......@@ -344,24 +345,24 @@ class LengthLimitingTextInputFormatter extends TextInputFormatter {
/// characters.
final int maxLength;
// TODO(justinmc): This should be updated to use characters instead of runes,
// see the comment in formatEditUpdate.
/// Truncate the given TextEditingValue to maxLength runes.
/// Truncate the given TextEditingValue to maxLength characters.
///
/// See also:
/// * [Dart's characters package](https://pub.dev/packages/characters).
/// * [Dart's documenetation on runes and grapheme clusters](https://dart.dev/guides/language/language-tour#runes-and-grapheme-clusters).
@visibleForTesting
static TextEditingValue truncate(TextEditingValue value, int maxLength) {
final TextSelection newSelection = value.selection.copyWith(
baseOffset: math.min(value.selection.start, maxLength),
extentOffset: math.min(value.selection.end, maxLength),
);
final RuneIterator iterator = RuneIterator(value.text);
if (iterator.moveNext())
for (int count = 0; count < maxLength; ++count)
if (!iterator.moveNext())
break;
final String truncated = value.text.substring(0, iterator.rawIndex);
final CharacterRange iterator = CharacterRange(value.text);
if (value.text.characters.length > maxLength) {
iterator.expandNext(maxLength);
}
final String truncated = iterator.current;
return TextEditingValue(
text: truncated,
selection: newSelection,
selection: value.selection.copyWith(
baseOffset: math.min(value.selection.start, truncated.length),
extentOffset: math.min(value.selection.end, truncated.length),
),
composing: TextRange.empty,
);
}
......@@ -371,18 +372,10 @@ class LengthLimitingTextInputFormatter extends TextInputFormatter {
TextEditingValue oldValue, // unused.
TextEditingValue newValue,
) {
// This does not count grapheme clusters (i.e. characters visible to the user),
// it counts Unicode runes, which leaves out a number of useful possible
// characters (like many emoji), so this will be inaccurate in the
// presence of those characters. The Dart lang bug
// https://github.com/dart-lang/sdk/issues/28404 has been filed to
// address this in Dart.
// TODO(justinmc): convert this to count actual characters using Dart's
// characters package (https://pub.dev/packages/characters).
if (maxLength != null && maxLength > 0 && newValue.text.runes.length > maxLength) {
if (maxLength != null && maxLength > 0 && newValue.text.characters.length > maxLength) {
// If already at the maximum and tried to enter even more, keep the old
// value.
if (oldValue.text.runes.length == maxLength) {
if (oldValue.text.characters.length == maxLength) {
return oldValue;
}
return truncate(newValue, maxLength);
......
......@@ -3518,6 +3518,36 @@ void main() {
expect(textController.text, '0123456789');
});
testWidgets('maxLength limits input with surrogate pairs.', (WidgetTester tester) async {
final TextEditingController textController = TextEditingController();
await tester.pumpWidget(boilerplate(
child: TextField(
controller: textController,
maxLength: 10,
),
));
const String surrogatePair = '😆';
await tester.enterText(find.byType(TextField), surrogatePair + '0123456789101112');
expect(textController.text, surrogatePair + '012345678');
});
testWidgets('maxLength limits input with grapheme clusters.', (WidgetTester tester) async {
final TextEditingController textController = TextEditingController();
await tester.pumpWidget(boilerplate(
child: TextField(
controller: textController,
maxLength: 10,
),
));
const String graphemeCluster = '👨‍👩‍👦';
await tester.enterText(find.byType(TextField), graphemeCluster + '0123456789101112');
expect(textController.text, graphemeCluster + '012345678');
});
testWidgets('maxLength limits input in the center of a maxed-out field.', (WidgetTester tester) async {
// Regression test for https://github.com/flutter/flutter/issues/37420.
final TextEditingController textController = TextEditingController();
......@@ -3645,6 +3675,96 @@ void main() {
expect(counterTextWidget.style.color, isNot(equals(Colors.deepPurpleAccent)));
});
testWidgets('maxLength shows warning when maxLengthEnforced is false with surrogate pairs.', (WidgetTester tester) async {
final TextEditingController textController = TextEditingController();
const TextStyle testStyle = TextStyle(color: Colors.deepPurpleAccent);
await tester.pumpWidget(boilerplate(
child: TextField(
decoration: const InputDecoration(errorStyle: testStyle),
controller: textController,
maxLength: 10,
maxLengthEnforced: false,
),
));
await tester.enterText(find.byType(TextField), '😆012345678910111');
await tester.pump();
expect(textController.text, '😆012345678910111');
expect(find.text('16/10'), findsOneWidget);
Text counterTextWidget = tester.widget(find.text('16/10'));
expect(counterTextWidget.style.color, equals(Colors.deepPurpleAccent));
await tester.enterText(find.byType(TextField), '😆012345678');
await tester.pump();
expect(textController.text, '😆012345678');
expect(find.text('10/10'), findsOneWidget);
counterTextWidget = tester.widget(find.text('10/10'));
expect(counterTextWidget.style.color, isNot(equals(Colors.deepPurpleAccent)));
});
testWidgets('maxLength shows warning when maxLengthEnforced is false with grapheme clusters.', (WidgetTester tester) async {
final TextEditingController textController = TextEditingController();
const TextStyle testStyle = TextStyle(color: Colors.deepPurpleAccent);
await tester.pumpWidget(boilerplate(
child: TextField(
decoration: const InputDecoration(errorStyle: testStyle),
controller: textController,
maxLength: 10,
maxLengthEnforced: false,
),
));
await tester.enterText(find.byType(TextField), '👨‍👩‍👦012345678910111');
await tester.pump();
expect(textController.text, '👨‍👩‍👦012345678910111');
expect(find.text('16/10'), findsOneWidget);
Text counterTextWidget = tester.widget(find.text('16/10'));
expect(counterTextWidget.style.color, equals(Colors.deepPurpleAccent));
await tester.enterText(find.byType(TextField), '👨‍👩‍👦012345678');
await tester.pump();
expect(textController.text, '👨‍👩‍👦012345678');
expect(find.text('10/10'), findsOneWidget);
counterTextWidget = tester.widget(find.text('10/10'));
expect(counterTextWidget.style.color, isNot(equals(Colors.deepPurpleAccent)));
});
testWidgets('maxLength limits input with surrogate pairs.', (WidgetTester tester) async {
final TextEditingController textController = TextEditingController();
await tester.pumpWidget(boilerplate(
child: TextField(
controller: textController,
maxLength: 10,
),
));
const String surrogatePair = '😆';
await tester.enterText(find.byType(TextField), surrogatePair + '0123456789101112');
expect(textController.text, surrogatePair + '012345678');
});
testWidgets('maxLength limits input with grapheme clusters.', (WidgetTester tester) async {
final TextEditingController textController = TextEditingController();
await tester.pumpWidget(boilerplate(
child: TextField(
controller: textController,
maxLength: 10,
),
));
const String graphemeCluster = '👨‍👩‍👦';
await tester.enterText(find.byType(TextField), graphemeCluster + '0123456789101112');
expect(textController.text, graphemeCluster + '012345678');
});
testWidgets('setting maxLength shows counter', (WidgetTester tester) async {
await tester.pumpWidget(const MaterialApp(
home: Material(
......@@ -3665,6 +3785,48 @@ void main() {
expect(find.text('5/10'), findsOneWidget);
});
testWidgets('maxLength counter measures surrogate pairs as one character', (WidgetTester tester) async {
await tester.pumpWidget(const MaterialApp(
home: Material(
child: Center(
child: TextField(
maxLength: 10,
),
),
),
),
);
expect(find.text('0/10'), findsOneWidget);
const String surrogatePair = '😆';
await tester.enterText(find.byType(TextField), surrogatePair);
await tester.pump();
expect(find.text('1/10'), findsOneWidget);
});
testWidgets('maxLength counter measures grapheme clusters as one character', (WidgetTester tester) async {
await tester.pumpWidget(const MaterialApp(
home: Material(
child: Center(
child: TextField(
maxLength: 10,
),
),
),
),
);
expect(find.text('0/10'), findsOneWidget);
const String familyEmoji = '👨‍👩‍👦';
await tester.enterText(find.byType(TextField), familyEmoji);
await tester.pump();
expect(find.text('1/10'), findsOneWidget);
});
testWidgets('setting maxLength to TextField.noMaxLength shows only entered length', (WidgetTester tester) async {
await tester.pumpWidget(const MaterialApp(
home: Material(
......
......@@ -27,7 +27,8 @@ void main() {
caretOffset = painter.getOffsetForCaret(ui.TextPosition(offset: text.length), ui.Rect.zero);
expect(caretOffset.dx, painter.width);
// Check that getOffsetForCaret handles a character that is encoded as a surrogate pair.
// Check that getOffsetForCaret handles a character that is encoded as a
// surrogate pair.
text = 'A\u{1F600}';
painter.text = TextSpan(text: text);
painter.layout();
......
......@@ -788,6 +788,106 @@ void main() {
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: '0123😆6789',
style: TextStyle(
height: 1.0, fontSize: 10.0, fontFamily: 'Ahem',
),
),
selection: const TextSelection.collapsed(
offset: 0,
),
);
layout(editable);
editable.hasFocus = true;
editable.selection = const TextSelection.collapsed(offset: 4);
pumpFrame();
await simulateKeyDownEvent(LogicalKeyboardKey.arrowRight, platform: 'android');
await simulateKeyUpEvent(LogicalKeyboardKey.arrowRight, platform: 'android');
expect(currentSelection.isCollapsed, true);
expect(currentSelection.baseOffset, 6);
editable.selection = currentSelection;
await simulateKeyDownEvent(LogicalKeyboardKey.arrowLeft, platform: 'android');
await simulateKeyUpEvent(LogicalKeyboardKey.arrowLeft, platform: 'android');
expect(currentSelection.isCollapsed, true);
expect(currentSelection.baseOffset, 4);
editable.selection = currentSelection;
await simulateKeyDownEvent(LogicalKeyboardKey.delete, platform: 'android');
await simulateKeyUpEvent(LogicalKeyboardKey.delete, platform: 'android');
expect(delegate.textEditingValue.text, '01236789');
}, skip: kIsWeb);
test('arrow keys and delete handle grapheme clusters 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: '0123👨‍👩‍👦2345',
style: TextStyle(
height: 1.0, fontSize: 10.0, fontFamily: 'Ahem',
),
),
selection: const TextSelection.collapsed(
offset: 0,
),
);
layout(editable);
editable.hasFocus = true;
editable.selection = const TextSelection.collapsed(offset: 4);
pumpFrame();
await simulateKeyDownEvent(LogicalKeyboardKey.arrowRight, platform: 'android');
await simulateKeyUpEvent(LogicalKeyboardKey.arrowRight, platform: 'android');
expect(currentSelection.isCollapsed, true);
expect(currentSelection.baseOffset, 12);
editable.selection = currentSelection;
await simulateKeyDownEvent(LogicalKeyboardKey.arrowLeft, platform: 'android');
await simulateKeyUpEvent(LogicalKeyboardKey.arrowLeft, platform: 'android');
expect(currentSelection.isCollapsed, true);
expect(currentSelection.baseOffset, 4);
editable.selection = currentSelection;
await simulateKeyDownEvent(LogicalKeyboardKey.delete, platform: 'android');
await simulateKeyUpEvent(LogicalKeyboardKey.delete, platform: 'android');
expect(delegate.textEditingValue.text, '01232345');
}, skip: kIsWeb);
test('arrow keys and delete handle surrogate pairs correctly', () async {
final TextSelectionDelegate delegate = FakeEditableTextState();
final ViewportOffset viewportOffset = ViewportOffset.zero();
......@@ -855,4 +955,98 @@ void main() {
const TextSelection(baseOffset: 0, extentOffset: 1));
expect(endpoints[0].point.dx, 0);
});
group('nextCharacter', () {
test('handles normal strings correctly', () {
expect(RenderEditable.nextCharacter(0, '01234567'), 1);
expect(RenderEditable.nextCharacter(3, '01234567'), 4);
expect(RenderEditable.nextCharacter(7, '01234567'), 8);
expect(RenderEditable.nextCharacter(8, '01234567'), 8);
});
test('throws for invalid indices', () {
expect(() => RenderEditable.nextCharacter(-1, '01234567'), throwsAssertionError);
expect(() => RenderEditable.nextCharacter(9, '01234567'), throwsAssertionError);
});
test('skips spaces in normal strings when includeWhitespace is false', () {
expect(RenderEditable.nextCharacter(3, '0123 5678', false), 5);
expect(RenderEditable.nextCharacter(4, '0123 5678', false), 5);
expect(RenderEditable.nextCharacter(3, '0123 0123', false), 10);
expect(RenderEditable.nextCharacter(2, '0123 0123', false), 3);
expect(RenderEditable.nextCharacter(4, '0123 0123', false), 10);
expect(RenderEditable.nextCharacter(9, '0123 0123', false), 10);
expect(RenderEditable.nextCharacter(10, '0123 0123', false), 11);
// If the subsequent characters are all whitespace, it returns the length
// of the string.
expect(RenderEditable.nextCharacter(5, '0123 ', false), 10);
});
test('handles surrogate pairs correctly', () {
expect(RenderEditable.nextCharacter(3, '0123👨👩👦0123'), 4);
expect(RenderEditable.nextCharacter(4, '0123👨👩👦0123'), 6);
expect(RenderEditable.nextCharacter(5, '0123👨👩👦0123'), 6);
expect(RenderEditable.nextCharacter(6, '0123👨👩👦0123'), 8);
expect(RenderEditable.nextCharacter(7, '0123👨👩👦0123'), 8);
expect(RenderEditable.nextCharacter(8, '0123👨👩👦0123'), 10);
expect(RenderEditable.nextCharacter(9, '0123👨👩👦0123'), 10);
expect(RenderEditable.nextCharacter(10, '0123👨👩👦0123'), 11);
});
test('handles extended grapheme clusters correctly', () {
expect(RenderEditable.nextCharacter(3, '0123👨‍👩‍👦2345'), 4);
expect(RenderEditable.nextCharacter(4, '0123👨‍👩‍👦2345'), 12);
// Even when extent falls within an extended grapheme cluster, it still
// identifies the whole grapheme cluster.
expect(RenderEditable.nextCharacter(5, '0123👨‍👩‍👦2345'), 12);
expect(RenderEditable.nextCharacter(12, '0123👨‍👩‍👦2345'), 13);
});
});
group('previousCharacter', () {
test('handles normal strings correctly', () {
expect(RenderEditable.previousCharacter(8, '01234567'), 7);
expect(RenderEditable.previousCharacter(0, '01234567'), 0);
expect(RenderEditable.previousCharacter(1, '01234567'), 0);
expect(RenderEditable.previousCharacter(5, '01234567'), 4);
expect(RenderEditable.previousCharacter(8, '01234567'), 7);
});
test('throws for invalid indices', () {
expect(() => RenderEditable.previousCharacter(-1, '01234567'), throwsAssertionError);
expect(() => RenderEditable.previousCharacter(9, '01234567'), throwsAssertionError);
});
test('skips spaces in normal strings when includeWhitespace is false', () {
expect(RenderEditable.previousCharacter(10, '0123 0123', false), 3);
expect(RenderEditable.previousCharacter(11, '0123 0123', false), 10);
expect(RenderEditable.previousCharacter(9, '0123 0123', false), 3);
expect(RenderEditable.previousCharacter(4, '0123 0123', false), 3);
expect(RenderEditable.previousCharacter(3, '0123 0123', false), 2);
// If the previous characters are all whitespace, it returns zero.
expect(RenderEditable.previousCharacter(3, ' 0123', false), 0);
});
test('handles surrogate pairs correctly', () {
expect(RenderEditable.previousCharacter(11, '0123👨👩👦0123'), 10);
expect(RenderEditable.previousCharacter(10, '0123👨👩👦0123'), 8);
expect(RenderEditable.previousCharacter(9, '0123👨👩👦0123'), 8);
expect(RenderEditable.previousCharacter(8, '0123👨👩👦0123'), 6);
expect(RenderEditable.previousCharacter(7, '0123👨👩👦0123'), 6);
expect(RenderEditable.previousCharacter(6, '0123👨👩👦0123'), 4);
expect(RenderEditable.previousCharacter(5, '0123👨👩👦0123'), 4);
expect(RenderEditable.previousCharacter(4, '0123👨👩👦0123'), 3);
expect(RenderEditable.previousCharacter(3, '0123👨👩👦0123'), 2);
});
test('handles extended grapheme clusters correctly', () {
expect(RenderEditable.previousCharacter(13, '0123👨‍👩‍👦2345'), 12);
// Even when extent falls within an extended grapheme cluster, it still
// identifies the whole grapheme cluster.
expect(RenderEditable.previousCharacter(12, '0123👨‍👩‍👦2345'), 4);
expect(RenderEditable.previousCharacter(11, '0123👨‍👩‍👦2345'), 4);
expect(RenderEditable.previousCharacter(5, '0123👨‍👩‍👦2345'), 4);
expect(RenderEditable.previousCharacter(4, '0123👨‍👩‍👦2345'), 3);
});
});
}
......@@ -8,6 +8,349 @@ import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
TextEditingValue testOldValue;
TextEditingValue testNewValue;
test('withFunction wraps formatting function', () {
testOldValue = const TextEditingValue();
testNewValue = const TextEditingValue();
TextEditingValue calledOldValue;
TextEditingValue calledNewValue;
final TextInputFormatter formatterUnderTest = TextInputFormatter.withFunction(
(TextEditingValue oldValue, TextEditingValue newValue) {
calledOldValue = oldValue;
calledNewValue = newValue;
return null;
}
);
formatterUnderTest.formatEditUpdate(testOldValue, testNewValue);
expect(calledOldValue, equals(testOldValue));
expect(calledNewValue, equals(testNewValue));
});
group('test provided formatters', () {
setUp(() {
// a1b(2c3
// d4)e5f6
// where the parentheses are the selection range.
testNewValue = const TextEditingValue(
text: 'a1b2c3\nd4e5f6',
selection: TextSelection(
baseOffset: 3,
extentOffset: 9,
),
);
});
test('test filtering formatter example', () {
const TextEditingValue intoTheWoods = TextEditingValue(text: 'Into the Woods');
expect(
FilteringTextInputFormatter('o', allow: true, replacementString: '*').formatEditUpdate(testOldValue, intoTheWoods),
const TextEditingValue(text: '*o*oo*'),
);
expect(
FilteringTextInputFormatter('o', allow: false, replacementString: '*').formatEditUpdate(testOldValue, intoTheWoods),
const TextEditingValue(text: 'Int* the W**ds'),
);
expect(
FilteringTextInputFormatter(RegExp('o+'), allow: true, replacementString: '*').formatEditUpdate(testOldValue, intoTheWoods),
const TextEditingValue(text: '*o*oo*'),
);
expect(
FilteringTextInputFormatter(RegExp('o+'), allow: false, replacementString: '*').formatEditUpdate(testOldValue, intoTheWoods),
const TextEditingValue(text: 'Int* the W*ds'),
);
const TextEditingValue selectedIntoTheWoods = TextEditingValue(text: 'Into the Woods', selection: TextSelection(baseOffset: 11, extentOffset: 14));
expect(
FilteringTextInputFormatter('o', allow: true, replacementString: '*').formatEditUpdate(testOldValue, selectedIntoTheWoods),
const TextEditingValue(text: '*o*oo*', selection: TextSelection(baseOffset: 4, extentOffset: 6)),
);
expect(
FilteringTextInputFormatter('o', allow: false, replacementString: '*').formatEditUpdate(testOldValue, selectedIntoTheWoods),
const TextEditingValue(text: 'Int* the W**ds', selection: TextSelection(baseOffset: 11, extentOffset: 14)),
);
expect(
FilteringTextInputFormatter(RegExp('o+'), allow: true, replacementString: '*').formatEditUpdate(testOldValue, selectedIntoTheWoods),
const TextEditingValue(text: '*o*oo*', selection: TextSelection(baseOffset: 4, extentOffset: 6)),
);
expect(
FilteringTextInputFormatter(RegExp('o+'), allow: false, replacementString: '*').formatEditUpdate(testOldValue, selectedIntoTheWoods),
const TextEditingValue(text: 'Int* the W**ds', selection: TextSelection(baseOffset: 11, extentOffset: 14)),
);
});
test('test filtering formatter, deny mode', () {
final TextEditingValue actualValue =
FilteringTextInputFormatter.deny(RegExp(r'[a-z]'))
.formatEditUpdate(testOldValue, testNewValue);
// Expecting
// 1(23
// 4)56
expect(actualValue, const TextEditingValue(
text: '123\n456',
selection: TextSelection(
baseOffset: 1,
extentOffset: 5,
),
));
});
test('test filtering formatter, deny mode (deprecated names)', () {
final TextEditingValue actualValue =
BlacklistingTextInputFormatter(RegExp(r'[a-z]'))
.formatEditUpdate(testOldValue, testNewValue);
// Expecting
// 1(23
// 4)56
expect(actualValue, const TextEditingValue(
text: '123\n456',
selection: TextSelection(
baseOffset: 1,
extentOffset: 5,
),
));
});
test('test single line formatter', () {
final TextEditingValue actualValue =
FilteringTextInputFormatter.singleLineFormatter
.formatEditUpdate(testOldValue, testNewValue);
// Expecting
// a1b(2c3d4)e5f6
expect(actualValue, const TextEditingValue(
text: 'a1b2c3d4e5f6',
selection: TextSelection(
baseOffset: 3,
extentOffset: 8,
),
));
});
test('test single line formatter (deprecated names)', () {
final TextEditingValue actualValue =
BlacklistingTextInputFormatter.singleLineFormatter
.formatEditUpdate(testOldValue, testNewValue);
// Expecting
// a1b(2c3d4)e5f6
expect(actualValue, const TextEditingValue(
text: 'a1b2c3d4e5f6',
selection: TextSelection(
baseOffset: 3,
extentOffset: 8,
),
));
});
test('test filtering formatter, allow mode', () {
final TextEditingValue actualValue =
FilteringTextInputFormatter.allow(RegExp(r'[a-c]'))
.formatEditUpdate(testOldValue, testNewValue);
// Expecting
// ab(c)
expect(actualValue, const TextEditingValue(
text: 'abc',
selection: TextSelection(
baseOffset: 2,
extentOffset: 3,
),
));
});
test('test filtering formatter, allow mode (deprecated names)', () {
final TextEditingValue actualValue =
WhitelistingTextInputFormatter(RegExp(r'[a-c]'))
.formatEditUpdate(testOldValue, testNewValue);
// Expecting
// ab(c)
expect(actualValue, const TextEditingValue(
text: 'abc',
selection: TextSelection(
baseOffset: 2,
extentOffset: 3,
),
));
});
test('test digits only formatter', () {
final TextEditingValue actualValue =
FilteringTextInputFormatter.digitsOnly
.formatEditUpdate(testOldValue, testNewValue);
// Expecting
// 1(234)56
expect(actualValue, const TextEditingValue(
text: '123456',
selection: TextSelection(
baseOffset: 1,
extentOffset: 4,
),
));
});
test('test digits only formatter (deprecated names)', () {
final TextEditingValue actualValue =
WhitelistingTextInputFormatter.digitsOnly
.formatEditUpdate(testOldValue, testNewValue);
// Expecting
// 1(234)56
expect(actualValue, const TextEditingValue(
text: '123456',
selection: TextSelection(
baseOffset: 1,
extentOffset: 4,
),
));
});
test('test length limiting formatter', () {
final TextEditingValue actualValue =
LengthLimitingTextInputFormatter(6)
.formatEditUpdate(testOldValue, testNewValue);
// Expecting
// a1b(2c3)
expect(actualValue, const TextEditingValue(
text: 'a1b2c3',
selection: TextSelection(
baseOffset: 3,
extentOffset: 6,
),
));
});
test('test length limiting formatter with zero-length string', () {
testNewValue = const TextEditingValue(
text: '',
selection: TextSelection(
baseOffset: 0,
extentOffset: 0,
),
);
final TextEditingValue actualValue =
LengthLimitingTextInputFormatter(1)
.formatEditUpdate(testOldValue, testNewValue);
// Expecting the empty string.
expect(actualValue, const TextEditingValue(
text: '',
selection: TextSelection(
baseOffset: 0,
extentOffset: 0,
),
));
});
test('test length limiting formatter with non-BMP Unicode scalar values', () {
testNewValue = const TextEditingValue(
text: '\u{1f984}\u{1f984}\u{1f984}\u{1f984}', // Unicode U+1f984 (UNICORN FACE)
selection: TextSelection(
// Caret is at the end of the string.
baseOffset: 8,
extentOffset: 8,
),
);
final TextEditingValue actualValue =
LengthLimitingTextInputFormatter(2)
.formatEditUpdate(testOldValue, testNewValue);
// Expecting two characters, with the caret moved to the new end of the
// string.
expect(actualValue, const TextEditingValue(
text: '\u{1f984}\u{1f984}',
selection: TextSelection(
baseOffset: 4,
extentOffset: 4,
),
));
});
test('test length limiting formatter with complex Unicode characters', () {
// TODO(gspencer): Test additional strings. We can do this once the
// formatter supports Unicode grapheme clusters.
//
// A formatter with max length 1 should accept:
// - The '\u{1F3F3}\u{FE0F}\u{200D}\u{1F308}' sequence (flag followed by
// a variation selector, a zero-width joiner, and a rainbow to make a rainbow
// flag).
// - The sequence '\u{0058}\u{0346}\u{0361}\u{035E}\u{032A}\u{031C}\u{0333}\u{0326}\u{031D}\u{0332}'
// (Latin X with many composed characters).
//
// A formatter should not count as a character:
// * The '\u{0000}\u{FEFF}' sequence. (NULL followed by zero-width no-break space).
//
// A formatter with max length 1 should truncate this to one character:
// * The '\u{1F3F3}\u{FE0F}\u{1F308}' sequence (flag with ignored variation
// selector followed by rainbow, should truncate to just flag).
// The U+1F984 U+0020 sequence: Unicorn face followed by a space should
// yield only the unicorn face.
testNewValue = const TextEditingValue(
text: '\u{1F984}\u{0020}',
selection: TextSelection(
baseOffset: 1,
extentOffset: 1,
),
);
TextEditingValue actualValue = LengthLimitingTextInputFormatter(1).formatEditUpdate(testOldValue, testNewValue);
expect(actualValue, const TextEditingValue(
text: '\u{1F984}',
selection: TextSelection(
baseOffset: 1,
extentOffset: 1,
),
));
// The U+0058 U+0059 sequence: Latin X followed by Latin Y, should yield
// Latin X.
testNewValue = const TextEditingValue(
text: '\u{0058}\u{0059}',
selection: TextSelection(
baseOffset: 1,
extentOffset: 1,
),
);
actualValue = LengthLimitingTextInputFormatter(1).formatEditUpdate(testOldValue, testNewValue);
expect(actualValue, const TextEditingValue(
text: '\u{0058}',
selection: TextSelection(
baseOffset: 1,
extentOffset: 1,
),
));
});
test('test length limiting formatter when selection is off the end', () {
final TextEditingValue actualValue =
LengthLimitingTextInputFormatter(2)
.formatEditUpdate(testOldValue, testNewValue);
// Expecting
// a1()
expect(actualValue, const TextEditingValue(
text: 'a1',
selection: TextSelection(
baseOffset: 2,
extentOffset: 2,
),
));
});
});
group('LengthLimitingTextInputFormatter', () {
group('truncate', () {
test('Removes characters from the end', () async {
......@@ -20,6 +363,40 @@ void main() {
.truncate(value, 10);
expect(truncated.text, '0123456789');
});
test('Counts surrogate pairs as single characters', () async {
const String stringOverflowing = '😆01234567890';
const TextEditingValue value = TextEditingValue(
text: stringOverflowing,
// Put the cursor at the end of the overflowing string to test if it
// ends up at the end of the new string after truncation.
selection: TextSelection.collapsed(offset: stringOverflowing.length),
composing: TextRange.empty,
);
final TextEditingValue truncated = LengthLimitingTextInputFormatter
.truncate(value, 10);
const String stringTruncated = '😆012345678';
expect(truncated.text, stringTruncated);
expect(truncated.selection.baseOffset, stringTruncated.length);
expect(truncated.selection.extentOffset, stringTruncated.length);
});
test('Counts grapheme clustsers as single characters', () async {
const String stringOverflowing = '👨‍👩‍👦01234567890';
const TextEditingValue value = TextEditingValue(
text: stringOverflowing,
// Put the cursor at the end of the overflowing string to test if it
// ends up at the end of the new string after truncation.
selection: TextSelection.collapsed(offset: stringOverflowing.length),
composing: TextRange.empty,
);
final TextEditingValue truncated = LengthLimitingTextInputFormatter
.truncate(value, 10);
const String stringTruncated = '👨‍👩‍👦012345678';
expect(truncated.text, stringTruncated);
expect(truncated.selection.baseOffset, stringTruncated.length);
expect(truncated.selection.extentOffset, stringTruncated.length);
});
});
group('formatEditUpdate', () {
......
// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
// @dart = 2.8
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
void main() {
const TextEditingValue testOldValue = TextEditingValue();
TextEditingValue testNewValue;
test('withFunction wraps formatting function', () {
testNewValue = const TextEditingValue();
TextEditingValue calledOldValue;
TextEditingValue calledNewValue;
final TextInputFormatter formatterUnderTest = TextInputFormatter.withFunction(
(TextEditingValue oldValue, TextEditingValue newValue) {
calledOldValue = oldValue;
calledNewValue = newValue;
return null;
}
);
formatterUnderTest.formatEditUpdate(testOldValue, testNewValue);
expect(calledOldValue, equals(testOldValue));
expect(calledNewValue, equals(testNewValue));
});
group('test provided formatters', () {
setUp(() {
// a1b(2c3
// d4)e5f6
// where the parentheses are the selection range.
testNewValue = const TextEditingValue(
text: 'a1b2c3\nd4e5f6',
selection: TextSelection(
baseOffset: 3,
extentOffset: 9,
),
);
});
test('test filtering formatter example', () {
const TextEditingValue intoTheWoods = TextEditingValue(text: 'Into the Woods');
expect(
FilteringTextInputFormatter('o', allow: true, replacementString: '*').formatEditUpdate(testOldValue, intoTheWoods),
const TextEditingValue(text: '*o*oo*'),
);
expect(
FilteringTextInputFormatter('o', allow: false, replacementString: '*').formatEditUpdate(testOldValue, intoTheWoods),
const TextEditingValue(text: 'Int* the W**ds'),
);
expect(
FilteringTextInputFormatter(RegExp('o+'), allow: true, replacementString: '*').formatEditUpdate(testOldValue, intoTheWoods),
const TextEditingValue(text: '*o*oo*'),
);
expect(
FilteringTextInputFormatter(RegExp('o+'), allow: false, replacementString: '*').formatEditUpdate(testOldValue, intoTheWoods),
const TextEditingValue(text: 'Int* the W*ds'),
);
const TextEditingValue selectedIntoTheWoods = TextEditingValue(text: 'Into the Woods', selection: TextSelection(baseOffset: 11, extentOffset: 14));
expect(
FilteringTextInputFormatter('o', allow: true, replacementString: '*').formatEditUpdate(testOldValue, selectedIntoTheWoods),
const TextEditingValue(text: '*o*oo*', selection: TextSelection(baseOffset: 4, extentOffset: 6)),
);
expect(
FilteringTextInputFormatter('o', allow: false, replacementString: '*').formatEditUpdate(testOldValue, selectedIntoTheWoods),
const TextEditingValue(text: 'Int* the W**ds', selection: TextSelection(baseOffset: 11, extentOffset: 14)),
);
expect(
FilteringTextInputFormatter(RegExp('o+'), allow: true, replacementString: '*').formatEditUpdate(testOldValue, selectedIntoTheWoods),
const TextEditingValue(text: '*o*oo*', selection: TextSelection(baseOffset: 4, extentOffset: 6)),
);
expect(
FilteringTextInputFormatter(RegExp('o+'), allow: false, replacementString: '*').formatEditUpdate(testOldValue, selectedIntoTheWoods),
const TextEditingValue(text: 'Int* the W**ds', selection: TextSelection(baseOffset: 11, extentOffset: 14)),
);
});
test('test filtering formatter, deny mode', () {
final TextEditingValue actualValue =
FilteringTextInputFormatter.deny(RegExp(r'[a-z]'))
.formatEditUpdate(testOldValue, testNewValue);
// Expecting
// 1(23
// 4)56
expect(actualValue, const TextEditingValue(
text: '123\n456',
selection: TextSelection(
baseOffset: 1,
extentOffset: 5,
),
));
});
test('test filtering formatter, deny mode (deprecated names)', () {
final TextEditingValue actualValue =
BlacklistingTextInputFormatter(RegExp(r'[a-z]'))
.formatEditUpdate(testOldValue, testNewValue);
// Expecting
// 1(23
// 4)56
expect(actualValue, const TextEditingValue(
text: '123\n456',
selection: TextSelection(
baseOffset: 1,
extentOffset: 5,
),
));
});
test('test single line formatter', () {
final TextEditingValue actualValue =
FilteringTextInputFormatter.singleLineFormatter
.formatEditUpdate(testOldValue, testNewValue);
// Expecting
// a1b(2c3d4)e5f6
expect(actualValue, const TextEditingValue(
text: 'a1b2c3d4e5f6',
selection: TextSelection(
baseOffset: 3,
extentOffset: 8,
),
));
});
test('test single line formatter (deprecated names)', () {
final TextEditingValue actualValue =
BlacklistingTextInputFormatter.singleLineFormatter
.formatEditUpdate(testOldValue, testNewValue);
// Expecting
// a1b(2c3d4)e5f6
expect(actualValue, const TextEditingValue(
text: 'a1b2c3d4e5f6',
selection: TextSelection(
baseOffset: 3,
extentOffset: 8,
),
));
});
test('test filtering formatter, allow mode', () {
final TextEditingValue actualValue =
FilteringTextInputFormatter.allow(RegExp(r'[a-c]'))
.formatEditUpdate(testOldValue, testNewValue);
// Expecting
// ab(c)
expect(actualValue, const TextEditingValue(
text: 'abc',
selection: TextSelection(
baseOffset: 2,
extentOffset: 3,
),
));
});
test('test filtering formatter, allow mode (deprecated names)', () {
final TextEditingValue actualValue =
WhitelistingTextInputFormatter(RegExp(r'[a-c]'))
.formatEditUpdate(testOldValue, testNewValue);
// Expecting
// ab(c)
expect(actualValue, const TextEditingValue(
text: 'abc',
selection: TextSelection(
baseOffset: 2,
extentOffset: 3,
),
));
});
test('test digits only formatter', () {
final TextEditingValue actualValue =
FilteringTextInputFormatter.digitsOnly
.formatEditUpdate(testOldValue, testNewValue);
// Expecting
// 1(234)56
expect(actualValue, const TextEditingValue(
text: '123456',
selection: TextSelection(
baseOffset: 1,
extentOffset: 4,
),
));
});
test('test digits only formatter (deprecated names)', () {
final TextEditingValue actualValue =
WhitelistingTextInputFormatter.digitsOnly
.formatEditUpdate(testOldValue, testNewValue);
// Expecting
// 1(234)56
expect(actualValue, const TextEditingValue(
text: '123456',
selection: TextSelection(
baseOffset: 1,
extentOffset: 4,
),
));
});
test('test length limiting formatter', () {
final TextEditingValue actualValue =
LengthLimitingTextInputFormatter(6)
.formatEditUpdate(testOldValue, testNewValue);
// Expecting
// a1b(2c3)
expect(actualValue, const TextEditingValue(
text: 'a1b2c3',
selection: TextSelection(
baseOffset: 3,
extentOffset: 6,
),
));
});
test('test length limiting formatter with zero-length string', () {
testNewValue = const TextEditingValue(
text: '',
selection: TextSelection(
baseOffset: 0,
extentOffset: 0,
),
);
final TextEditingValue actualValue =
LengthLimitingTextInputFormatter(1)
.formatEditUpdate(testOldValue, testNewValue);
// Expecting the empty string.
expect(actualValue, const TextEditingValue(
text: '',
selection: TextSelection(
baseOffset: 0,
extentOffset: 0,
),
));
});
test('test length limiting formatter with non-BMP Unicode scalar values', () {
testNewValue = const TextEditingValue(
text: '\u{1f984}\u{1f984}\u{1f984}\u{1f984}', // Unicode U+1f984 (UNICORN FACE)
selection: TextSelection(
baseOffset: 4,
extentOffset: 4,
),
);
final TextEditingValue actualValue =
LengthLimitingTextInputFormatter(2)
.formatEditUpdate(testOldValue, testNewValue);
// Expecting two runes.
expect(actualValue, const TextEditingValue(
text: '\u{1f984}\u{1f984}',
selection: TextSelection(
baseOffset: 2,
extentOffset: 2,
),
));
});
test('test length limiting formatter with complex Unicode characters', () {
// TODO(gspencer): Test additional strings. We can do this once the
// formatter supports Unicode grapheme clusters.
//
// A formatter with max length 1 should accept:
// - The '\u{1F3F3}\u{FE0F}\u{200D}\u{1F308}' sequence (flag followed by
// a variation selector, a zero-width joiner, and a rainbow to make a rainbow
// flag).
// - The sequence '\u{0058}\u{0346}\u{0361}\u{035E}\u{032A}\u{031C}\u{0333}\u{0326}\u{031D}\u{0332}'
// (Latin X with many composed characters).
//
// A formatter should not count as a character:
// * The '\u{0000}\u{FEFF}' sequence. (NULL followed by zero-width no-break space).
//
// A formatter with max length 1 should truncate this to one character:
// * The '\u{1F3F3}\u{FE0F}\u{1F308}' sequence (flag with ignored variation
// selector followed by rainbow, should truncate to just flag).
// The U+1F984 U+0020 sequence: Unicorn face followed by a space should
// yield only the unicorn face.
testNewValue = const TextEditingValue(
text: '\u{1F984}\u{0020}',
selection: TextSelection(
baseOffset: 1,
extentOffset: 1,
),
);
TextEditingValue actualValue = LengthLimitingTextInputFormatter(1).formatEditUpdate(testOldValue, testNewValue);
expect(actualValue, const TextEditingValue(
text: '\u{1F984}',
selection: TextSelection(
baseOffset: 1,
extentOffset: 1,
),
));
// The U+0058 U+0059 sequence: Latin X followed by Latin Y, should yield
// Latin X.
testNewValue = const TextEditingValue(
text: '\u{0058}\u{0059}',
selection: TextSelection(
baseOffset: 1,
extentOffset: 1,
),
);
actualValue = LengthLimitingTextInputFormatter(1).formatEditUpdate(testOldValue, testNewValue);
expect(actualValue, const TextEditingValue(
text: '\u{0058}',
selection: TextSelection(
baseOffset: 1,
extentOffset: 1,
),
));
});
test('test length limiting formatter when selection is off the end', () {
final TextEditingValue actualValue =
LengthLimitingTextInputFormatter(2)
.formatEditUpdate(testOldValue, testNewValue);
// Expecting
// a1()
expect(actualValue, const TextEditingValue(
text: 'a1',
selection: TextSelection(
baseOffset: 2,
extentOffset: 2,
),
));
});
});
}
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