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 'dart:ui' as ui show BoxHeightStyle, BoxWidthStyle;
|
||||||
|
|
||||||
|
import 'package:characters/characters.dart';
|
||||||
import 'package:flutter/cupertino.dart';
|
import 'package:flutter/cupertino.dart';
|
||||||
import 'package:flutter/rendering.dart';
|
import 'package:flutter/rendering.dart';
|
||||||
import 'package:flutter/services.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;
|
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() {
|
InputDecoration _getEffectiveDecoration() {
|
||||||
final MaterialLocalizations localizations = MaterialLocalizations.of(context);
|
final MaterialLocalizations localizations = MaterialLocalizations.of(context);
|
||||||
@ -851,7 +852,7 @@ class _TextFieldState extends State<TextField> implements TextSelectionGestureDe
|
|||||||
semanticCounterText = localizations.remainingTextFieldCharacterCount(remaining);
|
semanticCounterText = localizations.remainingTextFieldCharacterCount(remaining);
|
||||||
|
|
||||||
// Handle length exceeds maxLength
|
// Handle length exceeds maxLength
|
||||||
if (_effectiveController.value.text.runes.length > widget.maxLength) {
|
if (_effectiveController.value.text.characters.length > widget.maxLength) {
|
||||||
return effectiveDecoration.copyWith(
|
return effectiveDecoration.copyWith(
|
||||||
errorText: effectiveDecoration.errorText ?? '',
|
errorText: effectiveDecoration.errorText ?? '',
|
||||||
counterStyle: effectiveDecoration.errorStyle
|
counterStyle: effectiveDecoration.errorStyle
|
||||||
|
@ -600,7 +600,7 @@ class TextPainter {
|
|||||||
|
|
||||||
// Complex glyphs can be represented by two or more UTF16 codepoints. This
|
// 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'.
|
// 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;
|
return value & 0xF800 == 0xD800;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -608,7 +608,7 @@ class TextPainter {
|
|||||||
// up zero space and do not have valid bounding boxes around them.
|
// up zero space and do not have valid bounding boxes around them.
|
||||||
//
|
//
|
||||||
// We do not directly use the [Unicode] constants since they are strings.
|
// 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;
|
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
|
// Get the Rect of the cursor (in logical pixels) based off the near edge
|
||||||
// of the character upstream from the given string offset.
|
// 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) {
|
Rect _getRectFromUpstream(int offset, Rect caretPrototype) {
|
||||||
final String flattenedText = _text.toPlainText(includePlaceholders: false);
|
final String flattenedText = _text.toPlainText(includePlaceholders: false);
|
||||||
final int prevCodeUnit = _text.codeUnitAt(max(0, offset - 1));
|
final int prevCodeUnit = _text.codeUnitAt(max(0, offset - 1));
|
||||||
if (prevCodeUnit == null)
|
if (prevCodeUnit == null)
|
||||||
return 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);
|
final bool needsSearch = _isUtf16Surrogate(prevCodeUnit) || _text.codeUnitAt(offset) == _zwjUtf16 || _isUnicodeDirectionality(prevCodeUnit);
|
||||||
int graphemeClusterLength = needsSearch ? 2 : 1;
|
int graphemeClusterLength = needsSearch ? 2 : 1;
|
||||||
List<TextBox> boxes = <TextBox>[];
|
List<TextBox> boxes = <TextBox>[];
|
||||||
@ -688,8 +686,6 @@ class TextPainter {
|
|||||||
|
|
||||||
// Get the Rect of the cursor (in logical pixels) based off the near edge
|
// Get the Rect of the cursor (in logical pixels) based off the near edge
|
||||||
// of the character downstream from the given string offset.
|
// 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) {
|
Rect _getRectFromDownstream(int offset, Rect caretPrototype) {
|
||||||
final String flattenedText = _text.toPlainText(includePlaceholders: false);
|
final String flattenedText = _text.toPlainText(includePlaceholders: false);
|
||||||
// We cap the offset at the final index of the _text.
|
// We cap the offset at the final index of the _text.
|
||||||
|
@ -7,6 +7,7 @@
|
|||||||
import 'dart:math' as math;
|
import 'dart:math' as math;
|
||||||
import 'dart:ui' as ui show TextBox, lerpDouble, BoxHeightStyle, BoxWidthStyle;
|
import 'dart:ui' as ui show TextBox, lerpDouble, BoxHeightStyle, BoxWidthStyle;
|
||||||
|
|
||||||
|
import 'package:characters/characters.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/gestures.dart';
|
import 'package:flutter/gestures.dart';
|
||||||
import 'package:flutter/semantics.dart';
|
import 'package:flutter/semantics.dart';
|
||||||
@ -140,18 +141,6 @@ bool _isWhitespace(int codeUnit) {
|
|||||||
return true;
|
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
|
/// Displays some text in a scrollable container with a potentially blinking
|
||||||
/// cursor and with gesture recognizers.
|
/// cursor and with gesture recognizers.
|
||||||
///
|
///
|
||||||
@ -251,7 +240,7 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin {
|
|||||||
assert(ignorePointer != null),
|
assert(ignorePointer != null),
|
||||||
assert(textWidthBasis != null),
|
assert(textWidthBasis != null),
|
||||||
assert(paintCursorAboveText != null),
|
assert(paintCursorAboveText != null),
|
||||||
assert(obscuringCharacter != null && obscuringCharacter.length == 1),
|
assert(obscuringCharacter != null && obscuringCharacter.characters.length == 1),
|
||||||
assert(obscureText != null),
|
assert(obscureText != null),
|
||||||
assert(textSelectionDelegate != null),
|
assert(textSelectionDelegate != null),
|
||||||
assert(cursorWidth != null && cursorWidth >= 0.0),
|
assert(cursorWidth != null && cursorWidth >= 0.0),
|
||||||
@ -366,7 +355,7 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin {
|
|||||||
if (_obscuringCharacter == value) {
|
if (_obscuringCharacter == value) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
assert(value != null && value.length == 1);
|
assert(value != null && value.characters.length == 1);
|
||||||
_obscuringCharacter = value;
|
_obscuringCharacter = value;
|
||||||
markNeedsLayout();
|
markNeedsLayout();
|
||||||
}
|
}
|
||||||
@ -518,10 +507,6 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin {
|
|||||||
..._nonModifierKeys,
|
..._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) {
|
void _handleKeyEvent(RawKeyEvent keyEvent) {
|
||||||
if(kIsWeb) {
|
if(kIsWeb) {
|
||||||
// On web platform, we should ignore the key because it's processed already.
|
// 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(
|
void _handleMovement(
|
||||||
LogicalKeyboardKey key, {
|
LogicalKeyboardKey key, {
|
||||||
@required bool wordModifier,
|
@required bool wordModifier,
|
||||||
@ -575,23 +625,6 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin {
|
|||||||
final bool upArrow = key == LogicalKeyboardKey.arrowUp;
|
final bool upArrow = key == LogicalKeyboardKey.arrowUp;
|
||||||
final bool downArrow = key == LogicalKeyboardKey.arrowDown;
|
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)) {
|
if ((rightArrow || leftArrow) && !(rightArrow && leftArrow)) {
|
||||||
// Jump to begin/end of word.
|
// Jump to begin/end of word.
|
||||||
if (wordModifier) {
|
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
|
// so we go back to the first non-whitespace before asking for the word
|
||||||
// boundary, since _selectWordAtOffset finds the word boundaries without
|
// boundary, since _selectWordAtOffset finds the word boundaries without
|
||||||
// including whitespace.
|
// including whitespace.
|
||||||
final int startPoint = previousNonWhitespace(newSelection.extentOffset);
|
final int startPoint = previousCharacter(newSelection.extentOffset, _plainText, false);
|
||||||
final TextSelection textSelection = _selectWordAtOffset(TextPosition(offset: startPoint));
|
final TextSelection textSelection = _selectWordAtOffset(TextPosition(offset: startPoint));
|
||||||
newSelection = newSelection.copyWith(extentOffset: textSelection.baseOffset);
|
newSelection = newSelection.copyWith(extentOffset: textSelection.baseOffset);
|
||||||
} else {
|
} else {
|
||||||
@ -610,7 +643,7 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin {
|
|||||||
// so we go forward to the first non-whitespace character before asking
|
// so we go forward to the first non-whitespace character before asking
|
||||||
// for the word bounds, since _selectWordAtOffset finds the word
|
// for the word bounds, since _selectWordAtOffset finds the word
|
||||||
// boundaries without including whitespace.
|
// 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));
|
final TextSelection textSelection = _selectWordAtOffset(TextPosition(offset: startPoint));
|
||||||
newSelection = newSelection.copyWith(extentOffset: textSelection.extentOffset);
|
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
|
// so we go back to the first non-whitespace before asking for the line
|
||||||
// bounds, since _selectLineAtOffset finds the line boundaries without
|
// bounds, since _selectLineAtOffset finds the line boundaries without
|
||||||
// including whitespace (like the newline).
|
// 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));
|
final TextSelection textSelection = _selectLineAtOffset(TextPosition(offset: startPoint));
|
||||||
newSelection = newSelection.copyWith(extentOffset: textSelection.baseOffset);
|
newSelection = newSelection.copyWith(extentOffset: textSelection.baseOffset);
|
||||||
} else {
|
} else {
|
||||||
@ -630,22 +663,24 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin {
|
|||||||
// so we go forward to the first non-whitespace character before asking
|
// so we go forward to the first non-whitespace character before asking
|
||||||
// for the line bounds, since _selectLineAtOffset finds the line
|
// for the line bounds, since _selectLineAtOffset finds the line
|
||||||
// boundaries without including whitespace (like the newline).
|
// 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));
|
final TextSelection textSelection = _selectLineAtOffset(TextPosition(offset: startPoint));
|
||||||
newSelection = newSelection.copyWith(extentOffset: textSelection.extentOffset);
|
newSelection = newSelection.copyWith(extentOffset: textSelection.extentOffset);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if (rightArrow && newSelection.extentOffset < _plainText.length) {
|
if (rightArrow && newSelection.extentOffset < _plainText.length) {
|
||||||
final int delta = _isLeadingSurrogate(text.codeUnitAt(newSelection.extentOffset)) ? 2 : 1;
|
final int nextExtent = nextCharacter(newSelection.extentOffset, _plainText);
|
||||||
newSelection = newSelection.copyWith(extentOffset: newSelection.extentOffset + delta);
|
final int distance = nextExtent - newSelection.extentOffset;
|
||||||
|
newSelection = newSelection.copyWith(extentOffset: nextExtent);
|
||||||
if (shift) {
|
if (shift) {
|
||||||
_cursorResetLocation += 1;
|
_cursorResetLocation += distance;
|
||||||
}
|
}
|
||||||
} else if (leftArrow && newSelection.extentOffset > 0) {
|
} else if (leftArrow && newSelection.extentOffset > 0) {
|
||||||
final int delta = _isTrailingSurrogate(text.codeUnitAt(newSelection.extentOffset - 1)) ? 2 : 1;
|
final int previousExtent = previousCharacter(newSelection.extentOffset, _plainText);
|
||||||
newSelection = newSelection.copyWith(extentOffset: newSelection.extentOffset - delta);
|
final int distance = newSelection.extentOffset - previousExtent;
|
||||||
|
newSelection = newSelection.copyWith(extentOffset: previousExtent);
|
||||||
if (shift) {
|
if (shift) {
|
||||||
_cursorResetLocation -= 1;
|
_cursorResetLocation -= distance;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -763,7 +798,7 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin {
|
|||||||
void _handleDelete() {
|
void _handleDelete() {
|
||||||
final String textAfter = selection.textAfter(_plainText);
|
final String textAfter = selection.textAfter(_plainText);
|
||||||
if (textAfter.isNotEmpty) {
|
if (textAfter.isNotEmpty) {
|
||||||
final int deleteCount = _isLeadingSurrogate(textAfter.codeUnitAt(0)) ? 2 : 1;
|
final int deleteCount = nextCharacter(0, textAfter);
|
||||||
textSelectionDelegate.textEditingValue = TextEditingValue(
|
textSelectionDelegate.textEditingValue = TextEditingValue(
|
||||||
text: selection.textBefore(_plainText)
|
text: selection.textBefore(_plainText)
|
||||||
+ selection.textAfter(_plainText).substring(deleteCount),
|
+ selection.textAfter(_plainText).substring(deleteCount),
|
||||||
|
@ -6,6 +6,7 @@
|
|||||||
|
|
||||||
import 'dart:math' as math;
|
import 'dart:math' as math;
|
||||||
|
|
||||||
|
import 'package:characters/characters.dart';
|
||||||
import 'package:flutter/foundation.dart' show visibleForTesting;
|
import 'package:flutter/foundation.dart' show visibleForTesting;
|
||||||
import 'text_editing.dart';
|
import 'text_editing.dart';
|
||||||
import 'text_input.dart';
|
import 'text_input.dart';
|
||||||
@ -169,24 +170,24 @@ class LengthLimitingTextInputFormatter extends TextInputFormatter {
|
|||||||
/// characters.
|
/// characters.
|
||||||
final int maxLength;
|
final int maxLength;
|
||||||
|
|
||||||
// TODO(justinmc): This should be updated to use characters instead of runes,
|
/// Truncate the given TextEditingValue to maxLength characters.
|
||||||
// see the comment in formatEditUpdate.
|
///
|
||||||
/// Truncate the given TextEditingValue to maxLength runes.
|
/// 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
|
@visibleForTesting
|
||||||
static TextEditingValue truncate(TextEditingValue value, int maxLength) {
|
static TextEditingValue truncate(TextEditingValue value, int maxLength) {
|
||||||
final TextSelection newSelection = value.selection.copyWith(
|
final CharacterRange iterator = CharacterRange(value.text);
|
||||||
baseOffset: math.min(value.selection.start, maxLength),
|
if (value.text.characters.length > maxLength) {
|
||||||
extentOffset: math.min(value.selection.end, maxLength),
|
iterator.expandNext(maxLength);
|
||||||
);
|
}
|
||||||
final RuneIterator iterator = RuneIterator(value.text);
|
final String truncated = iterator.current;
|
||||||
if (iterator.moveNext())
|
|
||||||
for (int count = 0; count < maxLength; ++count)
|
|
||||||
if (!iterator.moveNext())
|
|
||||||
break;
|
|
||||||
final String truncated = value.text.substring(0, iterator.rawIndex);
|
|
||||||
return TextEditingValue(
|
return TextEditingValue(
|
||||||
text: truncated,
|
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,
|
composing: TextRange.empty,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -196,18 +197,10 @@ class LengthLimitingTextInputFormatter extends TextInputFormatter {
|
|||||||
TextEditingValue oldValue, // unused.
|
TextEditingValue oldValue, // unused.
|
||||||
TextEditingValue newValue,
|
TextEditingValue newValue,
|
||||||
) {
|
) {
|
||||||
// This does not count grapheme clusters (i.e. characters visible to the user),
|
if (maxLength != null && maxLength > 0 && newValue.text.characters.length > maxLength) {
|
||||||
// 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 already at the maximum and tried to enter even more, keep the old
|
// If already at the maximum and tried to enter even more, keep the old
|
||||||
// value.
|
// value.
|
||||||
if (oldValue.text.runes.length == maxLength) {
|
if (oldValue.text.characters.length == maxLength) {
|
||||||
return oldValue;
|
return oldValue;
|
||||||
}
|
}
|
||||||
return truncate(newValue, maxLength);
|
return truncate(newValue, maxLength);
|
||||||
|
@ -3433,6 +3433,36 @@ void main() {
|
|||||||
expect(textController.text, '0123456789');
|
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 {
|
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.
|
// Regression test for https://github.com/flutter/flutter/issues/37420.
|
||||||
final TextEditingController textController = TextEditingController();
|
final TextEditingController textController = TextEditingController();
|
||||||
@ -3539,6 +3569,96 @@ void main() {
|
|||||||
expect(counterTextWidget.style.color, isNot(equals(Colors.deepPurpleAccent)));
|
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 {
|
testWidgets('setting maxLength shows counter', (WidgetTester tester) async {
|
||||||
await tester.pumpWidget(const MaterialApp(
|
await tester.pumpWidget(const MaterialApp(
|
||||||
home: Material(
|
home: Material(
|
||||||
@ -3559,6 +3679,48 @@ void main() {
|
|||||||
expect(find.text('5/10'), findsOneWidget);
|
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 {
|
testWidgets('setting maxLength to TextField.noMaxLength shows only entered length', (WidgetTester tester) async {
|
||||||
await tester.pumpWidget(const MaterialApp(
|
await tester.pumpWidget(const MaterialApp(
|
||||||
home: Material(
|
home: Material(
|
||||||
|
@ -27,7 +27,8 @@ void main() {
|
|||||||
caretOffset = painter.getOffsetForCaret(ui.TextPosition(offset: text.length), ui.Rect.zero);
|
caretOffset = painter.getOffsetForCaret(ui.TextPosition(offset: text.length), ui.Rect.zero);
|
||||||
expect(caretOffset.dx, painter.width);
|
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}';
|
text = 'A\u{1F600}';
|
||||||
painter.text = TextSpan(text: text);
|
painter.text = TextSpan(text: text);
|
||||||
painter.layout();
|
painter.layout();
|
||||||
|
@ -750,6 +750,106 @@ void main() {
|
|||||||
expect(delegate.textEditingValue.text, 'est');
|
expect(delegate.textEditingValue.text, 'est');
|
||||||
}, skip: kIsWeb);
|
}, 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 {
|
test('arrow keys and delete handle surrogate pairs correctly', () async {
|
||||||
final TextSelectionDelegate delegate = FakeEditableTextState();
|
final TextSelectionDelegate delegate = FakeEditableTextState();
|
||||||
final ViewportOffset viewportOffset = ViewportOffset.zero();
|
final ViewportOffset viewportOffset = ViewportOffset.zero();
|
||||||
@ -817,4 +917,98 @@ void main() {
|
|||||||
const TextSelection(baseOffset: 0, extentOffset: 1));
|
const TextSelection(baseOffset: 0, extentOffset: 1));
|
||||||
expect(endpoints[0].point.dx, 0);
|
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';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
void main() {
|
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('LengthLimitingTextInputFormatter', () {
|
||||||
group('truncate', () {
|
group('truncate', () {
|
||||||
test('Removes characters from the end', () async {
|
test('Removes characters from the end', () async {
|
||||||
@ -20,6 +261,40 @@ void main() {
|
|||||||
.truncate(value, 10);
|
.truncate(value, 10);
|
||||||
expect(truncated.text, '0123456789');
|
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', () {
|
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