Unverified Commit 8c03ff8c authored by Greg Spencer's avatar Greg Spencer Committed by GitHub

Mark keys that match a shortcut, but have no action defined as "not handled". (#67359)

- - When I added notification of key events before processing them as text, it made it so that shortcut key bindings like the spacebar would prevent spaces from being inserted into text fields, which is obviously not desirable (and so that change was reverted). At the same time, we do want to make it possible to override key events so that they can do things like intercept a tab key or arrow keys that change the focus.

This PR changes the behavior of the Shortcuts widget so that if it has a shortcut defined, but no action is bound to the intent, then instead of responding that the key is "handled", it responds as if nothing handled it. This allows the engine to continue to process the key as text entry.

This PR includes:

- Modification of the callback type for key handlers to return a KeyEventResult instead of a bool, so that we can return more information (i.e. the extra state of "stop propagation").
- Modification of the ActionDispatcher.invokeAction contract to require that Action.isEnabled return true before calling it. It will now assert if the action isn't enabled when invokeAction is called. This is to allow optimization of the number of calls to isEnabled, since the shortcuts widget now wants to know if the action was enabled before deciding to either handle the key or to return ignored.
- Modification to ShortcutManager.handleKeypress to return KeyEventResult.ignored for keys which don't have an enabled action associated with them.
- Adds an attribute to DoNothingAction that allows it to mark a key as not handled, even though it does have an action associated with it. This will allow disabling of a shortcut for a subtree.
parent e422d5c7
......@@ -194,13 +194,13 @@ class UndoIntent extends Intent {
class UndoAction extends Action<UndoIntent> {
@override
bool isEnabled(UndoIntent intent) {
final UndoableActionDispatcher manager = Actions.of(primaryFocus?.context ?? FocusDemo.appKey.currentContext, nullOk: true) as UndoableActionDispatcher;
final UndoableActionDispatcher manager = Actions.of(primaryFocus?.context ?? FocusDemo.appKey.currentContext) as UndoableActionDispatcher;
return manager.canUndo;
}
@override
void invoke(UndoIntent intent) {
final UndoableActionDispatcher manager = Actions.of(primaryFocus?.context ?? FocusDemo.appKey.currentContext, nullOk: true) as UndoableActionDispatcher;
final UndoableActionDispatcher manager = Actions.of(primaryFocus?.context ?? FocusDemo.appKey.currentContext) as UndoableActionDispatcher;
manager?.undo();
}
}
......@@ -212,13 +212,13 @@ class RedoIntent extends Intent {
class RedoAction extends Action<RedoIntent> {
@override
bool isEnabled(RedoIntent intent) {
final UndoableActionDispatcher manager = Actions.of(primaryFocus.context, nullOk: true) as UndoableActionDispatcher;
final UndoableActionDispatcher manager = Actions.of(primaryFocus.context) as UndoableActionDispatcher;
return manager.canRedo;
}
@override
RedoAction invoke(RedoIntent intent) {
final UndoableActionDispatcher manager = Actions.of(primaryFocus.context, nullOk: true) as UndoableActionDispatcher;
final UndoableActionDispatcher manager = Actions.of(primaryFocus.context) as UndoableActionDispatcher;
manager?.redo();
return this;
}
......
......@@ -97,7 +97,7 @@ class _FocusDemoState extends State<FocusDemo> {
super.dispose();
}
bool _handleKeyPress(FocusNode node, RawKeyEvent event) {
KeyEventResult _handleKeyPress(FocusNode node, RawKeyEvent event) {
if (event is RawKeyDownEvent) {
print('Scope got key event: ${event.logicalKey}, $node');
print('Keys down: ${RawKeyboard.instance.keysPressed}');
......@@ -106,31 +106,31 @@ class _FocusDemoState extends State<FocusDemo> {
if (event.isShiftPressed) {
print('Moving to previous.');
node.previousFocus();
return true;
return KeyEventResult.handled;
} else {
print('Moving to next.');
node.nextFocus();
return true;
return KeyEventResult.handled;
}
}
if (event.logicalKey == LogicalKeyboardKey.arrowLeft) {
node.focusInDirection(TraversalDirection.left);
return true;
return KeyEventResult.handled;
}
if (event.logicalKey == LogicalKeyboardKey.arrowRight) {
node.focusInDirection(TraversalDirection.right);
return true;
return KeyEventResult.handled;
}
if (event.logicalKey == LogicalKeyboardKey.arrowUp) {
node.focusInDirection(TraversalDirection.up);
return true;
return KeyEventResult.handled;
}
if (event.logicalKey == LogicalKeyboardKey.arrowDown) {
node.focusInDirection(TraversalDirection.down);
return true;
return KeyEventResult.handled;
}
}
return false;
return KeyEventResult.ignored;
}
@override
......
......@@ -37,11 +37,11 @@ class _HardwareKeyDemoState extends State<RawKeyboardDemo> {
super.dispose();
}
bool _handleKeyEvent(FocusNode node, RawKeyEvent event) {
KeyEventResult _handleKeyEvent(FocusNode node, RawKeyEvent event) {
setState(() {
_event = event;
});
return false;
return KeyEventResult.ignored;
}
String _asHex(int value) => value != null ? '0x${value.toRadixString(16)}' : 'null';
......
......@@ -147,7 +147,30 @@ class ChangeNotifier implements Listenable {
/// Register a closure to be called when the object changes.
///
/// If the given closure is already registered, an additional instance is
/// added, and must be removed the same number of times it is added before it
/// will stop being called.
///
/// This method must not be called after [dispose] has been called.
///
/// {@template flutter.foundation.ChangeNotifier.addListener}
/// If a listener is added twice, and is removed once during an iteration
/// (e.g. in response to a notification), it will still be called again. If,
/// on the other hand, it is removed as many times as it was registered, then
/// it will no longer be called. This odd behavior is the result of the
/// [ChangeNotifier] not being able to determine which listener is being
/// removed, since they are identical, therefore it will conservatively still
/// call all the listeners when it knows that any are still registered.
///
/// This surprising behavior can be unexpectedly observed when registering a
/// listener on two separate objects which are both forwarding all
/// registrations to a common upstream object.
/// {@endtemplate}
///
/// See also:
///
/// * [removeListener], which removes a previously registered closure from
/// the list of closures that are notified when the object changes.
@override
void addListener(VoidCallback listener) {
assert(_debugAssertNotDisposed());
......@@ -161,18 +184,12 @@ class ChangeNotifier implements Listenable {
///
/// This method must not be called after [dispose] has been called.
///
/// If a listener had been added twice, and is removed once during an
/// iteration (i.e. in response to a notification), it will still be called
/// again. If, on the other hand, it is removed as many times as it was
/// registered, then it will no longer be called. This odd behavior is the
/// result of the [ChangeNotifier] not being able to determine which listener
/// is being removed, since they are identical, and therefore conservatively
/// still calling all the listeners when it knows that any are still
/// registered.
/// {@macro flutter.foundation.ChangeNotifier.addListener}
///
/// This surprising behavior can be unexpectedly observed when registering a
/// listener on two separate objects which are both forwarding all
/// registrations to a common upstream object.
/// See also:
///
/// * [addListener], which registers a closure to be called when the object
/// changes.
@override
void removeListener(VoidCallback listener) {
assert(_debugAssertNotDisposed());
......@@ -208,7 +225,7 @@ class ChangeNotifier implements Listenable {
///
/// This method must not be called after [dispose] has been called.
///
/// Surprising behavior can result when reentrantly removing a listener (i.e.
/// Surprising behavior can result when reentrantly removing a listener (e.g.
/// in response to a notification) that has been registered multiple times.
/// See the discussion at [removeListener].
@protected
......
......@@ -1095,6 +1095,7 @@ class WidgetsApp extends StatefulWidget {
/// The default value of [WidgetsApp.actions].
static Map<Type, Action<Intent>> defaultActions = <Type, Action<Intent>>{
DoNothingIntent: DoNothingAction(),
DoNothingAndStopPropagationIntent: DoNothingAction(consumesKey: false),
RequestFocusIntent: RequestFocusAction(),
NextFocusIntent: NextFocusAction(),
PreviousFocusIntent: PreviousFocusAction(),
......
......@@ -31,11 +31,32 @@ bool _focusDebug(String message, [Iterable<String>? details]) {
return true;
}
/// An enum that describes how to handle a key event handled by a
/// [FocusOnKeyCallback].
enum KeyEventResult {
/// The key event has been handled, and the event should not be propagated to
/// other key event handlers.
handled,
/// The key event has not been handled, and the event should continue to be
/// propagated to other key event handlers, even non-Flutter ones.
ignored,
/// The key event has not been handled, but the key event should not be
/// propagated to other key event handlers.
///
/// It will be returned to the platform embedding to be propagated to text
/// fields and non-Flutter key event handlers on the platform.
skipRemainingHandlers,
}
/// Signature of a callback used by [Focus.onKey] and [FocusScope.onKey]
/// to receive key events.
///
/// The [node] is the node that received the event.
typedef FocusOnKeyCallback = bool Function(FocusNode node, RawKeyEvent event);
///
/// Returns a [KeyEventResult] that describes how, and whether, the key event
/// was handled.
// TODO(gspencergoog): Convert this from dynamic to KeyEventResult once migration is complete.
typedef FocusOnKeyCallback = dynamic Function(FocusNode node, RawKeyEvent event);
/// An attachment point for a [FocusNode].
///
......@@ -321,7 +342,7 @@ enum UnfocusDisposition {
/// }
/// }
///
/// bool _handleKeyPress(FocusNode node, RawKeyEvent event) {
/// KeyEventResult _handleKeyPress(FocusNode node, RawKeyEvent event) {
/// if (event is RawKeyDownEvent) {
/// print('Focus node ${node.debugLabel} got key event: ${event.logicalKey}');
/// if (event.logicalKey == LogicalKeyboardKey.keyR) {
......@@ -329,22 +350,22 @@ enum UnfocusDisposition {
/// setState(() {
/// _color = Colors.red;
/// });
/// return true;
/// return KeyEventResult.handled;
/// } else if (event.logicalKey == LogicalKeyboardKey.keyG) {
/// print('Changing color to green.');
/// setState(() {
/// _color = Colors.green;
/// });
/// return true;
/// return KeyEventResult.handled;
/// } else if (event.logicalKey == LogicalKeyboardKey.keyB) {
/// print('Changing color to blue.');
/// setState(() {
/// _color = Colors.blue;
/// });
/// return true;
/// return KeyEventResult.handled;
/// }
/// }
/// return false;
/// return KeyEventResult.ignored;
/// }
///
/// @override
......@@ -1613,17 +1634,43 @@ class FocusManager with DiagnosticableTreeMixin, ChangeNotifier {
_updateHighlightMode();
assert(_focusDebug('Received key event ${event.logicalKey}'));
// Walk the current focus from the leaf to the root, calling each one's
// onKey on the way up, and if one responds that they handled it, stop.
if (_primaryFocus == null) {
assert(_focusDebug('No primary focus for key event, ignored: $event'));
return false;
}
// Walk the current focus from the leaf to the root, calling each one's
// onKey on the way up, and if one responds that they handled it or want to
// stop propagation, stop.
bool handled = false;
for (final FocusNode node in <FocusNode>[_primaryFocus!, ..._primaryFocus!.ancestors]) {
if (node.onKey != null && node.onKey!(node, event)) {
assert(_focusDebug('Node $node handled key event $event.'));
handled = true;
if (node.onKey != null) {
// TODO(gspencergoog): Convert this from dynamic to KeyEventResult once migration is complete.
final dynamic result = node.onKey!(node, event);
assert(result is bool || result is KeyEventResult,
'Value returned from onKey handler must be a non-null bool or KeyEventResult, not ${result.runtimeType}');
if (result is KeyEventResult) {
switch (result) {
case KeyEventResult.handled:
assert(_focusDebug('Node $node handled key event $event.'));
handled = true;
break;
case KeyEventResult.skipRemainingHandlers:
assert(_focusDebug('Node $node stopped key event propagation: $event.'));
handled = false;
break;
case KeyEventResult.ignored:
continue;
}
} else if (result is bool){
if (result) {
assert(_focusDebug('Node $node handled key event $event.'));
handled = true;
break;
} else {
continue;
}
}
break;
}
}
......
......@@ -60,7 +60,7 @@ import 'inherited_notifier.dart';
/// ```dart
/// Color _color = Colors.white;
///
/// bool _handleKeyPress(FocusNode node, RawKeyEvent event) {
/// KeyEventResult _handleKeyPress(FocusNode node, RawKeyEvent event) {
/// if (event is RawKeyDownEvent) {
/// print('Focus node ${node.debugLabel} got key event: ${event.logicalKey}');
/// if (event.logicalKey == LogicalKeyboardKey.keyR) {
......@@ -68,22 +68,22 @@ import 'inherited_notifier.dart';
/// setState(() {
/// _color = Colors.red;
/// });
/// return true;
/// return KeyEventResult.handled;
/// } else if (event.logicalKey == LogicalKeyboardKey.keyG) {
/// print('Changing color to green.');
/// setState(() {
/// _color = Colors.green;
/// });
/// return true;
/// return KeyEventResult.handled;
/// } else if (event.logicalKey == LogicalKeyboardKey.keyB) {
/// print('Changing color to blue.');
/// setState(() {
/// _color = Colors.blue;
/// });
/// return true;
/// return KeyEventResult.handled;
/// }
/// }
/// return false;
/// return KeyEventResult.ignored;
/// }
///
/// @override
......
......@@ -264,9 +264,13 @@ class ShortcutManager extends ChangeNotifier with Diagnosticable {
/// True if the [ShortcutManager] should not pass on keys that it doesn't
/// handle to any key-handling widgets that are ancestors to this one.
///
/// Setting [modal] to true is the equivalent of always handling any key given
/// to it, even if that key doesn't appear in the [shortcuts] map. Keys that
/// don't appear in the map will be dropped.
/// Setting [modal] to true will prevent any key event given to this manager
/// from being given to any ancestor managers, even if that key doesn't appear
/// in the [shortcuts] map.
///
/// The net effect of setting `modal` to true is to return
/// [KeyEventResult.skipRemainingHandlers] from [handleKeypress] if it does not
/// exist in the shortcut map, instead of returning [KeyEventResult.ignored].
final bool modal;
/// Returns the shortcut map.
......@@ -284,68 +288,91 @@ class ShortcutManager extends ChangeNotifier with Diagnosticable {
}
}
/// Handles a key pressed `event` in the given `context`.
/// Returns the [Intent], if any, that matches the current set of pressed
/// keys.
///
/// The optional `keysPressed` argument provides an override to keys that the
/// [RawKeyboard] reports. If not specified, uses [RawKeyboard.keysPressed]
/// instead.
/// Returns null if no intent matches the current set of pressed keys.
///
/// Defaults to a set derived from [RawKeyboard.keysPressed] if `keysPressed` is
/// not supplied.
Intent? _find({ LogicalKeySet? keysPressed }) {
if (keysPressed == null && RawKeyboard.instance.keysPressed.isEmpty) {
return null;
}
keysPressed ??= LogicalKeySet.fromSet(RawKeyboard.instance.keysPressed);
Intent? matchedIntent = _shortcuts[keysPressed];
if (matchedIntent == null) {
// If there's not a more specific match, We also look for any keys that
// have synonyms in the map. This is for things like left and right shift
// keys mapping to just the "shift" pseudo-key.
final Set<LogicalKeyboardKey> pseudoKeys = <LogicalKeyboardKey>{};
for (final KeyboardKey setKey in keysPressed.keys) {
if (setKey is LogicalKeyboardKey) {
final Set<LogicalKeyboardKey> synonyms = setKey.synonyms;
if (synonyms.isNotEmpty) {
// There currently aren't any synonyms that match more than one key.
assert(synonyms.length == 1, 'Unexpectedly encountered a key synonym with more than one key.');
pseudoKeys.add(synonyms.first);
} else {
pseudoKeys.add(setKey);
}
}
}
matchedIntent = _shortcuts[LogicalKeySet.fromSet(pseudoKeys)];
}
return matchedIntent;
}
/// Handles a key press `event` in the given `context`.
///
/// The optional `keysPressed` argument is used as the set of currently
/// pressed keys. Defaults to a set derived from [RawKeyboard.keysPressed] if
/// `keysPressed` is not supplied.
///
/// If a key mapping is found, then the associated action will be invoked
/// using the [Intent] that the [LogicalKeySet] maps to, and the currently
/// using the [Intent] that the `keysPressed` maps to, and the currently
/// focused widget's context (from [FocusManager.primaryFocus]).
///
/// The object returned is the result of [Action.invoke] being called on the
/// [Action] bound to the [Intent] that the key press maps to, or null, if the
/// key press didn't match any intent.
/// Returns a [KeyEventResult.handled] if an action was invoked, otherwise a
/// [KeyEventResult.skipRemainingHandlers] if [modal] is true, or if it maps to a
/// [DoNothingAction] with [DoNothingAction.consumesKey] set to false, and
/// in all other cases returns [KeyEventResult.ignored].
///
/// In order for an action to be invoked (and [KeyEventResult.handled]
/// returned), a pressed [KeySet] must be mapped to an [Intent], the [Intent]
/// must be mapped to an [Action], and the [Action] must be enabled.
@protected
bool handleKeypress(
KeyEventResult handleKeypress(
BuildContext context,
RawKeyEvent event, {
LogicalKeySet? keysPressed,
}) {
if (event is! RawKeyDownEvent) {
return false;
return KeyEventResult.ignored;
}
assert(context != null);
LogicalKeySet? keySet = keysPressed;
if (keySet == null) {
assert(RawKeyboard.instance.keysPressed.isNotEmpty,
'Received a key down event when no keys are in keysPressed. '
"This state can occur if the key event being sent doesn't properly "
'set its modifier flags. This was the event: $event and its data: '
'${event.data}');
// Avoid the crash in release mode, since it's easy to miss a particular
// bad key sequence in testing, and so shouldn't crash the app in release.
if (RawKeyboard.instance.keysPressed.isNotEmpty) {
keySet = LogicalKeySet.fromSet(RawKeyboard.instance.keysPressed);
} else {
return false;
}
}
Intent? matchedIntent = _shortcuts[keySet];
if (matchedIntent == null) {
// If there's not a more specific match, We also look for any keys that
// have synonyms in the map. This is for things like left and right shift
// keys mapping to just the "shift" pseudo-key.
final Set<LogicalKeyboardKey> pseudoKeys = <LogicalKeyboardKey>{};
for (final LogicalKeyboardKey setKey in keySet.keys) {
final Set<LogicalKeyboardKey> synonyms = setKey.synonyms;
if (synonyms.isNotEmpty) {
// There currently aren't any synonyms that match more than one key.
pseudoKeys.add(synonyms.first);
} else {
pseudoKeys.add(setKey);
}
}
matchedIntent = _shortcuts[LogicalKeySet.fromSet(pseudoKeys)];
}
assert(keysPressed != null || RawKeyboard.instance.keysPressed.isNotEmpty,
'Received a key down event when no keys are in keysPressed. '
"This state can occur if the key event being sent doesn't properly "
'set its modifier flags. This was the event: $event and its data: '
'${event.data}');
final Intent? matchedIntent = _find(keysPressed: keysPressed);
if (matchedIntent != null) {
final BuildContext? primaryContext = primaryFocus?.context;
final BuildContext primaryContext = primaryFocus!.context!;
assert (primaryContext != null);
Actions.invoke(primaryContext!, matchedIntent, nullOk: true);
return true;
final Action<Intent>? action = Actions.find<Intent>(
primaryContext,
intent: matchedIntent,
nullOk: true,
);
if (action != null && action.isEnabled(matchedIntent)) {
Actions.of(primaryContext).invokeAction(action, matchedIntent, primaryContext);
return action.consumesKey(matchedIntent)
? KeyEventResult.handled
: KeyEventResult.skipRemainingHandlers;
}
}
return false;
return modal ? KeyEventResult.skipRemainingHandlers : KeyEventResult.ignored;
}
@override
......@@ -433,7 +460,7 @@ class Shortcuts extends StatefulWidget {
}
return true;
}());
return inherited?.notifier;
return inherited?.manager;
}
@override
......@@ -480,11 +507,11 @@ class _ShortcutsState extends State<Shortcuts> {
manager.shortcuts = widget.shortcuts;
}
bool _handleOnKey(FocusNode node, RawKeyEvent event) {
KeyEventResult _handleOnKey(FocusNode node, RawKeyEvent event) {
if (node.context == null) {
return false;
return KeyEventResult.ignored;
}
return manager.handleKeypress(node.context!, event) || manager.modal;
return manager.handleKeypress(node.context!, event);
}
@override
......@@ -508,4 +535,6 @@ class _ShortcutsMarker extends InheritedNotifier<ShortcutManager> {
}) : assert(manager != null),
assert(child != null),
super(notifier: manager, child: child);
ShortcutManager get manager => super.notifier!;
}
......@@ -756,7 +756,7 @@ void main() {
Focus(
focusNode: focusNode,
onKey: (FocusNode node, RawKeyEvent event) {
return true; // handle all events.
return KeyEventResult.handled; // handle all events.
},
child: const SizedBox(),
),
......
......@@ -264,10 +264,7 @@ void main() {
);
await tester.pump();
final ActionDispatcher dispatcher = Actions.of(
containerKey.currentContext!,
nullOk: true,
);
final ActionDispatcher dispatcher = Actions.of(containerKey.currentContext!);
expect(dispatcher, equals(testDispatcher));
});
testWidgets('Action can be found with find', (WidgetTester tester) async {
......
......@@ -852,12 +852,12 @@ void main() {
testWidgets('Key handling bubbles up and terminates when handled.', (WidgetTester tester) async {
final Set<FocusNode> receivedAnEvent = <FocusNode>{};
final Set<FocusNode> shouldHandle = <FocusNode>{};
bool handleEvent(FocusNode node, RawKeyEvent event) {
KeyEventResult handleEvent(FocusNode node, RawKeyEvent event) {
if (shouldHandle.contains(node)) {
receivedAnEvent.add(node);
return true;
return KeyEventResult.handled;
}
return false;
return KeyEventResult.ignored;
}
Future<void> sendEvent() async {
......
......@@ -28,7 +28,7 @@ class TestDispatcher extends ActionDispatcher {
@override
Object? invokeAction(Action<TestIntent> action, Intent intent, [BuildContext? context]) {
final Object? result = super.invokeAction(action, intent, context);
postInvoke?.call(action: action, intent: intent, context: context, dispatcher: this);
postInvoke?.call(action: action, intent: intent, context: context!, dispatcher: this);
return result;
}
}
......@@ -43,8 +43,10 @@ class TestShortcutManager extends ShortcutManager {
List<LogicalKeyboardKey> keys;
@override
bool handleKeypress(BuildContext context, RawKeyEvent event, {LogicalKeySet? keysPressed}) {
keys.add(event.logicalKey);
KeyEventResult handleKeypress(BuildContext context, RawKeyEvent event, {LogicalKeySet? keysPressed}) {
if (event is RawKeyDownEvent) {
keys.add(event.logicalKey);
}
return super.handleKeypress(context, event, keysPressed: keysPressed);
}
}
......@@ -320,6 +322,142 @@ void main() {
expect(invoked, isFalse);
expect(pressedKeys, isEmpty);
});
testWidgets("Shortcuts that aren't bound to an action don't absorb keys meant for text fields", (WidgetTester tester) async {
final GlobalKey textFieldKey = GlobalKey();
final List<LogicalKeyboardKey> pressedKeys = <LogicalKeyboardKey>[];
final TestShortcutManager testManager = TestShortcutManager(pressedKeys);
await tester.pumpWidget(
MaterialApp(
home: Material(
child: Shortcuts(
manager: testManager,
shortcuts: <LogicalKeySet, Intent>{
LogicalKeySet(LogicalKeyboardKey.keyA): const TestIntent(),
},
child: TextField(key: textFieldKey, autofocus: true),
),
),
),
);
await tester.pump();
expect(Shortcuts.of(textFieldKey.currentContext!), isNotNull);
final bool handled = await tester.sendKeyEvent(LogicalKeyboardKey.keyA);
expect(handled, isFalse);
expect(pressedKeys, equals(<LogicalKeyboardKey>[LogicalKeyboardKey.keyA]));
});
testWidgets('Shortcuts that are bound to an action do override text fields', (WidgetTester tester) async {
final GlobalKey textFieldKey = GlobalKey();
final List<LogicalKeyboardKey> pressedKeys = <LogicalKeyboardKey>[];
final TestShortcutManager testManager = TestShortcutManager(pressedKeys);
bool invoked = false;
await tester.pumpWidget(
MaterialApp(
home: Material(
child: Shortcuts(
manager: testManager,
shortcuts: <LogicalKeySet, Intent>{
LogicalKeySet(LogicalKeyboardKey.keyA): const TestIntent(),
},
child: Actions(
actions: <Type, Action<Intent>>{
TestIntent: TestAction(
onInvoke: (Intent intent) {
invoked = true;
return invoked;
},
),
},
child: TextField(key: textFieldKey, autofocus: true),
),
),
),
),
);
await tester.pump();
expect(Shortcuts.of(textFieldKey.currentContext!), isNotNull);
final bool result = await tester.sendKeyEvent(LogicalKeyboardKey.keyA);
expect(result, isTrue);
expect(pressedKeys, equals(<LogicalKeyboardKey>[LogicalKeyboardKey.keyA]));
expect(invoked, isTrue);
});
testWidgets('Shortcuts can override intents that apply to text fields', (WidgetTester tester) async {
final GlobalKey textFieldKey = GlobalKey();
final List<LogicalKeyboardKey> pressedKeys = <LogicalKeyboardKey>[];
final TestShortcutManager testManager = TestShortcutManager(pressedKeys);
bool invoked = false;
await tester.pumpWidget(
MaterialApp(
home: Material(
child: Shortcuts(
manager: testManager,
shortcuts: <LogicalKeySet, Intent>{
LogicalKeySet(LogicalKeyboardKey.keyA): const TestIntent(),
},
child: Actions(
actions: <Type, Action<Intent>>{
TestIntent: TestAction(
onInvoke: (Intent intent) {
invoked = true;
return invoked;
},
),
},
child: Actions(
actions: <Type, Action<Intent>>{
TestIntent: DoNothingAction(consumesKey: false),
},
child: TextField(key: textFieldKey, autofocus: true),
),
),
),
),
),
);
await tester.pump();
expect(Shortcuts.of(textFieldKey.currentContext!), isNotNull);
final bool result = await tester.sendKeyEvent(LogicalKeyboardKey.keyA);
expect(result, isFalse);
expect(invoked, isFalse);
});
testWidgets('Shortcuts can override intents that apply to text fields with DoNothingAndStopPropagationIntent', (WidgetTester tester) async {
final GlobalKey textFieldKey = GlobalKey();
final List<LogicalKeyboardKey> pressedKeys = <LogicalKeyboardKey>[];
final TestShortcutManager testManager = TestShortcutManager(pressedKeys);
bool invoked = false;
await tester.pumpWidget(
MaterialApp(
home: Material(
child: Shortcuts(
manager: testManager,
shortcuts: <LogicalKeySet, Intent>{
LogicalKeySet(LogicalKeyboardKey.keyA): const TestIntent(),
},
child: Actions(
actions: <Type, Action<Intent>>{
TestIntent: TestAction(
onInvoke: (Intent intent) {
invoked = true;
return invoked;
},
),
},
child: Shortcuts(
shortcuts: <LogicalKeySet, Intent>{
LogicalKeySet(LogicalKeyboardKey.keyA): DoNothingAndStopPropagationIntent(),
},
child: TextField(key: textFieldKey, autofocus: true),
),
),
),
),
),
);
await tester.pump();
expect(Shortcuts.of(textFieldKey.currentContext!), isNotNull);
final bool result = await tester.sendKeyEvent(LogicalKeyboardKey.keyA);
expect(result, isFalse);
expect(invoked, isFalse);
});
test('Shortcuts diagnostics work.', () {
final DiagnosticPropertiesBuilder builder = DiagnosticPropertiesBuilder();
......
......@@ -841,15 +841,18 @@ abstract class WidgetController {
/// key press. To simulate individual down and/or up events, see
/// [sendKeyDownEvent] and [sendKeyUpEvent].
///
/// Returns true if the key down event was handled by the framework.
///
/// See also:
///
/// - [sendKeyDownEvent] to simulate only a key down event.
/// - [sendKeyUpEvent] to simulate only a key up event.
Future<void> sendKeyEvent(LogicalKeyboardKey key, { String platform = 'android' }) async {
Future<bool> sendKeyEvent(LogicalKeyboardKey key, { String platform = 'android' }) async {
assert(platform != null);
await simulateKeyDownEvent(key, platform: platform);
final bool handled = await simulateKeyDownEvent(key, platform: platform);
// Internally wrapped in async guard.
return simulateKeyUpEvent(key, platform: platform);
await simulateKeyUpEvent(key, platform: platform);
return handled;
}
/// Simulates sending a physical key down event through the system channel.
......@@ -864,11 +867,13 @@ abstract class WidgetController {
///
/// Keys that are down when the test completes are cleared after each test.
///
/// Returns true if the key event was handled by the framework.
///
/// See also:
///
/// - [sendKeyUpEvent] to simulate the corresponding key up event.
/// - [sendKeyEvent] to simulate both the key up and key down in the same call.
Future<void> sendKeyDownEvent(LogicalKeyboardKey key, { String platform = 'android' }) async {
Future<bool> sendKeyDownEvent(LogicalKeyboardKey key, { String platform = 'android' }) async {
assert(platform != null);
// Internally wrapped in async guard.
return simulateKeyDownEvent(key, platform: platform);
......@@ -883,11 +888,13 @@ abstract class WidgetController {
/// [Platform.operatingSystem] to make the event appear to be from that type
/// of system. Defaults to "android". May not be null.
///
/// Returns true if the key event was handled by the framework.
///
/// See also:
///
/// - [sendKeyDownEvent] to simulate the corresponding key down event.
/// - [sendKeyEvent] to simulate both the key up and key down in the same call.
Future<void> sendKeyUpEvent(LogicalKeyboardKey key, { String platform = 'android' }) async {
Future<bool> sendKeyUpEvent(LogicalKeyboardKey key, { String platform = 'android' }) async {
assert(platform != null);
// Internally wrapped in async guard.
return simulateKeyUpEvent(key, platform: platform);
......
......@@ -516,20 +516,32 @@ class KeyEventSimulator {
///
/// Keys that are down when the test completes are cleared after each test.
///
/// Returns true if the key event was handled by the framework.
///
/// See also:
///
/// - [simulateKeyUpEvent] to simulate the corresponding key up event.
static Future<void> simulateKeyDownEvent(LogicalKeyboardKey key, {String? platform, PhysicalKeyboardKey? physicalKey}) async {
return TestAsyncUtils.guard<void>(() async {
static Future<bool> simulateKeyDownEvent(LogicalKeyboardKey key, {String? platform, PhysicalKeyboardKey? physicalKey}) async {
return await TestAsyncUtils.guard<bool>(() async {
platform ??= Platform.operatingSystem;
assert(_osIsSupported(platform!), 'Platform $platform not supported for key simulation');
final Map<String, dynamic> data = getKeyData(key, platform: platform!, isDown: true, physicalKey: physicalKey);
bool result = false;
await ServicesBinding.instance!.defaultBinaryMessenger.handlePlatformMessage(
SystemChannels.keyEvent.name,
SystemChannels.keyEvent.codec.encodeMessage(data),
(ByteData? data) { },
(ByteData? data) {
if (data == null) {
return;
}
final Map<String, dynamic> decoded = SystemChannels.keyEvent.codec.decodeMessage(data) as Map<String, dynamic>;
if (decoded['handled'] as bool) {
result = true;
}
}
);
return result;
});
}
......@@ -543,20 +555,32 @@ class KeyEventSimulator {
/// system. Defaults to the operating system that the test is running on. Some
/// platforms (e.g. Windows, iOS) are not yet supported.
///
/// Returns true if the key event was handled by the framework.
///
/// See also:
///
/// - [simulateKeyDownEvent] to simulate the corresponding key down event.
static Future<void> simulateKeyUpEvent(LogicalKeyboardKey key, {String? platform, PhysicalKeyboardKey? physicalKey}) async {
return TestAsyncUtils.guard<void>(() async {
static Future<bool> simulateKeyUpEvent(LogicalKeyboardKey key, {String? platform, PhysicalKeyboardKey? physicalKey}) async {
return TestAsyncUtils.guard<bool>(() async {
platform ??= Platform.operatingSystem;
assert(_osIsSupported(platform!), 'Platform $platform not supported for key simulation');
final Map<String, dynamic> data = getKeyData(key, platform: platform!, isDown: false, physicalKey: physicalKey);
bool result = false;
await ServicesBinding.instance!.defaultBinaryMessenger.handlePlatformMessage(
SystemChannels.keyEvent.name,
SystemChannels.keyEvent.codec.encodeMessage(data),
(ByteData? data) { },
(ByteData? data) {
if (data == null) {
return;
}
final Map<String, dynamic> decoded = SystemChannels.keyEvent.codec.decodeMessage(data) as Map<String, dynamic>;
if (decoded['handled'] as bool) {
result = true;
}
}
);
return result;
});
}
}
......@@ -576,10 +600,12 @@ class KeyEventSimulator {
///
/// Keys that are down when the test completes are cleared after each test.
///
/// Returns true if the key event was handled by the framework.
///
/// See also:
///
/// - [simulateKeyUpEvent] to simulate the corresponding key up event.
Future<void> simulateKeyDownEvent(LogicalKeyboardKey key, {String? platform, PhysicalKeyboardKey? physicalKey}) {
Future<bool> simulateKeyDownEvent(LogicalKeyboardKey key, {String? platform, PhysicalKeyboardKey? physicalKey}) {
return KeyEventSimulator.simulateKeyDownEvent(key, platform: platform, physicalKey: physicalKey);
}
......@@ -596,9 +622,11 @@ Future<void> simulateKeyDownEvent(LogicalKeyboardKey key, {String? platform, Phy
/// system. Defaults to the operating system that the test is running on. Some
/// platforms (e.g. Windows, iOS) are not yet supported.
///
/// Returns true if the key event was handled by the framework.
///
/// See also:
///
/// - [simulateKeyDownEvent] to simulate the corresponding key down event.
Future<void> simulateKeyUpEvent(LogicalKeyboardKey key, {String? platform, PhysicalKeyboardKey? physicalKey}) {
Future<bool> simulateKeyUpEvent(LogicalKeyboardKey key, {String? platform, PhysicalKeyboardKey? physicalKey}) {
return KeyEventSimulator.simulateKeyUpEvent(key, platform: platform, physicalKey: physicalKey);
}
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