mirror of
https://github.com/flutter/flutter.git
synced 2026-02-20 02:29:02 +08:00
MacOS transpose keyboard shortcut (#104457)
Implements ctrl-T to transpose characters on Mac and iOS
This commit is contained in:
parent
15308b339b
commit
c135cd340d
@ -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),
|
||||
|
||||
@ -3223,6 +3223,47 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
|
||||
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) {
|
||||
final TextEditingValue oldValue = _value;
|
||||
final TextEditingValue newValue = intent.currentTextEditingValue.replaced(
|
||||
@ -3317,7 +3358,7 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
|
||||
DeleteToLineBreakIntent: _makeOverridable(_DeleteTextAction<DeleteToLineBreakIntent>(this, _linebreak)),
|
||||
|
||||
// Extend/Move Selection
|
||||
ExtendSelectionByCharacterIntent: _makeOverridable(_UpdateTextSelectionAction<ExtendSelectionByCharacterIntent>(this, false, _characterBoundary,)),
|
||||
ExtendSelectionByCharacterIntent: _makeOverridable(_UpdateTextSelectionAction<ExtendSelectionByCharacterIntent>(this, false, _characterBoundary)),
|
||||
ExtendSelectionToNextWordBoundaryIntent: _makeOverridable(_UpdateTextSelectionAction<ExtendSelectionToNextWordBoundaryIntent>(this, true, _nextWordBoundary)),
|
||||
ExtendSelectionToLineBreakIntent: _makeOverridable(_UpdateTextSelectionAction<ExtendSelectionToLineBreakIntent>(this, true, _linebreak)),
|
||||
ExpandSelectionToLineBreakIntent: _makeOverridable(CallbackAction<ExpandSelectionToLineBreakIntent>(onInvoke: _expandSelectionToLinebreak)),
|
||||
@ -3331,6 +3372,8 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
|
||||
SelectAllTextIntent: _makeOverridable(_SelectAllAction(this)),
|
||||
CopySelectionTextIntent: _makeOverridable(_CopySelectionAction(this)),
|
||||
PasteTextIntent: _makeOverridable(CallbackAction<PasteTextIntent>(onInvoke: (PasteTextIntent intent) => pasteText(intent.cause))),
|
||||
|
||||
TransposeCharactersIntent: _makeOverridable(_transposeCharactersAction),
|
||||
};
|
||||
|
||||
@override
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
@ -12339,6 +12339,198 @@ void main() {
|
||||
skip: kIsWeb, // [intended] on web these keys are handled by the browser.
|
||||
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 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user