diff --git a/packages/flutter/lib/src/rendering/editable.dart b/packages/flutter/lib/src/rendering/editable.dart index 4026111feb..8d4f26de5d 100644 --- a/packages/flutter/lib/src/rendering/editable.dart +++ b/packages/flutter/lib/src/rendering/editable.dart @@ -3042,22 +3042,43 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin { // If text is obscured, the entire sentence should be treated as one word. if (obscureText) { return TextSelection(baseOffset: 0, extentOffset: _plainText.length); - // If the word is a space, on iOS try to select the previous word instead. - // On Android try to select the previous word instead only if the text is read only. + // On iOS, select the previous word if there is a previous word, or select + // to the end of the next word if there is a next word. Select nothing if + // there is neither a previous word nor a next word. + // + // If the platform is Android and the text is read only, try to select the + // previous word if there is one; otherwise, select the single whitespace at + // the position. } else if (_isWhitespace(_plainText.codeUnitAt(position.offset)) && position.offset > 0) { assert(defaultTargetPlatform != null); final TextRange? previousWord = _getPreviousWord(word.start); switch (defaultTargetPlatform) { case TargetPlatform.iOS: + if (previousWord == null) { + final TextRange? nextWord = _getNextWord(word.start); + if (nextWord == null) { + return TextSelection.collapsed(offset: position.offset); + } + return TextSelection( + baseOffset: position.offset, + extentOffset: nextWord.end, + ); + } return TextSelection( - baseOffset: previousWord!.start, + baseOffset: previousWord.start, extentOffset: position.offset, ); case TargetPlatform.android: if (readOnly) { + if (previousWord == null) { + return TextSelection( + baseOffset: position.offset, + extentOffset: position.offset + 1, + ); + } return TextSelection( - baseOffset: previousWord!.start, + baseOffset: previousWord.start, extentOffset: position.offset, ); } diff --git a/packages/flutter/test/rendering/editable_test.dart b/packages/flutter/test/rendering/editable_test.dart index 1081703795..afc0c8d254 100644 --- a/packages/flutter/test/rendering/editable_test.dart +++ b/packages/flutter/test/rendering/editable_test.dart @@ -499,6 +499,136 @@ void main() { expect(currentSelection.extentOffset, 9); }, skip: isBrowser); // https://github.com/flutter/flutter/issues/61026 + test('selects readonly renderEditable matches native behavior for android', () { + // Regression test for https://github.com/flutter/flutter/issues/79166. + final TargetPlatform? previousPlatform = debugDefaultTargetPlatformOverride; + debugDefaultTargetPlatformOverride = TargetPlatform.android; + const String text = ' test'; + final TextSelectionDelegate delegate = FakeEditableTextState() + ..textEditingValue = const TextEditingValue(text: text); + final ViewportOffset viewportOffset = ViewportOffset.zero(); + late TextSelection currentSelection; + final RenderEditable editable = RenderEditable( + backgroundCursorColor: Colors.grey, + selectionColor: Colors.black, + textDirection: TextDirection.ltr, + cursorColor: Colors.red, + readOnly: true, + offset: viewportOffset, + textSelectionDelegate: delegate, + onSelectionChanged: (TextSelection selection, RenderEditable renderObject, SelectionChangedCause cause) { + currentSelection = selection; + }, + startHandleLayerLink: LayerLink(), + endHandleLayerLink: LayerLink(), + text: const TextSpan( + text: text, + style: TextStyle( + height: 1.0, fontSize: 10.0, fontFamily: 'Ahem', + ), + ), + selection: const TextSelection.collapsed( + offset: 4, + ), + ); + + layout(editable); + + // Select the second white space, where the text position = 1. + editable.selectWordsInRange(from: const Offset(10, 2), cause:SelectionChangedCause.longPress); + pumpFrame(); + expect(currentSelection.isCollapsed, false); + expect(currentSelection.baseOffset, 1); + expect(currentSelection.extentOffset, 2); + debugDefaultTargetPlatformOverride = previousPlatform; + }); + + test('selects renderEditable matches native behavior for iOS case 1', () { + // Regression test for https://github.com/flutter/flutter/issues/79166. + final TargetPlatform? previousPlatform = debugDefaultTargetPlatformOverride; + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + const String text = ' test'; + final TextSelectionDelegate delegate = FakeEditableTextState() + ..textEditingValue = const TextEditingValue(text: text); + final ViewportOffset viewportOffset = ViewportOffset.zero(); + late TextSelection currentSelection; + final RenderEditable editable = RenderEditable( + backgroundCursorColor: Colors.grey, + selectionColor: Colors.black, + textDirection: TextDirection.ltr, + cursorColor: Colors.red, + offset: viewportOffset, + textSelectionDelegate: delegate, + onSelectionChanged: (TextSelection selection, RenderEditable renderObject, SelectionChangedCause cause) { + currentSelection = selection; + }, + startHandleLayerLink: LayerLink(), + endHandleLayerLink: LayerLink(), + text: const TextSpan( + text: text, + style: TextStyle( + height: 1.0, fontSize: 10.0, fontFamily: 'Ahem', + ), + ), + selection: const TextSelection.collapsed( + offset: 4, + ), + ); + + layout(editable); + + // Select the second white space, where the text position = 1. + editable.selectWordsInRange(from: const Offset(10, 2), cause:SelectionChangedCause.longPress); + pumpFrame(); + expect(currentSelection.isCollapsed, false); + expect(currentSelection.baseOffset, 1); + expect(currentSelection.extentOffset, 6); + debugDefaultTargetPlatformOverride = previousPlatform; + }); + + test('selects renderEditable matches native behavior for iOS case 2', () { + // Regression test for https://github.com/flutter/flutter/issues/79166. + final TargetPlatform? previousPlatform = debugDefaultTargetPlatformOverride; + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + const String text = ' '; + final TextSelectionDelegate delegate = FakeEditableTextState() + ..textEditingValue = const TextEditingValue(text: text); + final ViewportOffset viewportOffset = ViewportOffset.zero(); + late TextSelection currentSelection; + final RenderEditable editable = RenderEditable( + backgroundCursorColor: Colors.grey, + selectionColor: Colors.black, + textDirection: TextDirection.ltr, + cursorColor: Colors.red, + offset: viewportOffset, + textSelectionDelegate: delegate, + onSelectionChanged: (TextSelection selection, RenderEditable renderObject, SelectionChangedCause cause) { + currentSelection = selection; + }, + startHandleLayerLink: LayerLink(), + endHandleLayerLink: LayerLink(), + text: const TextSpan( + text: text, + style: TextStyle( + height: 1.0, fontSize: 10.0, fontFamily: 'Ahem', + ), + ), + selection: const TextSelection.collapsed( + offset: 4, + ), + ); + + layout(editable); + + // Select the second white space, where the text position = 1. + editable.selectWordsInRange(from: const Offset(10, 2), cause:SelectionChangedCause.longPress); + pumpFrame(); + expect(currentSelection.isCollapsed, true); + expect(currentSelection.baseOffset, 1); + expect(currentSelection.extentOffset, 1); + debugDefaultTargetPlatformOverride = previousPlatform; + }); + test('selects correct place when offsets are flipped', () { const String text = 'abc def ghi'; final TextSelectionDelegate delegate = FakeEditableTextState()