diff --git a/packages/flutter/lib/src/material/text_field.dart b/packages/flutter/lib/src/material/text_field.dart index 54acfcd1a7..aea451a103 100644 --- a/packages/flutter/lib/src/material/text_field.dart +++ b/packages/flutter/lib/src/material/text_field.dart @@ -6,7 +6,6 @@ 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'; @@ -798,7 +797,7 @@ class _TextFieldState extends State implements TextSelectionGestureDe bool get _isEnabled => widget.enabled ?? widget.decoration?.enabled ?? true; - int get _currentLength => _effectiveController.value.text.characters.length; + int get _currentLength => _effectiveController.value.text.runes.length; InputDecoration _getEffectiveDecoration() { final MaterialLocalizations localizations = MaterialLocalizations.of(context); @@ -852,7 +851,7 @@ class _TextFieldState extends State implements TextSelectionGestureDe semanticCounterText = localizations.remainingTextFieldCharacterCount(remaining); // Handle length exceeds maxLength - if (_effectiveController.value.text.characters.length > widget.maxLength) { + if (_effectiveController.value.text.runes.length > widget.maxLength) { return effectiveDecoration.copyWith( errorText: effectiveDecoration.errorText ?? '', counterStyle: effectiveDecoration.errorStyle diff --git a/packages/flutter/lib/src/painting/text_painter.dart b/packages/flutter/lib/src/painting/text_painter.dart index cc7b9a74f7..ed50f6bd38 100644 --- a/packages/flutter/lib/src/painting/text_painter.dart +++ b/packages/flutter/lib/src/painting/text_painter.dart @@ -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'. - static bool _isUtf16Surrogate(int value) { + 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. - static bool _isUnicodeDirectionality(int value) { + bool _isUnicodeDirectionality(int value) { return value == 0x200F || value == 0x200E; } @@ -637,13 +637,15 @@ 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 boxes = []; @@ -686,6 +688,8 @@ 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. diff --git a/packages/flutter/lib/src/rendering/editable.dart b/packages/flutter/lib/src/rendering/editable.dart index 8dcfff7221..1e2e776bfe 100644 --- a/packages/flutter/lib/src/rendering/editable.dart +++ b/packages/flutter/lib/src/rendering/editable.dart @@ -7,7 +7,6 @@ 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'; @@ -141,6 +140,18 @@ bool _isWhitespace(int codeUnit) { return true; } +/// Returns true if [codeUnit] is a leading (high) surrogate for a surrogate +/// pair. +bool _isLeadingSurrogate(int codeUnit) { + return codeUnit & 0xFC00 == 0xD800; +} + +/// Returns true if [codeUnit] is a trailing (low) surrogate for a surrogate +/// pair. +bool _isTrailingSurrogate(int codeUnit) { + return codeUnit & 0xFC00 == 0xDC00; +} + /// Displays some text in a scrollable container with a potentially blinking /// cursor and with gesture recognizers. /// @@ -240,7 +251,7 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin { assert(ignorePointer != null), assert(textWidthBasis != null), assert(paintCursorAboveText != null), - assert(obscuringCharacter != null && obscuringCharacter.characters.length == 1), + assert(obscuringCharacter != null && obscuringCharacter.length == 1), assert(obscureText != null), assert(textSelectionDelegate != null), assert(cursorWidth != null && cursorWidth >= 0.0), @@ -355,7 +366,7 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin { if (_obscuringCharacter == value) { return; } - assert(value != null && value.characters.length == 1); + assert(value != null && value.length == 1); _obscuringCharacter = value; markNeedsLayout(); } @@ -507,6 +518,10 @@ 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. @@ -542,71 +557,6 @@ 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, @@ -625,6 +575,23 @@ 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) { @@ -635,7 +602,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 = previousCharacter(newSelection.extentOffset, _plainText, false); + final int startPoint = previousNonWhitespace(newSelection.extentOffset); final TextSelection textSelection = _selectWordAtOffset(TextPosition(offset: startPoint)); newSelection = newSelection.copyWith(extentOffset: textSelection.baseOffset); } else { @@ -643,7 +610,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 = nextCharacter(newSelection.extentOffset, _plainText, false); + final int startPoint = nextNonWhitespace(newSelection.extentOffset); final TextSelection textSelection = _selectWordAtOffset(TextPosition(offset: startPoint)); newSelection = newSelection.copyWith(extentOffset: textSelection.extentOffset); } @@ -655,7 +622,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 = previousCharacter(newSelection.extentOffset, _plainText, false); + final int startPoint = previousNonWhitespace(newSelection.extentOffset); final TextSelection textSelection = _selectLineAtOffset(TextPosition(offset: startPoint)); newSelection = newSelection.copyWith(extentOffset: textSelection.baseOffset); } else { @@ -663,24 +630,22 @@ 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 = nextCharacter(newSelection.extentOffset, _plainText, false); + final int startPoint = nextNonWhitespace(newSelection.extentOffset); final TextSelection textSelection = _selectLineAtOffset(TextPosition(offset: startPoint)); newSelection = newSelection.copyWith(extentOffset: textSelection.extentOffset); } } else { if (rightArrow && newSelection.extentOffset < _plainText.length) { - final int nextExtent = nextCharacter(newSelection.extentOffset, _plainText); - final int distance = nextExtent - newSelection.extentOffset; - newSelection = newSelection.copyWith(extentOffset: nextExtent); + final int delta = _isLeadingSurrogate(text.codeUnitAt(newSelection.extentOffset)) ? 2 : 1; + newSelection = newSelection.copyWith(extentOffset: newSelection.extentOffset + delta); if (shift) { - _cursorResetLocation += distance; + _cursorResetLocation += 1; } } else if (leftArrow && newSelection.extentOffset > 0) { - final int previousExtent = previousCharacter(newSelection.extentOffset, _plainText); - final int distance = newSelection.extentOffset - previousExtent; - newSelection = newSelection.copyWith(extentOffset: previousExtent); + final int delta = _isTrailingSurrogate(text.codeUnitAt(newSelection.extentOffset - 1)) ? 2 : 1; + newSelection = newSelection.copyWith(extentOffset: newSelection.extentOffset - delta); if (shift) { - _cursorResetLocation -= distance; + _cursorResetLocation -= 1; } } } @@ -798,7 +763,7 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin { void _handleDelete() { final String textAfter = selection.textAfter(_plainText); if (textAfter.isNotEmpty) { - final int deleteCount = nextCharacter(0, textAfter); + final int deleteCount = _isLeadingSurrogate(textAfter.codeUnitAt(0)) ? 2 : 1; textSelectionDelegate.textEditingValue = TextEditingValue( text: selection.textBefore(_plainText) + selection.textAfter(_plainText).substring(deleteCount), diff --git a/packages/flutter/lib/src/services/text_formatter.dart b/packages/flutter/lib/src/services/text_formatter.dart index c836adf937..766f9f6844 100644 --- a/packages/flutter/lib/src/services/text_formatter.dart +++ b/packages/flutter/lib/src/services/text_formatter.dart @@ -6,7 +6,6 @@ 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'; @@ -170,24 +169,24 @@ class LengthLimitingTextInputFormatter extends TextInputFormatter { /// characters. final int maxLength; - /// 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). + // TODO(justinmc): This should be updated to use characters instead of runes, + // see the comment in formatEditUpdate. + /// Truncate the given TextEditingValue to maxLength runes. @visibleForTesting static TextEditingValue truncate(TextEditingValue value, int maxLength) { - final CharacterRange iterator = CharacterRange(value.text); - if (value.text.characters.length > maxLength) { - iterator.expandNext(maxLength); - } - final String truncated = iterator.current; + 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); return TextEditingValue( text: truncated, - selection: value.selection.copyWith( - baseOffset: math.min(value.selection.start, truncated.length), - extentOffset: math.min(value.selection.end, truncated.length), - ), + selection: newSelection, composing: TextRange.empty, ); } @@ -197,10 +196,18 @@ class LengthLimitingTextInputFormatter extends TextInputFormatter { TextEditingValue oldValue, // unused. TextEditingValue newValue, ) { - if (maxLength != null && maxLength > 0 && newValue.text.characters.length > maxLength) { + // 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 already at the maximum and tried to enter even more, keep the old // value. - if (oldValue.text.characters.length == maxLength) { + if (oldValue.text.runes.length == maxLength) { return oldValue; } return truncate(newValue, maxLength); diff --git a/packages/flutter/test/material/text_field_test.dart b/packages/flutter/test/material/text_field_test.dart index 7e4afa1de6..1175240924 100644 --- a/packages/flutter/test/material/text_field_test.dart +++ b/packages/flutter/test/material/text_field_test.dart @@ -3433,36 +3433,6 @@ 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(); @@ -3569,96 +3539,6 @@ 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( @@ -3679,48 +3559,6 @@ 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( diff --git a/packages/flutter/test/painting/text_painter_test.dart b/packages/flutter/test/painting/text_painter_test.dart index 48ca81825c..d5de723639 100644 --- a/packages/flutter/test/painting/text_painter_test.dart +++ b/packages/flutter/test/painting/text_painter_test.dart @@ -27,8 +27,7 @@ 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(); diff --git a/packages/flutter/test/rendering/editable_test.dart b/packages/flutter/test/rendering/editable_test.dart index 6380cf7c85..08975953b5 100644 --- a/packages/flutter/test/rendering/editable_test.dart +++ b/packages/flutter/test/rendering/editable_test.dart @@ -750,106 +750,6 @@ 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(); @@ -917,98 +817,4 @@ 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); - }); - }); } diff --git a/packages/flutter/test/services/text_formatter_test.dart b/packages/flutter/test/services/text_formatter_test.dart index e55591fd5a..e90966fb7c 100644 --- a/packages/flutter/test/services/text_formatter_test.dart +++ b/packages/flutter/test/services/text_formatter_test.dart @@ -8,247 +8,6 @@ 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 { @@ -261,40 +20,6 @@ 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', () { diff --git a/packages/flutter/test/widgets/text_formatter_test.dart b/packages/flutter/test/widgets/text_formatter_test.dart new file mode 100644 index 0000000000..b37d8a3fdf --- /dev/null +++ b/packages/flutter/test/widgets/text_formatter_test.dart @@ -0,0 +1,251 @@ +// 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, + ), + )); + }); + + }); +}