From f6eab1d40e2d2ca8fdb111ffa3869a10b4e88125 Mon Sep 17 00:00:00 2001 From: Jenn Magder Date: Wed, 24 Mar 2021 18:25:43 -0700 Subject: [PATCH] Revert "Remove whitespace directionality formatter (#78501)" (#79014) This reverts commit d3ee5ace29e0ea173de16745e9acb0ef581b9dc2. --- .../lib/src/widgets/editable_text.dart | 200 +++++++++++ .../test/widgets/editable_text_test.dart | 321 +++++++++++++++--- 2 files changed, 465 insertions(+), 56 deletions(-) diff --git a/packages/flutter/lib/src/widgets/editable_text.dart b/packages/flutter/lib/src/widgets/editable_text.dart index a2586cd0560..26565c63fca 100644 --- a/packages/flutter/lib/src/widgets/editable_text.dart +++ b/packages/flutter/lib/src/widgets/editable_text.dart @@ -697,6 +697,15 @@ class EditableText extends StatefulWidget { /// context, the English phrase will be on the right and the Hebrew phrase on /// its left. /// + /// When LTR text is entered into an RTL field, or RTL text is entered into an + /// LTR field, [LRM](https://en.wikipedia.org/wiki/Left-to-right_mark) or + /// [RLM](https://en.wikipedia.org/wiki/Right-to-left_mark) characters will be + /// inserted alongside whitespace characters, respectively. This is to + /// eliminate ambiguous directionality in whitespace and ensure proper caret + /// placement. These characters will affect the length of the string and may + /// need to be parsed out when doing things like string comparison with other + /// text. + /// /// Defaults to the ambient [Directionality], if any. /// {@endtemplate} final TextDirection? textDirection; @@ -2234,6 +2243,8 @@ class EditableTextState extends State with AutomaticKeepAliveClien _lastBottomViewInset = WidgetsBinding.instance!.window.viewInsets.bottom; } + late final _WhitespaceDirectionalityFormatter _whitespaceFormatter = _WhitespaceDirectionalityFormatter(textDirection: _textDirection); + void _formatAndSetValue(TextEditingValue value, SelectionChangedCause? cause, {bool userInteraction = false}) { // Only apply input formatters if the text has changed (including uncommited // text in the composing region), or when the user committed the composing @@ -2252,6 +2263,14 @@ class EditableTextState extends State with AutomaticKeepAliveClien value, (TextEditingValue newValue, TextInputFormatter formatter) => formatter.formatEditUpdate(_value, newValue), ) ?? value; + + // Always pass the text through the whitespace directionality formatter to + // maintain expected behavior with carets on trailing whitespace. + // TODO(LongCatIsLooong): The if statement here is for retaining the + // previous behavior. The input formatter logic will be updated in an + // upcoming PR. + if (widget.inputFormatters?.isNotEmpty ?? false) + value = _whitespaceFormatter.formatEditUpdate(_value, value); } // Put all optional user callback invocations in a batch edit to prevent @@ -2866,3 +2885,184 @@ class _Editable extends LeafRenderObjectWidget { ..setPromptRectRange(promptRectRange); } } + +// This formatter inserts [Unicode.RLM] and [Unicode.LRM] into the +// string in order to preserve expected caret behavior when trailing +// whitespace is inserted. +// +// When typing in a direction that opposes the base direction +// of the paragraph, un-enclosed whitespace gets the directionality +// of the paragraph. This is often at odds with what is immediately +// being typed causing the caret to jump to the wrong side of the text. +// This formatter makes use of the RLM and LRM to cause the text +// shaper to inherently treat the whitespace as being surrounded +// by the directionality of the previous non-whitespace codepoint. +class _WhitespaceDirectionalityFormatter extends TextInputFormatter { + // The [textDirection] should be the base directionality of the + // paragraph/editable. + _WhitespaceDirectionalityFormatter({TextDirection? textDirection}) + : _baseDirection = textDirection, + _previousNonWhitespaceDirection = textDirection; + + // Using regex here instead of ICU is suboptimal, but is enough + // to produce the correct results for any reasonable input where this + // is even relevant. Using full ICU would be a much heavier change, + // requiring exposure of the C++ ICU API. + // + // LTR covers most scripts and symbols, including but not limited to Latin, + // ideographic scripts (Chinese, Japanese, etc), Cyrilic, Indic, and + // SE Asian scripts. + final RegExp _ltrRegExp = RegExp(r'[A-Za-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02B8\u0300-\u0590\u0800-\u1FFF\u2C00-\uFB1C\uFDFE-\uFE6F\uFEFD-\uFFFF]'); + // RTL covers Arabic, Hebrew, and other RTL languages such as Urdu, + // Aramic, Farsi, Dhivehi. + final RegExp _rtlRegExp = RegExp(r'[\u0591-\u07FF\uFB1D-\uFDFD\uFE70-\uFEFC]'); + // Although whitespaces are not the only codepoints that have weak directionality, + // these are the primary cause of the caret being misplaced. + final RegExp _whitespaceRegExp = RegExp(r'\s'); + + final TextDirection? _baseDirection; + // Tracks the directionality of the most recently encountered + // codepoint that was not whitespace. This becomes the direction of + // marker inserted to fully surround ambiguous whitespace. + TextDirection? _previousNonWhitespaceDirection; + + // Prevents the formatter from attempting more expensive formatting + // operations mixed directionality is found. + bool _hasOpposingDirection = false; + + // See [Unicode.RLM] and [Unicode.LRM]. + // + // We do not directly use the [Unicode] constants since they are strings. + static const int _rlm = 0x200F; + static const int _lrm = 0x200E; + + @override + TextEditingValue formatEditUpdate( + TextEditingValue oldValue, + TextEditingValue newValue, + ) { + // Skip formatting (which can be more expensive) if there are no cases of + // mixing directionality. Once a case of mixed directionality is found, + // always perform the formatting. + if (!_hasOpposingDirection) { + _hasOpposingDirection = _baseDirection == TextDirection.ltr ? + _rtlRegExp.hasMatch(newValue.text) : _ltrRegExp.hasMatch(newValue.text); + } + + if (_hasOpposingDirection) { + _previousNonWhitespaceDirection = _baseDirection; + + final List outputCodepoints = []; + + // 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 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 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; + } + + final bool isBackspace = oldValue.text.runes.length - newValue.text.runes.length == 1 && + isDirectionalityMarker(oldValue.text.runes.last) && + oldValue.text.substring(0, oldValue.text.length - 1) == newValue.text; + + bool previousWasWhitespace = false; + bool previousWasDirectionalityMarker = false; + int? previousNonWhitespaceCodepoint; + int index = 0; + for (final int codepoint in newValue.text.runes) { + if (isWhitespace(codepoint)) { + // Only compute the directionality of the non-whitespace + // when the value is needed. + if (!previousWasWhitespace && previousNonWhitespaceCodepoint != null) { + _previousNonWhitespaceDirection = getDirection(previousNonWhitespaceCodepoint); + } + // If we already added directionality for this run of whitespace, + // "shift" the marker added to the end of the whitespace run. + if (previousWasWhitespace) { + subtractFromLength(); + outputCodepoints.removeLast(); + } + // Handle trailing whitespace deleting the directionality char instead of the whitespace. + if (isBackspace && index == newValue.text.runes.length - 1) { + // Do not append the whitespace to the outputCodepoints. + subtractFromLength(); + } else { + outputCodepoints.add(codepoint); + addToLength(); + outputCodepoints.add(_previousNonWhitespaceDirection == TextDirection.rtl ? _rlm : _lrm); + } + + previousWasWhitespace = true; + previousWasDirectionalityMarker = false; + } else if (isDirectionalityMarker(codepoint)) { + // Handle pre-existing directionality markers. Use pre-existing marker + // instead of the one we add. + if (previousWasWhitespace) { + subtractFromLength(); + outputCodepoints.removeLast(); + } + outputCodepoints.add(codepoint); + + previousWasWhitespace = false; + previousWasDirectionalityMarker = true; + } else { + // If the whitespace was already enclosed by the same directionality, + // we can remove the artificially added marker. + if (!previousWasDirectionalityMarker && + previousWasWhitespace && + getDirection(codepoint) == _previousNonWhitespaceDirection) { + subtractFromLength(); + outputCodepoints.removeLast(); + } + // Normal character, track its codepoint add it to the string. + previousNonWhitespaceCodepoint = codepoint; + outputCodepoints.add(codepoint); + + previousWasWhitespace = false; + previousWasDirectionalityMarker = false; + } + index++; + } + final String formatted = String.fromCharCodes(outputCodepoints); + return TextEditingValue( + text: formatted, + selection: TextSelection( + baseOffset: selectionBase, + extentOffset: selectionExtent, + affinity: newValue.selection.affinity, + isDirectional: newValue.selection.isDirectional + ), + composing: TextRange(start: composingStart, end: composingEnd), + ); + } + return newValue; + } + + bool isWhitespace(int value) { + return _whitespaceRegExp.hasMatch(String.fromCharCode(value)); + } + + bool isDirectionalityMarker(int value) { + return value == _rlm || value == _lrm; + } + + TextDirection getDirection(int value) { + // Use the LTR version as short-circuiting will be more efficient since + // there are more LTR codepoints. + return _ltrRegExp.hasMatch(String.fromCharCode(value)) ? TextDirection.ltr : TextDirection.rtl; + } +} diff --git a/packages/flutter/test/widgets/editable_text_test.dart b/packages/flutter/test/widgets/editable_text_test.dart index 43e70367879..e60ab76ef59 100644 --- a/packages/flutter/test/widgets/editable_text_test.dart +++ b/packages/flutter/test/widgets/editable_text_test.dart @@ -121,62 +121,6 @@ void main() { equals(serializedActionName)); } - // Regression test for https://github.com/flutter/flutter/issues/34538. - testWidgets('RTL arabic correct caret placement after trailing whitespace', (WidgetTester tester) async { - final TextEditingController controller = TextEditingController(); - await tester.pumpWidget( - MediaQuery( - data: const MediaQueryData(), - child: Directionality( - textDirection: TextDirection.rtl, - 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)); - await tester.idle(); - - final EditableTextState state = tester.state(find.byType(EditableText)); - - // Simulates Gboard Persian input. - state.updateEditingValue(const TextEditingValue(text: 'گ', selection: TextSelection.collapsed(offset: 1))); - await tester.pump(); - double previousCaretXPosition = state.renderEditable.getLocalRectForCaret(state.textEditingValue.selection.base).left; - - state.updateEditingValue(const TextEditingValue(text: 'گی', selection: TextSelection.collapsed(offset: 2))); - await tester.pump(); - double caretXPosition = state.renderEditable.getLocalRectForCaret(state.textEditingValue.selection.base).left; - expect(caretXPosition, lessThan(previousCaretXPosition)); - previousCaretXPosition = caretXPosition; - - state.updateEditingValue(const TextEditingValue(text: 'گیگ', selection: TextSelection.collapsed(offset: 3))); - await tester.pump(); - caretXPosition = state.renderEditable.getLocalRectForCaret(state.textEditingValue.selection.base).left; - expect(caretXPosition, lessThan(previousCaretXPosition)); - previousCaretXPosition = caretXPosition; - - // Enter a whitespace in a RTL input field moves the caret to the left. - state.updateEditingValue(const TextEditingValue(text: 'گیگ ', selection: TextSelection.collapsed(offset: 4))); - await tester.pump(); - caretXPosition = state.renderEditable.getLocalRectForCaret(state.textEditingValue.selection.base).left; - expect(caretXPosition, lessThan(previousCaretXPosition)); - - expect(state.currentTextEditingValue.text, equals('گیگ ')); - }); - testWidgets('has expected defaults', (WidgetTester tester) async { await tester.pumpWidget( MediaQuery( @@ -6192,6 +6136,271 @@ void main() { expect(formatter.lastOldValue.text, 'test'); }); + 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}')); + }); + + testWidgets('Whitespace directionality formatter handles deletion of trailing whitespace', (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); + + // Simulate deleting only the trailing RTL mark. + state.updateEditingValue(const TextEditingValue(text: 'hello \u{200E}الْعَ بِيَّةُ \u{200F}')); + state.updateEditingValue(const TextEditingValue(text: 'hello \u{200E}الْعَ بِيَّةُ ')); + // The trailing space should be gone here. + expect(state.currentTextEditingValue.text, equals('hello \u{200E}الْعَ بِيَّةُ')); + }); + testWidgets('EditableText changes mouse cursor when hovered', (WidgetTester tester) async { await tester.pumpWidget( MediaQuery(