diff --git a/dev/manual_tests/lib/actions.dart b/dev/manual_tests/lib/actions.dart new file mode 100644 index 0000000000..6ec5890905 --- /dev/null +++ b/dev/manual_tests/lib/actions.dart @@ -0,0 +1,533 @@ +// 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 _completedActions = []; + // A stack of actions that can be redone. The most recent action performed is + // at the end of the list. + final List _undoneActions = []; + + 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 _listeners = {}; + + @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('undo stack', _completedActions)); + properties.add(IterableProperty('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('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('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('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('previous', _previousFocus)); + } +} + +class SetFocusAction extends SetFocusActionBase { + SetFocusAction() : super(key); + + static const LocalKey key = ValueKey(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(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(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(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 { + 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 { + 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(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: { + SetFocusAction.key: () => SetFocusAction(), + NextFocusAction.key: () => NextFocusAction(), + PreviousFocusAction.key: () => PreviousFocusAction(), + DirectionalFocusAction.key: () => DirectionalFocusAction(), + kUndoActionKey: () => kUndoAction, + kRedoActionKey: () => kRedoAction, + }, + child: DefaultFocusTraversal( + policy: ReadingOrderTraversalPolicy(), + child: Shortcuts( + shortcuts: { + 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: [ + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: const [ + DemoButton(name: 'One'), + DemoButton(name: 'Two'), + DemoButton(name: 'Three'), + ], + ), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: const [ + DemoButton(name: 'Four'), + DemoButton(name: 'Five'), + DemoButton(name: 'Six'), + ], + ), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: const [ + DemoButton(name: 'Seven'), + DemoButton(name: 'Eight'), + DemoButton(name: 'Nine'), + ], + ), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + 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, + ), + ), + ], + ), + ], + ); + }), + ), + ), + ), + ), + ), + ), + ), + ); + } +} diff --git a/dev/manual_tests/lib/focus.dart b/dev/manual_tests/lib/focus.dart index 846892c588..4f21eb79ec 100644 --- a/dev/manual_tests/lib/focus.dart +++ b/dev/manual_tests/lib/focus.dart @@ -138,8 +138,8 @@ class _FocusDemoState extends State { ], ), OutlineButton(onPressed: () => print('pressed'), child: const Text('PRESS ME')), - Padding( - padding: const EdgeInsets.all(8.0), + const Padding( + padding: EdgeInsets.all(8.0), child: TextField( decoration: InputDecoration(labelText: 'Enter Text', filled: true), ), diff --git a/dev/manual_tests/lib/hover.dart b/dev/manual_tests/lib/hover.dart index b6ca7644ba..f44e82e3f1 100644 --- a/dev/manual_tests/lib/hover.dart +++ b/dev/manual_tests/lib/hover.dart @@ -76,8 +76,8 @@ class _HoverDemoState extends State { ), ], ), - Padding( - padding: const EdgeInsets.all(8.0), + const Padding( + padding: EdgeInsets.all(8.0), child: TextField( decoration: InputDecoration(labelText: 'Enter Text', filled: true), ), diff --git a/dev/tools/gen_keycodes/data/keyboard_key.tmpl b/dev/tools/gen_keycodes/data/keyboard_key.tmpl index f627b784f6..562d408c4b 100644 --- a/dev/tools/gen_keycodes/data/keyboard_key.tmpl +++ b/dev/tools/gen_keycodes/data/keyboard_key.tmpl @@ -11,6 +11,19 @@ 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 /// [RawKeyEvent.logicalKey]. /// @@ -108,7 +121,7 @@ import 'package:flutter/foundation.dart'; /// to keyboard events. /// * [RawKeyboardListener], a widget used to listen to and supply handlers for /// keyboard events. -class LogicalKeyboardKey extends Diagnosticable { +class LogicalKeyboardKey extends KeyboardKey { /// Creates a LogicalKeyboardKey object with an optional key label and debug /// name. /// @@ -249,6 +262,18 @@ class LogicalKeyboardKey extends Diagnosticable { /// for keys which are not recognized. 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. /// /// This is used by platform-specific code to generate Flutter key codes. @@ -362,7 +387,7 @@ class LogicalKeyboardKey extends Diagnosticable { /// to keyboard events. /// * [RawKeyboardListener], a widget used to listen to and supply handlers for /// keyboard events. -class PhysicalKeyboardKey extends Diagnosticable { +class PhysicalKeyboardKey extends KeyboardKey { /// Creates a PhysicalKeyboardKey object with an optional debug name. /// /// The [usbHidUsage] must not be null. diff --git a/packages/flutter/lib/src/services/keyboard_key.dart b/packages/flutter/lib/src/services/keyboard_key.dart index a51ce855d9..bc69d8c406 100644 --- a/packages/flutter/lib/src/services/keyboard_key.dart +++ b/packages/flutter/lib/src/services/keyboard_key.dart @@ -11,6 +11,19 @@ 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 /// [RawKeyEvent.logicalKey]. /// @@ -108,7 +121,7 @@ import 'package:flutter/foundation.dart'; /// to keyboard events. /// * [RawKeyboardListener], a widget used to listen to and supply handlers for /// keyboard events. -class LogicalKeyboardKey extends Diagnosticable { +class LogicalKeyboardKey extends KeyboardKey { /// Creates a LogicalKeyboardKey object with an optional key label and debug /// name. /// @@ -249,6 +262,18 @@ class LogicalKeyboardKey extends Diagnosticable { /// for keys which are not recognized. 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. /// /// This is used by platform-specific code to generate Flutter key codes. @@ -1805,7 +1830,7 @@ class LogicalKeyboardKey extends Diagnosticable { /// to keyboard events. /// * [RawKeyboardListener], a widget used to listen to and supply handlers for /// keyboard events. -class PhysicalKeyboardKey extends Diagnosticable { +class PhysicalKeyboardKey extends KeyboardKey { /// Creates a PhysicalKeyboardKey object with an optional debug name. /// /// The [usbHidUsage] must not be null. diff --git a/packages/flutter/lib/src/services/raw_keyboard.dart b/packages/flutter/lib/src/services/raw_keyboard.dart index cf449ab275..a20dc529f3 100644 --- a/packages/flutter/lib/src/services/raw_keyboard.dart +++ b/packages/flutter/lib/src/services/raw_keyboard.dart @@ -233,7 +233,7 @@ abstract class RawKeyEventData { /// * [RawKeyboard], which uses this interface to expose key data. /// * [RawKeyboardListener], a widget that listens for raw key events. @immutable -abstract class RawKeyEvent { +abstract class RawKeyEvent extends Diagnosticable { /// Initializes fields for subclasses, and provides a const constructor for /// const subclasses. const RawKeyEvent({ @@ -406,6 +406,13 @@ abstract class RawKeyEvent { /// Platform-specific information about the key event. final RawKeyEventData data; + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(DiagnosticsProperty('logicalKey', logicalKey)); + properties.add(DiagnosticsProperty('physicalKey', physicalKey)); + } } /// The user has pressed a key on the keyboard. diff --git a/packages/flutter/lib/src/widgets/actions.dart b/packages/flutter/lib/src/widgets/actions.dart new file mode 100644 index 0000000000..2fe5ac6a14 --- /dev/null +++ b/packages/flutter/lib/src/widgets/actions.dart @@ -0,0 +1,308 @@ +// 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('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('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 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('dispatcher', dispatcher)); + properties.add(DiagnosticsProperty>('actions', actions)); + } +} diff --git a/packages/flutter/lib/src/widgets/focus_manager.dart b/packages/flutter/lib/src/widgets/focus_manager.dart index 3d4ddcd946..572e21886b 100644 --- a/packages/flutter/lib/src/widgets/focus_manager.dart +++ b/packages/flutter/lib/src/widgets/focus_manager.dart @@ -450,13 +450,13 @@ class FocusNode with DiagnosticableTreeMixin, ChangeNotifier { /// * [Focus.isAt], which is a static method that will return the focus /// state of the nearest ancestor [Focus] widget's focus node. bool get hasFocus { - if (_manager?._currentFocus == null) { + if (_manager?.primaryFocus == null) { return false; } if (hasPrimaryFocus) { 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. @@ -473,7 +473,7 @@ class FocusNode with DiagnosticableTreeMixin, ChangeNotifier { /// receive key events through its [onKey] handler. /// /// 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 /// this node, if it's a scope. @@ -554,7 +554,7 @@ class FocusNode with DiagnosticableTreeMixin, ChangeNotifier { if (hasFocus) { // If we are in the focus chain, but not the primary focus, then unfocus // the primary instead. - _manager._currentFocus.unfocus(); + _manager.primaryFocus.unfocus(); } } @@ -639,7 +639,7 @@ class FocusNode with DiagnosticableTreeMixin, ChangeNotifier { child._updateManager(_manager); if (hadFocus) { // 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) { DefaultFocusTraversal.of(child.context, nullOk: true)?.changedScope(node: child, oldScope: oldScope); @@ -722,12 +722,15 @@ class FocusNode with DiagnosticableTreeMixin, ChangeNotifier { _markAsDirty(newFocus: this); } - // Sets this node as the focused child for the enclosing scope, and that scope - // as the focused child for the scope above it, etc., until it reaches 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 so that if one is removed, the previous focus - // returns. + /// Sets this node as the [FocusScopeNode.focusedChild] of the enclosing + /// scope. + /// + /// Sets this node as the focused child for the enclosing scope, and that + /// scope as the focused child for the scope above it, etc., until it reaches + /// 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() { FocusNode scopeFocus = this; for (FocusScopeNode ancestor in ancestors.whereType()) { @@ -957,7 +960,7 @@ class FocusManager with DiagnosticableTreeMixin { void _handleRawKeyEvent(RawKeyEvent event) { // 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 (_currentFocus == null) { + if (_primaryFocus == null) { return; } Iterable allNodes(FocusNode node) sync* { @@ -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)) { break; } } } - // The node that currently has the primary focus. - FocusNode _currentFocus; + /// The node that currently has the primary focus. + FocusNode get primaryFocus => _primaryFocus; + FocusNode _primaryFocus; + // The node that has requested to have the primary focus, but hasn't been // given it yet. FocusNode _nextFocus; @@ -994,8 +999,8 @@ class FocusManager with DiagnosticableTreeMixin { // pending request to be focused should be canceled. void _willUnfocusNode(FocusNode node) { assert(node != null); - if (_currentFocus == node) { - _currentFocus = null; + if (_primaryFocus == node) { + _primaryFocus = null; _dirtyNodes.add(node); _markNeedsUpdate(); } @@ -1024,14 +1029,14 @@ class FocusManager with DiagnosticableTreeMixin { void _applyFocusChange() { _haveScheduledUpdate = false; - final FocusNode previousFocus = _currentFocus; - if (_currentFocus == null && _nextFocus == null) { + final FocusNode previousFocus = _primaryFocus; + if (_primaryFocus == null && _nextFocus == null) { // 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. _nextFocus = rootScope; } - if (_nextFocus != null && _nextFocus != _currentFocus) { - _currentFocus = _nextFocus; + if (_nextFocus != null && _nextFocus != _primaryFocus) { + _primaryFocus = _nextFocus; final Set previousPath = previousFocus?.ancestors?.toSet() ?? {}; final Set nextPath = _nextFocus.ancestors.toSet(); // Notify nodes that are newly focused. @@ -1040,12 +1045,12 @@ class FocusManager with DiagnosticableTreeMixin { _dirtyNodes.addAll(previousPath.difference(nextPath)); _nextFocus = null; } - if (previousFocus != _currentFocus) { + if (previousFocus != _primaryFocus) { if (previousFocus != null) { _dirtyNodes.add(previousFocus); } - if (_currentFocus != null) { - _dirtyNodes.add(_currentFocus); + if (_primaryFocus != null) { + _dirtyNodes.add(_primaryFocus); } } for (FocusNode node in _dirtyNodes) { @@ -1064,7 +1069,7 @@ class FocusManager with DiagnosticableTreeMixin { @override void debugFillProperties(DiagnosticPropertiesBuilder properties) { properties.add(FlagProperty('haveScheduledUpdate', value: _haveScheduledUpdate, ifTrue: 'UPDATE SCHEDULED')); - properties.add(DiagnosticsProperty('currentFocus', _currentFocus, defaultValue: null)); + properties.add(DiagnosticsProperty('currentFocus', primaryFocus, defaultValue: null)); } } diff --git a/packages/flutter/lib/src/widgets/shortcuts.dart b/packages/flutter/lib/src/widgets/shortcuts.dart new file mode 100644 index 0000000000..4723620aef --- /dev/null +++ b/packages/flutter/lib/src/widgets/shortcuts.dart @@ -0,0 +1,360 @@ +// 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 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 = {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 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 get keys => UnmodifiableSetView(_keys); + final Set _keys; + + @override + bool operator ==(Object other) { + if (other.runtimeType != runtimeType) { + return false; + } + final KeySet 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>('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 { + /// 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 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 shortcuts = const {}, + 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 get shortcuts => _shortcuts; + Map _shortcuts; + set shortcuts(Map 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 pseudoKeys; + for (LogicalKeyboardKey setKey in keySet.keys) { + final Set 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>('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 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('manager', manager)); + properties.add(DiagnosticsProperty>('shortcuts', shortcuts)); + } +} + +class _ShortcutsState extends State { + 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 { + const _ShortcutsMarker({ + @required ShortcutManager manager, + @required Widget child, + }) : assert(manager != null), + assert(child != null), + super(notifier: manager, child: child); +} diff --git a/packages/flutter/lib/widgets.dart b/packages/flutter/lib/widgets.dart index 04a30fb808..744a3fba75 100644 --- a/packages/flutter/lib/widgets.dart +++ b/packages/flutter/lib/widgets.dart @@ -14,6 +14,7 @@ library widgets; 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_list.dart'; export 'src/widgets/animated_size.dart'; @@ -87,6 +88,7 @@ export 'src/widgets/scroll_view.dart'; export 'src/widgets/scrollable.dart'; export 'src/widgets/scrollbar.dart'; export 'src/widgets/semantics_debugger.dart'; +export 'src/widgets/shortcuts.dart'; export 'src/widgets/single_child_scroll_view.dart'; export 'src/widgets/size_changed_layout_notifier.dart'; export 'src/widgets/sliver.dart'; diff --git a/packages/flutter/test/widgets/actions_test.dart b/packages/flutter/test/widgets/actions_test.dart new file mode 100644 index 0000000000..99398f6a87 --- /dev/null +++ b/packages/flutter/test/widgets/actions_test.dart @@ -0,0 +1,291 @@ +// 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(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: { + 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: { + 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: { + TestAction.key: () => testAction, + }, + child: Actions( + dispatcher: TestDispatcher(postInvoke: collect), + actions: const {}, + 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 {}, + 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('foo')).debugFillProperties(builder); + + final List description = builder.properties + .where((DiagnosticsNode node) { + return !node.isFiltered(DiagnosticLevel.info); + }) + .map((DiagnosticsNode node) => node.toString()) + .toList(); + + expect(description, equals(['key: [<\'foo\'>]'])); + }); + testWidgets('$CallbackAction debugFillProperties', (WidgetTester tester) async { + final DiagnosticPropertiesBuilder builder = DiagnosticPropertiesBuilder(); + + CallbackAction( + const ValueKey('foo'), + onInvoke: (FocusNode node, Intent intent) {}, + ).debugFillProperties(builder); + + final List description = builder.properties + .where((DiagnosticsNode node) { + return !node.isFiltered(DiagnosticLevel.info); + }) + .map((DiagnosticsNode node) => node.toString()) + .toList(); + + expect(description, equals(['intentKey: [<\'foo\'>]'])); + }); + testWidgets('default $Actions debugFillProperties', (WidgetTester tester) async { + final DiagnosticPropertiesBuilder builder = DiagnosticPropertiesBuilder(); + + Actions(actions: const {}, child: Container()).debugFillProperties(builder); + + final List 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('foo'), + actions: { + const ValueKey('bar'): () => TestAction(onInvoke: (FocusNode node, Intent intent) {}), + }, + child: Container(key: const ValueKey('baz')), + ).debugFillProperties(builder); + + final List 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}')); + }); + }); +} diff --git a/packages/flutter/test/widgets/shortcuts_test.dart b/packages/flutter/test/widgets/shortcuts_test.dart new file mode 100644 index 0000000000..46fcc7fa69 --- /dev/null +++ b/packages/flutter/test/widgets/shortcuts_test.dart @@ -0,0 +1,115 @@ +// 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.keyA, + LogicalKeyboardKey.keyB, + LogicalKeyboardKey.keyC, + LogicalKeyboardKey.keyD, + }); + expect( + set1.keys, + equals({ + LogicalKeyboardKey.keyA, + })); + expect( + set2.keys, + equals({ + LogicalKeyboardKey.keyA, + LogicalKeyboardKey.keyB, + })); + expect( + set3.keys, + equals({ + LogicalKeyboardKey.keyA, + LogicalKeyboardKey.keyB, + LogicalKeyboardKey.keyC, + })); + expect( + set4.keys, + equals({ + LogicalKeyboardKey.keyA, + LogicalKeyboardKey.keyB, + LogicalKeyboardKey.keyC, + LogicalKeyboardKey.keyD, + })); + expect( + setFromSet.keys, + equals({ + 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 map = {set1: 'one'}; + expect(map.containsKey(set1), isTrue); + expect(map.containsKey(LogicalKeySet(LogicalKeyboardKey.keyA)), isTrue); + expect( + set2, + equals(LogicalKeySet.fromSet({ + LogicalKeyboardKey.keyA, + LogicalKeyboardKey.keyB, + }))); + }); + test('$KeySet diagnostics work.', () { + final DiagnosticPropertiesBuilder builder = DiagnosticPropertiesBuilder(); + + LogicalKeySet( + LogicalKeyboardKey.keyA, + LogicalKeyboardKey.keyB, + ).debugFillProperties(builder); + + final List 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, () {}); +} diff --git a/packages/flutter_tools/ide_templates/intellij/.idea/runConfigurations/manual_tests___actions.xml.copy.tmpl b/packages/flutter_tools/ide_templates/intellij/.idea/runConfigurations/manual_tests___actions.xml.copy.tmpl new file mode 100644 index 0000000000..6d76f85631 --- /dev/null +++ b/packages/flutter_tools/ide_templates/intellij/.idea/runConfigurations/manual_tests___actions.xml.copy.tmpl @@ -0,0 +1,6 @@ + + + + \ No newline at end of file