Unverified Commit 387e2b06 authored by Greg Spencer's avatar Greg Spencer Committed by GitHub

Add actions and keyboard shortcut map support (#33298)

This implements the keyboard shortcut handling and action invocation in order to provide a place in the infrastructure for keyboard events to trigger actions. This will allow binding of key events to actions like "move the focus to the next widget" and "activate button".
parent a35d6615
// Copyright 2019 The Chromium 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/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
void main() {
runApp(const MaterialApp(
title: 'Actions Demo',
home: FocusDemo(),
));
}
/// Undoable Actions
/// An [ActionDispatcher] subclass that manages the invocation of undoable
/// actions.
class UndoableActionDispatcher extends ActionDispatcher implements Listenable {
/// Constructs a new [UndoableActionDispatcher].
///
/// The [maxUndoLevels] argument must not be null.
UndoableActionDispatcher({
int maxUndoLevels = _defaultMaxUndoLevels,
}) : assert(maxUndoLevels != null),
_maxUndoLevels = maxUndoLevels;
// A stack of actions that have been performed. The most recent action
// performed is at the end of the list.
final List<UndoableAction> _completedActions = <UndoableAction>[];
// A stack of actions that can be redone. The most recent action performed is
// at the end of the list.
final List<UndoableAction> _undoneActions = <UndoableAction>[];
static const int _defaultMaxUndoLevels = 1000;
/// The maximum number of undo levels allowed.
///
/// If this value is set to a value smaller than the number of completed
/// actions, then the stack of completed actions is truncated to only include
/// the last [maxUndoLevels] actions.
int get maxUndoLevels => _maxUndoLevels;
int _maxUndoLevels;
set maxUndoLevels(int value) {
_maxUndoLevels = value;
_pruneActions();
}
final Set<VoidCallback> _listeners = <VoidCallback>{};
@override
void addListener(VoidCallback listener) {
_listeners.add(listener);
}
@override
void removeListener(VoidCallback listener) {
_listeners.remove(listener);
}
/// Notifies listeners that the [ActionDispatcher] has changed state.
///
/// May only be called by subclasses.
@protected
void notifyListeners() {
for (VoidCallback callback in _listeners) {
callback();
}
}
@override
bool invokeAction(Action action, Intent intent, {FocusNode focusNode}) {
final bool result = super.invokeAction(action, intent, focusNode: focusNode);
print('Invoking ${action is UndoableAction ? 'undoable ' : ''}$intent as $action: $this ');
if (action is UndoableAction) {
_completedActions.add(action);
_undoneActions.clear();
_pruneActions();
notifyListeners();
}
return result;
}
// Enforces undo level limit.
void _pruneActions() {
while (_completedActions.length > _maxUndoLevels) {
_completedActions.removeAt(0);
}
}
/// Returns true if there is an action on the stack that can be undone.
bool get canUndo {
if (_completedActions.isNotEmpty) {
final Intent lastIntent = _completedActions.last.invocationIntent;
return lastIntent.isEnabled(WidgetsBinding.instance.focusManager.primaryFocus.context);
}
return false;
}
/// Returns true if an action that has been undone can be re-invoked.
bool get canRedo {
if (_undoneActions.isNotEmpty) {
final Intent lastIntent = _undoneActions.last.invocationIntent;
return lastIntent.isEnabled(WidgetsBinding.instance.focusManager.primaryFocus?.context);
}
return false;
}
/// Undoes the last action executed if possible.
///
/// Returns true if the action was successfully undone.
bool undo() {
print('Undoing. $this');
if (!canUndo) {
return false;
}
final UndoableAction action = _completedActions.removeLast();
action.undo();
_undoneActions.add(action);
notifyListeners();
return true;
}
/// Re-invokes a previously undone action, if possible.
///
/// Returns true if the action was successfully invoked.
bool redo() {
print('Redoing. $this');
if (!canRedo) {
return false;
}
final UndoableAction action = _undoneActions.removeLast();
action.invoke(action.invocationNode, action.invocationIntent);
_completedActions.add(action);
_pruneActions();
notifyListeners();
return true;
}
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties.add(IntProperty('undoable items', _completedActions.length));
properties.add(IntProperty('redoable items', _undoneActions.length));
properties.add(IterableProperty<UndoableAction>('undo stack', _completedActions));
properties.add(IterableProperty<UndoableAction>('redo stack', _undoneActions));
}
}
class UndoIntent extends Intent {
const UndoIntent() : super(kUndoActionKey);
@override
bool isEnabled(BuildContext context) {
final UndoableActionDispatcher manager = Actions.of(context, nullOk: true);
return manager.canUndo;
}
}
class RedoIntent extends Intent {
const RedoIntent() : super(kRedoActionKey);
@override
bool isEnabled(BuildContext context) {
final UndoableActionDispatcher manager = Actions.of(context, nullOk: true);
return manager.canRedo;
}
}
const LocalKey kUndoActionKey = ValueKey<String>('Undo');
const Intent kUndoIntent = UndoIntent();
final Action kUndoAction = CallbackAction(
kUndoActionKey,
onInvoke: (FocusNode node, Intent tag) {
if (node?.context == null) {
return;
}
final UndoableActionDispatcher manager = Actions.of(node.context, nullOk: true);
manager?.undo();
},
);
const LocalKey kRedoActionKey = ValueKey<String>('Redo');
const Intent kRedoIntent = RedoIntent();
final Action kRedoAction = CallbackAction(
kRedoActionKey,
onInvoke: (FocusNode node, Intent tag) {
if (node?.context == null) {
return;
}
final UndoableActionDispatcher manager = Actions.of(node.context, nullOk: true);
manager?.redo();
},
);
/// An action that can be undone.
abstract class UndoableAction extends Action {
/// A const constructor to [UndoableAction].
///
/// The [intentKey] parameter must not be null.
UndoableAction(LocalKey intentKey) : super(intentKey);
/// The node supplied when this command was invoked.
FocusNode get invocationNode => _invocationNode;
FocusNode _invocationNode;
@protected
set invocationNode(FocusNode value) => _invocationNode = value;
/// The [Intent] this action was originally invoked with.
Intent get invocationIntent => _invocationTag;
Intent _invocationTag;
@protected
set invocationIntent(Intent value) => _invocationTag = value;
/// Returns true if the data model can be returned to the state it was in
/// previous to this action being executed.
///
/// Default implementation returns true.
bool get undoable => true;
/// Reverts the data model to the state before this command executed.
@mustCallSuper
void undo();
@override
@mustCallSuper
void invoke(FocusNode node, Intent tag) {
invocationNode = node;
invocationIntent = tag;
}
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties.add(DiagnosticsProperty<FocusNode>('invocationNode', invocationNode));
}
}
class SetFocusActionBase extends UndoableAction {
SetFocusActionBase(LocalKey name) : super(name);
FocusNode _previousFocus;
@override
void invoke(FocusNode node, Intent tag) {
super.invoke(node, tag);
_previousFocus = WidgetsBinding.instance.focusManager.primaryFocus;
node.requestFocus();
}
@override
void undo() {
if (_previousFocus == null) {
WidgetsBinding.instance.focusManager.primaryFocus?.unfocus();
return;
}
if (_previousFocus is FocusScopeNode) {
// The only way a scope can be the _previousFocus is if there was no
// focusedChild for the scope when we invoked this action, so we need to
// return to that state.
// Unfocus the current node to remove it from the focused child list of
// the scope.
WidgetsBinding.instance.focusManager.primaryFocus?.unfocus();
// and then let the scope node be focused...
}
_previousFocus.requestFocus();
_previousFocus = null;
}
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties.add(DiagnosticsProperty<FocusNode>('previous', _previousFocus));
}
}
class SetFocusAction extends SetFocusActionBase {
SetFocusAction() : super(key);
static const LocalKey key = ValueKey<Type>(SetFocusAction);
@override
void invoke(FocusNode node, Intent tag) {
super.invoke(node, tag);
node.requestFocus();
}
}
/// Actions for manipulating focus.
class NextFocusAction extends SetFocusActionBase {
NextFocusAction() : super(key);
static const LocalKey key = ValueKey<Type>(NextFocusAction);
@override
void invoke(FocusNode node, Intent tag) {
super.invoke(node, tag);
node.nextFocus();
}
}
class PreviousFocusAction extends SetFocusActionBase {
PreviousFocusAction() : super(key);
static const LocalKey key = ValueKey<Type>(PreviousFocusAction);
@override
void invoke(FocusNode node, Intent tag) {
super.invoke(node, tag);
node.previousFocus();
}
}
class DirectionalFocusIntent extends Intent {
const DirectionalFocusIntent(this.direction) : super(DirectionalFocusAction.key);
final TraversalDirection direction;
}
class DirectionalFocusAction extends SetFocusActionBase {
DirectionalFocusAction() : super(key);
static const LocalKey key = ValueKey<Type>(DirectionalFocusAction);
TraversalDirection direction;
@override
void invoke(FocusNode node, DirectionalFocusIntent tag) {
super.invoke(node, tag);
final DirectionalFocusIntent args = tag;
node.focusInDirection(args.direction);
}
}
/// A button class that takes focus when clicked.
class DemoButton extends StatefulWidget {
const DemoButton({this.name});
final String name;
@override
_DemoButtonState createState() => _DemoButtonState();
}
class _DemoButtonState extends State<DemoButton> {
FocusNode _focusNode;
@override
void initState() {
super.initState();
_focusNode = FocusNode(debugLabel: widget.name);
}
void _handleOnPressed() {
print('Button ${widget.name} pressed.');
setState(() {
Actions.invoke(context, const Intent(SetFocusAction.key), focusNode: _focusNode);
});
}
@override
void dispose() {
super.dispose();
_focusNode.dispose();
}
@override
Widget build(BuildContext context) {
return FlatButton(
focusNode: _focusNode,
focusColor: Colors.red,
hoverColor: Colors.blue,
onPressed: () => _handleOnPressed(),
child: Text(widget.name),
);
}
}
class FocusDemo extends StatefulWidget {
const FocusDemo({Key key}) : super(key: key);
@override
_FocusDemoState createState() => _FocusDemoState();
}
class _FocusDemoState extends State<FocusDemo> {
FocusNode outlineFocus;
UndoableActionDispatcher dispatcher;
bool canUndo;
bool canRedo;
@override
void initState() {
super.initState();
outlineFocus = FocusNode(debugLabel: 'Demo Focus Node');
dispatcher = UndoableActionDispatcher();
canUndo = dispatcher.canUndo;
canRedo = dispatcher.canRedo;
dispatcher.addListener(_handleUndoStateChange);
}
void _handleUndoStateChange() {
if (dispatcher.canUndo != canUndo) {
setState(() {
canUndo = dispatcher.canUndo;
});
}
if (dispatcher.canRedo != canRedo) {
setState(() {
canRedo = dispatcher.canRedo;
});
}
}
@override
void dispose() {
dispatcher.removeListener(_handleUndoStateChange);
outlineFocus.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final TextTheme textTheme = Theme.of(context).textTheme;
return Shortcuts(
shortcuts: <LogicalKeySet, Intent>{
LogicalKeySet(LogicalKeyboardKey.tab): const Intent(NextFocusAction.key),
LogicalKeySet(LogicalKeyboardKey.shift, LogicalKeyboardKey.tab): const Intent(PreviousFocusAction.key),
LogicalKeySet(LogicalKeyboardKey.arrowUp): const DirectionalFocusIntent(TraversalDirection.up),
LogicalKeySet(LogicalKeyboardKey.arrowDown): const DirectionalFocusIntent(TraversalDirection.down),
LogicalKeySet(LogicalKeyboardKey.arrowLeft): const DirectionalFocusIntent(TraversalDirection.left),
LogicalKeySet(LogicalKeyboardKey.arrowRight): const DirectionalFocusIntent(TraversalDirection.right),
},
child: Actions(
dispatcher: dispatcher,
actions: <LocalKey, ActionFactory>{
SetFocusAction.key: () => SetFocusAction(),
NextFocusAction.key: () => NextFocusAction(),
PreviousFocusAction.key: () => PreviousFocusAction(),
DirectionalFocusAction.key: () => DirectionalFocusAction(),
kUndoActionKey: () => kUndoAction,
kRedoActionKey: () => kRedoAction,
},
child: DefaultFocusTraversal(
policy: ReadingOrderTraversalPolicy(),
child: Shortcuts(
shortcuts: <LogicalKeySet, Intent>{
LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.shift, LogicalKeyboardKey.keyZ): kRedoIntent,
LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.keyZ): kUndoIntent,
},
child: FocusScope(
debugLabel: 'Scope',
autofocus: true,
child: DefaultTextStyle(
style: textTheme.display1,
child: Scaffold(
appBar: AppBar(
title: const Text('Actions Demo'),
),
body: Center(
child: Builder(builder: (BuildContext context) {
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: const <Widget>[
DemoButton(name: 'One'),
DemoButton(name: 'Two'),
DemoButton(name: 'Three'),
],
),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: const <Widget>[
DemoButton(name: 'Four'),
DemoButton(name: 'Five'),
DemoButton(name: 'Six'),
],
),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: const <Widget>[
DemoButton(name: 'Seven'),
DemoButton(name: 'Eight'),
DemoButton(name: 'Nine'),
],
),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Padding(
padding: const EdgeInsets.all(8.0),
child: RaisedButton(
child: const Text('UNDO'),
onPressed: canUndo
? () {
Actions.invoke(context, kUndoIntent);
}
: null,
),
),
Padding(
padding: const EdgeInsets.all(8.0),
child: RaisedButton(
child: const Text('REDO'),
onPressed: canRedo
? () {
Actions.invoke(context, kRedoIntent);
}
: null,
),
),
],
),
],
);
}),
),
),
),
),
),
),
),
);
}
}
...@@ -138,8 +138,8 @@ class _FocusDemoState extends State<FocusDemo> { ...@@ -138,8 +138,8 @@ class _FocusDemoState extends State<FocusDemo> {
], ],
), ),
OutlineButton(onPressed: () => print('pressed'), child: const Text('PRESS ME')), OutlineButton(onPressed: () => print('pressed'), child: const Text('PRESS ME')),
Padding( const Padding(
padding: const EdgeInsets.all(8.0), padding: EdgeInsets.all(8.0),
child: TextField( child: TextField(
decoration: InputDecoration(labelText: 'Enter Text', filled: true), decoration: InputDecoration(labelText: 'Enter Text', filled: true),
), ),
......
...@@ -76,8 +76,8 @@ class _HoverDemoState extends State<HoverDemo> { ...@@ -76,8 +76,8 @@ class _HoverDemoState extends State<HoverDemo> {
), ),
], ],
), ),
Padding( const Padding(
padding: const EdgeInsets.all(8.0), padding: EdgeInsets.all(8.0),
child: TextField( child: TextField(
decoration: InputDecoration(labelText: 'Enter Text', filled: true), decoration: InputDecoration(labelText: 'Enter Text', filled: true),
), ),
......
...@@ -11,6 +11,19 @@ ...@@ -11,6 +11,19 @@
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
/// A base class for all keyboard key types.
///
/// See also:
///
/// * [PhysicalKeyboardKey], a class with static values that describe the keys
/// that are returned from [RawKeyEvent.physicalKey].
/// * [LogicalKeyboardKey], a class with static values that describe the keys
/// that are returned from [RawKeyEvent.logicalKey].
abstract class KeyboardKey extends Diagnosticable {
/// A const constructor so that subclasses may be const.
const KeyboardKey();
}
/// A class with static values that describe the keys that are returned from /// A class with static values that describe the keys that are returned from
/// [RawKeyEvent.logicalKey]. /// [RawKeyEvent.logicalKey].
/// ///
...@@ -108,7 +121,7 @@ import 'package:flutter/foundation.dart'; ...@@ -108,7 +121,7 @@ import 'package:flutter/foundation.dart';
/// to keyboard events. /// to keyboard events.
/// * [RawKeyboardListener], a widget used to listen to and supply handlers for /// * [RawKeyboardListener], a widget used to listen to and supply handlers for
/// keyboard events. /// keyboard events.
class LogicalKeyboardKey extends Diagnosticable { class LogicalKeyboardKey extends KeyboardKey {
/// Creates a LogicalKeyboardKey object with an optional key label and debug /// Creates a LogicalKeyboardKey object with an optional key label and debug
/// name. /// name.
/// ///
...@@ -249,6 +262,18 @@ class LogicalKeyboardKey extends Diagnosticable { ...@@ -249,6 +262,18 @@ class LogicalKeyboardKey extends Diagnosticable {
/// for keys which are not recognized. /// for keys which are not recognized.
static const int autogeneratedMask = 0x10000000000; static const int autogeneratedMask = 0x10000000000;
/// Mask for the synonym pseudo-keys generated for keys which appear in more
/// than one place on the keyboard.
///
/// IDs in this range are used to represent keys which appear in multiple
/// places on the keyboard, such as the SHIFT, ALT, CTRL, and numeric keypad
/// keys. These key codes will never be generated by the key event system, but
/// may be used in key maps to represent the union of all the keys of each
/// type in order to match them.
///
/// To look up the synonyms that are defined, look in the [synonyms] map.
static const int synonymMask = 0x20000000000;
/// The code prefix for keys which have a Unicode representation. /// The code prefix for keys which have a Unicode representation.
/// ///
/// This is used by platform-specific code to generate Flutter key codes. /// This is used by platform-specific code to generate Flutter key codes.
...@@ -362,7 +387,7 @@ class LogicalKeyboardKey extends Diagnosticable { ...@@ -362,7 +387,7 @@ class LogicalKeyboardKey extends Diagnosticable {
/// to keyboard events. /// to keyboard events.
/// * [RawKeyboardListener], a widget used to listen to and supply handlers for /// * [RawKeyboardListener], a widget used to listen to and supply handlers for
/// keyboard events. /// keyboard events.
class PhysicalKeyboardKey extends Diagnosticable { class PhysicalKeyboardKey extends KeyboardKey {
/// Creates a PhysicalKeyboardKey object with an optional debug name. /// Creates a PhysicalKeyboardKey object with an optional debug name.
/// ///
/// The [usbHidUsage] must not be null. /// The [usbHidUsage] must not be null.
......
...@@ -11,6 +11,19 @@ ...@@ -11,6 +11,19 @@
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
/// A base class for all keyboard key types.
///
/// See also:
///
/// * [PhysicalKeyboardKey], a class with static values that describe the keys
/// that are returned from [RawKeyEvent.physicalKey].
/// * [LogicalKeyboardKey], a class with static values that describe the keys
/// that are returned from [RawKeyEvent.logicalKey].
abstract class KeyboardKey extends Diagnosticable {
/// A const constructor so that subclasses may be const.
const KeyboardKey();
}
/// A class with static values that describe the keys that are returned from /// A class with static values that describe the keys that are returned from
/// [RawKeyEvent.logicalKey]. /// [RawKeyEvent.logicalKey].
/// ///
...@@ -108,7 +121,7 @@ import 'package:flutter/foundation.dart'; ...@@ -108,7 +121,7 @@ import 'package:flutter/foundation.dart';
/// to keyboard events. /// to keyboard events.
/// * [RawKeyboardListener], a widget used to listen to and supply handlers for /// * [RawKeyboardListener], a widget used to listen to and supply handlers for
/// keyboard events. /// keyboard events.
class LogicalKeyboardKey extends Diagnosticable { class LogicalKeyboardKey extends KeyboardKey {
/// Creates a LogicalKeyboardKey object with an optional key label and debug /// Creates a LogicalKeyboardKey object with an optional key label and debug
/// name. /// name.
/// ///
...@@ -249,6 +262,18 @@ class LogicalKeyboardKey extends Diagnosticable { ...@@ -249,6 +262,18 @@ class LogicalKeyboardKey extends Diagnosticable {
/// for keys which are not recognized. /// for keys which are not recognized.
static const int autogeneratedMask = 0x10000000000; static const int autogeneratedMask = 0x10000000000;
/// Mask for the synonym pseudo-keys generated for keys which appear in more
/// than one place on the keyboard.
///
/// IDs in this range are used to represent keys which appear in multiple
/// places on the keyboard, such as the SHIFT, ALT, CTRL, and numeric keypad
/// keys. These key codes will never be generated by the key event system, but
/// may be used in key maps to represent the union of all the keys of each
/// type in order to match them.
///
/// To look up the synonyms that are defined, look in the [synonyms] map.
static const int synonymMask = 0x20000000000;
/// The code prefix for keys which have a Unicode representation. /// The code prefix for keys which have a Unicode representation.
/// ///
/// This is used by platform-specific code to generate Flutter key codes. /// This is used by platform-specific code to generate Flutter key codes.
...@@ -1805,7 +1830,7 @@ class LogicalKeyboardKey extends Diagnosticable { ...@@ -1805,7 +1830,7 @@ class LogicalKeyboardKey extends Diagnosticable {
/// to keyboard events. /// to keyboard events.
/// * [RawKeyboardListener], a widget used to listen to and supply handlers for /// * [RawKeyboardListener], a widget used to listen to and supply handlers for
/// keyboard events. /// keyboard events.
class PhysicalKeyboardKey extends Diagnosticable { class PhysicalKeyboardKey extends KeyboardKey {
/// Creates a PhysicalKeyboardKey object with an optional debug name. /// Creates a PhysicalKeyboardKey object with an optional debug name.
/// ///
/// The [usbHidUsage] must not be null. /// The [usbHidUsage] must not be null.
......
...@@ -233,7 +233,7 @@ abstract class RawKeyEventData { ...@@ -233,7 +233,7 @@ abstract class RawKeyEventData {
/// * [RawKeyboard], which uses this interface to expose key data. /// * [RawKeyboard], which uses this interface to expose key data.
/// * [RawKeyboardListener], a widget that listens for raw key events. /// * [RawKeyboardListener], a widget that listens for raw key events.
@immutable @immutable
abstract class RawKeyEvent { abstract class RawKeyEvent extends Diagnosticable {
/// Initializes fields for subclasses, and provides a const constructor for /// Initializes fields for subclasses, and provides a const constructor for
/// const subclasses. /// const subclasses.
const RawKeyEvent({ const RawKeyEvent({
...@@ -406,6 +406,13 @@ abstract class RawKeyEvent { ...@@ -406,6 +406,13 @@ abstract class RawKeyEvent {
/// Platform-specific information about the key event. /// Platform-specific information about the key event.
final RawKeyEventData data; final RawKeyEventData data;
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties.add(DiagnosticsProperty<LogicalKeyboardKey>('logicalKey', logicalKey));
properties.add(DiagnosticsProperty<PhysicalKeyboardKey>('physicalKey', physicalKey));
}
} }
/// The user has pressed a key on the keyboard. /// The user has pressed a key on the keyboard.
......
// Copyright 2019 The Chromium 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/foundation.dart';
import 'binding.dart';
import 'focus_manager.dart';
import 'framework.dart';
/// Creates actions for use in defining shortcuts.
///
/// Used by clients of [ShortcutMap] to define shortcut maps.
typedef ActionFactory = Action Function();
/// A class representing a particular configuration of an action.
///
/// This class is what a key map in a [ShortcutMap] has as values, and is used
/// by an [ActionDispatcher] to look up an action and invoke it, giving it this
/// object to extract configuration information from.
///
/// If this intent returns false from [isEnabled], then its associated action will
/// not be invoked if requested.
class Intent extends Diagnosticable {
/// A const constructor for an [Intent].
///
/// The [key] argument must not be null.
const Intent(this.key) : assert(key != null);
/// The key for the action this intent is associated with.
final LocalKey key;
/// Returns true if the associated action is able to be executed in the
/// given `context`.
///
/// Returns true by default.
bool isEnabled(BuildContext context) => true;
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties.add(DiagnosticsProperty<LocalKey>('key', key));
}
}
/// Base class for actions.
///
/// As the name implies, an [Action] is an action or command to be performed.
/// They are typically invoked as a result of a user action, such as a keyboard
/// shortcut in a [Shortcuts] widget, which is used to look up an [Intent],
/// which is given to an [ActionDispatcher] to map the [Intent] to an [Action]
/// and invoke it.
///
/// The [ActionDispatcher] can invoke an [Action] on the primary focus, or
/// without regard for focus.
///
/// See also:
///
/// - [Shortcuts], which is a widget that contains a key map, in which it looks
/// up key combinations in order to invoke actions.
/// - [Actions], which is a widget that defines a map of [Intent] to [Action]
/// and allows redefining of actions for its descendants.
/// - [ActionDispatcher], a class that takes an [Action] and invokes it using a
/// [FocusNode] for context.
abstract class Action extends Diagnosticable {
/// A const constructor for an [Action].
///
/// The [intentKey] parameter must not be null.
const Action(this.intentKey) : assert(intentKey != null);
/// The unique key for this action.
///
/// This key will be used to map to this action in an [ActionDispatcher].
final LocalKey intentKey;
/// Called when the action is to be performed.
///
/// This is called by the [ActionDispatcher] when an action is accepted by a
/// [FocusNode] by returning true from its `onAction` callback, or when an
/// action is invoked using [ActionDispatcher.invokeAction].
///
/// This method is only meant to be invoked by an [ActionDispatcher], or by
/// subclasses.
///
/// Actions invoked directly with [ActionDispatcher.invokeAction] may receive a
/// null `node`. If the information available from a focus node is
/// needed in the action, use [ActionDispatcher.invokeFocusedAction] instead.
@protected
@mustCallSuper
void invoke(FocusNode node, covariant Intent tag);
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties.add(DiagnosticsProperty<LocalKey>('intentKey', intentKey));
}
}
/// The signature of a callback accepted by [CallbackAction].
typedef OnInvokeCallback = void Function(FocusNode node, Intent tag);
/// An [Action] that takes a callback in order to configure it without having to
/// subclass it.
///
/// See also:
///
/// - [Shortcuts], which is a widget that contains a key map, in which it looks
/// up key combinations in order to invoke actions.
/// - [Actions], which is a widget that defines a map of [Intent] to [Action]
/// and allows redefining of actions for its descendants.
/// - [ActionDispatcher], a class that takes an [Action] and invokes it using a
/// [FocusNode] for context.
class CallbackAction extends Action {
/// A const constructor for an [Action].
///
/// The `intentKey` and [onInvoke] parameters must not be null.
/// The [onInvoke] parameter is required.
const CallbackAction(LocalKey intentKey, {@required this.onInvoke})
: assert(onInvoke != null),
super(intentKey);
/// The callback to be called when invoked.
///
/// Must not be null.
@protected
final OnInvokeCallback onInvoke;
@override
void invoke(FocusNode node, Intent tag) => onInvoke.call(node, tag);
}
/// An action manager that simply invokes the actions given to it.
class ActionDispatcher extends Diagnosticable {
/// Const constructor so that subclasses can be const.
const ActionDispatcher();
/// Invokes the given action, optionally without regard for the currently
/// focused node in the focus tree.
///
/// Actions invoked will receive the given `focusNode`, or the
/// [FocusManager.primaryFocus] if the given `focusNode` is null.
///
/// The `action` and `intent` arguments must not be null.
bool invokeAction(Action action, Intent intent, {FocusNode focusNode}) {
assert(action != null);
assert(intent != null);
focusNode ??= WidgetsBinding.instance.focusManager.primaryFocus;
if (action != null && intent.isEnabled(focusNode.context)) {
action.invoke(focusNode, intent);
return true;
}
return false;
}
}
/// A widget that establishes an [ActionDispatcher] and a map of [Intent] to
/// [Action] to be used by its descendants when invoking an [Action].
///
/// Actions are typically invoked using [Actions.invoke] with the context
/// containing the ambient [Actions] widget.
///
/// See also:
///
/// * [ActionDispatcher], the object that this widget uses to manage actions.
/// * [Action], a class for containing and defining an invocation of a user
/// action.
/// * [Intent], a class that holds a unique [LocalKey] identifying an action,
/// as well as configuration information for running the [Action].
/// * [Shortcuts], a widget used to bind key combinations to [Intent]s.
class Actions extends InheritedWidget {
/// Creates an [Actions] widget.
///
/// The [child], [actions], and [dispatcher] arguments must not be null.
const Actions({
Key key,
this.dispatcher = const ActionDispatcher(),
@required this.actions,
@required Widget child,
}) : assert(dispatcher != null),
assert(actions != null),
super(key: key, child: child);
/// The [ActionDispatcher] object that invokes actions.
///
/// This is what is returned from [Actions.of], and used by [Actions.invoke].
final ActionDispatcher dispatcher;
/// A map of [Intent] keys to [ActionFactory] factory methods that defines
/// which actions this widget knows about.
final Map<LocalKey, ActionFactory> actions;
/// 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}) {
assert(context != null);
final Actions inherited = context.inheritFromWidgetOfExactType(Actions);
assert(() {
if (nullOk) {
return true;
}
if (inherited == null) {
throw FlutterError('Unable to find an $Actions widget in the 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 inherited?.dispatcher;
}
/// Invokes the action associated with the given [Intent] using the the
/// [Actions] widget that most tightly encloses the given [BuildContext].
///
/// The `context`, `intent` and `nullOk` arguments must not be null.
///
/// If the given `intent` isn't found in the first [Actions.actions] map, then
/// it will move up to the next [Actions] widget in the hierarchy until it
/// reaches the root.
///
/// Will throw if no ambient [Actions] widget is found, or if the given
/// `intent` doesn't map to an action in any of the the [Actions.actions] maps
/// that are found.
///
/// Setting `nullOk` to true means that if no ambient [Actions] widget is
/// found, then this method will return false instead of throwing.
static bool invoke(
BuildContext context,
Intent intent, {
FocusNode focusNode,
bool nullOk = false,
}) {
assert(context != null);
assert(intent != null);
Actions actions;
Action action;
bool visitAncestorElement(Element element) {
if (element.widget is! Actions) {
// Continue visiting.
return true;
}
// Below when we invoke the action, we need to use the dispatcher from the
// Actions widget where we found the action, in case they need to match.
actions = element.widget;
action = actions.actions[intent.key]?.call();
// Don't continue visiting if we successfully created an action.
return action == null;
}
context.visitAncestorElements(visitAncestorElement);
assert(() {
if (nullOk) {
return true;
}
if (actions == null) {
throw FlutterError('Unable to find a $Actions widget in the context.\n'
'$Actions.invoke() 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.invoke(). This can happen if the context comes '
'from a widget above those widgets.\n'
'The context used was:\n'
' $context');
}
if (action == null) {
throw FlutterError('Unable to find an action for an intent in the $Actions widget in the context.\n'
'$Actions.invoke() was called on an $Actions widget that doesn\'t '
'contain a mapping for the given intent.\n'
'The context used was:\n'
' $context\n'
'The intent requested was:\n'
' $intent');
}
return true;
}());
if (action == null) {
// Will only get here if nullOk is true.
return false;
}
// Invoke the action we found using the dispatcher from the Actions we
// found, using the given focus node. Or null, if nullOk is true, and we
// didn't find something.
return actions?.dispatcher?.invokeAction(action, intent, focusNode: focusNode);
}
@override
bool updateShouldNotify(Actions oldWidget) {
return oldWidget.dispatcher != dispatcher || oldWidget.actions != actions;
}
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties.add(DiagnosticsProperty<ActionDispatcher>('dispatcher', dispatcher));
properties.add(DiagnosticsProperty<Map<LocalKey, ActionFactory>>('actions', actions));
}
}
...@@ -450,13 +450,13 @@ class FocusNode with DiagnosticableTreeMixin, ChangeNotifier { ...@@ -450,13 +450,13 @@ class FocusNode with DiagnosticableTreeMixin, ChangeNotifier {
/// * [Focus.isAt], which is a static method that will return the focus /// * [Focus.isAt], which is a static method that will return the focus
/// state of the nearest ancestor [Focus] widget's focus node. /// state of the nearest ancestor [Focus] widget's focus node.
bool get hasFocus { bool get hasFocus {
if (_manager?._currentFocus == null) { if (_manager?.primaryFocus == null) {
return false; return false;
} }
if (hasPrimaryFocus) { if (hasPrimaryFocus) {
return true; return true;
} }
return _manager._currentFocus.ancestors.contains(this); return _manager.primaryFocus.ancestors.contains(this);
} }
/// Returns true if this node currently has the application-wide input focus. /// Returns true if this node currently has the application-wide input focus.
...@@ -473,7 +473,7 @@ class FocusNode with DiagnosticableTreeMixin, ChangeNotifier { ...@@ -473,7 +473,7 @@ class FocusNode with DiagnosticableTreeMixin, ChangeNotifier {
/// receive key events through its [onKey] handler. /// receive key events through its [onKey] handler.
/// ///
/// This object notifies its listeners whenever this value changes. /// This object notifies its listeners whenever this value changes.
bool get hasPrimaryFocus => _manager?._currentFocus == this; bool get hasPrimaryFocus => _manager?.primaryFocus == this;
/// Returns the nearest enclosing scope node above this node, including /// Returns the nearest enclosing scope node above this node, including
/// this node, if it's a scope. /// this node, if it's a scope.
...@@ -554,7 +554,7 @@ class FocusNode with DiagnosticableTreeMixin, ChangeNotifier { ...@@ -554,7 +554,7 @@ class FocusNode with DiagnosticableTreeMixin, ChangeNotifier {
if (hasFocus) { if (hasFocus) {
// If we are in the focus chain, but not the primary focus, then unfocus // If we are in the focus chain, but not the primary focus, then unfocus
// the primary instead. // the primary instead.
_manager._currentFocus.unfocus(); _manager.primaryFocus.unfocus();
} }
} }
...@@ -639,7 +639,7 @@ class FocusNode with DiagnosticableTreeMixin, ChangeNotifier { ...@@ -639,7 +639,7 @@ class FocusNode with DiagnosticableTreeMixin, ChangeNotifier {
child._updateManager(_manager); child._updateManager(_manager);
if (hadFocus) { if (hadFocus) {
// Update the focus chain for the current focus without changing it. // Update the focus chain for the current focus without changing it.
_manager?._currentFocus?._setAsFocusedChild(); _manager?.primaryFocus?._setAsFocusedChild();
} }
if (oldScope != null && child.context != null && child.enclosingScope != oldScope) { if (oldScope != null && child.context != null && child.enclosingScope != oldScope) {
DefaultFocusTraversal.of(child.context, nullOk: true)?.changedScope(node: child, oldScope: oldScope); DefaultFocusTraversal.of(child.context, nullOk: true)?.changedScope(node: child, oldScope: oldScope);
...@@ -722,12 +722,15 @@ class FocusNode with DiagnosticableTreeMixin, ChangeNotifier { ...@@ -722,12 +722,15 @@ class FocusNode with DiagnosticableTreeMixin, ChangeNotifier {
_markAsDirty(newFocus: this); _markAsDirty(newFocus: this);
} }
// Sets this node as the focused child for the enclosing scope, and that scope /// Sets this node as the [FocusScopeNode.focusedChild] of the enclosing
// as the focused child for the scope above it, etc., until it reaches the /// scope.
// root node. It doesn't change the primary focus, it just changes what node ///
// would be focused if the enclosing scope receives focus, and keeps track of /// Sets this node as the focused child for the enclosing scope, and that
// previously focused children so that if one is removed, the previous focus /// scope as the focused child for the scope above it, etc., until it reaches
// returns. /// the root node. It doesn't change the primary focus, it just changes what
/// node would be focused if the enclosing scope receives focus, and keeps
/// track of previously focused children in that scope, so that if the focused
/// child in that scope is removed, the previous focus returns.
void _setAsFocusedChild() { void _setAsFocusedChild() {
FocusNode scopeFocus = this; FocusNode scopeFocus = this;
for (FocusScopeNode ancestor in ancestors.whereType<FocusScopeNode>()) { for (FocusScopeNode ancestor in ancestors.whereType<FocusScopeNode>()) {
...@@ -957,7 +960,7 @@ class FocusManager with DiagnosticableTreeMixin { ...@@ -957,7 +960,7 @@ class FocusManager with DiagnosticableTreeMixin {
void _handleRawKeyEvent(RawKeyEvent event) { void _handleRawKeyEvent(RawKeyEvent event) {
// Walk the current focus from the leaf to the root, calling each one's // 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. // onKey on the way up, and if one responds that they handled it, stop.
if (_currentFocus == null) { if (_primaryFocus == null) {
return; return;
} }
Iterable<FocusNode> allNodes(FocusNode node) sync* { Iterable<FocusNode> allNodes(FocusNode node) sync* {
...@@ -967,15 +970,17 @@ class FocusManager with DiagnosticableTreeMixin { ...@@ -967,15 +970,17 @@ class FocusManager with DiagnosticableTreeMixin {
} }
} }
for (FocusNode node in allNodes(_currentFocus)) { for (FocusNode node in allNodes(_primaryFocus)) {
if (node.onKey != null && node.onKey(node, event)) { if (node.onKey != null && node.onKey(node, event)) {
break; break;
} }
} }
} }
// The node that currently has the primary focus. /// The node that currently has the primary focus.
FocusNode _currentFocus; FocusNode get primaryFocus => _primaryFocus;
FocusNode _primaryFocus;
// The node that has requested to have the primary focus, but hasn't been // The node that has requested to have the primary focus, but hasn't been
// given it yet. // given it yet.
FocusNode _nextFocus; FocusNode _nextFocus;
...@@ -994,8 +999,8 @@ class FocusManager with DiagnosticableTreeMixin { ...@@ -994,8 +999,8 @@ class FocusManager with DiagnosticableTreeMixin {
// pending request to be focused should be canceled. // pending request to be focused should be canceled.
void _willUnfocusNode(FocusNode node) { void _willUnfocusNode(FocusNode node) {
assert(node != null); assert(node != null);
if (_currentFocus == node) { if (_primaryFocus == node) {
_currentFocus = null; _primaryFocus = null;
_dirtyNodes.add(node); _dirtyNodes.add(node);
_markNeedsUpdate(); _markNeedsUpdate();
} }
...@@ -1024,14 +1029,14 @@ class FocusManager with DiagnosticableTreeMixin { ...@@ -1024,14 +1029,14 @@ class FocusManager with DiagnosticableTreeMixin {
void _applyFocusChange() { void _applyFocusChange() {
_haveScheduledUpdate = false; _haveScheduledUpdate = false;
final FocusNode previousFocus = _currentFocus; final FocusNode previousFocus = _primaryFocus;
if (_currentFocus == null && _nextFocus == null) { if (_primaryFocus == null && _nextFocus == null) {
// If we don't have any current focus, and nobody has asked to focus yet, // If we don't have any current focus, and nobody has asked to focus yet,
// then pick a first one using widget order as a default. // then pick a first one using widget order as a default.
_nextFocus = rootScope; _nextFocus = rootScope;
} }
if (_nextFocus != null && _nextFocus != _currentFocus) { if (_nextFocus != null && _nextFocus != _primaryFocus) {
_currentFocus = _nextFocus; _primaryFocus = _nextFocus;
final Set<FocusNode> previousPath = previousFocus?.ancestors?.toSet() ?? <FocusNode>{}; final Set<FocusNode> previousPath = previousFocus?.ancestors?.toSet() ?? <FocusNode>{};
final Set<FocusNode> nextPath = _nextFocus.ancestors.toSet(); final Set<FocusNode> nextPath = _nextFocus.ancestors.toSet();
// Notify nodes that are newly focused. // Notify nodes that are newly focused.
...@@ -1040,12 +1045,12 @@ class FocusManager with DiagnosticableTreeMixin { ...@@ -1040,12 +1045,12 @@ class FocusManager with DiagnosticableTreeMixin {
_dirtyNodes.addAll(previousPath.difference(nextPath)); _dirtyNodes.addAll(previousPath.difference(nextPath));
_nextFocus = null; _nextFocus = null;
} }
if (previousFocus != _currentFocus) { if (previousFocus != _primaryFocus) {
if (previousFocus != null) { if (previousFocus != null) {
_dirtyNodes.add(previousFocus); _dirtyNodes.add(previousFocus);
} }
if (_currentFocus != null) { if (_primaryFocus != null) {
_dirtyNodes.add(_currentFocus); _dirtyNodes.add(_primaryFocus);
} }
} }
for (FocusNode node in _dirtyNodes) { for (FocusNode node in _dirtyNodes) {
...@@ -1064,7 +1069,7 @@ class FocusManager with DiagnosticableTreeMixin { ...@@ -1064,7 +1069,7 @@ class FocusManager with DiagnosticableTreeMixin {
@override @override
void debugFillProperties(DiagnosticPropertiesBuilder properties) { void debugFillProperties(DiagnosticPropertiesBuilder properties) {
properties.add(FlagProperty('haveScheduledUpdate', value: _haveScheduledUpdate, ifTrue: 'UPDATE SCHEDULED')); properties.add(FlagProperty('haveScheduledUpdate', value: _haveScheduledUpdate, ifTrue: 'UPDATE SCHEDULED'));
properties.add(DiagnosticsProperty<FocusNode>('currentFocus', _currentFocus, defaultValue: null)); properties.add(DiagnosticsProperty<FocusNode>('currentFocus', primaryFocus, defaultValue: null));
} }
} }
......
// Copyright 2019 The Chromium 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:collection/collection.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'actions.dart';
import 'binding.dart';
import 'focus_manager.dart';
import 'focus_scope.dart';
import 'framework.dart';
import 'inherited_notifier.dart';
/// A set of [KeyboardKey]s that can be used as the keys in a [Map].
///
/// A key set contains the keys that are down simultaneously to represent a
/// shortcut.
///
/// This is a thin wrapper around a [Set], but changes the equality comparison
/// from an identity comparison to a contents comparison so that non-identical
/// sets with the same keys in them will compare as equal.
///
/// See also:
///
/// - [ShortcutManager], which uses [LogicalKeySet] (a [KeySet] subclass) to
/// define its key map.
class KeySet<T extends KeyboardKey> extends Diagnosticable {
/// A constructor for making a [KeySet] of up to four keys.
///
/// If you need a set of more than four keys, use [KeySet.fromSet].
///
/// The `key1` parameter must not be null. The same [KeyboardKey] may
/// not be appear more than once in the set.
KeySet(
T key1, [
T key2,
T key3,
T key4,
]) : assert(key1 != null),
_keys = <T>{key1} {
int count = 1;
if (key2 != null) {
_keys.add(key2);
assert(() {
count++;
return true;
}());
}
if (key3 != null) {
_keys.add(key3);
assert(() {
count++;
return true;
}());
}
if (key4 != null) {
_keys.add(key4);
assert(() {
count++;
return true;
}());
}
assert(_keys.length == count, 'Two or more provided keys are identical. Each key must appear only once.');
}
/// Create a [KeySet] from a set of [KeyboardKey]s.
///
/// Do not mutate the `keys` set after passing it to this object.
///
/// The `keys` set must not be null, contain nulls, or be empty.
KeySet.fromSet(Set<T> keys)
: assert(keys != null),
assert(keys.isNotEmpty),
assert(!keys.contains(null)),
_keys = keys;
/// Returns an unmodifiable view of the [KeyboardKey]s in this [KeySet].
Set<T> get keys => UnmodifiableSetView<T>(_keys);
final Set<T> _keys;
@override
bool operator ==(Object other) {
if (other.runtimeType != runtimeType) {
return false;
}
final KeySet<T> typedOther = other;
return _keys.length == typedOther._keys.length && _keys.containsAll(typedOther._keys);
}
@override
int get hashCode {
return hashList(_keys);
}
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties.add(DiagnosticsProperty<Set<T>>('keys', _keys));
}
}
/// A set of [LogicalKeyboardKey]s that can be used as the keys in a map.
///
/// A key set contains the keys that are down simultaneously to represent a
/// shortcut.
///
/// This is mainly used by [ShortcutManager] to allow the definition of shortcut
/// mappings.
///
/// This is a thin wrapper around a [Set], but changes the equality comparison
/// from an identity comparison to a contents comparison so that non-identical
/// sets with the same keys in them will compare as equal.
class LogicalKeySet extends KeySet<LogicalKeyboardKey> {
/// A constructor for making a [LogicalKeySet] of up to four keys.
///
/// If you need a set of more than four keys, use [LogicalKeySet.fromSet].
///
/// The `key1` parameter must not be null. The same [LogicalKeyboardKey] may
/// not be appear more than once in the set.
LogicalKeySet(
LogicalKeyboardKey key1, [
LogicalKeyboardKey key2,
LogicalKeyboardKey key3,
LogicalKeyboardKey key4,
]) : super(key1, key2, key3, key4);
/// Create a [LogicalKeySet] from a set of [LogicalKeyboardKey]s.
///
/// Do not mutate the `keys` set after passing it to this object.
///
/// The `keys` must not be null.
LogicalKeySet.fromSet(Set<LogicalKeyboardKey> keys) : super.fromSet(keys);
}
/// A manager of keyboard shortcut bindings.
///
/// A [ShortcutManager] is obtained by calling [Shortcuts.of] on the context of
/// the widget that you want to find a manager for.
class ShortcutManager extends ChangeNotifier with DiagnosticableMixin {
/// Constructs a [ShortcutManager].
///
/// The [shortcuts] argument must not be null.
ShortcutManager({
Map<LogicalKeySet, Intent> shortcuts = const <LogicalKeySet, Intent>{},
this.modal = false,
}) : assert(shortcuts != null),
_shortcuts = shortcuts;
/// 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.
final bool modal;
/// Returns the shortcut map.
///
/// When the map is changed, listeners to this manager will be notified.
///
/// The returned [LogicalKeyMap] should not be modified.
Map<LogicalKeySet, Intent> get shortcuts => _shortcuts;
Map<LogicalKeySet, Intent> _shortcuts;
set shortcuts(Map<LogicalKeySet, Intent> value) {
if (_shortcuts == value) {
return;
}
if (_shortcuts != value) {
_shortcuts = value;
notifyListeners();
}
}
/// Handles a key pressed `event` in the given `context`.
///
/// The optional `keysPressed` argument provides an override to keys that the
/// [RawKeyboard] reports. If not specified, uses [RawKeyboard.keysPressed]
/// instead.
@protected
bool handleKeypress(
BuildContext context,
RawKeyEvent event, {
LogicalKeySet keysPressed,
}) {
if (event is! RawKeyDownEvent) {
return false;
}
assert(context != null);
final LogicalKeySet keySet = keysPressed ?? LogicalKeySet.fromSet(RawKeyboard.instance.keysPressed);
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.
Set<LogicalKeyboardKey> pseudoKeys;
for (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)];
}
if (matchedIntent != null) {
final BuildContext primaryContext = WidgetsBinding.instance.focusManager.primaryFocus?.context;
if (primaryContext == null) {
return false;
}
return Actions.invoke(primaryContext, matchedIntent, nullOk: true);
}
return false;
}
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties.add(DiagnosticsProperty<Map<LogicalKeySet, Intent>>('shortcuts', _shortcuts));
properties.add(FlagProperty('modal', value: modal, ifTrue: 'modal', defaultValue: false));
}
}
/// A widget that establishes an [ShortcutManager] to be used by its descendants
/// when invoking an [Action] via a keyboard key combination that maps to an
/// [Intent].
///
/// See also:
///
/// * [Intent], a class for containing a description of a user
/// action to be invoked.
/// * [Action], a class for defining an invocation of a user action.
class Shortcuts extends StatefulWidget {
/// Creates a ActionManager object.
///
/// The [child] argument must not be null.
const Shortcuts({
Key key,
this.manager,
this.shortcuts,
this.child,
}) : super(key: key);
/// The [ShortcutManager] that will manage the mapping between key
/// combinations and [Action]s.
///
/// If not specified, uses a default-constructed [ShortcutManager].
///
/// This manager will be given new [shortcuts] to manage whenever the
/// [shortcuts] change materially.
final ShortcutManager manager;
/// The map of shortcuts that the [manager] will be given to manage.
final Map<LogicalKeySet, Intent> shortcuts;
/// The child widget for this [Shortcuts] widget.
///
/// {@macro flutter.widgets.child}
final Widget child;
/// Returns the [ActionDispatcher] that most tightly encloses the given
/// [BuildContext].
///
/// The [context] argument must not be null.
static ShortcutManager of(BuildContext context, {bool nullOk = false}) {
assert(context != null);
final _ShortcutsMarker inherited = context.inheritFromWidgetOfExactType(_ShortcutsMarker);
assert(() {
if (nullOk) {
return true;
}
if (inherited == null) {
throw FlutterError('Unable to find a $Shortcuts widget in the context.\n'
'$Shortcuts.of() was called with a context that does not contain a '
'$Shortcuts widget.\n'
'No $Shortcuts ancestor could be found starting from the context that was '
'passed to $Shortcuts.of().\n'
'The context used was:\n'
' $context');
}
return true;
}());
return inherited?.notifier;
}
@override
_ShortcutsState createState() => _ShortcutsState();
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties.add(DiagnosticsProperty<ShortcutManager>('manager', manager));
properties.add(DiagnosticsProperty<Map<LogicalKeySet, Intent>>('shortcuts', shortcuts));
}
}
class _ShortcutsState extends State<Shortcuts> {
ShortcutManager _internalManager;
ShortcutManager get manager => widget.manager ?? _internalManager;
@override
void dispose() {
_internalManager?.dispose();
super.dispose();
}
@override
void initState() {
super.initState();
if (widget.manager == null) {
_internalManager = ShortcutManager();
}
manager.shortcuts = widget.shortcuts;
}
@override
void didUpdateWidget(Shortcuts oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.manager != oldWidget.manager || widget.shortcuts != oldWidget.shortcuts) {
if (widget.manager != null) {
_internalManager?.dispose();
_internalManager = null;
} else {
_internalManager ??= ShortcutManager();
}
manager.shortcuts = widget.shortcuts;
}
}
bool _handleOnKey(FocusNode node, RawKeyEvent event) {
if (node.context == null) {
return false;
}
return manager.handleKeypress(node.context, event) || manager.modal;
}
@override
Widget build(BuildContext context) {
return Focus(
skipTraversal: true,
onKey: _handleOnKey,
child: _ShortcutsMarker(
manager: manager,
child: widget.child,
),
);
}
}
class _ShortcutsMarker extends InheritedNotifier<ShortcutManager> {
const _ShortcutsMarker({
@required ShortcutManager manager,
@required Widget child,
}) : assert(manager != null),
assert(child != null),
super(notifier: manager, child: child);
}
...@@ -14,6 +14,7 @@ library widgets; ...@@ -14,6 +14,7 @@ library widgets;
export 'package:vector_math/vector_math_64.dart' show Matrix4; export 'package:vector_math/vector_math_64.dart' show Matrix4;
export 'src/widgets/actions.dart';
export 'src/widgets/animated_cross_fade.dart'; export 'src/widgets/animated_cross_fade.dart';
export 'src/widgets/animated_list.dart'; export 'src/widgets/animated_list.dart';
export 'src/widgets/animated_size.dart'; export 'src/widgets/animated_size.dart';
...@@ -87,6 +88,7 @@ export 'src/widgets/scroll_view.dart'; ...@@ -87,6 +88,7 @@ export 'src/widgets/scroll_view.dart';
export 'src/widgets/scrollable.dart'; export 'src/widgets/scrollable.dart';
export 'src/widgets/scrollbar.dart'; export 'src/widgets/scrollbar.dart';
export 'src/widgets/semantics_debugger.dart'; export 'src/widgets/semantics_debugger.dart';
export 'src/widgets/shortcuts.dart';
export 'src/widgets/single_child_scroll_view.dart'; export 'src/widgets/single_child_scroll_view.dart';
export 'src/widgets/size_changed_layout_notifier.dart'; export 'src/widgets/size_changed_layout_notifier.dart';
export 'src/widgets/sliver.dart'; export 'src/widgets/sliver.dart';
......
// Copyright 2019 The Chromium 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/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
typedef PostInvokeCallback = void Function({Action action, Intent intent, FocusNode focusNode, ActionDispatcher dispatcher});
class TestAction extends CallbackAction {
const TestAction({
@required OnInvokeCallback onInvoke,
}) : assert(onInvoke != null),
super(key, onInvoke: onInvoke);
static const LocalKey key = ValueKey<Type>(TestAction);
void _testInvoke(FocusNode node, Intent invocation) => invoke(node, invocation);
}
class TestDispatcher extends ActionDispatcher {
const TestDispatcher({this.postInvoke});
final PostInvokeCallback postInvoke;
@override
bool invokeAction(Action action, Intent intent, {FocusNode focusNode}) {
final bool result = super.invokeAction(action, intent, focusNode: focusNode);
postInvoke?.call(action: action, intent: intent, focusNode: focusNode, dispatcher: this);
return result;
}
}
class TestDispatcher1 extends TestDispatcher {
const TestDispatcher1({PostInvokeCallback postInvoke}) : super(postInvoke: postInvoke);
}
void main() {
test('$Action passes parameters on when invoked.', () {
bool invoked = false;
FocusNode passedNode;
final TestAction action = TestAction(onInvoke: (FocusNode node, Intent invocation) {
invoked = true;
passedNode = node;
});
final FocusNode testNode = FocusNode(debugLabel: 'Test Node');
action._testInvoke(testNode, null);
expect(passedNode, equals(testNode));
expect(action.intentKey, equals(TestAction.key));
expect(invoked, isTrue);
});
group(ActionDispatcher, () {
test('$ActionDispatcher invokes actions when asked.', () {
bool invoked = false;
FocusNode passedNode;
const ActionDispatcher dispatcher = ActionDispatcher();
final FocusNode testNode = FocusNode(debugLabel: 'Test Node');
final bool result = dispatcher.invokeAction(
TestAction(
onInvoke: (FocusNode node, Intent invocation) {
invoked = true;
passedNode = node;
},
),
const Intent(TestAction.key),
focusNode: testNode,
);
expect(passedNode, equals(testNode));
expect(result, isTrue);
expect(invoked, isTrue);
});
});
group(Actions, () {
Intent invokedIntent;
Action invokedAction;
FocusNode invokedNode;
ActionDispatcher invokedDispatcher;
void collect({Action action, Intent intent, FocusNode focusNode, ActionDispatcher dispatcher}) {
invokedIntent = intent;
invokedAction = action;
invokedNode = focusNode;
invokedDispatcher = dispatcher;
}
void clear() {
invokedIntent = null;
invokedAction = null;
invokedNode = null;
invokedDispatcher = null;
}
setUp(clear);
testWidgets('$Actions widget can invoke actions with default dispatcher', (WidgetTester tester) async {
final GlobalKey containerKey = GlobalKey();
bool invoked = false;
FocusNode passedNode;
final FocusNode testNode = FocusNode(debugLabel: 'Test Node');
await tester.pumpWidget(
Actions(
actions: <LocalKey, ActionFactory>{
TestAction.key: () => TestAction(
onInvoke: (FocusNode node, Intent invocation) {
invoked = true;
passedNode = node;
},
),
},
child: Container(key: containerKey),
),
);
await tester.pump();
final bool result = Actions.invoke(
containerKey.currentContext,
const Intent(TestAction.key),
focusNode: testNode,
);
expect(passedNode, equals(testNode));
expect(result, isTrue);
expect(invoked, isTrue);
});
testWidgets('$Actions widget can invoke actions with custom dispatcher', (WidgetTester tester) async {
final GlobalKey containerKey = GlobalKey();
bool invoked = false;
const Intent intent = Intent(TestAction.key);
FocusNode passedNode;
final FocusNode testNode = FocusNode(debugLabel: 'Test Node');
final Action testAction = TestAction(
onInvoke: (FocusNode node, Intent intent) {
invoked = true;
passedNode = node;
},
);
await tester.pumpWidget(
Actions(
dispatcher: TestDispatcher(postInvoke: collect),
actions: <LocalKey, ActionFactory>{
TestAction.key: () => testAction,
},
child: Container(key: containerKey),
),
);
await tester.pump();
final bool result = Actions.invoke(
containerKey.currentContext,
intent,
focusNode: testNode,
);
expect(passedNode, equals(testNode));
expect(invokedNode, equals(testNode));
expect(result, isTrue);
expect(invoked, isTrue);
expect(invokedIntent, equals(intent));
});
testWidgets('$Actions widget can invoke actions in ancestor dispatcher', (WidgetTester tester) async {
final GlobalKey containerKey = GlobalKey();
bool invoked = false;
const Intent intent = Intent(TestAction.key);
FocusNode passedNode;
final FocusNode testNode = FocusNode(debugLabel: 'Test Node');
final Action testAction = TestAction(
onInvoke: (FocusNode node, Intent invocation) {
invoked = true;
passedNode = node;
},
);
await tester.pumpWidget(
Actions(
dispatcher: TestDispatcher1(postInvoke: collect),
actions: <LocalKey, ActionFactory>{
TestAction.key: () => testAction,
},
child: Actions(
dispatcher: TestDispatcher(postInvoke: collect),
actions: const <LocalKey, ActionFactory>{},
child: Container(key: containerKey),
),
),
);
await tester.pump();
final bool result = Actions.invoke(
containerKey.currentContext,
intent,
focusNode: testNode,
);
expect(passedNode, equals(testNode));
expect(invokedNode, equals(testNode));
expect(result, isTrue);
expect(invoked, isTrue);
expect(invokedIntent, equals(intent));
expect(invokedAction, equals(testAction));
expect(invokedDispatcher.runtimeType, equals(TestDispatcher1));
});
testWidgets('$Actions widget can be found with of', (WidgetTester tester) async {
final GlobalKey containerKey = GlobalKey();
final ActionDispatcher testDispatcher = TestDispatcher1(postInvoke: collect);
await tester.pumpWidget(
Actions(
dispatcher: testDispatcher,
actions: const <LocalKey, ActionFactory>{},
child: Container(key: containerKey),
),
);
await tester.pump();
final ActionDispatcher dispatcher = Actions.of(
containerKey.currentContext, nullOk: true,
);
expect(dispatcher, equals(testDispatcher));
});
});
group('Diagnostics', () {
testWidgets('default $Intent debugFillProperties', (WidgetTester tester) async {
final DiagnosticPropertiesBuilder builder = DiagnosticPropertiesBuilder();
const Intent(ValueKey<String>('foo')).debugFillProperties(builder);
final List<String> description = builder.properties
.where((DiagnosticsNode node) {
return !node.isFiltered(DiagnosticLevel.info);
})
.map((DiagnosticsNode node) => node.toString())
.toList();
expect(description, equals(<String>['key: [<\'foo\'>]']));
});
testWidgets('$CallbackAction debugFillProperties', (WidgetTester tester) async {
final DiagnosticPropertiesBuilder builder = DiagnosticPropertiesBuilder();
CallbackAction(
const ValueKey<String>('foo'),
onInvoke: (FocusNode node, Intent intent) {},
).debugFillProperties(builder);
final List<String> description = builder.properties
.where((DiagnosticsNode node) {
return !node.isFiltered(DiagnosticLevel.info);
})
.map((DiagnosticsNode node) => node.toString())
.toList();
expect(description, equals(<String>['intentKey: [<\'foo\'>]']));
});
testWidgets('default $Actions debugFillProperties', (WidgetTester tester) async {
final DiagnosticPropertiesBuilder builder = DiagnosticPropertiesBuilder();
Actions(actions: const <LocalKey, ActionFactory>{}, child: Container()).debugFillProperties(builder);
final List<String> description = builder.properties
.where((DiagnosticsNode node) {
return !node.isFiltered(DiagnosticLevel.info);
})
.map((DiagnosticsNode node) => node.toString())
.toList();
expect(description[0], equalsIgnoringHashCodes('dispatcher: ActionDispatcher#00000'));
expect(description[1], equals('actions: {}'));
});
testWidgets('$Actions implements debugFillProperties', (WidgetTester tester) async {
final DiagnosticPropertiesBuilder builder = DiagnosticPropertiesBuilder();
Actions(
key: const ValueKey<String>('foo'),
actions: <LocalKey, ActionFactory>{
const ValueKey<String>('bar'): () => TestAction(onInvoke: (FocusNode node, Intent intent) {}),
},
child: Container(key: const ValueKey<String>('baz')),
).debugFillProperties(builder);
final List<String> description = builder.properties
.where((DiagnosticsNode node) {
return !node.isFiltered(DiagnosticLevel.info);
})
.map((DiagnosticsNode node) => node.toString())
.toList();
expect(description[0], equalsIgnoringHashCodes('dispatcher: ActionDispatcher#00000'));
expect(description[1], equals('actions: {[<\'bar\'>]: Closure: () => TestAction}'));
});
});
}
// Copyright 2019 The Chromium 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/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
group(LogicalKeySet, () {
test('$LogicalKeySet passes parameters correctly.', () {
final LogicalKeySet set1 = LogicalKeySet(LogicalKeyboardKey.keyA);
final LogicalKeySet set2 = LogicalKeySet(
LogicalKeyboardKey.keyA,
LogicalKeyboardKey.keyB,
);
final LogicalKeySet set3 = LogicalKeySet(
LogicalKeyboardKey.keyA,
LogicalKeyboardKey.keyB,
LogicalKeyboardKey.keyC,
);
final LogicalKeySet set4 = LogicalKeySet(
LogicalKeyboardKey.keyA,
LogicalKeyboardKey.keyB,
LogicalKeyboardKey.keyC,
LogicalKeyboardKey.keyD,
);
final LogicalKeySet setFromSet = LogicalKeySet.fromSet(<LogicalKeyboardKey>{
LogicalKeyboardKey.keyA,
LogicalKeyboardKey.keyB,
LogicalKeyboardKey.keyC,
LogicalKeyboardKey.keyD,
});
expect(
set1.keys,
equals(<LogicalKeyboardKey>{
LogicalKeyboardKey.keyA,
}));
expect(
set2.keys,
equals(<LogicalKeyboardKey>{
LogicalKeyboardKey.keyA,
LogicalKeyboardKey.keyB,
}));
expect(
set3.keys,
equals(<LogicalKeyboardKey>{
LogicalKeyboardKey.keyA,
LogicalKeyboardKey.keyB,
LogicalKeyboardKey.keyC,
}));
expect(
set4.keys,
equals(<LogicalKeyboardKey>{
LogicalKeyboardKey.keyA,
LogicalKeyboardKey.keyB,
LogicalKeyboardKey.keyC,
LogicalKeyboardKey.keyD,
}));
expect(
setFromSet.keys,
equals(<LogicalKeyboardKey>{
LogicalKeyboardKey.keyA,
LogicalKeyboardKey.keyB,
LogicalKeyboardKey.keyC,
LogicalKeyboardKey.keyD,
}));
});
test('$LogicalKeySet works as a map key.', () {
final LogicalKeySet set1 = LogicalKeySet(LogicalKeyboardKey.keyA);
final LogicalKeySet set2 = LogicalKeySet(
LogicalKeyboardKey.keyA,
LogicalKeyboardKey.keyB,
);
final Map<LogicalKeySet, String> map = <LogicalKeySet, String>{set1: 'one'};
expect(map.containsKey(set1), isTrue);
expect(map.containsKey(LogicalKeySet(LogicalKeyboardKey.keyA)), isTrue);
expect(
set2,
equals(LogicalKeySet.fromSet(<LogicalKeyboardKey>{
LogicalKeyboardKey.keyA,
LogicalKeyboardKey.keyB,
})));
});
test('$KeySet diagnostics work.', () {
final DiagnosticPropertiesBuilder builder = DiagnosticPropertiesBuilder();
LogicalKeySet(
LogicalKeyboardKey.keyA,
LogicalKeyboardKey.keyB,
).debugFillProperties(builder);
final List<String> description = builder.properties
.where((DiagnosticsNode node) {
return !node.isFiltered(DiagnosticLevel.info);
})
.map((DiagnosticsNode node) => node.toString())
.toList();
expect(description.length, equals(1));
expect(
description[0],
equalsIgnoringHashCodes(
'keys: {LogicalKeyboardKey#00000(keyId: "0x00000061", keyLabel: "a", debugName: "Key A"), LogicalKeyboardKey#00000(keyId: "0x00000062", keyLabel: "b", debugName: "Key B")}'));
});
});
group(ShortcutManager, () {
test('$ShortcutManager .', () {
});
});
group(Shortcuts, () {});
}
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="manual_tests - actions" type="FlutterRunConfigurationType" factoryName="Flutter" singleton="false">
<option name="filePath" value="$PROJECT_DIR$/dev/manual_tests/lib/actions.dart" />
<method v="2" />
</configuration>
</component>
\ No newline at end of file
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