mirror of
https://github.com/flutter/flutter.git
synced 2025-06-03 00:51:18 +00:00
Characters Package (#53381)
This commit is contained in:
parent
c5527dc8c4
commit
e0ed12c73a
@ -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';
|
||||
@ -797,7 +798,7 @@ 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;
|
||||
|
||||
InputDecoration _getEffectiveDecoration() {
|
||||
final MaterialLocalizations localizations = MaterialLocalizations.of(context);
|
||||
@ -851,7 +852,7 @@ class _TextFieldState extends State<TextField> implements TextSelectionGestureDe
|
||||
semanticCounterText = localizations.remainingTextFieldCharacterCount(remaining);
|
||||
|
||||
// Handle length exceeds maxLength
|
||||
if (_effectiveController.value.text.runes.length > widget.maxLength) {
|
||||
if (_effectiveController.value.text.characters.length > widget.maxLength) {
|
||||
return effectiveDecoration.copyWith(
|
||||
errorText: effectiveDecoration.errorText ?? '',
|
||||
counterStyle: effectiveDecoration.errorStyle
|
||||
|
@ -600,7 +600,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;
|
||||
}
|
||||
|
||||
@ -608,7 +608,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;
|
||||
}
|
||||
|
||||
@ -637,15 +637,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>[];
|
||||
@ -688,8 +686,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.
|
||||
///
|
||||
@ -251,7 +240,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),
|
||||
@ -366,7 +355,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();
|
||||
}
|
||||
@ -518,10 +507,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.
|
||||
@ -557,6 +542,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.characters.first.toString().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,
|
||||
@ -575,23 +625,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) {
|
||||
@ -602,7 +635,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 {
|
||||
@ -610,7 +643,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);
|
||||
}
|
||||
@ -622,7 +655,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 {
|
||||
@ -630,22 +663,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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -763,7 +798,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' show visibleForTesting;
|
||||
import 'text_editing.dart';
|
||||
import 'text_input.dart';
|
||||
@ -169,24 +170,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,
|
||||
);
|
||||
}
|
||||
@ -196,18 +197,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);
|
||||
|
@ -3433,6 +3433,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();
|
||||
@ -3539,6 +3569,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(
|
||||
@ -3559,6 +3679,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();
|
||||
|
@ -750,6 +750,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();
|
||||
@ -817,4 +917,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,247 @@ 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 blacklisting formatter', () {
|
||||
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 =
|
||||
BlacklistingTextInputFormatter.singleLineFormatter
|
||||
.formatEditUpdate(testOldValue, testNewValue);
|
||||
|
||||
// Expecting
|
||||
// a1b(2c3d4)e5f6
|
||||
expect(actualValue, const TextEditingValue(
|
||||
text: 'a1b2c3d4e5f6',
|
||||
selection: TextSelection(
|
||||
baseOffset: 3,
|
||||
extentOffset: 8,
|
||||
),
|
||||
));
|
||||
});
|
||||
|
||||
test('test whitelisting formatter', () {
|
||||
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 =
|
||||
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(
|
||||
// Each character is a surrogate pair and has a length of 2, so the
|
||||
// full length is 8.
|
||||
baseOffset: 8,
|
||||
extentOffset: 8,
|
||||
),
|
||||
);
|
||||
|
||||
final TextEditingValue actualValue =
|
||||
LengthLimitingTextInputFormatter(2)
|
||||
.formatEditUpdate(testOldValue, testNewValue);
|
||||
|
||||
// Expecting two runes.
|
||||
expect(actualValue, const TextEditingValue(
|
||||
text: '\u{1f984}\u{1f984}',
|
||||
selection: TextSelection(
|
||||
// The maxLength is set to 2 characters, and since the unicorn face
|
||||
// emoji is a surrogate pair, the length of the string is 4.
|
||||
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 +261,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', () {
|
||||
|
@ -1,251 +0,0 @@
|
||||
// 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() {
|
||||
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 blacklisting formatter', () {
|
||||
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 =
|
||||
BlacklistingTextInputFormatter.singleLineFormatter
|
||||
.formatEditUpdate(testOldValue, testNewValue);
|
||||
|
||||
// Expecting
|
||||
// a1b(2c3d4)e5f6
|
||||
expect(actualValue, const TextEditingValue(
|
||||
text: 'a1b2c3d4e5f6',
|
||||
selection: TextSelection(
|
||||
baseOffset: 3,
|
||||
extentOffset: 8,
|
||||
),
|
||||
));
|
||||
});
|
||||
|
||||
test('test whitelisting formatter', () {
|
||||
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 =
|
||||
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,
|
||||
),
|
||||
));
|
||||
});
|
||||
|
||||
});
|
||||
}
|
Loading…
Reference in New Issue
Block a user