diff --git a/packages/flutter/lib/src/rendering/editable.dart b/packages/flutter/lib/src/rendering/editable.dart index 618da95f84c..65fb041fad9 100644 --- a/packages/flutter/lib/src/rendering/editable.dart +++ b/packages/flutter/lib/src/rendering/editable.dart @@ -1930,6 +1930,40 @@ 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. + } else if (text?.text != null + && _isWhitespace(text!.text!.codeUnitAt(position.offset)) + && position.offset > 0) { + assert(defaultTargetPlatform != null); + switch (defaultTargetPlatform) { + case TargetPlatform.iOS: + int startIndex = position.offset - 1; + while (startIndex > 0 + && (_isWhitespace(text!.text!.codeUnitAt(startIndex)) + || text!.text! == '\u200e' || text!.text! == '\u200f')) { + startIndex--; + } + if (startIndex > 0) { + final TextPosition positionBeforeSpace = TextPosition( + offset: startIndex, + affinity: position.affinity, + ); + final TextRange wordBeforeSpace = _textPainter.getWordBoundary( + positionBeforeSpace, + ); + startIndex = wordBeforeSpace.start; + } + return TextSelection( + baseOffset: startIndex, + extentOffset: position.offset, + ); + case TargetPlatform.android: + case TargetPlatform.fuchsia: + case TargetPlatform.macOS: + case TargetPlatform.linux: + case TargetPlatform.windows: + break; + } } return TextSelection(baseOffset: word.start, extentOffset: word.end); } diff --git a/packages/flutter/test/cupertino/text_field_test.dart b/packages/flutter/test/cupertino/text_field_test.dart index b1ade38b9c6..452408a2c33 100644 --- a/packages/flutter/test/cupertino/text_field_test.dart +++ b/packages/flutter/test/cupertino/text_field_test.dart @@ -1720,6 +1720,126 @@ void main() { }, ); + testWidgets('double tapping a space selects the previous word on iOS', (WidgetTester tester) async { + final TextEditingController controller = TextEditingController( + text: ' blah blah \n blah', + ); + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: CupertinoTextField( + controller: controller, + maxLines: 2, + ), + ), + ), + ); + + expect(controller.value.selection, isNotNull); + expect(controller.value.selection.baseOffset, -1); + expect(controller.value.selection.extentOffset, -1); + + // Put the cursor at the end of the field. + await tester.tapAt(textOffsetToPosition(tester, 19)); + expect(controller.value.selection, isNotNull); + expect(controller.value.selection.baseOffset, 19); + expect(controller.value.selection.extentOffset, 19); + + // Double tapping the second space selects the previous word. + await tester.pump(const Duration(milliseconds: 500)); + await tester.tapAt(textOffsetToPosition(tester, 5)); + await tester.pump(const Duration(milliseconds: 50)); + await tester.tapAt(textOffsetToPosition(tester, 5)); + await tester.pumpAndSettle(); + expect(controller.value.selection, isNotNull); + expect(controller.value.selection.baseOffset, 1); + expect(controller.value.selection.extentOffset, 5); + + // Put the cursor at the end of the field. + await tester.tapAt(textOffsetToPosition(tester, 19)); + expect(controller.value.selection, isNotNull); + expect(controller.value.selection.baseOffset, 19); + expect(controller.value.selection.extentOffset, 19); + + // Double tapping the first space selects the space. + await tester.pump(const Duration(milliseconds: 500)); + await tester.tapAt(textOffsetToPosition(tester, 0)); + await tester.pump(const Duration(milliseconds: 50)); + await tester.tapAt(textOffsetToPosition(tester, 0)); + await tester.pumpAndSettle(); + expect(controller.value.selection, isNotNull); + expect(controller.value.selection.baseOffset, 0); + expect(controller.value.selection.extentOffset, 1); + + // Put the cursor at the end of the field. + await tester.tapAt(textOffsetToPosition(tester, 19)); + expect(controller.value.selection, isNotNull); + expect(controller.value.selection.baseOffset, 19); + expect(controller.value.selection.extentOffset, 19); + + // Double tapping the last space selects all previous contiguous spaces on + // both lines and the previous word. + await tester.pump(const Duration(milliseconds: 500)); + await tester.tapAt(textOffsetToPosition(tester, 14)); + await tester.pump(const Duration(milliseconds: 50)); + await tester.tapAt(textOffsetToPosition(tester, 14)); + await tester.pumpAndSettle(); + expect(controller.value.selection, isNotNull); + expect(controller.value.selection.baseOffset, 6); + expect(controller.value.selection.extentOffset, 14); + }, variant: const TargetPlatformVariant({ TargetPlatform.iOS })); + + testWidgets('double tapping a space selects the space on Mac', (WidgetTester tester) async { + final TextEditingController controller = TextEditingController( + text: ' blah blah', + ); + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: CupertinoTextField( + controller: controller, + ), + ), + ), + ); + + expect(controller.value.selection, isNotNull); + expect(controller.value.selection.baseOffset, -1); + expect(controller.value.selection.extentOffset, -1); + + // Put the cursor at the end of the field. + await tester.tapAt(textOffsetToPosition(tester, 10)); + expect(controller.value.selection, isNotNull); + expect(controller.value.selection.baseOffset, 10); + expect(controller.value.selection.extentOffset, 10); + + // Double tapping the second space selects it. + await tester.pump(const Duration(milliseconds: 500)); + await tester.tapAt(textOffsetToPosition(tester, 5)); + await tester.pump(const Duration(milliseconds: 50)); + await tester.tapAt(textOffsetToPosition(tester, 5)); + await tester.pumpAndSettle(); + expect(controller.value.selection, isNotNull); + expect(controller.value.selection.baseOffset, 5); + expect(controller.value.selection.extentOffset, 6); + + // Put the cursor at the end of the field. + await tester.tapAt(textOffsetToPosition(tester, 10)); + expect(controller.value.selection, isNotNull); + expect(controller.value.selection.baseOffset, 10); + expect(controller.value.selection.extentOffset, 10); + + // Double tapping the first space selects it. + await tester.pump(const Duration(milliseconds: 500)); + await tester.tapAt(textOffsetToPosition(tester, 0)); + await tester.pump(const Duration(milliseconds: 50)); + await tester.tapAt(textOffsetToPosition(tester, 0)); + await tester.pumpAndSettle(); + expect(controller.value.selection, isNotNull); + expect(controller.value.selection.baseOffset, 0); + expect(controller.value.selection.extentOffset, 1); + }, variant: const TargetPlatformVariant({ TargetPlatform.macOS })); + testWidgets( 'An obscured CupertinoTextField is not selectable when disabled', (WidgetTester tester) async { diff --git a/packages/flutter/test/material/text_field_test.dart b/packages/flutter/test/material/text_field_test.dart index 002c33d8990..ee8fce1aa0a 100644 --- a/packages/flutter/test/material/text_field_test.dart +++ b/packages/flutter/test/material/text_field_test.dart @@ -7044,6 +7044,129 @@ void main() { expect(find.byType(CupertinoButton), findsNWidgets(3)); }, variant: const TargetPlatformVariant({ TargetPlatform.iOS, TargetPlatform.macOS })); + testWidgets('double tapping a space selects the previous word on iOS', (WidgetTester tester) async { + final TextEditingController controller = TextEditingController( + text: ' blah blah \n blah', + ); + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center( + child: TextField( + controller: controller, + ), + ), + ), + ), + ); + + expect(controller.value.selection, isNotNull); + expect(controller.value.selection.baseOffset, -1); + expect(controller.value.selection.extentOffset, -1); + + // Put the cursor at the end of the field. + await tester.tapAt(textOffsetToPosition(tester, 19)); + expect(controller.value.selection, isNotNull); + expect(controller.value.selection.baseOffset, 19); + expect(controller.value.selection.extentOffset, 19); + + // Double tapping does the same thing. + await tester.pump(const Duration(milliseconds: 500)); + await tester.tapAt(textOffsetToPosition(tester, 5)); + await tester.pump(const Duration(milliseconds: 50)); + await tester.tapAt(textOffsetToPosition(tester, 5)); + await tester.pumpAndSettle(); + expect(controller.value.selection, isNotNull); + expect(controller.value.selection.extentOffset, 5); + expect(controller.value.selection.baseOffset, 1); + + // Put the cursor at the end of the field. + await tester.tapAt(textOffsetToPosition(tester, 19)); + expect(controller.value.selection, isNotNull); + expect(controller.value.selection.baseOffset, 19); + expect(controller.value.selection.extentOffset, 19); + + // Double tapping does the same thing for the first space. + await tester.pump(const Duration(milliseconds: 500)); + await tester.tapAt(textOffsetToPosition(tester, 0)); + await tester.pump(const Duration(milliseconds: 50)); + await tester.tapAt(textOffsetToPosition(tester, 0)); + await tester.pumpAndSettle(); + expect(controller.value.selection, isNotNull); + expect(controller.value.selection.baseOffset, 0); + expect(controller.value.selection.extentOffset, 1); + + // Put the cursor at the end of the field. + await tester.tapAt(textOffsetToPosition(tester, 19)); + expect(controller.value.selection, isNotNull); + expect(controller.value.selection.baseOffset, 19); + expect(controller.value.selection.extentOffset, 19); + + // Double tapping the last space selects all previous contiguous spaces on + // both lines and the previous word. + await tester.pump(const Duration(milliseconds: 500)); + await tester.tapAt(textOffsetToPosition(tester, 14)); + await tester.pump(const Duration(milliseconds: 50)); + await tester.tapAt(textOffsetToPosition(tester, 14)); + await tester.pumpAndSettle(); + expect(controller.value.selection, isNotNull); + expect(controller.value.selection.baseOffset, 6); + expect(controller.value.selection.extentOffset, 14); + }, variant: const TargetPlatformVariant({ TargetPlatform.iOS })); + + testWidgets('selecting a space selects the space on non-iOS platforms', (WidgetTester tester) async { + final TextEditingController controller = TextEditingController( + text: ' blah blah', + ); + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center( + child: TextField( + controller: controller, + ), + ), + ), + ), + ); + + expect(controller.value.selection, isNotNull); + expect(controller.value.selection.baseOffset, -1); + expect(controller.value.selection.extentOffset, -1); + + // Put the cursor at the end of the field. + await tester.tapAt(textOffsetToPosition(tester, 10)); + expect(controller.value.selection, isNotNull); + expect(controller.value.selection.baseOffset, 10); + expect(controller.value.selection.extentOffset, 10); + + // Double tapping the second space selects it. + await tester.pump(const Duration(milliseconds: 500)); + await tester.tapAt(textOffsetToPosition(tester, 5)); + await tester.pump(const Duration(milliseconds: 50)); + await tester.tapAt(textOffsetToPosition(tester, 5)); + await tester.pumpAndSettle(); + expect(controller.value.selection, isNotNull); + expect(controller.value.selection.baseOffset, 5); + expect(controller.value.selection.extentOffset, 6); + + // Put the cursor at the end of the field. + await tester.tapAt(textOffsetToPosition(tester, 10)); + expect(controller.value.selection, isNotNull); + expect(controller.value.selection.baseOffset, 10); + expect(controller.value.selection.extentOffset, 10); + + // Double tapping the second space selects it. + await tester.pump(const Duration(milliseconds: 500)); + await tester.tapAt(textOffsetToPosition(tester, 0)); + await tester.pump(const Duration(milliseconds: 50)); + await tester.tapAt(textOffsetToPosition(tester, 0)); + await tester.pumpAndSettle(); + expect(controller.value.selection, isNotNull); + expect(controller.value.selection.baseOffset, 0); + expect(controller.value.selection.extentOffset, 1); + }, variant: const TargetPlatformVariant({ TargetPlatform.macOS, TargetPlatform.windows, TargetPlatform.linux, TargetPlatform.fuchsia, TargetPlatform.android })); + testWidgets('force press does not select a word', (WidgetTester tester) async { final TextEditingController controller = TextEditingController( text: 'Atwater Peel Sherbrooke Bonaventure',