Unverified Commit c135cd34 authored by Justin McCandless's avatar Justin McCandless Committed by GitHub

MacOS transpose keyboard shortcut (#104457)

Implements ctrl-T to transpose characters on Mac and iOS
parent 15308b33
...@@ -299,6 +299,8 @@ class DefaultTextEditingShortcuts extends StatelessWidget { ...@@ -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.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.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.home): const ScrollToDocumentBoundaryIntent(forward: false),
const SingleActivator(LogicalKeyboardKey.end): const ScrollToDocumentBoundaryIntent(forward: true), const SingleActivator(LogicalKeyboardKey.end): const ScrollToDocumentBoundaryIntent(forward: true),
const SingleActivator(LogicalKeyboardKey.home, shift: true): const ExpandSelectionToDocumentBoundaryIntent(forward: false), const SingleActivator(LogicalKeyboardKey.home, shift: true): const ExpandSelectionToDocumentBoundaryIntent(forward: false),
......
...@@ -3223,6 +3223,47 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien ...@@ -3223,6 +3223,47 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
return Action<T>.overridable(context: context, defaultAction: defaultAction); return Action<T>.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<TransposeCharactersIntent> _transposeCharactersAction = CallbackAction<TransposeCharactersIntent>(onInvoke: _transposeCharacters);
void _replaceText(ReplaceTextIntent intent) { void _replaceText(ReplaceTextIntent intent) {
final TextEditingValue oldValue = _value; final TextEditingValue oldValue = _value;
final TextEditingValue newValue = intent.currentTextEditingValue.replaced( final TextEditingValue newValue = intent.currentTextEditingValue.replaced(
...@@ -3317,7 +3358,7 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien ...@@ -3317,7 +3358,7 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
DeleteToLineBreakIntent: _makeOverridable(_DeleteTextAction<DeleteToLineBreakIntent>(this, _linebreak)), DeleteToLineBreakIntent: _makeOverridable(_DeleteTextAction<DeleteToLineBreakIntent>(this, _linebreak)),
// Extend/Move Selection // Extend/Move Selection
ExtendSelectionByCharacterIntent: _makeOverridable(_UpdateTextSelectionAction<ExtendSelectionByCharacterIntent>(this, false, _characterBoundary,)), ExtendSelectionByCharacterIntent: _makeOverridable(_UpdateTextSelectionAction<ExtendSelectionByCharacterIntent>(this, false, _characterBoundary)),
ExtendSelectionToNextWordBoundaryIntent: _makeOverridable(_UpdateTextSelectionAction<ExtendSelectionToNextWordBoundaryIntent>(this, true, _nextWordBoundary)), ExtendSelectionToNextWordBoundaryIntent: _makeOverridable(_UpdateTextSelectionAction<ExtendSelectionToNextWordBoundaryIntent>(this, true, _nextWordBoundary)),
ExtendSelectionToLineBreakIntent: _makeOverridable(_UpdateTextSelectionAction<ExtendSelectionToLineBreakIntent>(this, true, _linebreak)), ExtendSelectionToLineBreakIntent: _makeOverridable(_UpdateTextSelectionAction<ExtendSelectionToLineBreakIntent>(this, true, _linebreak)),
ExpandSelectionToLineBreakIntent: _makeOverridable(CallbackAction<ExpandSelectionToLineBreakIntent>(onInvoke: _expandSelectionToLinebreak)), ExpandSelectionToLineBreakIntent: _makeOverridable(CallbackAction<ExpandSelectionToLineBreakIntent>(onInvoke: _expandSelectionToLinebreak)),
...@@ -3331,6 +3372,8 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien ...@@ -3331,6 +3372,8 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
SelectAllTextIntent: _makeOverridable(_SelectAllAction(this)), SelectAllTextIntent: _makeOverridable(_SelectAllAction(this)),
CopySelectionTextIntent: _makeOverridable(_CopySelectionAction(this)), CopySelectionTextIntent: _makeOverridable(_CopySelectionAction(this)),
PasteTextIntent: _makeOverridable(CallbackAction<PasteTextIntent>(onInvoke: (PasteTextIntent intent) => pasteText(intent.cause))), PasteTextIntent: _makeOverridable(CallbackAction<PasteTextIntent>(onInvoke: (PasteTextIntent intent) => pasteText(intent.cause))),
TransposeCharactersIntent: _makeOverridable(_transposeCharactersAction),
}; };
@override @override
......
...@@ -329,3 +329,10 @@ class UpdateSelectionIntent extends Intent { ...@@ -329,3 +329,10 @@ class UpdateSelectionIntent extends Intent {
/// {@macro flutter.widgets.TextEditingIntents.cause} /// {@macro flutter.widgets.TextEditingIntents.cause}
final SelectionChangedCause 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();
}
...@@ -12339,6 +12339,198 @@ void main() { ...@@ -12339,6 +12339,198 @@ void main() {
skip: kIsWeb, // [intended] on web these keys are handled by the browser. skip: kIsWeb, // [intended] on web these keys are handled by the browser.
variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS, TargetPlatform.macOS }), variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS, TargetPlatform.macOS }),
); );
group('ctrl-T to transpose', () {
Future<void> 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>{ 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>{ TargetPlatform.iOS, TargetPlatform.macOS }),
);
});
}); });
} }
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment