diff --git a/packages/flutter/lib/src/services/text_formatter.dart b/packages/flutter/lib/src/services/text_formatter.dart index b9420e012d7..50723033717 100644 --- a/packages/flutter/lib/src/services/text_formatter.dart +++ b/packages/flutter/lib/src/services/text_formatter.dart @@ -121,6 +121,94 @@ class _SimpleTextInputFormatter extends TextInputFormatter { } } +// A mutable, half-open range [`base`, `extent`) within a string. +class _MutableTextRange { + _MutableTextRange(this.base, this.extent); + + static _MutableTextRange? fromComposingRange(TextRange range) { + return range.isValid && !range.isCollapsed + ? _MutableTextRange(range.start, range.end) + : null; + } + + static _MutableTextRange? fromTextSelection(TextSelection selection) { + return selection.isValid + ? _MutableTextRange(selection.baseOffset, selection.extentOffset) + : null; + } + + /// The start index of the range, inclusive. + /// + /// The value of [base] should always be greater than or equal to 0, and can + /// be larger than, smaller than, or equal to [extent]. + int base; + + /// The end index of the range, exclusive. + /// + /// The value of [extent] should always be greater than or equal to 0, and can + /// be larger than, smaller than, or equal to [base]. + int extent; +} + +// The intermediate state of a [FilteringTextInputFormatter] when it's +// formatting a new user input. +class _TextEditingValueAccumulator { + _TextEditingValueAccumulator(this.inputValue) + : selection = _MutableTextRange.fromTextSelection(inputValue.selection), + composingRegion = _MutableTextRange.fromComposingRange(inputValue.composing); + + // The original string that was sent to the [FilteringTextInputFormatter] as + // input. + final TextEditingValue inputValue; + + /// The [StringBuffer] that contains the string which has already been + /// formatted. + /// + /// In a [FilteringTextInputFormatter], typically the replacement string, + /// instead of the original string within the given range, is written to this + /// [StringBuffer]. + final StringBuffer stringBuffer = StringBuffer(); + + /// The updated selection, as well as the original selection from the input + /// [TextEditingValue] of the [FilteringTextInputFormatter]. + /// + /// This parameter will be null if the input [TextEditingValue.selection] is + /// invalid. + final _MutableTextRange? selection; + + /// The updated composing region, as well as the original composing region + /// from the input [TextEditingValue] of the [FilteringTextInputFormatter]. + /// + /// This parameter will be null if the input [TextEditingValue.composing] is + /// invalid or collapsed. + final _MutableTextRange? composingRegion; + + // Whether this state object has reached its end-of-life. + bool debugFinalized = false; + + TextEditingValue finalize() { + debugFinalized = true; + final _MutableTextRange? selection = this.selection; + final _MutableTextRange? composingRegion = this.composingRegion; + return TextEditingValue( + text: stringBuffer.toString(), + composing: composingRegion == null || composingRegion.base == composingRegion.extent + ? TextRange.empty + : TextRange(start: composingRegion.base, end: composingRegion.extent), + selection: selection == null + ? const TextSelection.collapsed(offset: -1) + : TextSelection( + baseOffset: selection.base, + extentOffset: selection.extent, + // Try to preserve the selection affinity and isDirectional. This + // may not make sense if the selection has changed. + affinity: inputValue.selection.affinity, + isDirectional: inputValue.selection.isDirectional, + ), + ); + } +} + /// A [TextInputFormatter] that prevents the insertion of characters /// matching (or not matching) a particular pattern. /// @@ -159,33 +247,26 @@ class FilteringTextInputFormatter extends TextInputFormatter { /// The [filterPattern] and [replacementString] arguments /// must not be null. FilteringTextInputFormatter.allow( - this.filterPattern, { - this.replacementString = '', - }) : assert(filterPattern != null), - assert(replacementString != null), - allow = true; + Pattern filterPattern, { + String replacementString = '', + }) : this(filterPattern, allow: true, replacementString: replacementString); /// Creates a formatter that blocks characters matching a pattern. /// /// The [filterPattern] and [replacementString] arguments /// must not be null. FilteringTextInputFormatter.deny( - this.filterPattern, { - this.replacementString = '', - }) : assert(filterPattern != null), - assert(replacementString != null), - allow = false; + Pattern filterPattern, { + String replacementString = '', + }) : this(filterPattern, allow: false, replacementString: replacementString); - /// A [Pattern] to match and replace in incoming [TextEditingValue]s. + /// A [Pattern] to match or replace in incoming [TextEditingValue]s. /// /// The behavior of the pattern depends on the [allow] property. If /// it is true, then this is an allow list, specifying a pattern that /// characters must match to be accepted. Otherwise, it is a deny list, /// specifying a pattern that characters must not match to be accepted. /// - /// In general, the pattern should only match one character at a - /// time. See the discussion at [replacementString]. - /// /// {@tool snippet} /// Typically the pattern is a regular expression, as in: /// @@ -246,16 +327,18 @@ class FilteringTextInputFormatter extends TextInputFormatter { /// string) because both of the "o"s would be matched simultaneously /// by the pattern. /// - /// Additionally, each segment of the string before, during, and - /// after the current selection in the [TextEditingValue] is handled - /// separately. This means that, in the case of the "Into the Woods" - /// example above, if the selection ended between the two "o"s in - /// "Woods", even if the pattern was `RegExp('o+')`, the result - /// would be "Int* the W**ds", since the two "o"s would be handled - /// in separate passes. + /// The filter may adjust the selection and the composing region of the text + /// after applying the text replacement, such that they still cover the same + /// text. For instance, if the pattern was `o+` and the last character "s" was + /// selected: "Into The Wood|s|", then the result will be "Into The W*d|s|", + /// with the selection still around the same character "s" despite that it is + /// now the 12th character. /// - /// See also [String.splitMapJoin], which is used to implement this - /// behavior in both cases. + /// In the case where one end point of the selection (or the composing region) + /// is strictly inside the banned pattern (for example, "Into The |Wo|ods"), + /// that endpoint will be moved to the end of the replacement string (it will + /// become "Into The |W*|ds" if the pattern was `o+` and the original text and + /// selection were "Into The |Wo|ods"). final String replacementString; @override @@ -263,16 +346,59 @@ class FilteringTextInputFormatter extends TextInputFormatter { TextEditingValue oldValue, // unused. TextEditingValue newValue, ) { - return _selectionAwareTextManipulation( - newValue, - (String substring) { - return substring.splitMapJoin( - filterPattern, - onMatch: !allow ? (Match match) => replacementString : null, - onNonMatch: allow ? (String nonMatch) => nonMatch.isNotEmpty ? replacementString : '' : null, - ); - }, - ); + final _TextEditingValueAccumulator formatState = _TextEditingValueAccumulator(newValue); + assert(!formatState.debugFinalized); + + final Iterable matches = filterPattern.allMatches(newValue.text); + Match? previousMatch; + for (final Match match in matches) { + assert(match.end >= match.start); + // Compute the non-match region between this `Match` and the previous + // `Match`. Depending on the value of `allow`, either the match region or + // the non-match region is the banned pattern. + // + // The non-matching region. + _processRegion(allow, previousMatch?.end ?? 0, match.start, formatState); + assert(!formatState.debugFinalized); + // The matched region. + _processRegion(!allow, match.start, match.end, formatState); + assert(!formatState.debugFinalized); + + previousMatch = match; + } + + // Handle the last non-matching region between the last match region and the + // end of the text. + _processRegion(allow, previousMatch?.end ?? 0, newValue.text.length, formatState); + assert(!formatState.debugFinalized); + return formatState.finalize(); + } + + void _processRegion(bool isBannedRegion, int regionStart, int regionEnd, _TextEditingValueAccumulator state) { + final String replacementString = isBannedRegion + ? (regionStart == regionEnd ? '' : this.replacementString) + : state.inputValue.text.substring(regionStart, regionEnd); + + state.stringBuffer.write(replacementString); + + if (replacementString.length == regionEnd - regionStart) { + // We don't have to adjust the indices if the replaced string and the + // replacement string have the same length. + return; + } + + int adjustIndex(int originalIndex) { + // The length added by adding the replacementString. + final int replacedLength = originalIndex <= regionStart && originalIndex < regionEnd ? 0 : replacementString.length; + // The length removed by removing the replacementRange. + final int removedLength = originalIndex.clamp(regionStart, regionEnd) - regionStart; + return replacedLength - removedLength; + } + + state.selection?.base += adjustIndex(state.inputValue.selection.baseOffset); + state.selection?.extent += adjustIndex(state.inputValue.selection.extentOffset); + state.composingRegion?.base += adjustIndex(state.inputValue.composing.start); + state.composingRegion?.extent += adjustIndex(state.inputValue.composing.end); } /// A [TextInputFormatter] that forces input to be a single line. @@ -527,45 +653,3 @@ class LengthLimitingTextInputFormatter extends TextInputFormatter { } } } - -TextEditingValue _selectionAwareTextManipulation( - TextEditingValue value, - String Function(String substring) substringManipulation, -) { - final int selectionStartIndex = value.selection.start; - final int selectionEndIndex = value.selection.end; - String manipulatedText; - TextSelection? manipulatedSelection; - if (selectionStartIndex < 0 || selectionEndIndex < 0) { - manipulatedText = substringManipulation(value.text); - } else { - final String beforeSelection = substringManipulation( - value.text.substring(0, selectionStartIndex), - ); - final String inSelection = substringManipulation( - value.text.substring(selectionStartIndex, selectionEndIndex), - ); - final String afterSelection = substringManipulation( - value.text.substring(selectionEndIndex), - ); - manipulatedText = beforeSelection + inSelection + afterSelection; - if (value.selection.baseOffset > value.selection.extentOffset) { - manipulatedSelection = value.selection.copyWith( - baseOffset: beforeSelection.length + inSelection.length, - extentOffset: beforeSelection.length, - ); - } else { - manipulatedSelection = value.selection.copyWith( - baseOffset: beforeSelection.length, - extentOffset: beforeSelection.length + inSelection.length, - ); - } - } - return TextEditingValue( - text: manipulatedText, - selection: manipulatedSelection ?? const TextSelection.collapsed(offset: -1), - composing: manipulatedText == value.text - ? value.composing - : TextRange.empty, - ); -} diff --git a/packages/flutter/lib/src/services/text_input.dart b/packages/flutter/lib/src/services/text_input.dart index 8b2cbf08550..40b5379d839 100644 --- a/packages/flutter/lib/src/services/text_input.dart +++ b/packages/flutter/lib/src/services/text_input.dart @@ -773,9 +773,38 @@ class TextEditingValue { final String text; /// The range of text that is currently selected. + /// + /// When [selection] is a [TextSelection] that has the same non-negative + /// `baseOffset` and `extentOffset`, the [selection] property represents the + /// caret position. + /// + /// If the current [selection] has a negative `baseOffset` or `extentOffset`, + /// then the text currently does not have a selection or a caret location, and + /// most text editing operations that rely on the current selection (for + /// instance, insert a character at the caret location) will do nothing. final TextSelection selection; /// The range of text that is still being composed. + /// + /// Composing regions are created by input methods (IMEs) to indicate the text + /// within a certain range is provisional. For instance, the Android Gboard + /// app's English keyboard puts the current word under the caret into a + /// composing region to indicate the word is subject to autocorrect or + /// prediction changes. + /// + /// Composing regions can also be used for performing multistage input, which + /// is typically used by IMEs designed for phoetic keyboard to enter + /// ideographic symbols. As an example, many CJK keyboards require the user to + /// enter a latin alphabet sequence and then convert it to CJK characters. On + /// iOS, the default software keyboards do not have a dedicated view to show + /// the unfinished latin sequence, so it's displayed directly in the text + /// field, inside of a composing region. + /// + /// The composing region should typically only be changed by the IME, or the + /// user via interacting with the IME. + /// + /// If the range represented by this property is [TextRange.empty], then the + /// text is not currently being composed. final TextRange composing; /// A value that corresponds to the empty string with no selection and no composing range. diff --git a/packages/flutter/lib/src/widgets/editable_text.dart b/packages/flutter/lib/src/widgets/editable_text.dart index 80aaefaf47d..21942f0f8f2 100644 --- a/packages/flutter/lib/src/widgets/editable_text.dart +++ b/packages/flutter/lib/src/widgets/editable_text.dart @@ -2375,10 +2375,19 @@ class EditableTextState extends State with AutomaticKeepAliveClien final bool selectionChanged = _value.selection != value.selection; if (textChanged) { - value = widget.inputFormatters?.fold( - value, - (TextEditingValue newValue, TextInputFormatter formatter) => formatter.formatEditUpdate(_value, newValue), - ) ?? value; + try { + value = widget.inputFormatters?.fold( + value, + (TextEditingValue newValue, TextInputFormatter formatter) => formatter.formatEditUpdate(_value, newValue), + ) ?? value; + } catch (exception, stack) { + FlutterError.reportError(FlutterErrorDetails( + exception: exception, + stack: stack, + library: 'widgets', + context: ErrorDescription('while applying input formatters'), + )); + } } // Put all optional user callback invocations in a batch edit to prevent diff --git a/packages/flutter/test/services/text_formatter_test.dart b/packages/flutter/test/services/text_formatter_test.dart index 2385ad309b7..ab168ea358c 100644 --- a/packages/flutter/test/services/text_formatter_test.dart +++ b/packages/flutter/test/services/text_formatter_test.dart @@ -64,6 +64,7 @@ void main() { const TextEditingValue(text: 'Int* the W*ds'), ); + // "Into the Wo|ods|" const TextEditingValue selectedIntoTheWoods = TextEditingValue(text: 'Into the Woods', selection: TextSelection(baseOffset: 11, extentOffset: 14)); expect( FilteringTextInputFormatter('o', allow: true, replacementString: '*').formatEditUpdate(testOldValue, selectedIntoTheWoods), @@ -79,7 +80,7 @@ void main() { ); expect( FilteringTextInputFormatter(RegExp('o+'), allow: false, replacementString: '*').formatEditUpdate(testOldValue, selectedIntoTheWoods), - const TextEditingValue(text: 'Int* the W**ds', selection: TextSelection(baseOffset: 11, extentOffset: 14)), + const TextEditingValue(text: 'Int* the W*ds', selection: TextSelection(baseOffset: 11, extentOffset: 13)), ); }); @@ -624,4 +625,247 @@ void main() { // cursor must be now at fourth position (right after the number 9) expect(formatted.selection.baseOffset, equals(4)); }); + + + test('FilteringTextInputFormatter should filter independent of selection', () { + // Regression test for https://github.com/flutter/flutter/issues/80842. + + final TextInputFormatter formatter = FilteringTextInputFormatter.deny('abc', replacementString: '*'); + + const TextEditingValue oldValue = TextEditingValue.empty; + const TextEditingValue newValue = TextEditingValue(text: 'abcabcabc'); + + final String filteredText = formatter.formatEditUpdate(oldValue, newValue).text; + + for (int i = 0; i < newValue.text.length; i += 1) { + final String text = formatter.formatEditUpdate( + oldValue, + newValue.copyWith(selection: TextSelection.collapsed(offset: i)), + ).text; + expect(filteredText, text); + } + }); + + test('FilteringTextInputFormatter should filter independent of composingRegion', () { + final TextInputFormatter formatter = FilteringTextInputFormatter.deny('abc', replacementString: '*'); + + const TextEditingValue oldValue = TextEditingValue.empty; + const TextEditingValue newValue = TextEditingValue(text: 'abcabcabc'); + + final String filteredText = formatter.formatEditUpdate(oldValue, newValue).text; + + for (int i = 0; i < newValue.text.length; i += 1) { + final String text = formatter.formatEditUpdate( + oldValue, + newValue.copyWith(composing: TextRange.collapsed(i)), + ).text; + expect(filteredText, text); + } + }); + + test('FilteringTextInputFormatter basic filtering test', () { + final RegExp filter = RegExp('[A-Za-z0-9.@-]*'); + final TextInputFormatter formatter = FilteringTextInputFormatter.allow(filter); + + const TextEditingValue oldValue = TextEditingValue.empty; + const TextEditingValue newValue = TextEditingValue(text: 'ab&&ca@bcabc'); + + expect(formatter.formatEditUpdate(oldValue, newValue).text, 'abca@bcabc'); + }); + + group('FilteringTextInputFormatter region', () { + const TextEditingValue oldValue = TextEditingValue.empty; + + test('Preserves selection region', () { + const TextEditingValue newValue = TextEditingValue(text: 'AAABBBCCC'); + + // AAA | BBB | CCC => AAA | **** | CCC + expect( + FilteringTextInputFormatter.deny('BBB', replacementString: '****').formatEditUpdate( + oldValue, + newValue.copyWith( + selection: const TextSelection(baseOffset: 6, extentOffset: 3), + ), + ).selection, + const TextSelection(baseOffset: 7, extentOffset: 3), + ); + + // AAA | BBB CCC | => AAA | **** CCC | + expect( + FilteringTextInputFormatter.deny('BBB', replacementString: '****').formatEditUpdate( + oldValue, + newValue.copyWith( + selection: const TextSelection(baseOffset: 9, extentOffset: 3), + ), + ).selection, + const TextSelection(baseOffset: 10, extentOffset: 3), + ); + + // AAA BBB | CCC | => AAA **** | CCC | + expect( + FilteringTextInputFormatter.deny('BBB', replacementString: '****').formatEditUpdate( + oldValue, + newValue.copyWith( + selection: const TextSelection(baseOffset: 9, extentOffset: 6), + ), + ).selection, + const TextSelection(baseOffset: 10, extentOffset: 7), + ); + + // AAAB | B | BCCC => AAA***|CCC + // Same length replacement, keep the selection at where it is. + expect( + FilteringTextInputFormatter.deny('BBB', replacementString: '***').formatEditUpdate( + oldValue, + newValue.copyWith( + selection: const TextSelection(baseOffset: 5, extentOffset: 4), + ), + ).selection, + const TextSelection(baseOffset: 5, extentOffset: 4), + ); + + // AAA | BBB | CCC => AAA | CCC + expect( + FilteringTextInputFormatter.deny('BBB', replacementString: '').formatEditUpdate( + oldValue, + newValue.copyWith( + selection: const TextSelection(baseOffset: 6, extentOffset: 3), + ), + ).selection, + const TextSelection(baseOffset: 3, extentOffset: 3), + ); + + expect( + FilteringTextInputFormatter.deny('BBB', replacementString: '').formatEditUpdate( + oldValue, + newValue.copyWith( + selection: const TextSelection(baseOffset: 6, extentOffset: 3), + ), + ).selection, + const TextSelection(baseOffset: 3, extentOffset: 3), + ); + + // The unfortunate case, we don't know for sure where to put the selection + // so put it after the replacement string. + // AAAB|B|BCCC => AAA****|CCC + expect( + FilteringTextInputFormatter.deny('BBB', replacementString: '****').formatEditUpdate( + oldValue, + newValue.copyWith( + selection: const TextSelection(baseOffset: 5, extentOffset: 4), + ), + ).selection, + const TextSelection(baseOffset: 7, extentOffset: 7), + ); + }); + + test('Preserves selection region, allow', () { + const TextEditingValue newValue = TextEditingValue(text: 'AAABBBCCC'); + + // AAA | BBB | CCC => **** | BBB | **** + expect( + FilteringTextInputFormatter.allow('BBB', replacementString: '****').formatEditUpdate( + oldValue, + newValue.copyWith( + selection: const TextSelection(baseOffset: 6, extentOffset: 3), + ), + ).selection, + const TextSelection(baseOffset: 7, extentOffset: 4), + ); + + // | AAABBBCCC | => | ****BBB**** | + expect( + FilteringTextInputFormatter.allow('BBB', replacementString: '****').formatEditUpdate( + oldValue, + newValue.copyWith( + selection: const TextSelection(baseOffset: 9, extentOffset: 0), + ), + ).selection, + const TextSelection(baseOffset: 11, extentOffset: 0), + ); + + // AAABBB | CCC | => ****BBB | **** | + expect( + FilteringTextInputFormatter.allow('BBB', replacementString: '****').formatEditUpdate( + oldValue, + newValue.copyWith( + selection: const TextSelection(baseOffset: 9, extentOffset: 6), + ), + ).selection, + const TextSelection(baseOffset: 11, extentOffset: 7), + ); + + // Overlapping matches: AAA | BBBBB | CCC => | BBB | + expect( + FilteringTextInputFormatter.allow('BBB', replacementString: '').formatEditUpdate( + oldValue, + const TextEditingValue( + text: 'AAABBBBBCCC', + selection: TextSelection(baseOffset: 8, extentOffset: 3), + ), + ).selection, + const TextSelection(baseOffset: 3, extentOffset: 0), + ); + }); + + test('Preserves composing region', () { + const TextEditingValue newValue = TextEditingValue(text: 'AAABBBCCC'); + + // AAA | BBB | CCC => AAA | **** | CCC + expect( + FilteringTextInputFormatter.deny('BBB', replacementString: '****').formatEditUpdate( + oldValue, + newValue.copyWith( + composing: const TextRange(start: 3, end: 6), + ), + ).composing, + const TextRange(start: 3, end: 7), + ); + + // AAA | BBB CCC | => AAA | **** CCC | + expect( + FilteringTextInputFormatter.deny('BBB', replacementString: '****').formatEditUpdate( + oldValue, + newValue.copyWith( + composing: const TextRange(start: 3, end: 9), + ), + ).composing, + const TextRange(start: 3, end: 10), + ); + + // AAA BBB | CCC | => AAA **** | CCC | + expect( + FilteringTextInputFormatter.deny('BBB', replacementString: '****').formatEditUpdate( + oldValue, + newValue.copyWith( + composing: const TextRange(start: 6, end: 9), + ), + ).composing, + const TextRange(start: 7, end: 10), + ); + + // AAAB | B | BCCC => AAA*** | CCC + // Same length replacement, don't move the composing region. + expect( + FilteringTextInputFormatter.deny('BBB', replacementString: '***').formatEditUpdate( + oldValue, + newValue.copyWith( + composing: const TextRange(start: 4, end: 5), + ), + ).composing, + const TextRange(start: 4, end: 5), + ); + + // AAA | BBB | CCC => | AAA CCC + expect( + FilteringTextInputFormatter.deny('BBB', replacementString: '').formatEditUpdate( + oldValue, + newValue.copyWith( + composing: const TextRange(start: 3, end: 6), + ), + ).composing, + TextRange.empty, + ); + }); + }); } diff --git a/packages/flutter/test/widgets/editable_text_test.dart b/packages/flutter/test/widgets/editable_text_test.dart index 060d4f20b39..926db407174 100644 --- a/packages/flutter/test/widgets/editable_text_test.dart +++ b/packages/flutter/test/widgets/editable_text_test.dart @@ -7750,6 +7750,39 @@ void main() { expect(error, isFlutterError); expect(error.toString(), contains(errorText)); }); + + testWidgets('input formatters can throw errors', (WidgetTester tester) async { + final TextInputFormatter badFormatter = TextInputFormatter.withFunction( + (TextEditingValue oldValue, TextEditingValue newValue) => throw FlutterError(errorText), + ); + final TextEditingController controller = TextEditingController( + text: 'flutter is the best!', + ); + await tester.pumpWidget(MaterialApp( + home: EditableText( + showSelectionHandles: true, + maxLines: 2, + controller: controller, + inputFormatters: [badFormatter], + focusNode: FocusNode(), + cursorColor: Colors.red, + backgroundCursorColor: Colors.blue, + style: Typography.material2018(platform: TargetPlatform.android).black.subtitle1!.copyWith(fontFamily: 'Roboto'), + keyboardType: TextInputType.text, + ), + )); + + // Interact with the field to establish the input connection. + await tester.tap(find.byType(EditableText)); + await tester.pump(); + + await tester.enterText(find.byType(EditableText), 'text'); + + final dynamic error = tester.takeException(); + expect(error, isFlutterError); + expect(error.toString(), contains(errorText)); + expect(controller.text, 'text'); + }); }); // Regression test for https://github.com/flutter/flutter/issues/72400.