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.
This commit is contained in:
Greg Spencer 2020-10-19 11:26:50 -07:00 committed by GitHub
parent e422d5c724
commit 8c03ff8c1d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 513 additions and 175 deletions

View file

@ -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;
}

View file

@ -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

View file

@ -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';

View file

@ -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

View file

@ -6,6 +6,7 @@ import 'package:flutter/foundation.dart';
import 'package:flutter/scheduler.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart';
import 'basic.dart';
import 'focus_manager.dart';
@ -28,7 +29,7 @@ BuildContext _getParent(BuildContext context) {
return parent;
}
/// A class representing a particular configuration of an [Action].
/// An abstract class representing a particular configuration of an [Action].
///
/// This class is what the [Shortcuts.shortcuts] map has as values, and is used
/// by an [ActionDispatcher] to look up an action and invoke it, giving it this
@ -40,11 +41,12 @@ BuildContext _getParent(BuildContext context) {
/// [Intent] using the [Actions] widget that most tightly encloses the given
/// [BuildContext].
@immutable
class Intent with Diagnosticable {
abstract class Intent with Diagnosticable {
/// A const constructor for an [Intent].
const Intent();
/// An intent that can't be mapped to an action.
/// An intent that is mapped to a [DoNothingAction], which, as the name
/// implies, does nothing.
///
/// This Intent is mapped to an action in the [WidgetsApp] that does nothing,
/// so that it can be bound to a key in a [Shortcuts] widget in order to
@ -92,6 +94,19 @@ abstract class Action<T extends Intent> with Diagnosticable {
/// [notifyActionListeners] to notify any listeners of the change.
bool isEnabled(covariant T intent) => true;
/// Indicates whether this action should treat key events mapped to this
/// action as being "handled" when it is invoked via the key event.
///
/// If the key is handled, then no other key event handlers in the focus chain
/// will receive the event.
///
/// If the key event is not handled, it will be passed back to the engine, and
/// continue to be processed there, allowing text fields and non-Flutter
/// widgets to receive the key event.
///
/// The default implementation returns true.
bool consumesKey(covariant T intent) => true;
/// Called when the action is to be performed.
///
/// This is called by the [ActionDispatcher] when an action is invoked via
@ -380,21 +395,25 @@ class ActionDispatcher with Diagnosticable {
/// the action is a [ContextAction], then the context from the [primaryFocus]
/// is used.
///
/// Returns the object returned from [Action.invoke] if the action was
/// successfully invoked, and null if the action is not enabled. May also
/// return null if [Action.invoke] returns null.
Object? invokeAction(covariant Action<Intent> action, covariant Intent intent, [BuildContext? context]) {
/// Returns the object returned from [Action.invoke].
///
/// The caller must receive a `true` result from [Action.isEnabled] before
/// calling this function. This function will assert if the action is not
/// enabled when called.
Object? invokeAction(
covariant Action<Intent> action,
covariant Intent intent, [
BuildContext? context,
]) {
assert(action != null);
assert(intent != null);
context ??= primaryFocus?.context;
if (action.isEnabled(intent)) {
if (action is ContextAction) {
return action.invoke(intent, context);
} else {
return action.invoke(intent);
}
assert(action.isEnabled(intent), 'Action must be enabled when calling invokeAction');
if (action is ContextAction) {
context ??= primaryFocus?.context;
return action.invoke(intent, context);
} else {
return action.invoke(intent);
}
return null;
}
}
@ -496,7 +515,11 @@ class Actions extends StatefulWidget {
final Action<T>? action = Actions.find<T>(context, nullOk: nullOk);
if (action != null && action.isEnabled(intent)) {
return () {
Actions.of(context).invokeAction(action, intent, context);
// Could be that the action was enabled when the closure was created,
// but is now no longer enabled, so check again.
if (action.isEnabled(intent)) {
Actions.of(context).invokeAction(action, intent, context);
}
};
}
return null;
@ -507,12 +530,26 @@ class Actions extends StatefulWidget {
/// Creates a dependency on the [Actions] widget that maps the bound action so
/// that if the actions change, the context will be rebuilt and find the
/// updated action.
static Action<T>? find<T extends Intent>(BuildContext context, {bool nullOk = false}) {
///
/// The optional `intent` argument supplies the type of the intent to look for
/// if the concrete type of the intent sought isn't available. If not
/// supplied, then `T` is used.
static Action<T>? find<T extends Intent>(BuildContext context, {bool nullOk = false, T? intent}) {
Action<T>? action;
// Specialize the type if a runtime example instance of the intent is given.
// This allows this function to be called by code that doesn't know the
// concrete type of the intent at compile time.
final Type type = intent?.runtimeType ?? T;
assert(type != Intent,
'The type passed to "find" resolved to "Intent": either a non-Intent'
'generic type argument or an example intent derived from Intent must be'
'specified. Intent may be used as the generic type as long as the optional'
'"intent" argument is passed.');
_visitActionsAncestors(context, (InheritedElement element) {
final _ActionsMarker actions = element.widget as _ActionsMarker;
final Action<T>? result = actions.actions[T] as Action<T>?;
final Action<T>? result = actions.actions[type] as Action<T>?;
if (result != null) {
context.dependOnInheritedElement(element);
action = result;
@ -526,14 +563,14 @@ class Actions extends StatefulWidget {
return true;
}
if (action == null) {
throw FlutterError('Unable to find an action for a $T in an $Actions widget '
throw FlutterError('Unable to find an action for a $type in an $Actions widget '
'in the given context.\n'
"$Actions.find() was called on a context that doesn\'t contain an "
'$Actions widget with a mapping for the given intent type.\n'
'The context used was:\n'
' $context\n'
'The intent type requested was:\n'
' $T');
' $type');
}
return true;
}());
@ -543,31 +580,11 @@ class Actions extends StatefulWidget {
/// Returns the [ActionDispatcher] associated with the [Actions] widget that
/// most tightly encloses the given [BuildContext].
///
/// Will throw if no ambient [Actions] widget is found.
///
/// If `nullOk` is set to true, then if no ambient [Actions] widget is found,
/// this will return null.
///
/// The `context` argument must not be null.
static ActionDispatcher of(BuildContext context, {bool nullOk = false}) {
/// Will return a newly created [ActionDispatcher] if no ambient [Actions]
/// widget is found.
static ActionDispatcher of(BuildContext context) {
assert(context != null);
final _ActionsMarker? marker = context.dependOnInheritedWidgetOfExactType<_ActionsMarker>();
assert(() {
if (nullOk) {
return true;
}
if (marker == null) {
throw FlutterError('Unable to find an $Actions widget in the given context.\n'
'$Actions.of() was called with a context that does not contain an '
'$Actions widget.\n'
'No $Actions ancestor could be found starting from the context that '
'was passed to $Actions.of(). This can happen if the context comes '
'from a widget above those widgets.\n'
'The context used was:\n'
' $context');
}
return true;
}());
return marker?.dispatcher ?? _findDispatcher(context);
}
@ -632,9 +649,15 @@ class Actions extends StatefulWidget {
}
return true;
}());
// Invoke the action we found using the relevant dispatcher from the Actions
// Element we found.
return actionElement != null ? _findDispatcher(actionElement!).invokeAction(action!, intent, context) : null;
if (actionElement == null || action == null) {
return null;
}
if (action!.isEnabled(intent)) {
// Invoke the action we found using the relevant dispatcher from the Actions
// Element we found.
return _findDispatcher(actionElement!).invokeAction(action!, intent, context);
}
return null;
}
@override
@ -1116,12 +1139,19 @@ class _FocusableActionDetectorState extends State<FocusableActionDetector> {
}
}
/// An [Intent], that, as the name implies, is bound to a [DoNothingAction].
/// An [Intent], that is bound to a [DoNothingAction].
///
/// Attaching a [DoNothingIntent] to a [Shortcuts] mapping is one way to disable
/// a keyboard shortcut defined by a widget higher in the widget hierarchy.
/// a keyboard shortcut defined by a widget higher in the widget hierarchy and
/// consume any key event that triggers it via a shortcut.
///
/// This intent cannot be subclassed.
///
/// See also:
///
/// * [DoNothingAndStopPropagationIntent], a similar intent that will not
/// handle the key event, but will still keep it from being passed to other key
/// handlers in the focus chain.
class DoNothingIntent extends Intent {
/// Creates a const [DoNothingIntent].
factory DoNothingIntent() => const DoNothingIntent._();
@ -1130,17 +1160,61 @@ class DoNothingIntent extends Intent {
const DoNothingIntent._();
}
/// An [Action], that, as the name implies, does nothing.
/// An [Intent], that is bound to a [DoNothingAction], but, in addition to not
/// performing an action, also stops the propagation of the key event bound to
/// this intent to other key event handlers in the focus chain.
///
/// Attaching a [DoNothingAction] to an [Actions] mapping is one way to disable
/// an action defined by a widget higher in the widget hierarchy.
/// Attaching a [DoNothingAndStopPropagationIntent] to a [Shortcuts.shortcuts]
/// mapping is one way to disable a keyboard shortcut defined by a widget higher
/// in the widget hierarchy. In addition, the bound [DoNothingAction] will
/// return false from [DoNothingAction.consumesKey], causing the key bound to
/// this intent to be passed on to the platform embedding as "not handled" with
/// out passing it to other key handlers in the focus chain (e.g. parent
/// `Shortcuts` widgets higher up in the chain).
///
/// This action can be bound to any intent.
/// This intent cannot be subclassed.
///
/// See also:
/// - [DoNothingIntent], which is an intent that can be bound to a keystroke in
///
/// * [DoNothingIntent], a similar intent that will handle the key event.
class DoNothingAndStopPropagationIntent extends Intent {
/// Creates a const [DoNothingAndStopPropagationIntent].
factory DoNothingAndStopPropagationIntent() => const DoNothingAndStopPropagationIntent._();
// Make DoNothingAndStopPropagationIntent constructor private so it can't be subclassed.
const DoNothingAndStopPropagationIntent._();
}
/// An [Action], that doesn't perform any action when invoked.
///
/// Attaching a [DoNothingAction] to an [Actions.actions] mapping is a way to
/// disable an action defined by a widget higher in the widget hierarchy.
///
/// If [consumesKey] returns false, then not only will this action do nothing,
/// but it will stop the propagation of the key event used to trigger it to
/// other widgets in the focus chain and tell the embedding that the key wasn't
/// handled, allowing text input fields or other non-Flutter elements to receive
/// that key event. The return value of [consumesKey] can be set via the
/// `consumesKey` argument to the constructor.
///
/// This action can be bound to any [Intent].
///
/// See also:
/// - [DoNothingIntent], which is an intent that can be bound to a [KeySet] in
/// a [Shortcuts] widget to do nothing.
/// - [DoNothingAndStopPropagationIntent], which is an intent that can be bound
/// to a [KeySet] in a [Shortcuts] widget to do nothing and also stop key event
/// propagation to other key handlers in the focus chain.
class DoNothingAction extends Action<Intent> {
/// Creates a [DoNothingAction].
///
/// The optional [consumesKey] argument defaults to true.
DoNothingAction({bool consumesKey = true}) : _consumesKey = consumesKey;
@override
bool consumesKey(Intent intent) => _consumesKey;
final bool _consumesKey;
@override
void invoke(Intent intent) {}
}

View file

@ -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(),

View file

@ -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;
}
}

View file

@ -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

View file

@ -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.
///
/// If a key mapping is found, then the associated action will be invoked
/// using the [Intent] that the [LogicalKeySet] 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.
@protected
bool handleKeypress(
BuildContext context,
RawKeyEvent event, {
LogicalKeySet? keysPressed,
}) {
if (event is! RawKeyDownEvent) {
return false;
/// 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;
}
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];
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 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);
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)];
}
if (matchedIntent != null) {
final BuildContext? primaryContext = primaryFocus?.context;
assert (primaryContext != null);
Actions.invoke(primaryContext!, matchedIntent, nullOk: true);
return true;
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 `keysPressed` maps to, and the currently
/// focused widget's context (from [FocusManager.primaryFocus]).
///
/// 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
KeyEventResult handleKeypress(
BuildContext context,
RawKeyEvent event, {
LogicalKeySet? keysPressed,
}) {
if (event is! RawKeyDownEvent) {
return KeyEventResult.ignored;
}
return false;
assert(context != null);
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!;
assert (primaryContext != null);
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 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!;
}

View file

@ -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(),
),

View file

@ -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 {

View file

@ -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 {

View file

@ -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();

View file

@ -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);

View file

@ -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);
}