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

Windows home/end shortcuts (#90840)

Support for Windows home/end keyboard shortcuts
parent f62a1280
......@@ -256,6 +256,10 @@ class DefaultTextEditingShortcuts extends Shortcuts {
SingleActivator(LogicalKeyboardKey.end, shift: true): ExtendSelectionToLineBreakIntent(forward: true, collapseSelection: false),
// The following key combinations have no effect on text editing on this
// platform:
// * Control + end
// * Control + home
// * Control + shift + end
// * Control + shift + home
// * Meta + X
// * Meta + C
// * Meta + V
......@@ -301,8 +305,8 @@ class DefaultTextEditingShortcuts extends Shortcuts {
SingleActivator(LogicalKeyboardKey.arrowLeft, shift: true, alt: true): ExtendSelectionToNextWordBoundaryOrCaretLocationIntent(forward: false),
SingleActivator(LogicalKeyboardKey.arrowRight, shift: true, alt: true): ExtendSelectionToNextWordBoundaryOrCaretLocationIntent(forward: true),
SingleActivator(LogicalKeyboardKey.arrowUp, shift: true, alt: true): ExtendSelectionToLineBreakIntent(forward: false, collapseSelection: false),
SingleActivator(LogicalKeyboardKey.arrowDown, shift: true, alt: true): ExtendSelectionToLineBreakIntent(forward: true, collapseSelection: false),
SingleActivator(LogicalKeyboardKey.arrowUp, shift: true, alt: true): ExtendSelectionToLineBreakIntent(forward: false, collapseSelection: false, collapseAtReversal: true),
SingleActivator(LogicalKeyboardKey.arrowDown, shift: true, alt: true): ExtendSelectionToLineBreakIntent(forward: true, collapseSelection: false, collapseAtReversal: true),
SingleActivator(LogicalKeyboardKey.arrowLeft, meta: true): ExtendSelectionToLineBreakIntent(forward: false, collapseSelection: true),
SingleActivator(LogicalKeyboardKey.arrowRight, meta: true): ExtendSelectionToLineBreakIntent(forward: true, collapseSelection: true),
......@@ -321,6 +325,14 @@ class DefaultTextEditingShortcuts extends Shortcuts {
SingleActivator(LogicalKeyboardKey.keyC, meta: true): CopySelectionTextIntent.copy,
SingleActivator(LogicalKeyboardKey.keyV, meta: true): PasteTextIntent(SelectionChangedCause.keyboard),
SingleActivator(LogicalKeyboardKey.keyA, meta: true): SelectAllTextIntent(SelectionChangedCause.keyboard),
// The following key combinations have no effect on text editing on this
// platform:
// * End
// * Home
// * Control + end
// * Control + home
// * Control + shift + end
// * Control + shift + home
};
// The following key combinations have no effect on text editing on this
......@@ -339,7 +351,17 @@ class DefaultTextEditingShortcuts extends Shortcuts {
// * Meta + shift + arrow up
// * Meta + delete
// * Meta + backspace
static const Map<ShortcutActivator, Intent> _windowsShortcuts = _linuxShortcuts;
static const Map<ShortcutActivator, Intent> _windowsShortcuts = <ShortcutActivator, Intent>{
..._commonShortcuts,
SingleActivator(LogicalKeyboardKey.home): ExtendSelectionToLineBreakIntent(forward: false, collapseSelection: true),
SingleActivator(LogicalKeyboardKey.end): ExtendSelectionToLineBreakIntent(forward: true, collapseSelection: true),
SingleActivator(LogicalKeyboardKey.home, shift: true): ExtendSelectionToLineBreakIntent(forward: false, collapseSelection: false),
SingleActivator(LogicalKeyboardKey.end, shift: true): ExtendSelectionToLineBreakIntent(forward: true, collapseSelection: false),
SingleActivator(LogicalKeyboardKey.home, control: true): ExtendSelectionToDocumentBoundaryIntent(forward: false, collapseSelection: true),
SingleActivator(LogicalKeyboardKey.end, control: true): ExtendSelectionToDocumentBoundaryIntent(forward: true, collapseSelection: true),
SingleActivator(LogicalKeyboardKey.home, shift: true, control: true): ExtendSelectionToDocumentBoundaryIntent(forward: false, collapseSelection: false),
SingleActivator(LogicalKeyboardKey.end, shift: true, control: true): ExtendSelectionToDocumentBoundaryIntent(forward: true, collapseSelection: false),
};
// Web handles its text selection natively and doesn't use any of these
// shortcuts in Flutter.
......@@ -384,6 +406,8 @@ class DefaultTextEditingShortcuts extends Shortcuts {
SingleActivator(LogicalKeyboardKey.arrowUp, shift: true): DoNothingAndStopPropagationTextIntent(),
SingleActivator(LogicalKeyboardKey.end, shift: true): DoNothingAndStopPropagationTextIntent(),
SingleActivator(LogicalKeyboardKey.home, shift: true): DoNothingAndStopPropagationTextIntent(),
SingleActivator(LogicalKeyboardKey.end, control: true): DoNothingAndStopPropagationTextIntent(),
SingleActivator(LogicalKeyboardKey.home, control: true): DoNothingAndStopPropagationTextIntent(),
SingleActivator(LogicalKeyboardKey.space): DoNothingAndStopPropagationTextIntent(),
SingleActivator(LogicalKeyboardKey.keyX, control: true): DoNothingAndStopPropagationTextIntent(),
SingleActivator(LogicalKeyboardKey.keyX, meta: true): DoNothingAndStopPropagationTextIntent(),
......
......@@ -3639,6 +3639,7 @@ class _UpdateTextSelectionAction<T extends DirectionalCaretMovementIntent> exten
UpdateSelectionIntent(state._value, _collapse(textBoundarySelection), SelectionChangedCause.keyboard),
);
}
final TextPosition extent = textBoundarySelection.extent;
final TextPosition newExtent = intent.forward
? textBoundary.getTrailingTextBoundaryAt(extent)
......@@ -3648,6 +3649,20 @@ class _UpdateTextSelectionAction<T extends DirectionalCaretMovementIntent> exten
? TextSelection.fromPosition(newExtent)
: textBoundarySelection.extendTo(newExtent);
// If collapseAtReversal is true and would have an effect, collapse it.
if (!selection.isCollapsed && intent.collapseAtReversal
&& (selection.baseOffset < selection.extentOffset !=
newSelection.baseOffset < newSelection.extentOffset)) {
return Actions.invoke(
context!,
UpdateSelectionIntent(
state._value,
TextSelection.fromPosition(selection.base),
SelectionChangedCause.keyboard,
),
);
}
return Actions.invoke(
context!,
UpdateSelectionIntent(textBoundary.textEditingValue, newSelection, SelectionChangedCause.keyboard),
......
......@@ -62,8 +62,12 @@ class DeleteToLineBreakIntent extends DirectionalTextEditingIntent {
/// new location.
abstract class DirectionalCaretMovementIntent extends DirectionalTextEditingIntent {
/// Creates a [DirectionalCaretMovementIntent].
const DirectionalCaretMovementIntent(bool forward, this.collapseSelection)
: super(forward);
const DirectionalCaretMovementIntent(
bool forward,
this.collapseSelection,
[this.collapseAtReversal = false]
) : assert(!collapseSelection || !collapseAtReversal),
super(forward);
/// Whether this [Intent] should make the selection collapsed (so it becomes a
/// caret), after the movement.
......@@ -76,6 +80,16 @@ abstract class DirectionalCaretMovementIntent extends DirectionalTextEditingInte
/// both the [TextSelection.base] and the [TextSelection.extent] to the new
/// location.
final bool collapseSelection;
/// Whether to collapse the selection when it would otherwise reverse order.
///
/// For example, consider when forward is true and the extent is before the
/// base. If collapseAtReversal is true, then this will cause the selection to
/// collapse at the base. If it's false, then the extent will be placed at the
/// linebreak, reversing the order of base and offset.
///
/// Cannot be true when collapseSelection is true.
final bool collapseAtReversal;
}
/// Expands, or moves the current selection from the current
......@@ -126,7 +140,9 @@ class ExtendSelectionToLineBreakIntent extends DirectionalCaretMovementIntent {
const ExtendSelectionToLineBreakIntent({
required bool forward,
required bool collapseSelection,
}) : super(forward, collapseSelection);
bool collapseAtReversal = false,
}) : assert(!collapseSelection || !collapseAtReversal),
super(forward, collapseSelection, collapseAtReversal);
}
/// Expands, or moves the current selection from the current
......
// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
class TestLeftIntent extends Intent {
const TestLeftIntent();
}
class TestRightIntent extends Intent {
const TestRightIntent();
}
void main() {
testWidgets('DoNothingAndStopPropagationTextIntent', (WidgetTester tester) async {
bool leftCalled = false;
bool rightCalled = false;
final TextEditingController controller = TextEditingController(
text: 'blah1 blah2',
);
final FocusNode focusNodeTarget = FocusNode();
final FocusNode focusNodeNonTarget = FocusNode();
await tester.pumpWidget(MaterialApp(
theme: ThemeData(),
home: Scaffold(
body: Builder(
builder: (BuildContext context) {
return Shortcuts(
shortcuts: const <ShortcutActivator, Intent>{
SingleActivator(LogicalKeyboardKey.arrowLeft): TestLeftIntent(),
SingleActivator(LogicalKeyboardKey.arrowRight): TestRightIntent(),
},
child: Shortcuts(
shortcuts: const <ShortcutActivator, Intent>{
SingleActivator(LogicalKeyboardKey.arrowRight): DoNothingAndStopPropagationTextIntent(),
},
child: Actions(
// These Actions intercept default Intents, set a flag that they
// were called, and then call through to the default Action.
actions: <Type, Action<Intent>>{
TestLeftIntent: CallbackAction<TestLeftIntent>(onInvoke: (Intent intent) {
leftCalled = true;
}),
TestRightIntent: CallbackAction<TestRightIntent>(onInvoke: (Intent intent) {
rightCalled = true;
}),
},
child: Center(
child: Column(
children: <Widget>[
EditableText(
controller: controller,
focusNode: focusNodeTarget,
style: Typography.material2018().black.subtitle1!,
cursorColor: Colors.blue,
backgroundCursorColor: Colors.grey,
),
Focus(
focusNode: focusNodeNonTarget,
child: const Text('focusable'),
),
],
),
),
),
),
);
},
),
),
));
// Focus on the EditableText, which is a TextEditingActionTarget.
focusNodeTarget.requestFocus();
await tester.pump();
expect(focusNodeTarget.hasFocus, isTrue);
expect(focusNodeNonTarget.hasFocus, isFalse);
expect(controller.selection.isCollapsed, isTrue);
expect(controller.selection.baseOffset, 11);
// The left arrow key's Action is called.
await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft);
await tester.pump();
expect(leftCalled, isTrue);
expect(rightCalled, isFalse);
leftCalled = false;
// The right arrow key is blocked by DoNothingAndStopPropagationTextIntent.
await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight);
await tester.pump();
expect(rightCalled, isFalse);
expect(leftCalled, isFalse);
// Focus on the other node, which is not a TextEditingActionTarget.
focusNodeNonTarget.requestFocus();
await tester.pump();
expect(focusNodeTarget.hasFocus, isFalse);
expect(focusNodeNonTarget.hasFocus, isTrue);
// The left arrow key's Action is called as normal.
await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft);
await tester.pump();
expect(leftCalled, isTrue);
expect(rightCalled, isFalse);
leftCalled = false;
// The right arrow key's Action is also called. That's because
// DoNothingAndStopPropagationTextIntent only applies if a
// TextEditingActionTarget is currently focused.
await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight);
await tester.pump();
expect(leftCalled, isFalse);
expect(rightCalled, isTrue);
}, variant: KeySimulatorTransitModeVariant.all());
}
......@@ -5556,6 +5556,271 @@ void main() {
variant: TargetPlatformVariant.all(),
);
testWidgets('shift + home/end keys (Windows only)', (WidgetTester tester) async {
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();
// Move the selection away from the start so it can invert.
await sendKeys(
tester,
<LogicalKeyboardKey>[
LogicalKeyboardKey.arrowRight,
LogicalKeyboardKey.arrowRight,
LogicalKeyboardKey.arrowRight,
LogicalKeyboardKey.arrowRight,
],
targetPlatform: defaultTargetPlatform,
);
await tester.pump();
expect(
controller.selection,
equals(const TextSelection.collapsed(
offset: 4,
)),
);
// Press shift + end and extend the selection to the end of the line.
await sendKeys(
tester,
<LogicalKeyboardKey>[
LogicalKeyboardKey.end,
],
shift: true,
targetPlatform: defaultTargetPlatform,
);
await tester.pump();
expect(
controller.selection,
equals(const TextSelection(
baseOffset: 4,
extentOffset: 19,
affinity: TextAffinity.upstream,
)),
);
// Press shift + home and the selection inverts and extends to the start, it
// does not collapse and stop at the inversion.
await sendKeys(
tester,
<LogicalKeyboardKey>[
LogicalKeyboardKey.home,
],
shift: true,
targetPlatform: defaultTargetPlatform,
);
await tester.pump();
expect(
controller.selection,
equals(const TextSelection(
baseOffset: 4,
extentOffset: 0,
)),
);
// Press shift + end again and the selection inverts and extends to the end,
// again it does not stop at the inversion.
await sendKeys(
tester,
<LogicalKeyboardKey>[
LogicalKeyboardKey.end,
],
shift: true,
targetPlatform: defaultTargetPlatform,
);
await tester.pump();
expect(
controller.selection,
equals(const TextSelection(
baseOffset: 4,
extentOffset: 19,
)),
);
},
skip: kIsWeb, // [intended] on web these keys are handled by the browser.
variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.windows })
);
testWidgets('control + home/end keys (Windows only)', (WidgetTester tester) async {
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();
await sendKeys(
tester,
<LogicalKeyboardKey>[
LogicalKeyboardKey.end,
],
shortcutModifier: true,
targetPlatform: defaultTargetPlatform,
);
await tester.pump();
expect(
controller.selection,
equals(const TextSelection.collapsed(
offset: testText.length,
affinity: TextAffinity.upstream,
)),
);
await sendKeys(
tester,
<LogicalKeyboardKey>[
LogicalKeyboardKey.home,
],
shortcutModifier: true,
targetPlatform: defaultTargetPlatform,
);
await tester.pump();
expect(
controller.selection,
equals(const TextSelection.collapsed(offset: 0)),
);
},
skip: kIsWeb, // [intended] on web these keys are handled by the browser.
variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.windows })
);
testWidgets('control + shift + home/end keys (Windows only)', (WidgetTester tester) async {
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();
await sendKeys(
tester,
<LogicalKeyboardKey>[
LogicalKeyboardKey.end,
],
shortcutModifier: true,
shift: true,
targetPlatform: defaultTargetPlatform,
);
await tester.pump();
expect(
controller.selection,
equals(const TextSelection(
baseOffset: 0,
extentOffset: testText.length,
)),
);
// Collapse the selection at the end.
await sendKeys(
tester,
<LogicalKeyboardKey>[
LogicalKeyboardKey.arrowRight,
],
targetPlatform: defaultTargetPlatform,
);
await tester.pump();
expect(
controller.selection,
equals(const TextSelection.collapsed(
offset: testText.length,
affinity: TextAffinity.upstream,
)),
);
await sendKeys(
tester,
<LogicalKeyboardKey>[
LogicalKeyboardKey.home,
],
shortcutModifier: true,
shift: true,
targetPlatform: defaultTargetPlatform,
);
await tester.pump();
expect(
controller.selection,
equals(const TextSelection(
baseOffset: testText.length,
extentOffset: 0,
)),
);
},
skip: kIsWeb, // [intended] on web these keys are handled by the browser.
variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.windows })
);
// Regression test for https://github.com/flutter/flutter/issues/31287
testWidgets('text selection handle visibility', (WidgetTester tester) async {
// Text with two separate words to select.
......
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