// Copyright 2014 The Flutter Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. import 'dart:collection'; import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; import 'actions.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 { /// 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 = HashSet()..add(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 = HashSet.from(keys); /// Returns a copy of the [KeyboardKey]s in this [KeySet]. Set get keys => _keys.toSet(); final HashSet _keys; @override // ignore: avoid_equals_and_hash_code_on_mutable_classes, to remove in NNBD with a late final hashcode bool operator ==(Object other) { if (other.runtimeType != runtimeType) { return false; } return other is KeySet && setEquals(other._keys, _keys); } // Arrays used to temporarily store hash codes for sorting. static final List _tempHashStore3 = [0, 0, 0]; // used to sort exactly 3 keys static final List _tempHashStore4 = [0, 0, 0, 0]; // used to sort exactly 4 keys // Cached hash code value. Improves [hashCode] performance by 27%-900%, // depending on key set size and read/write ratio. int? _hashCode; @override // ignore: avoid_equals_and_hash_code_on_mutable_classes, to remove in NNBD with a late final hashcode int get hashCode { // Return cached hash code if available. if (_hashCode != null) { return _hashCode!; } // Compute order-independent hash and cache it. final int length = _keys.length; final Iterator iterator = _keys.iterator; // There's always at least one key. Just extract it. iterator.moveNext(); final int h1 = iterator.current.hashCode; if (length == 1) { // Don't do anything fancy if there's exactly one key. return _hashCode = h1; } iterator.moveNext(); final int h2 = iterator.current.hashCode; if (length == 2) { // No need to sort if there's two keys, just compare them. return _hashCode = h1 < h2 ? hashValues(h1, h2) : hashValues(h2, h1); } // Sort key hash codes and feed to hashList to ensure the aggregate // hash code does not depend on the key order. final List sortedHashes = length == 3 ? _tempHashStore3 : _tempHashStore4; sortedHashes[0] = h1; sortedHashes[1] = h2; iterator.moveNext(); sortedHashes[2] = iterator.current.hashCode; if (length == 4) { iterator.moveNext(); sortedHashes[3] = iterator.current.hashCode; } sortedHashes.sort(); return _hashCode = hashList(sortedHashes); } } /// 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] and [Shortcuts] widget 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 with Diagnosticable { /// 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); static final Set _modifiers = { LogicalKeyboardKey.alt, LogicalKeyboardKey.control, LogicalKeyboardKey.meta, LogicalKeyboardKey.shift, }; /// Returns a description of the key set that is short and readable. /// /// Intended to be used in debug mode for logging purposes. String debugDescribeKeys() { final List sortedKeys = keys.toList()..sort( (LogicalKeyboardKey a, LogicalKeyboardKey b) { // Put the modifiers first. If it has a synonym, then it's something // like shiftLeft, altRight, etc. final bool aIsModifier = a.synonyms.isNotEmpty || _modifiers.contains(a); final bool bIsModifier = b.synonyms.isNotEmpty || _modifiers.contains(b); if (aIsModifier && !bIsModifier) { return -1; } else if (bIsModifier && !aIsModifier) { return 1; } return a.debugName!.compareTo(b.debugName!); } ); return sortedKeys.map((LogicalKeyboardKey key) => key.debugName.toString()).join(' + '); } @override void debugFillProperties(DiagnosticPropertiesBuilder properties) { super.debugFillProperties(properties); properties.add(DiagnosticsProperty>('keys', _keys, description: debugDescribeKeys())); } } /// A [DiagnosticsProperty] which handles formatting a `Map` (the same type as the [Shortcuts.shortcuts] property) so that its /// diagnostic output is human-readable. class ShortcutMapProperty extends DiagnosticsProperty> { /// Create a diagnostics property for `Map` objects, /// which are the same type as the [Shortcuts.shortcuts] property. /// /// The [showName] and [level] arguments must not be null. ShortcutMapProperty( String name, Map value, { bool showName = true, Object defaultValue = kNoDefaultValue, DiagnosticLevel level = DiagnosticLevel.info, String? description, }) : assert(showName != null), assert(level != null), super( name, value, showName: showName, defaultValue: defaultValue, level: level, description: description, ); @override Map get value => super.value!; @override String valueToString({ TextTreeConfiguration? parentConfiguration }) { return '{${value.keys.map((LogicalKeySet keySet) => '{${keySet.debugDescribeKeys()}}: ${value[keySet]}').join(', ')}}'; } } /// 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 Diagnosticable { /// 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 will prevent any key event given to this manager /// from being given to any ancestor managers, even if that key doesn't appear /// in the [shortcuts] map. /// /// The net effect of setting `modal` to true is to return /// [KeyEventResult.skipRemainingHandlers] from [handleKeypress] if it does /// not exist in the shortcut map, instead of returning /// [KeyEventResult.ignored]. final bool modal; /// Returns the shortcut map. /// /// When the map is changed, listeners to this manager will be notified. /// /// The returned map should not be modified. Map get shortcuts => _shortcuts; Map _shortcuts; set shortcuts(Map value) { assert(value != null); if (!mapEquals(_shortcuts, value)) { _shortcuts = value; notifyListeners(); } } /// Returns the [Intent], if any, that matches the current set of pressed /// keys. /// /// Returns null if no intent matches the current set of pressed keys. /// /// Defaults to a set derived from [RawKeyboard.keysPressed] if `keysPressed` /// is not supplied. Intent? _find({ LogicalKeySet? keysPressed }) { if (keysPressed == null && RawKeyboard.instance.keysPressed.isEmpty) { return null; } keysPressed ??= LogicalKeySet.fromSet(RawKeyboard.instance.keysPressed); Intent? matchedIntent = _shortcuts[keysPressed]; if (matchedIntent == null) { // If there's not a more specific match, We also look for any keys that // have synonyms in the map. This is for things like left and right shift // keys mapping to just the "shift" pseudo-key. final Set pseudoKeys = {}; for (final KeyboardKey setKey in keysPressed.keys) { if (setKey is LogicalKeyboardKey) { final Set synonyms = setKey.synonyms; if (synonyms.isNotEmpty) { // There currently aren't any synonyms that match more than one key. assert(synonyms.length == 1, 'Unexpectedly encountered a key synonym with more than one key.'); pseudoKeys.add(synonyms.first); } else { pseudoKeys.add(setKey); } } } matchedIntent = _shortcuts[LogicalKeySet.fromSet(pseudoKeys)]; } return matchedIntent; } /// Handles a key press `event` in the given `context`. /// /// The optional `keysPressed` argument is used as the set of currently /// pressed keys. Defaults to a set derived from [RawKeyboard.keysPressed] if /// `keysPressed` is not supplied. /// /// If a key mapping is found, then the associated action will be invoked /// using the [Intent] that the `keysPressed` maps to, and the currently /// focused widget's context (from [FocusManager.primaryFocus]). /// /// Returns a [KeyEventResult.handled] if an action was invoked, otherwise a /// [KeyEventResult.skipRemainingHandlers] if [modal] is true, or if it maps /// to a [DoNothingAction] with [DoNothingAction.consumesKey] set to false, /// and in all other cases returns [KeyEventResult.ignored]. /// /// In order for an action to be invoked (and [KeyEventResult.handled] /// returned), a pressed [KeySet] must be mapped to an [Intent], the [Intent] /// must be mapped to an [Action], and the [Action] must be enabled. @protected KeyEventResult handleKeypress( BuildContext context, RawKeyEvent event, { LogicalKeySet? keysPressed, }) { if (event is! RawKeyDownEvent) { return KeyEventResult.ignored; } assert(context != null); assert(keysPressed != null || RawKeyboard.instance.keysPressed.isNotEmpty, 'Received a key down event when no keys are in keysPressed. ' "This state can occur if the key event being sent doesn't properly " 'set its modifier flags. This was the event: $event and its data: ' '${event.data}'); final Intent? matchedIntent = _find(keysPressed: keysPressed); if (matchedIntent != null) { final BuildContext primaryContext = primaryFocus!.context!; assert (primaryContext != null); final Action? action = Actions.maybeFind( primaryContext, intent: matchedIntent, ); if (action != null && action.isEnabled(matchedIntent)) { Actions.of(primaryContext).invokeAction(action, matchedIntent, primaryContext); return action.consumesKey(matchedIntent) ? KeyEventResult.handled : KeyEventResult.skipRemainingHandlers; } } return modal ? KeyEventResult.skipRemainingHandlers : KeyEventResult.ignored; } @override void debugFillProperties(DiagnosticPropertiesBuilder properties) { super.debugFillProperties(properties); properties.add(DiagnosticsProperty>('shortcuts', _shortcuts)); properties.add(FlagProperty('modal', value: modal, ifTrue: 'modal', defaultValue: false)); } } /// A widget to that creates key bindings to specific actions for its /// descendants. /// /// This widget establishes a [ShortcutManager] to be used by its descendants /// when invoking an [Action] via a keyboard key combination that maps to an /// [Intent]. /// /// {@tool dartpad --template=stateful_widget_scaffold_center} /// /// Here, we will use the [Shortcuts] and [Actions] widgets to add and subtract /// from a counter. When the child widget has keyboard focus, and a user presses /// the keys that have been defined in [Shortcuts], the action that is bound /// to the appropriate [Intent] for the key is invoked. /// /// It also shows the use of a [CallbackAction] to avoid creating a new [Action] /// subclass. /// /// ```dart imports /// import 'package:flutter/services.dart'; /// ``` /// /// ```dart preamble /// class IncrementIntent extends Intent { /// const IncrementIntent(); /// } /// /// class DecrementIntent extends Intent { /// const DecrementIntent(); /// } /// ``` /// /// ```dart /// int count = 0; /// /// @override /// Widget build(BuildContext context) { /// return Shortcuts( /// shortcuts: { /// LogicalKeySet(LogicalKeyboardKey.arrowUp): const IncrementIntent(), /// LogicalKeySet(LogicalKeyboardKey.arrowDown): const DecrementIntent(), /// }, /// child: Actions( /// actions: >{ /// IncrementIntent: CallbackAction( /// onInvoke: (IncrementIntent intent) => setState(() { /// count = count + 1; /// }), /// ), /// DecrementIntent: CallbackAction( /// onInvoke: (DecrementIntent intent) => setState(() { /// count = count - 1; /// }), /// ), /// }, /// child: Focus( /// autofocus: true, /// child: Column( /// children: [ /// const Text('Add to the counter by pressing the up arrow key'), /// const Text( /// 'Subtract from the counter by pressing the down arrow key'), /// Text('count: $count'), /// ], /// ), /// ), /// ), /// ); /// } /// ``` /// {@end-tool} /// /// {@tool dartpad --template=stateful_widget_scaffold_center} /// /// This slightly more complicated, but more flexible, example creates a custom /// [Action] subclass to increment and decrement within a widget (a [Column]) /// that has keyboard focus. When the user presses the up and down arrow keys, /// the counter will increment and decrement a data model using the custom /// actions. /// /// One thing that this demonstrates is passing arguments to the [Intent] to be /// carried to the [Action]. This shows how actions can get data either from /// their own construction (like the `model` in this example), or from the /// intent passed to them when invoked (like the increment `amount` in this /// example). /// /// ```dart imports /// import 'package:flutter/services.dart'; /// ``` /// /// ```dart preamble /// class Model with ChangeNotifier { /// int count = 0; /// void incrementBy(int amount) { /// count += amount; /// notifyListeners(); /// } /// /// void decrementBy(int amount) { /// count -= amount; /// notifyListeners(); /// } /// } /// /// class IncrementIntent extends Intent { /// const IncrementIntent(this.amount); /// /// final int amount; /// } /// /// class DecrementIntent extends Intent { /// const DecrementIntent(this.amount); /// /// final int amount; /// } /// /// class IncrementAction extends Action { /// IncrementAction(this.model); /// /// final Model model; /// /// @override /// void invoke(covariant IncrementIntent intent) { /// model.incrementBy(intent.amount); /// } /// } /// /// class DecrementAction extends Action { /// DecrementAction(this.model); /// /// final Model model; /// /// @override /// void invoke(covariant DecrementIntent intent) { /// model.decrementBy(intent.amount); /// } /// } /// ``` /// /// ```dart /// Model model = Model(); /// /// @override /// Widget build(BuildContext context) { /// return Shortcuts( /// shortcuts: { /// LogicalKeySet(LogicalKeyboardKey.arrowUp): const IncrementIntent(2), /// LogicalKeySet(LogicalKeyboardKey.arrowDown): const DecrementIntent(2), /// }, /// child: Actions( /// actions: >{ /// IncrementIntent: IncrementAction(model), /// DecrementIntent: DecrementAction(model), /// }, /// child: Focus( /// autofocus: true, /// child: Column( /// children: [ /// const Text('Add to the counter by pressing the up arrow key'), /// const Text( /// 'Subtract from the counter by pressing the down arrow key'), /// AnimatedBuilder( /// animation: model, /// builder: (BuildContext context, Widget? child) { /// return Text('count: ${model.count}'); /// }, /// ), /// ], /// ), /// ), /// ), /// ); /// } /// ``` /// {@end-tool} /// /// 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. /// * [CallbackAction], a class for creating an action from a callback. class Shortcuts extends StatefulWidget { /// Creates a const [Shortcuts] widget. /// /// The [child] and [shortcuts] arguments are required and must not be null. const Shortcuts({ Key? key, this.manager, required this.shortcuts, required this.child, this.debugLabel, }) : assert(shortcuts != null), assert(child != null), 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; /// {@template flutter.widgets.shortcuts.shortcuts} /// The map of shortcuts that the [ShortcutManager] will be given to manage. /// /// For performance reasons, it is recommended that a pre-built map is passed /// in here (e.g. a final variable from your widget class) instead of defining /// it inline in the build function. /// {@endtemplate} final Map shortcuts; /// The child widget for this [Shortcuts] widget. /// /// {@macro flutter.widgets.ProxyWidget.child} final Widget child; /// The debug label that is printed for this node when logged. /// /// If this label is set, then it will be displayed instead of the shortcut /// map when logged. /// /// This allows simplifying the diagnostic output to avoid cluttering it /// unnecessarily with large default shortcut maps. final String? debugLabel; /// Returns the [ShortcutManager] that most tightly encloses the given /// [BuildContext]. /// /// The [context] argument must not be null. /// /// If no [Shortcuts] widget encloses the context given, will assert in debug /// mode and throw an exception in release mode. /// /// See also: /// /// * [maybeOf], which is similar to this function, but will return null if /// it doesn't find a [Shortcuts] ancestor. static ShortcutManager of(BuildContext context) { assert(context != null); final _ShortcutsMarker? inherited = context.dependOnInheritedWidgetOfExactType<_ShortcutsMarker>(); assert(() { 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!.manager; } /// Returns the [ShortcutManager] that most tightly encloses the given /// [BuildContext]. /// /// The [context] argument must not be null. /// /// If no [Shortcuts] widget encloses the context given, will return null. /// /// See also: /// /// * [of], which is similar to this function, but returns a non-nullable /// result, and will throw an exception if it doesn't find a [Shortcuts] /// ancestor. static ShortcutManager? maybeOf(BuildContext context) { assert(context != null); final _ShortcutsMarker? inherited = context.dependOnInheritedWidgetOfExactType<_ShortcutsMarker>(); return inherited?.manager; } @override _ShortcutsState createState() => _ShortcutsState(); @override void debugFillProperties(DiagnosticPropertiesBuilder properties) { super.debugFillProperties(properties); properties.add(DiagnosticsProperty('manager', manager, defaultValue: null)); properties.add(ShortcutMapProperty('shortcuts', shortcuts, description: debugLabel?.isNotEmpty ?? false ? debugLabel : null)); } } 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) { if (widget.manager != null) { _internalManager?.dispose(); _internalManager = null; } else { _internalManager ??= ShortcutManager(); } } manager.shortcuts = widget.shortcuts; } KeyEventResult _handleOnKey(FocusNode node, RawKeyEvent event) { if (node.context == null) { return KeyEventResult.ignored; } return manager.handleKeypress(node.context!, event); } @override Widget build(BuildContext context) { return Focus( debugLabel: '$Shortcuts', canRequestFocus: false, 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); ShortcutManager get manager => super.notifier!; }