diff --git a/packages/flutter/lib/src/widgets/default_text_editing_shortcuts.dart b/packages/flutter/lib/src/widgets/default_text_editing_shortcuts.dart index 5193b7bf522..2c426848307 100644 --- a/packages/flutter/lib/src/widgets/default_text_editing_shortcuts.dart +++ b/packages/flutter/lib/src/widgets/default_text_editing_shortcuts.dart @@ -299,6 +299,8 @@ class DefaultTextEditingShortcuts extends StatelessWidget { const SingleActivator(LogicalKeyboardKey.arrowUp, shift: true, meta: true): const ExtendSelectionToDocumentBoundaryIntent(forward: false, collapseSelection: false), const SingleActivator(LogicalKeyboardKey.arrowDown, shift: true, meta: true): const ExtendSelectionToDocumentBoundaryIntent(forward: true, collapseSelection: false), + const SingleActivator(LogicalKeyboardKey.keyT, control: true): const TransposeCharactersIntent(), + const SingleActivator(LogicalKeyboardKey.home): const ScrollToDocumentBoundaryIntent(forward: false), const SingleActivator(LogicalKeyboardKey.end): const ScrollToDocumentBoundaryIntent(forward: true), const SingleActivator(LogicalKeyboardKey.home, shift: true): const ExpandSelectionToDocumentBoundaryIntent(forward: false), diff --git a/packages/flutter/lib/src/widgets/editable_text.dart b/packages/flutter/lib/src/widgets/editable_text.dart index 0bee2f99cf7..9e832448dd3 100644 --- a/packages/flutter/lib/src/widgets/editable_text.dart +++ b/packages/flutter/lib/src/widgets/editable_text.dart @@ -3223,6 +3223,47 @@ class EditableTextState extends State with AutomaticKeepAliveClien return Action.overridable(context: context, defaultAction: defaultAction); } + /// Transpose the characters immediately before and after the current + /// collapsed selection. + /// + /// When the cursor is at the end of the text, transposes the last two + /// characters, if they exist. + /// + /// When the cursor is at the start of the text, does nothing. + void _transposeCharacters(TransposeCharactersIntent intent) { + if (_value.text.characters.length <= 1 + || _value.selection == null + || !_value.selection.isCollapsed + || _value.selection.baseOffset == 0) { + return; + } + + final String text = _value.text; + final TextSelection selection = _value.selection; + final bool atEnd = selection.baseOffset == text.length; + final CharacterRange transposing = CharacterRange.at(text, selection.baseOffset); + if (atEnd) { + transposing.moveBack(2); + } else { + transposing..moveBack()..expandNext(); + } + assert(transposing.currentCharacters.length == 2); + + userUpdateTextEditingValue( + TextEditingValue( + text: transposing.stringBefore + + transposing.currentCharacters.last + + transposing.currentCharacters.first + + transposing.stringAfter, + selection: TextSelection.collapsed( + offset: transposing.stringBeforeLength + transposing.current.length, + ), + ), + SelectionChangedCause.keyboard, + ); + } + late final Action _transposeCharactersAction = CallbackAction(onInvoke: _transposeCharacters); + void _replaceText(ReplaceTextIntent intent) { final TextEditingValue oldValue = _value; final TextEditingValue newValue = intent.currentTextEditingValue.replaced( @@ -3317,7 +3358,7 @@ class EditableTextState extends State with AutomaticKeepAliveClien DeleteToLineBreakIntent: _makeOverridable(_DeleteTextAction(this, _linebreak)), // Extend/Move Selection - ExtendSelectionByCharacterIntent: _makeOverridable(_UpdateTextSelectionAction(this, false, _characterBoundary,)), + ExtendSelectionByCharacterIntent: _makeOverridable(_UpdateTextSelectionAction(this, false, _characterBoundary)), ExtendSelectionToNextWordBoundaryIntent: _makeOverridable(_UpdateTextSelectionAction(this, true, _nextWordBoundary)), ExtendSelectionToLineBreakIntent: _makeOverridable(_UpdateTextSelectionAction(this, true, _linebreak)), ExpandSelectionToLineBreakIntent: _makeOverridable(CallbackAction(onInvoke: _expandSelectionToLinebreak)), @@ -3331,6 +3372,8 @@ class EditableTextState extends State with AutomaticKeepAliveClien SelectAllTextIntent: _makeOverridable(_SelectAllAction(this)), CopySelectionTextIntent: _makeOverridable(_CopySelectionAction(this)), PasteTextIntent: _makeOverridable(CallbackAction(onInvoke: (PasteTextIntent intent) => pasteText(intent.cause))), + + TransposeCharactersIntent: _makeOverridable(_transposeCharactersAction), }; @override diff --git a/packages/flutter/lib/src/widgets/text_editing_intents.dart b/packages/flutter/lib/src/widgets/text_editing_intents.dart index dc713054d1f..a7be37d704c 100644 --- a/packages/flutter/lib/src/widgets/text_editing_intents.dart +++ b/packages/flutter/lib/src/widgets/text_editing_intents.dart @@ -329,3 +329,10 @@ class UpdateSelectionIntent extends Intent { /// {@macro flutter.widgets.TextEditingIntents.cause} final SelectionChangedCause cause; } + +/// An [Intent] that represents a user interaction that attempts to swap the +/// characters immediately around the cursor. +class TransposeCharactersIntent extends Intent { + /// Creates a [TransposeCharactersIntent]. + const TransposeCharactersIntent(); +} diff --git a/packages/flutter/test/widgets/editable_text_test.dart b/packages/flutter/test/widgets/editable_text_test.dart index e63934d641e..3e46034def0 100644 --- a/packages/flutter/test/widgets/editable_text_test.dart +++ b/packages/flutter/test/widgets/editable_text_test.dart @@ -12339,6 +12339,198 @@ void main() { skip: kIsWeb, // [intended] on web these keys are handled by the browser. variant: const TargetPlatformVariant({ TargetPlatform.iOS, TargetPlatform.macOS }), ); + + group('ctrl-T to transpose', () { + Future ctrlT(WidgetTester tester, String platform) async { + await tester.sendKeyDownEvent( + LogicalKeyboardKey.controlLeft, + platform: platform, + ); + await tester.pump(); + await tester.sendKeyEvent(LogicalKeyboardKey.keyT, platform: platform); + await tester.pump(); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft, platform: platform); + await tester.pump(); + } + + testWidgets('with normal characters', (WidgetTester tester) async { + final String targetPlatformString = defaultTargetPlatform.toString(); + final String platform = targetPlatformString.substring(targetPlatformString.indexOf('.') + 1).toLowerCase(); + + final TextEditingController controller = TextEditingController(text: testText); + controller.selection = const TextSelection( + baseOffset: 0, + extentOffset: 0, + affinity: TextAffinity.upstream, + ); + await tester.pumpWidget(MaterialApp( + home: Align( + alignment: Alignment.topLeft, + child: SizedBox( + width: 400, + child: EditableText( + maxLines: 10, + controller: controller, + showSelectionHandles: true, + autofocus: true, + focusNode: FocusNode(), + style: Typography.material2018().black.subtitle1!, + cursorColor: Colors.blue, + backgroundCursorColor: Colors.grey, + selectionControls: materialTextSelectionControls, + keyboardType: TextInputType.text, + textAlign: TextAlign.right, + ), + ), + ), + )); + + await tester.pump(); // Wait for autofocus to take effect. + + expect(controller.selection.isCollapsed, isTrue); + expect(controller.selection.baseOffset, 0); + + // ctrl-T does nothing at the start of the field. + await ctrlT(tester, platform); + expect(controller.selection.isCollapsed, isTrue); + expect(controller.selection.baseOffset, 0); + + controller.selection = const TextSelection( + baseOffset: 1, + extentOffset: 4, + ); + await tester.pump(); + expect(controller.selection.isCollapsed, isFalse); + expect(controller.selection.baseOffset, 1); + expect(controller.selection.extentOffset, 4); + + // ctrl-T does nothing when the selection isn't collapsed. + await ctrlT(tester, platform); + expect(controller.selection.isCollapsed, isFalse); + expect(controller.selection.baseOffset, 1); + expect(controller.selection.extentOffset, 4); + + controller.selection = const TextSelection.collapsed(offset: 5); + await tester.pump(); + expect(controller.selection.isCollapsed, isTrue); + expect(controller.selection.baseOffset, 5); + + // ctrl-T swaps the previous and next characters when they exist. + await ctrlT(tester, platform); + expect(controller.selection.isCollapsed, isTrue); + expect(controller.selection.baseOffset, 6); + expect(controller.text.substring(0, 19), 'Now si the time for'); + + await ctrlT(tester, platform); + expect(controller.selection.isCollapsed, isTrue); + expect(controller.selection.baseOffset, 7); + expect(controller.text.substring(0, 19), 'Now s ithe time for'); + + await ctrlT(tester, platform); + expect(controller.selection.isCollapsed, isTrue); + expect(controller.selection.baseOffset, 8); + expect(controller.text.substring(0, 19), 'Now s tihe time for'); + + controller.selection = TextSelection.collapsed( + offset: controller.text.length, + ); + await tester.pump(); + expect(controller.selection.isCollapsed, isTrue); + expect(controller.selection.baseOffset, controller.text.length); + expect(controller.text.substring(55, 72), 'of their country.'); + + await ctrlT(tester, platform); + expect(controller.selection.isCollapsed, isTrue); + expect(controller.selection.baseOffset, controller.text.length); + expect(controller.text.substring(55, 72), 'of their countr.y'); + }, + skip: kIsWeb, // [intended] on web these keys are handled by the browser. + variant: const TargetPlatformVariant({ TargetPlatform.iOS, TargetPlatform.macOS }), + ); + + testWidgets('with extended grapheme clusters', (WidgetTester tester) async { + final String targetPlatformString = defaultTargetPlatform.toString(); + final String platform = targetPlatformString.substring(targetPlatformString.indexOf('.') + 1).toLowerCase(); + + final TextEditingController controller = TextEditingController( + // One extended grapheme cluster of length 8 and one surrogate pair of + // length 2. + text: '👨‍👩‍👦😆', + ); + controller.selection = const TextSelection( + baseOffset: 0, + extentOffset: 0, + affinity: TextAffinity.upstream, + ); + await tester.pumpWidget(MaterialApp( + home: Align( + alignment: Alignment.topLeft, + child: SizedBox( + width: 400, + child: EditableText( + maxLines: 10, + controller: controller, + showSelectionHandles: true, + autofocus: true, + focusNode: FocusNode(), + style: Typography.material2018().black.subtitle1!, + cursorColor: Colors.blue, + backgroundCursorColor: Colors.grey, + selectionControls: materialTextSelectionControls, + keyboardType: TextInputType.text, + textAlign: TextAlign.right, + ), + ), + ), + )); + + await tester.pump(); // Wait for autofocus to take effect. + + expect(controller.selection.isCollapsed, isTrue); + expect(controller.selection.baseOffset, 0); + + // ctrl-T does nothing at the start of the field. + await ctrlT(tester, platform); + expect(controller.selection.isCollapsed, isTrue); + expect(controller.selection.baseOffset, 0); + expect(controller.text, '👨‍👩‍👦😆'); + + controller.selection = const TextSelection( + baseOffset: 8, + extentOffset: 10, + ); + await tester.pump(); + expect(controller.selection.isCollapsed, isFalse); + expect(controller.selection.baseOffset, 8); + expect(controller.selection.extentOffset, 10); + + // ctrl-T does nothing when the selection isn't collapsed. + await ctrlT(tester, platform); + expect(controller.selection.isCollapsed, isFalse); + expect(controller.selection.baseOffset, 8); + expect(controller.selection.extentOffset, 10); + expect(controller.text, '👨‍👩‍👦😆'); + + controller.selection = const TextSelection.collapsed(offset: 8); + await tester.pump(); + expect(controller.selection.isCollapsed, isTrue); + expect(controller.selection.baseOffset, 8); + + // ctrl-T swaps the previous and next characters when they exist. + await ctrlT(tester, platform); + expect(controller.selection.isCollapsed, isTrue); + expect(controller.selection.baseOffset, 10); + expect(controller.text, '😆👨‍👩‍👦'); + + await ctrlT(tester, platform); + expect(controller.selection.isCollapsed, isTrue); + expect(controller.selection.baseOffset, 10); + expect(controller.text, '👨‍👩‍👦😆'); + }, + skip: kIsWeb, // [intended] on web these keys are handled by the browser. + variant: const TargetPlatformVariant({ TargetPlatform.iOS, TargetPlatform.macOS }), + ); + }); }); }