diff --git a/packages/flutter/lib/src/widgets/editable_text.dart b/packages/flutter/lib/src/widgets/editable_text.dart index 79b323cb29c..e2c271ba0ab 100644 --- a/packages/flutter/lib/src/widgets/editable_text.dart +++ b/packages/flutter/lib/src/widgets/editable_text.dart @@ -1230,6 +1230,9 @@ class EditableTextState extends State with AutomaticKeepAliveClien // _lastFormattedUnmodifiedTextEditingValue tracks the last value // that the formatter ran on and is used to prevent double-formatting. TextEditingValue _lastFormattedUnmodifiedTextEditingValue; + // _lastFormattedValue tracks the last post-format value, so that it can be + // reused without rerunning the formatter when the input value is repeated. + TextEditingValue _lastFormattedValue; // _receivedRemoteTextEditingValue is the direct value last passed in // updateEditingValue. This value does not get updated with the formatted // version. @@ -1660,15 +1663,30 @@ class EditableTextState extends State with AutomaticKeepAliveClien // Check if the new value is the same as the current local value, or is the same // as the post-formatting value of the previous pass. final bool textChanged = _value?.text != value?.text; - final bool isRepeat = value?.text == _lastFormattedUnmodifiedTextEditingValue?.text; - if (textChanged && !isRepeat && widget.inputFormatters != null && widget.inputFormatters.isNotEmpty) { - for (final TextInputFormatter formatter in widget.inputFormatters) + final bool isRepeatText = value?.text == _lastFormattedUnmodifiedTextEditingValue?.text; + final bool isRepeatSelection = value?.selection == _lastFormattedUnmodifiedTextEditingValue?.selection; + final bool isRepeatComposing = value?.composing == _lastFormattedUnmodifiedTextEditingValue?.composing; + // Only format when the text has changed and there are available formatters. + if (!isRepeatText && textChanged && widget.inputFormatters != null && widget.inputFormatters.isNotEmpty) { + for (final TextInputFormatter formatter in widget.inputFormatters) { value = formatter.formatEditUpdate(_value, value); - _value = value; - _updateRemoteEditingValueIfNeeded(); - } else { - _value = value; + } + // Always pass the text through the whitespace directionality formatter to + // maintain expected behavior with carets on trailing whitespace. + value = _whitespaceFormatter.formatEditUpdate(_value, value); + _lastFormattedValue = value; } + // If the text, selection, or composing region has changed, we should update the + // locally stored TextEditingValue to the new one. + if (!isRepeatText || !isRepeatSelection || !isRepeatComposing) { + _value = value; + } else if (textChanged && _lastFormattedValue != null) { + _value = _lastFormattedValue; + } + // Always attempt to send the value. If the value has changed, then it will send, + // otherwise, it will short-circuit. + _updateRemoteEditingValueIfNeeded(); + if (textChanged && widget.onChanged != null) widget.onChanged(value.text); _lastFormattedUnmodifiedTextEditingValue = _receivedRemoteTextEditingValue; @@ -2207,14 +2225,22 @@ class _WhitespaceDirectionalityFormatter extends TextInputFormatter { // We add/subtract from these as we insert/remove markers. int selectionBase = newValue.selection.baseOffset; int selectionExtent = newValue.selection.extentOffset; + int composingStart = newValue.composing.start; + int composingEnd = newValue.composing.end; - void addToSelection() { + void addToLength() { selectionBase += outputCodepoints.length <= selectionBase ? 1 : 0; selectionExtent += outputCodepoints.length <= selectionExtent ? 1 : 0; + + composingStart += outputCodepoints.length <= composingStart ? 1 : 0; + composingEnd += outputCodepoints.length <= composingEnd ? 1 : 0; } - void subtractFromSelection() { + void subtractFromLength() { selectionBase -= outputCodepoints.length < selectionBase ? 1 : 0; selectionExtent -= outputCodepoints.length < selectionExtent ? 1 : 0; + + composingStart -= outputCodepoints.length < composingStart ? 1 : 0; + composingEnd -= outputCodepoints.length < composingEnd ? 1 : 0; } bool previousWasWhitespace = false; @@ -2230,11 +2256,11 @@ class _WhitespaceDirectionalityFormatter extends TextInputFormatter { // If we already added directionality for this run of whitespace, // "shift" the marker added to the end of the whitespace run. if (previousWasWhitespace) { - subtractFromSelection(); + subtractFromLength(); outputCodepoints.removeLast(); } outputCodepoints.add(codepoint); - addToSelection(); + addToLength(); outputCodepoints.add(_previousNonWhitespaceDirection == TextDirection.rtl ? _rlm : _lrm); previousWasWhitespace = true; @@ -2243,7 +2269,7 @@ class _WhitespaceDirectionalityFormatter extends TextInputFormatter { // Handle pre-existing directionality markers. Use pre-existing marker // instead of the one we add. if (previousWasWhitespace) { - subtractFromSelection(); + subtractFromLength(); outputCodepoints.removeLast(); } outputCodepoints.add(codepoint); @@ -2256,7 +2282,7 @@ class _WhitespaceDirectionalityFormatter extends TextInputFormatter { if (!previousWasDirectionalityMarker && previousWasWhitespace && getDirection(codepoint) == _previousNonWhitespaceDirection) { - subtractFromSelection(); + subtractFromLength(); outputCodepoints.removeLast(); } // Normal character, track its codepoint add it to the string. @@ -2276,6 +2302,7 @@ class _WhitespaceDirectionalityFormatter extends TextInputFormatter { affinity: newValue.selection.affinity, isDirectional: newValue.selection.isDirectional ), + composing: TextRange(start: composingStart, end: composingEnd), ); } return newValue; diff --git a/packages/flutter/test/widgets/editable_text_test.dart b/packages/flutter/test/widgets/editable_text_test.dart index 43e12710323..916e9947b08 100644 --- a/packages/flutter/test/widgets/editable_text_test.dart +++ b/packages/flutter/test/widgets/editable_text_test.dart @@ -4238,21 +4238,324 @@ void main() { expect(formatter.log, referenceLog); }); + + testWidgets('formatter logic handles repeat filtering', (WidgetTester tester) async { + final MockTextFormatter formatter = MockTextFormatter(); + await tester.pumpWidget( + MediaQuery( + data: const MediaQueryData(devicePixelRatio: 1.0), + child: Directionality( + textDirection: TextDirection.ltr, + child: FocusScope( + node: focusScopeNode, + autofocus: true, + child: EditableText( + backgroundCursorColor: Colors.grey, + controller: controller, + focusNode: focusNode, + maxLines: 1, // Sets text keyboard implicitly. + style: textStyle, + cursorColor: cursorColor, + inputFormatters: [formatter], + ), + ), + ), + ), + ); + + await tester.tap(find.byType(EditableText)); + await tester.showKeyboard(find.byType(EditableText)); + controller.text = ''; + await tester.idle(); + + final EditableTextState state = + tester.state(find.byType(EditableText)); + expect(tester.testTextInput.editingState['text'], equals('')); + expect(state.wantKeepAlive, true); + + expect(formatter.formatCallCount, 0); + state.updateEditingValue(const TextEditingValue(text: '01')); + expect(formatter.formatCallCount, 1); + state.updateEditingValue(const TextEditingValue(text: '012')); + expect(formatter.formatCallCount, 2); + state.updateEditingValue(const TextEditingValue(text: '0123')); // Text change causes reformat + expect(formatter.formatCallCount, 3); + state.updateEditingValue(const TextEditingValue(text: '0123')); // Repeat, does not format + expect(formatter.formatCallCount, 3); + state.updateEditingValue(const TextEditingValue(text: '0123')); // Repeat, does not format + expect(formatter.formatCallCount, 3); + state.updateEditingValue(const TextEditingValue(text: '0123', selection: TextSelection.collapsed(offset: 2))); // Selection change does not reformat + expect(formatter.formatCallCount, 3); + state.updateEditingValue(const TextEditingValue(text: '0123', selection: TextSelection.collapsed(offset: 2))); // Repeat, does not format + expect(formatter.formatCallCount, 3); + state.updateEditingValue(const TextEditingValue(text: '0123', selection: TextSelection.collapsed(offset: 2))); // Repeat, does not format + expect(formatter.formatCallCount, 3); + state.updateEditingValue(const TextEditingValue(text: '0123', selection: TextSelection.collapsed(offset: 2), composing: TextRange(start: 1, end: 2))); // Composing change does not reformat + expect(formatter.formatCallCount, 3); + expect(formatter.lastOldValue.composing, const TextRange(start: -1, end: -1)); + expect(formatter.lastNewValue.composing, const TextRange(start: -1, end: -1)); + state.updateEditingValue(const TextEditingValue(text: '01234', selection: TextSelection.collapsed(offset: 2))); // Formats, with oldValue containing composing region. + expect(formatter.formatCallCount, 4); + expect(formatter.lastOldValue.composing, const TextRange(start: 1, end: 2)); + expect(formatter.lastNewValue.composing, const TextRange(start: -1, end: -1)); + + const List referenceLog = [ + '[1]: , 01', + '[1]: normal aa', + '[2]: aa, 012', + '[2]: normal aaaa', + '[3]: aaaa, 0123', + '[3]: normal aaaaaa', + '[4]: 0123, 01234', + '[4]: normal aaaaaaaa' + ]; + + expect(formatter.log, referenceLog); + }); + + testWidgets('Whitespace directionality formatter input Arabic', (WidgetTester tester) async { + final TextEditingController controller = TextEditingController(text: 'testText'); + await tester.pumpWidget( + MediaQuery( + data: const MediaQueryData(devicePixelRatio: 1.0), + child: Directionality( + textDirection: TextDirection.ltr, + child: FocusScope( + node: focusScopeNode, + autofocus: true, + child: EditableText( + backgroundCursorColor: Colors.blue, + controller: controller, + focusNode: focusNode, + maxLines: 1, // Sets text keyboard implicitly. + style: textStyle, + cursorColor: cursorColor, + ), + ), + ), + ), + ); + + await tester.tap(find.byType(EditableText)); + await tester.showKeyboard(find.byType(EditableText)); + controller.text = ''; + await tester.idle(); + + final EditableTextState state = + tester.state(find.byType(EditableText)); + expect(tester.testTextInput.editingState['text'], equals('')); + expect(state.wantKeepAlive, true); + + // Simple mixed directional input. + state.updateEditingValue(const TextEditingValue(text: 'h')); + state.updateEditingValue(const TextEditingValue(text: 'he')); + state.updateEditingValue(const TextEditingValue(text: 'hel')); + state.updateEditingValue(const TextEditingValue(text: 'hell')); + state.updateEditingValue(const TextEditingValue(text: 'hello')); + expect(state.currentTextEditingValue.text, equals('hello')); + state.updateEditingValue(const TextEditingValue(text: 'hello ', composing: TextRange(start: 4, end: 5))); + expect(state.currentTextEditingValue.text, equals('hello ')); + state.updateEditingValue(const TextEditingValue(text: 'hello ا', composing: TextRange(start: 4, end: 6))); + expect(state.currentTextEditingValue.text, equals('hello \u{200E}ا')); + expect(state.currentTextEditingValue.composing, equals(const TextRange(start: 4, end: 7))); + state.updateEditingValue(const TextEditingValue(text: 'hello الْ', composing: TextRange(start: 4, end: 7))); + state.updateEditingValue(const TextEditingValue(text: 'hello الْعَ', composing: TextRange(start: 4, end: 8))); + state.updateEditingValue(const TextEditingValue(text: 'hello الْعَ ', composing: TextRange(start: 4, end: 9))); + expect(state.currentTextEditingValue.text, equals('hello \u{200E}الْعَ \u{200F}')); + expect(state.currentTextEditingValue.composing, equals(const TextRange(start: 4, end: 10))); + state.updateEditingValue(const TextEditingValue(text: 'hello الْعَ بِيَّةُ')); + state.updateEditingValue(const TextEditingValue(text: 'hello الْعَ بِيَّةُ ')); + expect(state.currentTextEditingValue.text, equals('hello \u{200E}الْعَ بِيَّةُ \u{200F}')); + }); + + testWidgets('Whitespace directionality formatter doesn\'t overwrite existing Arabic', (WidgetTester tester) async { + final TextEditingController controller = TextEditingController(text: 'testText'); + await tester.pumpWidget( + MediaQuery( + data: const MediaQueryData(devicePixelRatio: 1.0), + child: Directionality( + textDirection: TextDirection.ltr, + child: FocusScope( + node: focusScopeNode, + autofocus: true, + child: EditableText( + backgroundCursorColor: Colors.blue, + controller: controller, + focusNode: focusNode, + maxLines: 1, // Sets text keyboard implicitly. + style: textStyle, + cursorColor: cursorColor, + ), + ), + ), + ), + ); + + await tester.tap(find.byType(EditableText)); + await tester.showKeyboard(find.byType(EditableText)); + controller.text = ''; + await tester.idle(); + + final EditableTextState state = + tester.state(find.byType(EditableText)); + expect(tester.testTextInput.editingState['text'], equals('')); + expect(state.wantKeepAlive, true); + + // Does not overwrite existing RLM or LRM characters + state.updateEditingValue(const TextEditingValue(text: 'hello \u{200F}ا')); + expect(state.currentTextEditingValue.text, equals('hello \u{200F}ا')); + state.updateEditingValue(const TextEditingValue(text: 'hello \u{200F}ا \u{200E}ا ا ')); + expect(state.currentTextEditingValue.text, equals('hello \u{200F}ا \u{200E}ا ا \u{200F}')); + + // Handles only directionality markers. + state.updateEditingValue(const TextEditingValue(text: '\u{200E}\u{200F}')); + expect(state.currentTextEditingValue.text, equals('\u{200E}\u{200F}')); + state.updateEditingValue(const TextEditingValue(text: '\u{200E}\u{200F}\u{200E}\u{200F}\u{200E}\u{200F}')); + expect(state.currentTextEditingValue.text, equals('\u{200E}\u{200F}\u{200E}\u{200F}\u{200E}\u{200F}')); + state.updateEditingValue(const TextEditingValue(text: '\u{200E}\u{200F}\u{200F}\u{200F}')); + expect(state.currentTextEditingValue.text, equals('\u{200E}\u{200F}\u{200F}\u{200F}')); + }); + + testWidgets('Whitespace directionality formatter is not leaky Arabic', (WidgetTester tester) async { + final TextEditingController controller = TextEditingController(text: 'testText'); + await tester.pumpWidget( + MediaQuery( + data: const MediaQueryData(devicePixelRatio: 1.0), + child: Directionality( + textDirection: TextDirection.ltr, + child: FocusScope( + node: focusScopeNode, + autofocus: true, + child: EditableText( + backgroundCursorColor: Colors.blue, + controller: controller, + focusNode: focusNode, + maxLines: 1, // Sets text keyboard implicitly. + style: textStyle, + cursorColor: cursorColor, + ), + ), + ), + ), + ); + + await tester.tap(find.byType(EditableText)); + await tester.showKeyboard(find.byType(EditableText)); + controller.text = ''; + await tester.idle(); + + final EditableTextState state = + tester.state(find.byType(EditableText)); + expect(tester.testTextInput.editingState['text'], equals('')); + expect(state.wantKeepAlive, true); + + // Can be passed through formatter repeatedly without leaking/growing. + state.updateEditingValue(const TextEditingValue(text: 'hello \u{200E}عَ \u{200F}عَ \u{200F}عَ \u{200F}')); + expect(state.currentTextEditingValue.text, equals('hello \u{200E}عَ \u{200F}عَ \u{200F}عَ \u{200F}')); + state.updateEditingValue(const TextEditingValue(text: 'hello \u{200E}عَ \u{200F}عَ \u{200F}عَ \u{200F}')); + expect(state.currentTextEditingValue.text, equals('hello \u{200E}عَ \u{200F}عَ \u{200F}عَ \u{200F}')); + state.updateEditingValue(const TextEditingValue(text: 'hello \u{200E}عَ \u{200F}عَ \u{200F}عَ \u{200F}')); + expect(state.currentTextEditingValue.text, equals('hello \u{200E}عَ \u{200F}عَ \u{200F}عَ \u{200F}')); + }); + + testWidgets('Whitespace directionality formatter emojis', (WidgetTester tester) async { + final TextEditingController controller = TextEditingController(text: 'testText'); + await tester.pumpWidget( + MediaQuery( + data: const MediaQueryData(devicePixelRatio: 1.0), + child: Directionality( + textDirection: TextDirection.ltr, + child: FocusScope( + node: focusScopeNode, + autofocus: true, + child: EditableText( + backgroundCursorColor: Colors.blue, + controller: controller, + focusNode: focusNode, + maxLines: 1, // Sets text keyboard implicitly. + style: textStyle, + cursorColor: cursorColor, + ), + ), + ), + ), + ); + + await tester.tap(find.byType(EditableText)); + await tester.showKeyboard(find.byType(EditableText)); + controller.text = ''; + await tester.idle(); + + final EditableTextState state = + tester.state(find.byType(EditableText)); + expect(tester.testTextInput.editingState['text'], equals('')); + expect(state.wantKeepAlive, true); + + // Doesn't eat emojis + state.updateEditingValue(const TextEditingValue(text: '\u{200E}😀😁😂🤣😃 💑 👩‍❤️‍👩 👨‍❤️‍👨 💏 👩‍❤️‍💋‍👩 👨‍❤️‍💋‍👨 👪 👨‍👩‍👧 👨‍👩‍👧‍👦 👨‍👩‍👦‍👦 \u{200F}')); + expect(state.currentTextEditingValue.text, equals('\u{200E}😀😁😂🤣😃 💑 👩‍❤️‍👩 👨‍❤️‍👨 💏 👩‍❤️‍💋‍👩 👨‍❤️‍💋‍👨 👪 👨‍👩‍👧 👨‍👩‍👧‍👦 👨‍👩‍👦‍👦 \u{200F}')); + state.updateEditingValue(const TextEditingValue(text: '\u{200E}🇧🇼🇧🇷🇮🇴 🇻🇬🇧🇳wahhh!🇧🇬🇧🇫 🇧🇮🇰🇭عَ عَ 🇨🇲 🇨🇦🇮🇨 🇨🇻🇧🇶 🇰🇾🇨🇫 🇹🇩🇨🇱 🇨🇳🇨🇽\u{200F}')); + expect(state.currentTextEditingValue.text, equals('\u{200E}🇧🇼🇧🇷🇮🇴 🇻🇬🇧🇳wahhh!🇧🇬🇧🇫 🇧🇮🇰🇭عَ عَ \u{200F}🇨🇲 🇨🇦🇮🇨 🇨🇻🇧🇶 🇰🇾🇨🇫 🇹🇩🇨🇱 🇨🇳🇨🇽\u{200F}')); + }); + + testWidgets('Whitespace directionality formatter emojis', (WidgetTester tester) async { + final TextEditingController controller = TextEditingController(text: 'testText'); + await tester.pumpWidget( + MediaQuery( + data: const MediaQueryData(devicePixelRatio: 1.0), + child: Directionality( + textDirection: TextDirection.ltr, + child: FocusScope( + node: focusScopeNode, + autofocus: true, + child: EditableText( + backgroundCursorColor: Colors.blue, + controller: controller, + focusNode: focusNode, + maxLines: 1, // Sets text keyboard implicitly. + style: textStyle, + cursorColor: cursorColor, + ), + ), + ), + ), + ); + + await tester.tap(find.byType(EditableText)); + await tester.showKeyboard(find.byType(EditableText)); + controller.text = ''; + await tester.idle(); + + final EditableTextState state = + tester.state(find.byType(EditableText)); + expect(tester.testTextInput.editingState['text'], equals('')); + expect(state.wantKeepAlive, true); + + // Doesn't eat emojis + state.updateEditingValue(const TextEditingValue(text: '\u{200E}😀😁😂🤣😃 💑 👩‍❤️‍👩 👨‍❤️‍👨 💏 👩‍❤️‍💋‍👩 👨‍❤️‍💋‍👨 👪 👨‍👩‍👧 👨‍👩‍👧‍👦 👨‍👩‍👦‍👦 \u{200F}')); + expect(state.currentTextEditingValue.text, equals('\u{200E}😀😁😂🤣😃 💑 👩‍❤️‍👩 👨‍❤️‍👨 💏 👩‍❤️‍💋‍👩 👨‍❤️‍💋‍👨 👪 👨‍👩‍👧 👨‍👩‍👧‍👦 👨‍👩‍👦‍👦 \u{200F}')); + state.updateEditingValue(const TextEditingValue(text: '\u{200E}🇧🇼🇧🇷🇮🇴 🇻🇬🇧🇳wahhh!🇧🇬🇧🇫 🇧🇮🇰🇭عَ عَ 🇨🇲 🇨🇦🇮🇨 🇨🇻🇧🇶 🇰🇾🇨🇫 🇹🇩🇨🇱 🇨🇳🇨🇽\u{200F}')); + expect(state.currentTextEditingValue.text, equals('\u{200E}🇧🇼🇧🇷🇮🇴 🇻🇬🇧🇳wahhh!🇧🇬🇧🇫 🇧🇮🇰🇭عَ عَ \u{200F}🇨🇲 🇨🇦🇮🇨 🇨🇻🇧🇶 🇰🇾🇨🇫 🇹🇩🇨🇱 🇨🇳🇨🇽\u{200F}')); + }); } class MockTextFormatter extends TextInputFormatter { - MockTextFormatter() : _counter = 0, log = []; + MockTextFormatter() : formatCallCount = 0, log = []; - int _counter; + int formatCallCount; List log; + TextEditingValue lastOldValue; + TextEditingValue lastNewValue; @override TextEditingValue formatEditUpdate( TextEditingValue oldValue, TextEditingValue newValue, ) { - _counter++; - log.add('[$_counter]: ${oldValue.text}, ${newValue.text}'); + lastOldValue = oldValue; + lastNewValue = newValue; + formatCallCount++; + log.add('[$formatCallCount]: ${oldValue.text}, ${newValue.text}'); TextEditingValue finalValue; if (newValue.text.length < oldValue.text.length) { finalValue = _handleTextDeletion(oldValue, newValue); @@ -4265,14 +4568,14 @@ class MockTextFormatter extends TextInputFormatter { TextEditingValue _handleTextDeletion( TextEditingValue oldValue, TextEditingValue newValue) { - final String result = 'a' * (_counter - 2); - log.add('[$_counter]: deleting $result'); + final String result = 'a' * (formatCallCount - 2); + log.add('[$formatCallCount]: deleting $result'); return TextEditingValue(text: result); } TextEditingValue _formatText(TextEditingValue value) { - final String result = 'a' * _counter * 2; - log.add('[$_counter]: normal $result'); + final String result = 'a' * formatCallCount * 2; + log.add('[$formatCallCount]: normal $result'); return TextEditingValue(text: result); } }