diff --git a/packages/flutter/lib/src/painting/text_painter.dart b/packages/flutter/lib/src/painting/text_painter.dart index f3486a13e036..7c7fa0d7fe38 100644 --- a/packages/flutter/lib/src/painting/text_painter.dart +++ b/packages/flutter/lib/src/painting/text_painter.dart @@ -170,11 +170,11 @@ class WordBoundary extends TextBoundary { // single code point that represents a supplementary character. static int _codePointFromSurrogates(int highSurrogate, int lowSurrogate) { assert( - TextPainter._isHighSurrogate(highSurrogate), + TextPainter.isHighSurrogate(highSurrogate), 'U+${highSurrogate.toRadixString(16).toUpperCase().padLeft(4, "0")}) is not a high surrogate.', ); assert( - TextPainter._isLowSurrogate(lowSurrogate), + TextPainter.isLowSurrogate(lowSurrogate), 'U+${lowSurrogate.toRadixString(16).toUpperCase().padLeft(4, "0")}) is not a low surrogate.', ); const int base = 0x010000 - (0xD800 << 10) - 0xDC00; @@ -991,17 +991,34 @@ class TextPainter { canvas.drawParagraph(_paragraph!, offset); } - // Returns true iff the given value is a valid UTF-16 high surrogate. The value - // must be a UTF-16 code unit, meaning it must be in the range 0x0000-0xFFFF. - // - // See also: - // * https://en.wikipedia.org/wiki/UTF-16#Code_points_from_U+010000_to_U+10FFFF - static bool _isHighSurrogate(int value) { + // Returns true if value falls in the valid range of the UTF16 encoding. + static bool _isUTF16(int value) { + return value >= 0x0 && value <= 0xFFFFF; + } + + /// Returns true iff the given value is a valid UTF-16 high (first) surrogate. + /// The value must be a UTF-16 code unit, meaning it must be in the range + /// 0x0000-0xFFFF. + /// + /// See also: + /// * https://en.wikipedia.org/wiki/UTF-16#Code_points_from_U+010000_to_U+10FFFF + /// * [isLowSurrogate], which checks the same thing for low (second) + /// surrogates. + static bool isHighSurrogate(int value) { + assert(_isUTF16(value)); return value & 0xFC00 == 0xD800; } - // Whether the given UTF-16 code unit is a low (second) surrogate. - static bool _isLowSurrogate(int value) { + /// Returns true iff the given value is a valid UTF-16 low (second) surrogate. + /// The value must be a UTF-16 code unit, meaning it must be in the range + /// 0x0000-0xFFFF. + /// + /// See also: + /// * https://en.wikipedia.org/wiki/UTF-16#Code_points_from_U+010000_to_U+10FFFF + /// * [isHighSurrogate], which checks the same thing for high (first) + /// surrogates. + static bool isLowSurrogate(int value) { + assert(_isUTF16(value)); return value & 0xFC00 == 0xDC00; } @@ -1021,7 +1038,7 @@ class TextPainter { return null; } // TODO(goderbauer): doesn't handle extended grapheme clusters with more than one Unicode scalar value (https://github.com/flutter/flutter/issues/13404). - return _isHighSurrogate(nextCodeUnit) ? offset + 2 : offset + 1; + return isHighSurrogate(nextCodeUnit) ? offset + 2 : offset + 1; } /// Returns the closest offset before `offset` at which the input cursor can @@ -1032,7 +1049,7 @@ class TextPainter { return null; } // TODO(goderbauer): doesn't handle extended grapheme clusters with more than one Unicode scalar value (https://github.com/flutter/flutter/issues/13404). - return _isLowSurrogate(prevCodeUnit) ? offset - 2 : offset - 1; + return isLowSurrogate(prevCodeUnit) ? offset - 2 : offset - 1; } // Unicode value for a zero width joiner character. @@ -1052,7 +1069,7 @@ class TextPainter { const int NEWLINE_CODE_UNIT = 10; // Check for multi-code-unit glyphs such as emojis or zero width joiner. - final bool needsSearch = _isHighSurrogate(prevCodeUnit) || _isLowSurrogate(prevCodeUnit) || _text!.codeUnitAt(offset) == _zwjUtf16 || _isUnicodeDirectionality(prevCodeUnit); + final bool needsSearch = isHighSurrogate(prevCodeUnit) || isLowSurrogate(prevCodeUnit) || _text!.codeUnitAt(offset) == _zwjUtf16 || _isUnicodeDirectionality(prevCodeUnit); int graphemeClusterLength = needsSearch ? 2 : 1; List boxes = []; while (boxes.isEmpty) { @@ -1103,7 +1120,7 @@ class TextPainter { final int nextCodeUnit = plainText.codeUnitAt(min(offset, plainTextLength - 1)); // Check for multi-code-unit glyphs such as emojis or zero width joiner - final bool needsSearch = _isHighSurrogate(nextCodeUnit) || _isLowSurrogate(nextCodeUnit) || nextCodeUnit == _zwjUtf16 || _isUnicodeDirectionality(nextCodeUnit); + final bool needsSearch = isHighSurrogate(nextCodeUnit) || isLowSurrogate(nextCodeUnit) || nextCodeUnit == _zwjUtf16 || _isUnicodeDirectionality(nextCodeUnit); int graphemeClusterLength = needsSearch ? 2 : 1; List boxes = []; while (boxes.isEmpty) { diff --git a/packages/flutter/lib/src/services/text_boundary.dart b/packages/flutter/lib/src/services/text_boundary.dart index ff6f318bc232..e7e453e2a384 100644 --- a/packages/flutter/lib/src/services/text_boundary.dart +++ b/packages/flutter/lib/src/services/text_boundary.dart @@ -31,6 +31,9 @@ abstract class TextBoundary { /// `position`, or null if no boundaries can be found. /// /// The return value, if not null, is usually less than or equal to `position`. + /// + /// The range of the return value is given by the closed interval + /// `[0, string.length]`. int? getLeadingTextBoundaryAt(int position) { if (position < 0) { return null; @@ -39,10 +42,13 @@ abstract class TextBoundary { return start >= 0 ? start : null; } - /// Returns the offset of the closest text boundaries after the given `position`, - /// or null if there is no boundaries can be found after `position`. + /// Returns the offset of the closest text boundary after the given + /// `position`, or null if there is no boundary can be found after `position`. /// /// The return value, if not null, is usually greater than `position`. + /// + /// The range of the return value is given by the closed interval + /// `[0, string.length]`. int? getTrailingTextBoundaryAt(int position) { final int end = getTextBoundaryAt(max(0, position)).end; return end >= 0 ? end : null; diff --git a/packages/flutter/lib/src/widgets/editable_text.dart b/packages/flutter/lib/src/widgets/editable_text.dart index 51e2faebfe4e..87bb165979d8 100644 --- a/packages/flutter/lib/src/widgets/editable_text.dart +++ b/packages/flutter/lib/src/widgets/editable_text.dart @@ -4246,7 +4246,7 @@ class EditableTextState extends State with AutomaticKeepAliveClien // --------------------------- Text Editing Actions --------------------------- - TextBoundary _characterBoundary() => widget.obscureText ? _CodeUnitBoundary(_value.text) : CharacterBoundary(_value.text); + TextBoundary _characterBoundary() => widget.obscureText ? _CodePointBoundary(_value.text) : CharacterBoundary(_value.text); TextBoundary _nextWordBoundary() => widget.obscureText ? _documentBoundary() : renderEditable.wordBoundaries.moveByWordBoundary; TextBoundary _linebreak() => widget.obscureText ? _documentBoundary() : LineBoundary(renderEditable); TextBoundary _paragraphBoundary() => ParagraphBoundary(_value.text); @@ -5076,21 +5076,76 @@ class _ScribblePlaceholder extends WidgetSpan { } } -/// A text boundary that uses code units as logical boundaries. +/// A text boundary that uses code points as logical boundaries. /// -/// This text boundary treats every character in input string as an utf-16 code -/// unit. This can be useful when handling text without any grapheme cluster, -/// e.g. password input in [EditableText]. If you are handling text that may -/// include grapheme clusters, consider using [CharacterBoundary]. -class _CodeUnitBoundary extends TextBoundary { - const _CodeUnitBoundary(this._text); +/// A code point represents a single character. This may be smaller than what is +/// represented by a user-perceived character, or grapheme. For example, a +/// single grapheme (in this case a Unicode extended grapheme cluster) like +/// "👨‍👩‍👦" consists of five code points: the man emoji, a zero +/// width joiner, the woman emoji, another zero width joiner, and the boy emoji. +/// The [String] has a length of eight because each emoji consists of two code +/// units. +/// +/// Code units are the units by which Dart's String class is measured, which is +/// encoded in UTF-16. +/// +/// See also: +/// +/// * [String.runes], which deals with code points like this class. +/// * [String.characters], which deals with graphemes. +/// * [CharacterBoundary], which is a [TextBoundary] like this class, but whose +/// boundaries are graphemes instead of code points. +class _CodePointBoundary extends TextBoundary { + const _CodePointBoundary(this._text); final String _text; + // Returns true if the given position falls in the center of a surrogate pair. + bool _breaksSurrogatePair(int position) { + assert(position > 0 && position < _text.length && _text.length > 1); + return TextPainter.isHighSurrogate(_text.codeUnitAt(position - 1)) + && TextPainter.isLowSurrogate(_text.codeUnitAt(position)); + } + @override - int getLeadingTextBoundaryAt(int position) => position.clamp(0, _text.length); // ignore_clamp_double_lint + int? getLeadingTextBoundaryAt(int position) { + if (_text.isEmpty || position < 0) { + return null; + } + if (position == 0) { + return 0; + } + if (position >= _text.length) { + return _text.length; + } + if (_text.length <= 1) { + return position; + } + + return _breaksSurrogatePair(position) + ? position - 1 + : position; + } + @override - int getTrailingTextBoundaryAt(int position) => (position + 1).clamp(0, _text.length); // ignore_clamp_double_lint + int? getTrailingTextBoundaryAt(int position) { + if (_text.isEmpty || position >= _text.length) { + return null; + } + if (position < 0) { + return 0; + } + if (position == _text.length - 1) { + return _text.length; + } + if (_text.length <= 1) { + return position; + } + + return _breaksSurrogatePair(position + 1) + ? position + 2 + : position + 1; + } } // ------------------------------- Text Actions ------------------------------- diff --git a/packages/flutter/test/widgets/editable_text_shortcuts_test.dart b/packages/flutter/test/widgets/editable_text_shortcuts_test.dart index 73c517b6c78a..b6efb36c221d 100644 --- a/packages/flutter/test/widgets/editable_text_shortcuts_test.dart +++ b/packages/flutter/test/widgets/editable_text_shortcuts_test.dart @@ -444,14 +444,15 @@ void main() { await tester.pumpWidget(buildEditableText(obscured: true)); await sendKeyCombination(tester, const SingleActivator(trigger)); + // Both emojis that were partially selected are deleted entirely. expect( controller.text, - '👨‍👩‍👦👨‍👩‍👦', + '‍👩‍👦👨‍👩‍👦', ); expect( controller.selection, - const TextSelection.collapsed(offset: 1), + const TextSelection.collapsed(offset: 0), ); }, variant: TargetPlatformVariant.all(excluding: { TargetPlatform.iOS })); }); diff --git a/packages/flutter/test/widgets/editable_text_test.dart b/packages/flutter/test/widgets/editable_text_test.dart index 07d37ca52d11..ee94c538cb5e 100644 --- a/packages/flutter/test/widgets/editable_text_test.dart +++ b/packages/flutter/test/widgets/editable_text_test.dart @@ -16091,6 +16091,408 @@ testWidgets('Floating cursor ending with selection', (WidgetTester tester) async expect(state.buildTextSpan().style!.fontWeight, FontWeight.bold); }); + + testWidgets('code points are treated as single characters in obscure mode', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: EditableText( + backgroundCursorColor: Colors.grey, + controller: TextEditingController(), + focusNode: focusNode, + obscureText: true, + toolbarOptions: const ToolbarOptions( + copy: true, + cut: true, + paste: true, + selectAll: true, + ), + style: textStyle, + cursorColor: cursorColor, + selectionControls: materialTextSelectionControls, + ), + ), + ); + + await tester.tap(find.byType(EditableText)); + await tester.enterText(find.byType(EditableText), '👨‍👩‍👦'); + await tester.pump(); + + final EditableTextState state = tester.state(find.byType(EditableText)); + expect(state.textEditingValue.text, '👨‍👩‍👦'); + // 👨‍👩‍👦| + expect( + state.textEditingValue.selection, + const TextSelection.collapsed(offset: 8), + ); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft); + await tester.pump(); + // 👨‍👩‍|👦 + expect( + state.textEditingValue.selection, + const TextSelection.collapsed(offset: 6), + ); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft); + await tester.pump(); + // 👨‍👩|‍👦 + expect( + state.textEditingValue.selection, + const TextSelection.collapsed(offset: 5), + ); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft); + await tester.pump(); + // 👨‍|👩‍👦 + expect( + state.textEditingValue.selection, + const TextSelection.collapsed(offset: 3), + ); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft); + await tester.pump(); + // 👨|‍👩‍👦 + expect( + state.textEditingValue.selection, + const TextSelection.collapsed(offset: 2), + ); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft); + await tester.pump(); + // |👨‍👩‍👦 + expect( + state.textEditingValue.selection, + const TextSelection.collapsed(offset: 0), + ); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); + await tester.pump(); + // 👨|‍👩‍👦 + expect( + state.textEditingValue.selection, + const TextSelection.collapsed(offset: 2), + ); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); + await tester.pump(); + // 👨‍|👩‍👦 + expect( + state.textEditingValue.selection, + const TextSelection.collapsed(offset: 3), + ); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); + await tester.pump(); + // 👨‍👩|‍👦 + expect( + state.textEditingValue.selection, + const TextSelection.collapsed(offset: 5), + ); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); + await tester.pump(); + // 👨‍👩‍|👦 + expect( + state.textEditingValue.selection, + const TextSelection.collapsed(offset: 6), + ); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); + await tester.pump(); + // 👨‍👩‍👦| + expect( + state.textEditingValue.selection, + const TextSelection.collapsed(offset: 8), + ); + + await tester.sendKeyEvent(LogicalKeyboardKey.backspace); + await tester.pump(); + expect(state.textEditingValue.text, '👨‍👩‍'); + + await tester.sendKeyEvent(LogicalKeyboardKey.backspace); + await tester.pump(); + expect(state.textEditingValue.text, '👨‍👩'); + + await tester.sendKeyEvent(LogicalKeyboardKey.backspace); + await tester.pump(); + expect(state.textEditingValue.text, '👨‍'); + + await tester.sendKeyEvent(LogicalKeyboardKey.backspace); + await tester.pump(); + expect(state.textEditingValue.text, '👨'); + + await tester.sendKeyEvent(LogicalKeyboardKey.backspace); + await tester.pump(); + expect(state.textEditingValue.text, ''); + }, + skip: kIsWeb, // [intended] + ); + + testWidgets('when manually placing the cursor in the middle of a code point', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: EditableText( + backgroundCursorColor: Colors.grey, + controller: TextEditingController(), + focusNode: focusNode, + obscureText: true, + toolbarOptions: const ToolbarOptions( + copy: true, + cut: true, + paste: true, + selectAll: true, + ), + style: textStyle, + cursorColor: cursorColor, + selectionControls: materialTextSelectionControls, + ), + ), + ); + + await tester.tap(find.byType(EditableText)); + await tester.enterText(find.byType(EditableText), '👨‍👩‍👦'); + await tester.pump(); + + final EditableTextState state = tester.state(find.byType(EditableText)); + expect(state.textEditingValue.text, '👨‍👩‍👦'); + expect( + state.textEditingValue.selection, + const TextSelection.collapsed(offset: 8), + ); + + // Place the cursor in the middle of the last code point, which consists of + // two code units. + await tester.tapAt(textOffsetToPosition(tester, 7)); + await tester.pump(); + expect( + state.textEditingValue.selection, + const TextSelection.collapsed(offset: 7), + ); + + // Using the arrow keys moves out of the code unit. + await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft); + await tester.pump(); + expect( + state.textEditingValue.selection, + const TextSelection.collapsed(offset: 6), + ); + + await tester.tapAt(textOffsetToPosition(tester, 7)); + await tester.pump(); + expect( + state.textEditingValue.selection, + const TextSelection.collapsed(offset: 7), + ); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); + await tester.pump(); + expect( + state.textEditingValue.selection, + const TextSelection.collapsed(offset: 8), + ); + + // Pressing delete doesn't delete only the left code unit, it deletes the + // entire code point (both code units, one to the left and one to the right + // of the cursor). + await tester.tapAt(textOffsetToPosition(tester, 7)); + await tester.pump(); + expect( + state.textEditingValue.selection, + const TextSelection.collapsed(offset: 7), + ); + + await tester.sendKeyEvent(LogicalKeyboardKey.backspace); + await tester.pump(); + expect(state.textEditingValue.text, '👨‍👩‍'); + expect( + state.textEditingValue.selection, + const TextSelection.collapsed(offset: 6), + ); + }, + skip: kIsWeb, // [intended] + ); + + testWidgets('when inserting a malformed string', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: EditableText( + backgroundCursorColor: Colors.grey, + controller: TextEditingController(), + focusNode: focusNode, + obscureText: true, + toolbarOptions: const ToolbarOptions( + copy: true, + cut: true, + paste: true, + selectAll: true, + ), + style: textStyle, + cursorColor: cursorColor, + selectionControls: materialTextSelectionControls, + ), + ), + ); + + await tester.tap(find.byType(EditableText)); + // This malformed string is the result of removing the final code unit from + // the extended grapheme cluster "👨‍👩‍👦", so that the final + // surrogate pair (the "👦" emoji or "\uD83D\uDC66"), only has its high + // surrogate. + await tester.enterText(find.byType(EditableText), '👨‍👩‍\uD83D'); + await tester.pump(); + + final EditableTextState state = tester.state(find.byType(EditableText)); + expect(state.textEditingValue.text, '👨‍👩‍\uD83D'); + expect( + state.textEditingValue.selection, + const TextSelection.collapsed(offset: 7), + ); + + // The dangling high surrogate is treated as a single rune. + await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft); + await tester.pump(); + expect( + state.textEditingValue.selection, + const TextSelection.collapsed(offset: 6), + ); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); + await tester.pump(); + expect( + state.textEditingValue.selection, + const TextSelection.collapsed(offset: 7), + ); + + await tester.sendKeyEvent(LogicalKeyboardKey.backspace); + await tester.pump(); + expect(state.textEditingValue.text, '👨‍👩‍'); + expect( + state.textEditingValue.selection, + const TextSelection.collapsed(offset: 6), + ); + }, + skip: kIsWeb, // [intended] + ); + + testWidgets('when inserting a malformed string that is a sequence of dangling high surrogates', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: EditableText( + backgroundCursorColor: Colors.grey, + controller: TextEditingController(), + focusNode: focusNode, + obscureText: true, + toolbarOptions: const ToolbarOptions( + copy: true, + cut: true, + paste: true, + selectAll: true, + ), + style: textStyle, + cursorColor: cursorColor, + selectionControls: materialTextSelectionControls, + ), + ), + ); + + await tester.tap(find.byType(EditableText)); + // This string is the high surrogate from the emoji "👦" ("\uD83D\uDC66"), + // repeated. + await tester.enterText(find.byType(EditableText), '\uD83D\uD83D\uD83D\uD83D\uD83D\uD83D'); + await tester.pump(); + + final EditableTextState state = tester.state(find.byType(EditableText)); + expect(state.textEditingValue.text, '\uD83D\uD83D\uD83D\uD83D\uD83D\uD83D'); + expect( + state.textEditingValue.selection, + const TextSelection.collapsed(offset: 6), + ); + + // Each dangling high surrogate is treated as a single character. + await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft); + await tester.pump(); + expect( + state.textEditingValue.selection, + const TextSelection.collapsed(offset: 5), + ); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); + await tester.pump(); + expect( + state.textEditingValue.selection, + const TextSelection.collapsed(offset: 6), + ); + + await tester.sendKeyEvent(LogicalKeyboardKey.backspace); + await tester.pump(); + expect(state.textEditingValue.text, '\uD83D\uD83D\uD83D\uD83D\uD83D'); + expect( + state.textEditingValue.selection, + const TextSelection.collapsed(offset: 5), + ); + }, + skip: kIsWeb, // [intended] + ); + + testWidgets('when inserting a malformed string that is a sequence of dangling low surrogates', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: EditableText( + backgroundCursorColor: Colors.grey, + controller: TextEditingController(), + focusNode: focusNode, + obscureText: true, + toolbarOptions: const ToolbarOptions( + copy: true, + cut: true, + paste: true, + selectAll: true, + ), + style: textStyle, + cursorColor: cursorColor, + selectionControls: materialTextSelectionControls, + ), + ), + ); + + await tester.tap(find.byType(EditableText)); + // This string is the low surrogate from the emoji "👦" ("\uD83D\uDC66"), + // repeated. + await tester.enterText(find.byType(EditableText), '\uDC66\uDC66\uDC66\uDC66\uDC66\uDC66'); + await tester.pump(); + + final EditableTextState state = tester.state(find.byType(EditableText)); + expect(state.textEditingValue.text, '\uDC66\uDC66\uDC66\uDC66\uDC66\uDC66'); + expect( + state.textEditingValue.selection, + const TextSelection.collapsed(offset: 6), + ); + + // Each dangling high surrogate is treated as a single character. + await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft); + await tester.pump(); + expect( + state.textEditingValue.selection, + const TextSelection.collapsed(offset: 5), + ); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); + await tester.pump(); + expect( + state.textEditingValue.selection, + const TextSelection.collapsed(offset: 6), + ); + + await tester.sendKeyEvent(LogicalKeyboardKey.backspace); + await tester.pump(); + expect(state.textEditingValue.text, '\uDC66\uDC66\uDC66\uDC66\uDC66'); + expect( + state.textEditingValue.selection, + const TextSelection.collapsed(offset: 5), + ); + }, + skip: kIsWeb, // [intended] + ); } class UnsettableController extends TextEditingController {