diff --git a/packages/flutter/lib/src/painting/text_painter.dart b/packages/flutter/lib/src/painting/text_painter.dart index a65949cd64b..22cc9fcb46c 100644 --- a/packages/flutter/lib/src/painting/text_painter.dart +++ b/packages/flutter/lib/src/painting/text_painter.dart @@ -602,6 +602,14 @@ class TextPainter { return value & 0xF800 == 0xD800; } + // Checks if the glyph is either [Unicode.RLM] or [Unicode.LRM]. These values take + // up zero space and do not have valid bounding boxes around them. + // + // We do not directly use the [Unicode] constants since they are strings. + bool _isUnicodeDirectionality(int value) { + return value == 0x200F || value == 0x200E; + } + /// Returns the closest offset after `offset` at which the input cursor can be /// positioned. int getOffsetAfter(int offset) { @@ -636,7 +644,7 @@ class TextPainter { return null; // Check for multi-code-unit glyphs such as emojis or zero width joiner - final bool needsSearch = _isUtf16Surrogate(prevCodeUnit) || _text.codeUnitAt(offset) == _zwjUtf16; + final bool needsSearch = _isUtf16Surrogate(prevCodeUnit) || _text.codeUnitAt(offset) == _zwjUtf16 || _isUnicodeDirectionality(prevCodeUnit); int graphemeClusterLength = needsSearch ? 2 : 1; List boxes = []; while (boxes.isEmpty && flattenedText != null) { @@ -687,7 +695,7 @@ class TextPainter { if (nextCodeUnit == null) return null; // Check for multi-code-unit glyphs such as emojis or zero width joiner - final bool needsSearch = _isUtf16Surrogate(nextCodeUnit) || nextCodeUnit == _zwjUtf16; + final bool needsSearch = _isUtf16Surrogate(nextCodeUnit) || nextCodeUnit == _zwjUtf16 || _isUnicodeDirectionality(nextCodeUnit); int graphemeClusterLength = needsSearch ? 2 : 1; List boxes = []; while (boxes.isEmpty && flattenedText != null) { diff --git a/packages/flutter/lib/src/widgets/editable_text.dart b/packages/flutter/lib/src/widgets/editable_text.dart index 55411a0aae4..c525826d406 100644 --- a/packages/flutter/lib/src/widgets/editable_text.dart +++ b/packages/flutter/lib/src/widgets/editable_text.dart @@ -1655,7 +1655,11 @@ class EditableTextState extends State with AutomaticKeepAliveClien _lastBottomViewInset = WidgetsBinding.instance.window.viewInsets.bottom; } + _WhitespaceDirectionalityFormatter _whitespaceFormatter; + void _formatAndSetValue(TextEditingValue value) { + _whitespaceFormatter ??= _WhitespaceDirectionalityFormatter(textDirection: _textDirection); + // 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; @@ -1666,6 +1670,9 @@ class EditableTextState extends State with AutomaticKeepAliveClien for (final TextInputFormatter formatter in widget.inputFormatters) { value = formatter.formatEditUpdate(_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 has changed or the selection has changed, we should update the @@ -2145,3 +2152,163 @@ class _Editable extends LeafRenderObjectWidget { ..paintCursorAboveText = paintCursorAboveText; } } + +// 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 immeditely +// 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; + + void addToSelection() { + selectionBase += outputCodepoints.length <= selectionBase ? 1 : 0; + selectionExtent += outputCodepoints.length <= selectionExtent ? 1 : 0; + } + void subtractFromSelection() { + selectionBase -= outputCodepoints.length < selectionBase ? 1 : 0; + selectionExtent -= outputCodepoints.length < selectionExtent ? 1 : 0; + } + + bool previousWasWhitespace = false; + bool previousWasDirectionalityMarker = false; + int previousNonWhitespaceCodepoint; + 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) { + subtractFromSelection(); + outputCodepoints.removeLast(); + } + outputCodepoints.add(codepoint); + addToSelection(); + 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) { + subtractFromSelection(); + outputCodepoints.removeLast(); + } + outputCodepoints.add(codepoint); + + previousWasWhitespace = false; + previousWasDirectionalityMarker = true; + } else { + // If the whitespace was already enclosed by the same directionality, + // we can remove the artifically added marker. + if (!previousWasDirectionalityMarker && + previousWasWhitespace && + getDirection(codepoint) == _previousNonWhitespaceDirection) { + subtractFromSelection(); + outputCodepoints.removeLast(); + } + // Normal character, track its codepoint add it to the string. + previousNonWhitespaceCodepoint = codepoint; + outputCodepoints.add(codepoint); + + previousWasWhitespace = false; + previousWasDirectionalityMarker = false; + } + } + final String formatted = String.fromCharCodes(outputCodepoints); + return TextEditingValue( + text: formatted, + selection: TextSelection( + baseOffset: selectionBase, + extentOffset: selectionExtent, + affinity: newValue.selection.affinity, + isDirectional: newValue.selection.isDirectional + ), + ); + } + 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 f9d3d4d2b6c..8362db89e17 100644 --- a/packages/flutter/test/widgets/editable_text_test.dart +++ b/packages/flutter/test/widgets/editable_text_test.dart @@ -4302,6 +4302,229 @@ void main() { 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 ')); + expect(state.currentTextEditingValue.text, equals('hello ')); + state.updateEditingValue(const TextEditingValue(text: 'hello ا')); + expect(state.currentTextEditingValue.text, equals('hello \u{200E}ا')); + state.updateEditingValue(const TextEditingValue(text: 'hello الْ')); + state.updateEditingValue(const TextEditingValue(text: 'hello الْعَ')); + state.updateEditingValue(const TextEditingValue(text: 'hello الْعَ ')); + expect(state.currentTextEditingValue.text, equals('hello \u{200E}الْعَ \u{200F}')); + 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 {