1// Copyright 2014 The Flutter Authors. All rights reserved.
2// Use of this source code is governed by a BSD-style license that can be
3// found in the LICENSE file.
4
5/// @docImport 'package:flutter/cupertino.dart';
6/// @docImport 'package:flutter/material.dart';
7///
8/// @docImport 'app.dart';
9/// @docImport 'basic.dart';
10library;
11
12import 'dart:collection';
13
14import 'package:flutter/foundation.dart';
15import 'package:flutter/scheduler.dart';
16import 'package:flutter/services.dart';
17
18import 'actions.dart';
19import 'focus_manager.dart';
20import 'focus_scope.dart';
21import 'framework.dart';
22import 'platform_menu_bar.dart';
23
24final Set<LogicalKeyboardKey> _controlSynonyms = LogicalKeyboardKey.expandSynonyms(
25 <LogicalKeyboardKey>{LogicalKeyboardKey.control},
26);
27final Set<LogicalKeyboardKey> _shiftSynonyms = LogicalKeyboardKey.expandSynonyms(
28 <LogicalKeyboardKey>{LogicalKeyboardKey.shift},
29);
30final Set<LogicalKeyboardKey> _altSynonyms = LogicalKeyboardKey.expandSynonyms(<LogicalKeyboardKey>{
31 LogicalKeyboardKey.alt,
32});
33final Set<LogicalKeyboardKey> _metaSynonyms = LogicalKeyboardKey.expandSynonyms(
34 <LogicalKeyboardKey>{LogicalKeyboardKey.meta},
35);
36
37/// A set of [KeyboardKey]s that can be used as the keys in a [Map].
38///
39/// A key set contains the keys that are down simultaneously to represent a
40/// shortcut.
41///
42/// This is a thin wrapper around a [Set], but changes the equality comparison
43/// from an identity comparison to a contents comparison so that non-identical
44/// sets with the same keys in them will compare as equal.
45///
46/// See also:
47///
48/// * [ShortcutManager], which uses [LogicalKeySet] (a [KeySet] subclass) to
49/// define its key map.
50@immutable
51class KeySet<T extends KeyboardKey> {
52 /// A constructor for making a [KeySet] of up to four keys.
53 ///
54 /// If you need a set of more than four keys, use [KeySet.fromSet].
55 ///
56 /// The same [KeyboardKey] may not be appear more than once in the set.
57 KeySet(T key1, [T? key2, T? key3, T? key4]) : _keys = HashSet<T>()..add(key1) {
58 int count = 1;
59 if (key2 != null) {
60 _keys.add(key2);
61 assert(() {
62 count++;
63 return true;
64 }());
65 }
66 if (key3 != null) {
67 _keys.add(key3);
68 assert(() {
69 count++;
70 return true;
71 }());
72 }
73 if (key4 != null) {
74 _keys.add(key4);
75 assert(() {
76 count++;
77 return true;
78 }());
79 }
80 assert(
81 _keys.length == count,
82 'Two or more provided keys are identical. Each key must appear only once.',
83 );
84 }
85
86 /// Create a [KeySet] from a set of [KeyboardKey]s.
87 ///
88 /// Do not mutate the `keys` set after passing it to this object.
89 ///
90 /// The `keys` set must not be empty.
91 KeySet.fromSet(Set<T> keys)
92 : assert(keys.isNotEmpty),
93 assert(!keys.contains(null)),
94 _keys = HashSet<T>.of(keys);
95
96 /// Returns a copy of the [KeyboardKey]s in this [KeySet].
97 Set<T> get keys => _keys.toSet();
98 final HashSet<T> _keys;
99
100 @override
101 bool operator ==(Object other) {
102 if (other.runtimeType != runtimeType) {
103 return false;
104 }
105 return other is KeySet<T> && setEquals<T>(other._keys, _keys);
106 }
107
108 // Cached hash code value. Improves [hashCode] performance by 27%-900%,
109 // depending on key set size and read/write ratio.
110 @override
111 late final int hashCode = _computeHashCode(_keys);
112
113 // Arrays used to temporarily store hash codes for sorting.
114 static final List<int> _tempHashStore3 = <int>[0, 0, 0]; // used to sort exactly 3 keys
115 static final List<int> _tempHashStore4 = <int>[0, 0, 0, 0]; // used to sort exactly 4 keys
116 static int _computeHashCode<T>(Set<T> keys) {
117 // Compute order-independent hash and cache it.
118 final int length = keys.length;
119 final Iterator<T> iterator = keys.iterator;
120
121 // There's always at least one key. Just extract it.
122 iterator.moveNext();
123 final int h1 = iterator.current.hashCode;
124
125 if (length == 1) {
126 // Don't do anything fancy if there's exactly one key.
127 return h1;
128 }
129
130 iterator.moveNext();
131 final int h2 = iterator.current.hashCode;
132 if (length == 2) {
133 // No need to sort if there's two keys, just compare them.
134 return h1 < h2 ? Object.hash(h1, h2) : Object.hash(h2, h1);
135 }
136
137 // Sort key hash codes and feed to Object.hashAll to ensure the aggregate
138 // hash code does not depend on the key order.
139 final List<int> sortedHashes = length == 3 ? _tempHashStore3 : _tempHashStore4;
140 sortedHashes[0] = h1;
141 sortedHashes[1] = h2;
142 iterator.moveNext();
143 sortedHashes[2] = iterator.current.hashCode;
144 if (length == 4) {
145 iterator.moveNext();
146 sortedHashes[3] = iterator.current.hashCode;
147 }
148 sortedHashes.sort();
149 return Object.hashAll(sortedHashes);
150 }
151}
152
153/// Determines how the state of a lock key is used to accept a shortcut.
154enum LockState {
155 /// The lock key state is not used to determine [SingleActivator.accepts] result.
156 ignored,
157
158 /// The lock key must be locked to trigger the shortcut.
159 locked,
160
161 /// The lock key must be unlocked to trigger the shortcut.
162 unlocked,
163}
164
165/// An interface to define the keyboard key combination to trigger a shortcut.
166///
167/// [ShortcutActivator]s are used by [Shortcuts] widgets, and are mapped to
168/// [Intent]s, the intended behavior that the key combination should trigger.
169/// When a [Shortcuts] widget receives a key event, its [ShortcutManager] looks
170/// up the first matching [ShortcutActivator], and signals the corresponding
171/// [Intent], which might trigger an action as defined by a hierarchy of
172/// [Actions] widgets. For a detailed introduction on the mechanism and use of
173/// the shortcut-action system, see [Actions].
174///
175/// The matching [ShortcutActivator] is looked up in the following way:
176///
177/// * Find the registered [ShortcutActivator]s whose [triggers] contain the
178/// incoming event.
179/// * Of the previous list, finds the first activator whose [accepts] returns
180/// true in the order of insertion.
181///
182/// See also:
183///
184/// * [SingleActivator], an implementation that represents a single key combined
185/// with modifiers (control, shift, alt, meta).
186/// * [CharacterActivator], an implementation that represents key combinations
187/// that result in the specified character, such as question mark.
188/// * [LogicalKeySet], an implementation that requires one or more
189/// [LogicalKeyboardKey]s to be pressed at the same time. Prefer
190/// [SingleActivator] when possible.
191abstract class ShortcutActivator {
192 /// Abstract const constructor. This constructor enables subclasses to provide
193 /// const constructors so that they can be used in const expressions.
194 const ShortcutActivator();
195
196 /// An optional property to provide all the keys that might be the final event
197 /// to trigger this shortcut.
198 ///
199 /// For example, for `Ctrl-A`, [LogicalKeyboardKey.keyA] is the only trigger,
200 /// while [LogicalKeyboardKey.control] is not, because the shortcut should
201 /// only work by pressing KeyA *after* Ctrl, but not before. For `Ctrl-A-E`,
202 /// on the other hand, both KeyA and KeyE should be triggers, since either of
203 /// them is allowed to trigger.
204 ///
205 /// If provided, trigger keys can be used as a first-pass filter for incoming
206 /// events in order to optimize lookups, as [Intent]s are stored in a [Map]
207 /// and indexed by trigger keys. It is up to the individual implementers of
208 /// this interface to decide if they ignore triggers or not.
209 ///
210 /// Subclasses should make sure that the return value of this method does not
211 /// change throughout the lifespan of this object.
212 ///
213 /// This method might also return null, which means this activator declares
214 /// all keys as trigger keys. Activators whose [triggers] return null will be
215 /// tested with [accepts] on every event. Since this becomes a linear search,
216 /// and having too many might impact performance, it is preferred to return
217 /// non-null [triggers] whenever possible.
218 Iterable<LogicalKeyboardKey>? get triggers => null;
219
220 /// Whether the triggering `event` and the keyboard `state` at the time of the
221 /// event meet required conditions, providing that the event is a triggering
222 /// event.
223 ///
224 /// For example, for `Ctrl-A`, it has to check if the event is a
225 /// [KeyDownEvent], if either side of the Ctrl key is pressed, and none of the
226 /// Shift keys, Alt keys, or Meta keys are pressed; it doesn't have to check
227 /// if KeyA is pressed, since it's already guaranteed.
228 ///
229 /// As a possible performance improvement, implementers of this function are
230 /// encouraged (but not required) to check the [triggers] member, if it is
231 /// non-null, to see if it contains the event's logical key before doing more
232 /// complicated work.
233 ///
234 /// This method must not cause any side effects for the `state`. Typically
235 /// this is only used to query whether [HardwareKeyboard.logicalKeysPressed]
236 /// contains a key.
237 ///
238 /// See also:
239 ///
240 /// * [LogicalKeyboardKey.collapseSynonyms], which helps deciding whether a
241 /// modifier key is pressed when the side variation is not important.
242 bool accepts(KeyEvent event, HardwareKeyboard state);
243
244 /// Returns true if the event and current [HardwareKeyboard] state would cause
245 /// this [ShortcutActivator] to be activated.
246 @Deprecated(
247 'Call accepts on the activator instead. '
248 'This feature was deprecated after v3.16.0-15.0.pre.',
249 )
250 static bool isActivatedBy(ShortcutActivator activator, KeyEvent event) {
251 return activator.accepts(event, HardwareKeyboard.instance);
252 }
253
254 /// Returns a description of the key set that is short and readable.
255 ///
256 /// Intended to be used in debug mode for logging purposes.
257 String debugDescribeKeys();
258}
259
260/// A set of [LogicalKeyboardKey]s that can be used as the keys in a map.
261///
262/// [LogicalKeySet] can be used as a [ShortcutActivator]. It is not recommended
263/// to use [LogicalKeySet] for a common shortcut such as `Delete` or `Ctrl+C`,
264/// prefer [SingleActivator] when possible, whose behavior more closely resembles
265/// that of typical platforms.
266///
267/// When used as a [ShortcutActivator], [LogicalKeySet] will activate the intent
268/// when all [keys] are pressed, and no others, except that modifier keys are
269/// considered without considering sides (e.g. control left and control right are
270/// considered the same).
271///
272/// {@tool dartpad}
273/// In the following example, the counter is increased when the following key
274/// sequences are pressed:
275///
276/// * Control left, then C.
277/// * Control right, then C.
278/// * C, then Control left.
279///
280/// But not when:
281///
282/// * Control left, then A, then C.
283///
284/// ** See code in examples/api/lib/widgets/shortcuts/logical_key_set.0.dart **
285/// {@end-tool}
286///
287/// This is also a thin wrapper around a [Set], but changes the equality
288/// comparison from an identity comparison to a contents comparison so that
289/// non-identical sets with the same keys in them will compare as equal.
290
291class LogicalKeySet extends KeySet<LogicalKeyboardKey>
292 with Diagnosticable
293 implements ShortcutActivator {
294 /// A constructor for making a [LogicalKeySet] of up to four keys.
295 ///
296 /// If you need a set of more than four keys, use [LogicalKeySet.fromSet].
297 ///
298 /// The same [LogicalKeyboardKey] may not be appear more than once in the set.
299 LogicalKeySet(super.key1, [super.key2, super.key3, super.key4]);
300
301 /// Create a [LogicalKeySet] from a set of [LogicalKeyboardKey]s.
302 ///
303 /// Do not mutate the `keys` set after passing it to this object.
304 LogicalKeySet.fromSet(super.keys) : super.fromSet();
305
306 @override
307 Iterable<LogicalKeyboardKey> get triggers => _triggers;
308 late final Set<LogicalKeyboardKey> _triggers =
309 keys
310 .expand((LogicalKeyboardKey key) => _unmapSynonyms[key] ?? <LogicalKeyboardKey>[key])
311 .toSet();
312
313 bool _checkKeyRequirements(Set<LogicalKeyboardKey> pressed) {
314 final Set<LogicalKeyboardKey> collapsedRequired = LogicalKeyboardKey.collapseSynonyms(keys);
315 final Set<LogicalKeyboardKey> collapsedPressed = LogicalKeyboardKey.collapseSynonyms(pressed);
316 return collapsedRequired.length == collapsedPressed.length &&
317 collapsedRequired.difference(collapsedPressed).isEmpty;
318 }
319
320 @override
321 bool accepts(KeyEvent event, HardwareKeyboard state) {
322 if (event is! KeyDownEvent && event is! KeyRepeatEvent) {
323 return false;
324 }
325 return triggers.contains(event.logicalKey) && _checkKeyRequirements(state.logicalKeysPressed);
326 }
327
328 static final Set<LogicalKeyboardKey> _modifiers = <LogicalKeyboardKey>{
329 LogicalKeyboardKey.alt,
330 LogicalKeyboardKey.control,
331 LogicalKeyboardKey.meta,
332 LogicalKeyboardKey.shift,
333 };
334 static final Map<LogicalKeyboardKey, List<LogicalKeyboardKey>> _unmapSynonyms =
335 <LogicalKeyboardKey, List<LogicalKeyboardKey>>{
336 LogicalKeyboardKey.control: <LogicalKeyboardKey>[
337 LogicalKeyboardKey.controlLeft,
338 LogicalKeyboardKey.controlRight,
339 ],
340 LogicalKeyboardKey.shift: <LogicalKeyboardKey>[
341 LogicalKeyboardKey.shiftLeft,
342 LogicalKeyboardKey.shiftRight,
343 ],
344 LogicalKeyboardKey.alt: <LogicalKeyboardKey>[
345 LogicalKeyboardKey.altLeft,
346 LogicalKeyboardKey.altRight,
347 ],
348 LogicalKeyboardKey.meta: <LogicalKeyboardKey>[
349 LogicalKeyboardKey.metaLeft,
350 LogicalKeyboardKey.metaRight,
351 ],
352 };
353
354 @override
355 String debugDescribeKeys() {
356 final List<LogicalKeyboardKey> sortedKeys =
357 keys.toList()..sort((LogicalKeyboardKey a, LogicalKeyboardKey b) {
358 // Put the modifiers first. If it has a synonym, then it's something
359 // like shiftLeft, altRight, etc.
360 final bool aIsModifier = a.synonyms.isNotEmpty || _modifiers.contains(a);
361 final bool bIsModifier = b.synonyms.isNotEmpty || _modifiers.contains(b);
362 if (aIsModifier && !bIsModifier) {
363 return -1;
364 } else if (bIsModifier && !aIsModifier) {
365 return 1;
366 }
367 return a.debugName!.compareTo(b.debugName!);
368 });
369 return sortedKeys.map<String>((LogicalKeyboardKey key) => key.debugName.toString()).join(' + ');
370 }
371
372 @override
373 void debugFillProperties(DiagnosticPropertiesBuilder properties) {
374 super.debugFillProperties(properties);
375 properties.add(
376 DiagnosticsProperty<Set<LogicalKeyboardKey>>('keys', _keys, description: debugDescribeKeys()),
377 );
378 }
379}
380
381/// A [DiagnosticsProperty] which handles formatting a `Map<LogicalKeySet, Intent>`
382/// (the same type as the [Shortcuts.shortcuts] property) so that its
383/// diagnostic output is human-readable.
384class ShortcutMapProperty extends DiagnosticsProperty<Map<ShortcutActivator, Intent>> {
385 /// Create a diagnostics property for `Map<ShortcutActivator, Intent>` objects,
386 /// which are the same type as the [Shortcuts.shortcuts] property.
387 ShortcutMapProperty(
388 String super.name,
389 Map<ShortcutActivator, Intent> super.value, {
390 super.showName,
391 Object super.defaultValue,
392 super.level,
393 super.description,
394 });
395
396 @override
397 Map<ShortcutActivator, Intent> get value => super.value!;
398
399 @override
400 String valueToString({TextTreeConfiguration? parentConfiguration}) {
401 return '{${value.keys.map<String>((ShortcutActivator keySet) => '{${keySet.debugDescribeKeys()}}: ${value[keySet]}').join(', ')}}';
402 }
403}
404
405/// A shortcut key combination of a single key and modifiers.
406///
407/// The [SingleActivator] implements typical shortcuts such as:
408///
409/// * ArrowLeft
410/// * Shift + Delete
411/// * Control + Alt + Meta + Shift + A
412///
413/// More specifically, it creates shortcut key combinations that are composed of a
414/// [trigger] key, and zero, some, or all of the four modifiers (control, shift,
415/// alt, meta). The shortcut is activated when the following conditions are met:
416///
417/// * The incoming event is a down event for a [trigger] key.
418/// * If [control] is true, then at least one control key must be held.
419/// Otherwise, no control keys must be held.
420/// * Similar conditions apply for the [alt], [shift], and [meta] keys.
421///
422/// This resembles the typical behavior of most operating systems, and handles
423/// modifier keys differently from [LogicalKeySet] in the following way:
424///
425/// * [SingleActivator]s allow additional non-modifier keys being pressed in
426/// order to activate the shortcut. For example, pressing key X while holding
427/// ControlLeft *and key A* will be accepted by
428/// `SingleActivator(LogicalKeyboardKey.keyX, control: true)`.
429/// * [SingleActivator]s do not consider modifiers to be a trigger key. For
430/// example, pressing ControlLeft while holding key X *will not* activate a
431/// `SingleActivator(LogicalKeyboardKey.keyX, control: true)`.
432///
433/// See also:
434///
435/// * [CharacterActivator], an activator that represents key combinations
436/// that result in the specified character, such as question mark.
437class SingleActivator with Diagnosticable, MenuSerializableShortcut implements ShortcutActivator {
438 /// Triggered when the [trigger] key is pressed while the modifiers are held.
439 ///
440 /// The [trigger] should be the non-modifier key that is pressed after all the
441 /// modifiers, such as [LogicalKeyboardKey.keyC] as in `Ctrl+C`. It must not
442 /// be a modifier key (sided or unsided).
443 ///
444 /// The [control], [shift], [alt], and [meta] flags represent whether the
445 /// respective modifier keys should be held (true) or released (false). They
446 /// default to false.
447 ///
448 /// By default, the activator is checked on all [KeyDownEvent] events for the
449 /// [trigger] key. If [includeRepeats] is false, only [trigger] key events
450 /// which are not [KeyRepeatEvent]s will be considered.
451 ///
452 /// {@tool dartpad}
453 /// In the following example, the shortcut `Control + C` increases the
454 /// counter:
455 ///
456 /// ** See code in examples/api/lib/widgets/shortcuts/single_activator.0.dart **
457 /// {@end-tool}
458 const SingleActivator(
459 this.trigger, {
460 this.control = false,
461 this.shift = false,
462 this.alt = false,
463 this.meta = false,
464 this.numLock = LockState.ignored,
465 this.includeRepeats = true,
466 }) : // The enumerated check with `identical` is cumbersome but the only way
467 // since const constructors can not call functions such as `==` or
468 // `Set.contains`. Checking with `identical` might not work when the
469 // key object is created from ID, but it covers common cases.
470 assert(
471 !identical(trigger, LogicalKeyboardKey.control) &&
472 !identical(trigger, LogicalKeyboardKey.controlLeft) &&
473 !identical(trigger, LogicalKeyboardKey.controlRight) &&
474 !identical(trigger, LogicalKeyboardKey.shift) &&
475 !identical(trigger, LogicalKeyboardKey.shiftLeft) &&
476 !identical(trigger, LogicalKeyboardKey.shiftRight) &&
477 !identical(trigger, LogicalKeyboardKey.alt) &&
478 !identical(trigger, LogicalKeyboardKey.altLeft) &&
479 !identical(trigger, LogicalKeyboardKey.altRight) &&
480 !identical(trigger, LogicalKeyboardKey.meta) &&
481 !identical(trigger, LogicalKeyboardKey.metaLeft) &&
482 !identical(trigger, LogicalKeyboardKey.metaRight),
483 );
484
485 /// The non-modifier key of the shortcut that is pressed after all modifiers
486 /// to activate the shortcut.
487 ///
488 /// For example, for `Control + C`, [trigger] should be
489 /// [LogicalKeyboardKey.keyC].
490 final LogicalKeyboardKey trigger;
491
492 /// Whether either (or both) control keys should be held for [trigger] to
493 /// activate the shortcut.
494 ///
495 /// It defaults to false, meaning all Control keys must be released when the
496 /// event is received in order to activate the shortcut. If it's true, then
497 /// either or both Control keys must be pressed.
498 ///
499 /// See also:
500 ///
501 /// * [LogicalKeyboardKey.controlLeft], [LogicalKeyboardKey.controlRight].
502 final bool control;
503
504 /// Whether either (or both) shift keys should be held for [trigger] to
505 /// activate the shortcut.
506 ///
507 /// It defaults to false, meaning all Shift keys must be released when the
508 /// event is received in order to activate the shortcut. If it's true, then
509 /// either or both Shift keys must be pressed.
510 ///
511 /// See also:
512 ///
513 /// * [LogicalKeyboardKey.shiftLeft], [LogicalKeyboardKey.shiftRight].
514 final bool shift;
515
516 /// Whether either (or both) alt keys should be held for [trigger] to
517 /// activate the shortcut.
518 ///
519 /// It defaults to false, meaning all Alt keys must be released when the
520 /// event is received in order to activate the shortcut. If it's true, then
521 /// either or both Alt keys must be pressed.
522 ///
523 /// See also:
524 ///
525 /// * [LogicalKeyboardKey.altLeft], [LogicalKeyboardKey.altRight].
526 final bool alt;
527
528 /// Whether either (or both) meta keys should be held for [trigger] to
529 /// activate the shortcut.
530 ///
531 /// It defaults to false, meaning all Meta keys must be released when the
532 /// event is received in order to activate the shortcut. If it's true, then
533 /// either or both Meta keys must be pressed.
534 ///
535 /// See also:
536 ///
537 /// * [LogicalKeyboardKey.metaLeft], [LogicalKeyboardKey.metaRight].
538 final bool meta;
539
540 /// Whether the NumLock key state should be checked for [trigger] to activate
541 /// the shortcut.
542 ///
543 /// It defaults to [LockState.ignored], meaning the NumLock state is ignored
544 /// when the event is received in order to activate the shortcut.
545 /// If it's [LockState.locked], then the NumLock key must be locked.
546 /// If it's [LockState.unlocked], then the NumLock key must be unlocked.
547 ///
548 /// See also:
549 ///
550 /// * [LogicalKeyboardKey.numLock].
551 final LockState numLock;
552
553 /// Whether this activator accepts repeat events of the [trigger] key.
554 ///
555 /// If [includeRepeats] is true, the activator is checked on all
556 /// [KeyDownEvent] or [KeyRepeatEvent]s for the [trigger] key. If
557 /// [includeRepeats] is false, only [trigger] key events which are
558 /// [KeyDownEvent]s will be considered.
559 final bool includeRepeats;
560
561 @override
562 Iterable<LogicalKeyboardKey> get triggers => <LogicalKeyboardKey>[trigger];
563
564 bool _shouldAcceptModifiers(Set<LogicalKeyboardKey> pressed) {
565 return control == pressed.intersection(_controlSynonyms).isNotEmpty &&
566 shift == pressed.intersection(_shiftSynonyms).isNotEmpty &&
567 alt == pressed.intersection(_altSynonyms).isNotEmpty &&
568 meta == pressed.intersection(_metaSynonyms).isNotEmpty;
569 }
570
571 bool _shouldAcceptNumLock(HardwareKeyboard state) {
572 return switch (numLock) {
573 LockState.ignored => true,
574 LockState.locked => state.lockModesEnabled.contains(KeyboardLockMode.numLock),
575 LockState.unlocked => !state.lockModesEnabled.contains(KeyboardLockMode.numLock),
576 };
577 }
578
579 @override
580 bool accepts(KeyEvent event, HardwareKeyboard state) {
581 return (event is KeyDownEvent || (includeRepeats && event is KeyRepeatEvent)) &&
582 triggers.contains(event.logicalKey) &&
583 _shouldAcceptModifiers(state.logicalKeysPressed) &&
584 _shouldAcceptNumLock(state);
585 }
586
587 @override
588 ShortcutSerialization serializeForMenu() {
589 return ShortcutSerialization.modifier(
590 trigger,
591 shift: shift,
592 alt: alt,
593 meta: meta,
594 control: control,
595 );
596 }
597
598 /// Returns a short and readable description of the key combination.
599 ///
600 /// Intended to be used in debug mode for logging purposes. In release mode,
601 /// [debugDescribeKeys] returns an empty string.
602 @override
603 String debugDescribeKeys() {
604 String result = '';
605 assert(() {
606 final List<String> keys = <String>[
607 if (control) 'Control',
608 if (alt) 'Alt',
609 if (meta) 'Meta',
610 if (shift) 'Shift',
611 trigger.debugName ?? trigger.toStringShort(),
612 ];
613 result = keys.join(' + ');
614 return true;
615 }());
616 return result;
617 }
618
619 @override
620 void debugFillProperties(DiagnosticPropertiesBuilder properties) {
621 super.debugFillProperties(properties);
622 properties.add(MessageProperty('keys', debugDescribeKeys()));
623 properties.add(
624 FlagProperty('includeRepeats', value: includeRepeats, ifFalse: 'excluding repeats'),
625 );
626 }
627}
628
629/// A shortcut combination that is triggered by a key event that produces a
630/// specific character.
631///
632/// Keys often produce different characters when combined with modifiers. For
633/// example, it might be helpful for the user to bring up a help menu by
634/// pressing the question mark ('?'). However, there is no logical key that
635/// directly represents a question mark. Although 'Shift+Slash' produces a '?'
636/// character on a US keyboard, its logical key is still considered a Slash key,
637/// and hard-coding 'Shift+Slash' in this situation is unfriendly to other
638/// keyboard layouts.
639///
640/// For example, `CharacterActivator('?')` is triggered when a key combination
641/// results in a question mark, which is 'Shift+Slash' on a US keyboard, but
642/// 'Shift+Comma' on a French keyboard.
643///
644/// {@tool dartpad}
645/// In the following example, when a key combination results in a question mark,
646/// the [SnackBar] gets shown:
647///
648/// ** See code in examples/api/lib/widgets/shortcuts/character_activator.0.dart **
649/// {@end-tool}
650///
651/// The [alt], [control], and [meta] flags represent whether the respective
652/// modifier keys should be held (true) or released (false). They default to
653/// false. [CharacterActivator] cannot check shifted keys, since the Shift key
654/// affects the resulting character, and will accept whether either of the
655/// Shift keys are pressed or not, as long as the key event produces the
656/// correct character.
657///
658/// By default, the activator is checked on all [KeyDownEvent] or
659/// [KeyRepeatEvent]s for the [character] in combination with the requested
660/// modifier keys. If `includeRepeats` is false, only the [character] events
661/// with that are [KeyDownEvent]s will be considered.
662///
663/// {@template flutter.widgets.shortcuts.CharacterActivator.alt}
664/// On macOS and iOS, the [alt] flag indicates that the Option key (⌥) is
665/// pressed. Because the Option key affects the character generated on these
666/// platforms, it can be unintuitive to define [CharacterActivator]s for them.
667///
668/// For instance, if you want the shortcut to trigger when Option+s (⌥-s) is
669/// pressed, and what you intend is to trigger whenever the character 'ß' is
670/// produced, you would use `CharacterActivator('ß')` or
671/// `CharacterActivator('ß', alt: true)` instead of `CharacterActivator('s',
672/// alt: true)`. This is because `CharacterActivator('s', alt: true)` will
673/// never trigger, since the 's' character can't be produced when the Option
674/// key is held down.
675///
676/// If what is intended is that the shortcut is triggered when Option+s (⌥-s)
677/// is pressed, regardless of which character is produced, it is better to use
678/// [SingleActivator], as in `SingleActivator(LogicalKeyboardKey.keyS, alt:
679/// true)`.
680/// {@endtemplate}
681///
682/// See also:
683///
684/// * [SingleActivator], an activator that represents a single key combined
685/// with modifiers, such as `Ctrl+C` or `Ctrl-Right Arrow`.
686class CharacterActivator
687 with Diagnosticable, MenuSerializableShortcut
688 implements ShortcutActivator {
689 /// Triggered when the key event yields the given character.
690 const CharacterActivator(
691 this.character, {
692 this.alt = false,
693 this.control = false,
694 this.meta = false,
695 this.includeRepeats = true,
696 });
697
698 /// Whether either (or both) Alt keys should be held for the [character] to
699 /// activate the shortcut.
700 ///
701 /// It defaults to false, meaning all Alt keys must be released when the event
702 /// is received in order to activate the shortcut. If it's true, then either
703 /// one or both Alt keys must be pressed.
704 ///
705 /// {@macro flutter.widgets.shortcuts.CharacterActivator.alt}
706 ///
707 /// See also:
708 ///
709 /// * [LogicalKeyboardKey.altLeft], [LogicalKeyboardKey.altRight].
710 final bool alt;
711
712 /// Whether either (or both) Control keys should be held for the [character]
713 /// to activate the shortcut.
714 ///
715 /// It defaults to false, meaning all Control keys must be released when the
716 /// event is received in order to activate the shortcut. If it's true, then
717 /// either one or both Control keys must be pressed.
718 ///
719 /// See also:
720 ///
721 /// * [LogicalKeyboardKey.controlLeft], [LogicalKeyboardKey.controlRight].
722 final bool control;
723
724 /// Whether either (or both) Meta keys should be held for the [character] to
725 /// activate the shortcut.
726 ///
727 /// It defaults to false, meaning all Meta keys must be released when the
728 /// event is received in order to activate the shortcut. If it's true, then
729 /// either one or both Meta keys must be pressed.
730 ///
731 /// See also:
732 ///
733 /// * [LogicalKeyboardKey.metaLeft], [LogicalKeyboardKey.metaRight].
734 final bool meta;
735
736 /// Whether this activator accepts repeat events of the [character].
737 ///
738 /// If [includeRepeats] is true, the activator is checked on all
739 /// [KeyDownEvent] and [KeyRepeatEvent]s for the [character]. If
740 /// [includeRepeats] is false, only the [character] events that are
741 /// [KeyDownEvent]s will be considered.
742 final bool includeRepeats;
743
744 /// The character which triggers the shortcut.
745 ///
746 /// This is typically a single-character string, such as '?' or 'Å“', although
747 /// [CharacterActivator] doesn't check the length of [character] or whether it
748 /// can be matched by any key combination at all. It is case-sensitive, since
749 /// the [character] is directly compared by `==` to the character reported by
750 /// the platform.
751 ///
752 /// See also:
753 ///
754 /// * [KeyEvent.character], the character of a key event.
755 final String character;
756
757 @override
758 Iterable<LogicalKeyboardKey>? get triggers => null;
759
760 bool _shouldAcceptModifiers(Set<LogicalKeyboardKey> pressed) {
761 // Doesn't look for shift, since the character will encode that.
762 return control == pressed.intersection(_controlSynonyms).isNotEmpty &&
763 alt == pressed.intersection(_altSynonyms).isNotEmpty &&
764 meta == pressed.intersection(_metaSynonyms).isNotEmpty;
765 }
766
767 @override
768 bool accepts(KeyEvent event, HardwareKeyboard state) {
769 // Ignore triggers, since we're only interested in the character.
770 return event.character == character &&
771 (event is KeyDownEvent || (includeRepeats && event is KeyRepeatEvent)) &&
772 _shouldAcceptModifiers(state.logicalKeysPressed);
773 }
774
775 @override
776 String debugDescribeKeys() {
777 String result = '';
778 assert(() {
779 final List<String> keys = <String>[
780 if (alt) 'Alt',
781 if (control) 'Control',
782 if (meta) 'Meta',
783 "'$character'",
784 ];
785 result = keys.join(' + ');
786 return true;
787 }());
788 return result;
789 }
790
791 @override
792 ShortcutSerialization serializeForMenu() {
793 return ShortcutSerialization.character(character, alt: alt, control: control, meta: meta);
794 }
795
796 @override
797 void debugFillProperties(DiagnosticPropertiesBuilder properties) {
798 super.debugFillProperties(properties);
799 properties.add(MessageProperty('character', debugDescribeKeys()));
800 properties.add(
801 FlagProperty('includeRepeats', value: includeRepeats, ifFalse: 'excluding repeats'),
802 );
803 }
804}
805
806class _ActivatorIntentPair with Diagnosticable {
807 const _ActivatorIntentPair(this.activator, this.intent);
808 final ShortcutActivator activator;
809 final Intent intent;
810
811 @override
812 void debugFillProperties(DiagnosticPropertiesBuilder properties) {
813 super.debugFillProperties(properties);
814 properties.add(DiagnosticsProperty<String>('activator', activator.debugDescribeKeys()));
815 properties.add(DiagnosticsProperty<Intent>('intent', intent));
816 }
817}
818
819/// A manager of keyboard shortcut bindings used by [Shortcuts] to handle key
820/// events.
821///
822/// The manager may be listened to (with [addListener]/[removeListener]) for
823/// change notifications when the shortcuts change.
824///
825/// Typically, a [Shortcuts] widget supplies its own manager, but in uncommon
826/// cases where overriding the usual shortcut manager behavior is desired, a
827/// subclassed [ShortcutManager] may be supplied.
828class ShortcutManager with Diagnosticable, ChangeNotifier {
829 /// Constructs a [ShortcutManager].
830 ShortcutManager({
831 Map<ShortcutActivator, Intent> shortcuts = const <ShortcutActivator, Intent>{},
832 this.modal = false,
833 }) : _shortcuts = shortcuts {
834 if (kFlutterMemoryAllocationsEnabled) {
835 ChangeNotifier.maybeDispatchObjectCreation(this);
836 }
837 }
838
839 /// True if the [ShortcutManager] should not pass on keys that it doesn't
840 /// handle to any key-handling widgets that are ancestors to this one.
841 ///
842 /// Setting [modal] to true will prevent any key event given to this manager
843 /// from being given to any ancestor managers, even if that key doesn't appear
844 /// in the [shortcuts] map.
845 ///
846 /// The net effect of setting [modal] to true is to return
847 /// [KeyEventResult.skipRemainingHandlers] from [handleKeypress] if it does
848 /// not exist in the shortcut map, instead of returning
849 /// [KeyEventResult.ignored].
850 final bool modal;
851
852 /// Returns the shortcut map.
853 ///
854 /// When the map is changed, listeners to this manager will be notified.
855 ///
856 /// The returned map should not be modified.
857 Map<ShortcutActivator, Intent> get shortcuts => _shortcuts;
858 Map<ShortcutActivator, Intent> _shortcuts = <ShortcutActivator, Intent>{};
859 set shortcuts(Map<ShortcutActivator, Intent> value) {
860 if (!mapEquals<ShortcutActivator, Intent>(_shortcuts, value)) {
861 _shortcuts = value;
862 _indexedShortcutsCache = null;
863 notifyListeners();
864 }
865 }
866
867 static Map<LogicalKeyboardKey?, List<_ActivatorIntentPair>> _indexShortcuts(
868 Map<ShortcutActivator, Intent> source,
869 ) {
870 final Map<LogicalKeyboardKey?, List<_ActivatorIntentPair>> result =
871 <LogicalKeyboardKey?, List<_ActivatorIntentPair>>{};
872 source.forEach((ShortcutActivator activator, Intent intent) {
873 // This intermediate variable is necessary to comply with Dart analyzer.
874 final Iterable<LogicalKeyboardKey?>? nullableTriggers = activator.triggers;
875 for (final LogicalKeyboardKey? trigger in nullableTriggers ?? <LogicalKeyboardKey?>[null]) {
876 result
877 .putIfAbsent(trigger, () => <_ActivatorIntentPair>[])
878 .add(_ActivatorIntentPair(activator, intent));
879 }
880 });
881 return result;
882 }
883
884 Map<LogicalKeyboardKey?, List<_ActivatorIntentPair>> get _indexedShortcuts {
885 return _indexedShortcutsCache ??= _indexShortcuts(shortcuts);
886 }
887
888 Map<LogicalKeyboardKey?, List<_ActivatorIntentPair>>? _indexedShortcutsCache;
889
890 Iterable<_ActivatorIntentPair> _getCandidates(LogicalKeyboardKey key) {
891 return <_ActivatorIntentPair>[
892 ..._indexedShortcuts[key] ?? <_ActivatorIntentPair>[],
893 ..._indexedShortcuts[null] ?? <_ActivatorIntentPair>[],
894 ];
895 }
896
897 /// Returns the [Intent], if any, that matches the current set of pressed
898 /// keys.
899 ///
900 /// Returns null if no intent matches the current set of pressed keys.
901 Intent? _find(KeyEvent event, HardwareKeyboard state) {
902 for (final _ActivatorIntentPair activatorIntent in _getCandidates(event.logicalKey)) {
903 if (activatorIntent.activator.accepts(event, state)) {
904 return activatorIntent.intent;
905 }
906 }
907 return null;
908 }
909
910 /// Handles a key press `event` in the given `context`.
911 ///
912 /// If a key mapping is found, then the associated action will be invoked
913 /// using the [Intent] activated by the [ShortcutActivator] in the [shortcuts]
914 /// map, and the currently focused widget's context (from
915 /// [FocusManager.primaryFocus]).
916 ///
917 /// Returns a [KeyEventResult.handled] if an action was invoked, otherwise a
918 /// [KeyEventResult.skipRemainingHandlers] if [modal] is true, or if it maps
919 /// to a [DoNothingAction] with [DoNothingAction.consumesKey] set to false,
920 /// and in all other cases returns [KeyEventResult.ignored].
921 ///
922 /// In order for an action to be invoked (and [KeyEventResult.handled]
923 /// returned), a [ShortcutActivator] must accept the given [KeyEvent], be
924 /// mapped to an [Intent], the [Intent] must be mapped to an [Action], and the
925 /// [Action] must be enabled.
926 @protected
927 KeyEventResult handleKeypress(BuildContext context, KeyEvent event) {
928 // Marking some variables as "late" ensures that they aren't evaluated unless needed.
929 late final Intent? intent = _find(event, HardwareKeyboard.instance);
930 late final BuildContext? context = primaryFocus?.context;
931 late final Action<Intent>? action = Actions.maybeFind<Intent>(context!, intent: intent);
932
933 if (intent != null && context != null && action != null) {
934 final (bool enabled, Object? invokeResult) = Actions.of(
935 context,
936 ).invokeActionIfEnabled(action, intent, context);
937
938 if (enabled) {
939 return action.toKeyEventResult(intent, invokeResult);
940 }
941 }
942 return modal ? KeyEventResult.skipRemainingHandlers : KeyEventResult.ignored;
943 }
944
945 @override
946 void debugFillProperties(DiagnosticPropertiesBuilder properties) {
947 super.debugFillProperties(properties);
948 properties.add(DiagnosticsProperty<Map<ShortcutActivator, Intent>>('shortcuts', shortcuts));
949 properties.add(FlagProperty('modal', value: modal, ifTrue: 'modal', defaultValue: false));
950 }
951}
952
953/// A widget that creates key bindings to specific actions for its
954/// descendants.
955///
956/// {@youtube 560 315 https://www.youtube.com/watch?v=6ZcQmdoz9N8}
957///
958/// This widget establishes a [ShortcutManager] to be used by its descendants
959/// when invoking an [Action] via a keyboard key combination that maps to an
960/// [Intent].
961///
962/// This is similar to but more powerful than the [CallbackShortcuts] widget.
963/// Unlike [CallbackShortcuts], this widget separates key bindings and their
964/// implementations. This separation allows [Shortcuts] to have key bindings
965/// that adapt to the focused context. For example, the desired action for a
966/// deletion intent may be to delete a character in a text input, or to delete
967/// a file in a file menu.
968///
969/// See the article on
970/// [Using Actions and Shortcuts](https://flutter.dev/to/actions-shortcuts)
971/// for a detailed explanation.
972///
973/// {@tool dartpad}
974/// Here, we will use the [Shortcuts] and [Actions] widgets to add and subtract
975/// from a counter. When the child widget has keyboard focus, and a user presses
976/// the keys that have been defined in [Shortcuts], the action that is bound
977/// to the appropriate [Intent] for the key is invoked.
978///
979/// It also shows the use of a [CallbackAction] to avoid creating a new [Action]
980/// subclass.
981///
982/// ** See code in examples/api/lib/widgets/shortcuts/shortcuts.0.dart **
983/// {@end-tool}
984///
985/// {@tool dartpad}
986/// This slightly more complicated, but more flexible, example creates a custom
987/// [Action] subclass to increment and decrement within a widget (a [Column])
988/// that has keyboard focus. When the user presses the up and down arrow keys,
989/// the counter will increment and decrement a data model using the custom
990/// actions.
991///
992/// One thing that this demonstrates is passing arguments to the [Intent] to be
993/// carried to the [Action]. This shows how actions can get data either from
994/// their own construction (like the `model` in this example), or from the
995/// intent passed to them when invoked (like the increment `amount` in this
996/// example).
997///
998/// ** See code in examples/api/lib/widgets/shortcuts/shortcuts.1.dart **
999/// {@end-tool}
1000///
1001/// See also:
1002///
1003/// * [CallbackShortcuts], a simpler but less flexible widget that defines key
1004/// bindings that invoke callbacks.
1005/// * [Intent], a class for containing a description of a user action to be
1006/// invoked.
1007/// * [Action], a class for defining an invocation of a user action.
1008/// * [CallbackAction], a class for creating an action from a callback.
1009class Shortcuts extends StatefulWidget {
1010 /// Creates a const [Shortcuts] widget that owns the map of shortcuts and
1011 /// creates its own manager.
1012 ///
1013 /// When using this constructor, [manager] will return null.
1014 ///
1015 /// The [child] and [shortcuts] arguments are required.
1016 ///
1017 /// See also:
1018 ///
1019 /// * [Shortcuts.manager], a constructor that uses a [ShortcutManager] to
1020 /// manage the shortcuts list instead.
1021 const Shortcuts({
1022 super.key,
1023 required Map<ShortcutActivator, Intent> shortcuts,
1024 required this.child,
1025 this.debugLabel,
1026 this.includeSemantics = true,
1027 }) : _shortcuts = shortcuts,
1028 manager = null;
1029
1030 /// Creates a const [Shortcuts] widget that uses the [manager] to
1031 /// manage the map of shortcuts.
1032 ///
1033 /// If this constructor is used, [shortcuts] will return the contents of
1034 /// [ShortcutManager.shortcuts].
1035 ///
1036 /// The [child] and [manager] arguments are required.
1037 const Shortcuts.manager({
1038 super.key,
1039 required ShortcutManager this.manager,
1040 required this.child,
1041 this.debugLabel,
1042 this.includeSemantics = true,
1043 }) : _shortcuts = const <ShortcutActivator, Intent>{};
1044
1045 /// The [ShortcutManager] that will manage the mapping between key
1046 /// combinations and [Action]s.
1047 ///
1048 /// If this widget was created with [Shortcuts.manager], then
1049 /// [ShortcutManager.shortcuts] will be used as the source for shortcuts. If
1050 /// the unnamed constructor is used, this manager will be null, and a
1051 /// default-constructed [ShortcutManager] will be used.
1052 final ShortcutManager? manager;
1053
1054 /// {@template flutter.widgets.shortcuts.shortcuts}
1055 /// The map of shortcuts that describes the mapping between a key sequence
1056 /// defined by a [ShortcutActivator] and the [Intent] that will be emitted
1057 /// when that key sequence is pressed.
1058 /// {@endtemplate}
1059 Map<ShortcutActivator, Intent> get shortcuts {
1060 return manager == null ? _shortcuts : manager!.shortcuts;
1061 }
1062
1063 final Map<ShortcutActivator, Intent> _shortcuts;
1064
1065 /// The child widget for this [Shortcuts] widget.
1066 ///
1067 /// {@macro flutter.widgets.ProxyWidget.child}
1068 final Widget child;
1069
1070 /// The debug label that is printed for this node when logged.
1071 ///
1072 /// If this label is set, then it will be displayed instead of the shortcut
1073 /// map when logged.
1074 ///
1075 /// This allows simplifying the diagnostic output to avoid cluttering it
1076 /// unnecessarily with large default shortcut maps.
1077 final String? debugLabel;
1078
1079 /// {@macro flutter.widgets.Focus.includeSemantics}
1080 final bool includeSemantics;
1081
1082 @override
1083 State<Shortcuts> createState() => _ShortcutsState();
1084
1085 @override
1086 void debugFillProperties(DiagnosticPropertiesBuilder properties) {
1087 super.debugFillProperties(properties);
1088 properties.add(DiagnosticsProperty<ShortcutManager>('manager', manager, defaultValue: null));
1089 properties.add(
1090 ShortcutMapProperty(
1091 'shortcuts',
1092 shortcuts,
1093 description: debugLabel?.isNotEmpty ?? false ? debugLabel : null,
1094 ),
1095 );
1096 }
1097}
1098
1099class _ShortcutsState extends State<Shortcuts> {
1100 ShortcutManager? _internalManager;
1101 ShortcutManager get manager => widget.manager ?? _internalManager!;
1102
1103 @override
1104 void dispose() {
1105 _internalManager?.dispose();
1106 super.dispose();
1107 }
1108
1109 @override
1110 void initState() {
1111 super.initState();
1112 if (widget.manager == null) {
1113 _internalManager = ShortcutManager();
1114 _internalManager!.shortcuts = widget.shortcuts;
1115 }
1116 }
1117
1118 @override
1119 void didUpdateWidget(Shortcuts oldWidget) {
1120 super.didUpdateWidget(oldWidget);
1121 if (widget.manager != oldWidget.manager) {
1122 if (widget.manager != null) {
1123 _internalManager?.dispose();
1124 _internalManager = null;
1125 } else {
1126 _internalManager ??= ShortcutManager();
1127 }
1128 }
1129 _internalManager?.shortcuts = widget.shortcuts;
1130 }
1131
1132 KeyEventResult _handleOnKeyEvent(FocusNode node, KeyEvent event) {
1133 if (node.context == null) {
1134 return KeyEventResult.ignored;
1135 }
1136 return manager.handleKeypress(node.context!, event);
1137 }
1138
1139 @override
1140 Widget build(BuildContext context) {
1141 return Focus(
1142 debugLabel: '$Shortcuts',
1143 canRequestFocus: false,
1144 onKeyEvent: _handleOnKeyEvent,
1145 includeSemantics: widget.includeSemantics,
1146 child: widget.child,
1147 );
1148 }
1149}
1150
1151/// A widget that binds key combinations to specific callbacks.
1152///
1153/// {@youtube 560 315 https://www.youtube.com/watch?v=VcQQ1ns_qNY}
1154///
1155/// This is similar to but simpler than the [Shortcuts] widget as it doesn't
1156/// require [Intent]s and [Actions] widgets. Instead, it accepts a map
1157/// of [ShortcutActivator]s to [VoidCallback]s.
1158///
1159/// Unlike [Shortcuts], this widget does not separate key bindings and their
1160/// implementations. This separation allows [Shortcuts] to have key bindings
1161/// that adapt to the focused context. For example, the desired action for a
1162/// deletion intent may be to delete a character in a text input, or to delete
1163/// a file in a file menu.
1164///
1165/// {@tool dartpad}
1166/// This example uses the [CallbackShortcuts] widget to add and subtract
1167/// from a counter when the up or down arrow keys are pressed.
1168///
1169/// ** See code in examples/api/lib/widgets/shortcuts/callback_shortcuts.0.dart **
1170/// {@end-tool}
1171///
1172/// [Shortcuts] and [CallbackShortcuts] can both be used in the same app. As
1173/// with any key handling widget, if this widget handles a key event then
1174/// widgets above it in the focus chain will not receive the event. This means
1175/// that if this widget handles a key, then an ancestor [Shortcuts] widget (or
1176/// any other key handling widget) will not receive that key. Similarly, if
1177/// a descendant of this widget handles the key, then the key event will not
1178/// reach this widget for handling.
1179///
1180/// See the article on
1181/// [Using Actions and Shortcuts](https://flutter.dev/to/actions-shortcuts)
1182/// for a detailed explanation.
1183///
1184/// See also:
1185/// * [Shortcuts], a more powerful widget for defining key bindings.
1186/// * [Focus], a widget that defines which widgets can receive keyboard focus.
1187class CallbackShortcuts extends StatelessWidget {
1188 /// Creates a const [CallbackShortcuts] widget.
1189 const CallbackShortcuts({super.key, required this.bindings, required this.child});
1190
1191 /// A map of key combinations to callbacks used to define the shortcut
1192 /// bindings.
1193 ///
1194 /// If a descendant of this widget has focus, and a key is pressed, the
1195 /// activator keys of this map will be asked if they accept the key event. If
1196 /// they do, then the corresponding callback is invoked, and the key event
1197 /// propagation is halted. If none of the activators accept the key event,
1198 /// then the key event continues to be propagated up the focus chain.
1199 ///
1200 /// If more than one activator accepts the key event, then all of the
1201 /// callbacks associated with activators that accept the key event are
1202 /// invoked.
1203 ///
1204 /// Some examples of [ShortcutActivator] subclasses that can be used to define
1205 /// the key combinations here are [SingleActivator], [CharacterActivator], and
1206 /// [LogicalKeySet].
1207 final Map<ShortcutActivator, VoidCallback> bindings;
1208
1209 /// The widget below this widget in the tree.
1210 ///
1211 /// {@macro flutter.widgets.ProxyWidget.child}
1212 final Widget child;
1213
1214 // A helper function to make the stack trace more useful if the callback
1215 // throws, by providing the activator and event as arguments that will appear
1216 // in the stack trace.
1217 bool _applyKeyEventBinding(ShortcutActivator activator, KeyEvent event) {
1218 if (activator.accepts(event, HardwareKeyboard.instance)) {
1219 bindings[activator]!.call();
1220 return true;
1221 }
1222 return false;
1223 }
1224
1225 @override
1226 Widget build(BuildContext context) {
1227 return Focus(
1228 canRequestFocus: false,
1229 skipTraversal: true,
1230 onKeyEvent: (FocusNode node, KeyEvent event) {
1231 KeyEventResult result = KeyEventResult.ignored;
1232 // Activates all key bindings that match, returns "handled" if any handle it.
1233 for (final ShortcutActivator activator in bindings.keys) {
1234 result = _applyKeyEventBinding(activator, event) ? KeyEventResult.handled : result;
1235 }
1236 return result;
1237 },
1238 child: child,
1239 );
1240 }
1241}
1242
1243/// A entry returned by [ShortcutRegistry.addAll] that allows the caller to
1244/// identify the shortcuts they registered with the [ShortcutRegistry] through
1245/// the [ShortcutRegistrar].
1246///
1247/// When the entry is no longer needed, [dispose] should be called, and the
1248/// entry should no longer be used.
1249class ShortcutRegistryEntry {
1250 // Tokens can only be created by the ShortcutRegistry.
1251 const ShortcutRegistryEntry._(this.registry);
1252
1253 /// The [ShortcutRegistry] that this entry was issued by.
1254 final ShortcutRegistry registry;
1255
1256 /// Replaces the given shortcut bindings in the [ShortcutRegistry] that this
1257 /// entry was created from.
1258 ///
1259 /// This method will assert in debug mode if another [ShortcutRegistryEntry]
1260 /// exists (i.e. hasn't been disposed of) that has already added a given
1261 /// shortcut.
1262 ///
1263 /// It will also assert if this entry has already been disposed.
1264 ///
1265 /// If two equivalent, but different, [ShortcutActivator]s are added, all of
1266 /// them will be executed when triggered. For example, if both
1267 /// `SingleActivator(LogicalKeyboardKey.keyA)` and `CharacterActivator('a')`
1268 /// are added, then both will be executed when an "a" key is pressed.
1269 void replaceAll(Map<ShortcutActivator, Intent> value) {
1270 registry._replaceAll(this, value);
1271 }
1272
1273 /// Called when the entry is no longer needed.
1274 ///
1275 /// Call this will remove all shortcuts associated with this
1276 /// [ShortcutRegistryEntry] from the [registry].
1277 @mustCallSuper
1278 void dispose() {
1279 registry._disposeEntry(this);
1280 }
1281}
1282
1283/// A class used by [ShortcutRegistrar] that allows adding or removing shortcut
1284/// bindings by descendants of the [ShortcutRegistrar].
1285///
1286/// You can reach the nearest [ShortcutRegistry] using [of] and [maybeOf].
1287///
1288/// The registry may be listened to (with [addListener]/[removeListener]) for
1289/// change notifications when the registered shortcuts change. Change
1290/// notifications take place after the current frame is drawn, so that
1291/// widgets that are not descendants of the registry can listen to it (e.g. in
1292/// overlays).
1293class ShortcutRegistry with ChangeNotifier {
1294 /// Creates an instance of [ShortcutRegistry].
1295 ShortcutRegistry() {
1296 if (kFlutterMemoryAllocationsEnabled) {
1297 ChangeNotifier.maybeDispatchObjectCreation(this);
1298 }
1299 }
1300
1301 bool _notificationScheduled = false;
1302 bool _disposed = false;
1303
1304 @override
1305 void dispose() {
1306 super.dispose();
1307 _disposed = true;
1308 }
1309
1310 /// Gets the combined shortcut bindings from all contexts that are registered
1311 /// with this [ShortcutRegistry].
1312 ///
1313 /// Listeners will be notified when the value returned by this getter changes.
1314 ///
1315 /// Returns a copy: modifying the returned map will have no effect.
1316 Map<ShortcutActivator, Intent> get shortcuts {
1317 assert(ChangeNotifier.debugAssertNotDisposed(this));
1318 return <ShortcutActivator, Intent>{
1319 for (final MapEntry<ShortcutRegistryEntry, Map<ShortcutActivator, Intent>> entry
1320 in _registeredShortcuts.entries)
1321 ...entry.value,
1322 };
1323 }
1324
1325 final Map<ShortcutRegistryEntry, Map<ShortcutActivator, Intent>> _registeredShortcuts =
1326 <ShortcutRegistryEntry, Map<ShortcutActivator, Intent>>{};
1327
1328 /// Adds all the given shortcut bindings to this [ShortcutRegistry], and
1329 /// returns a entry for managing those bindings.
1330 ///
1331 /// The entry should have [ShortcutRegistryEntry.dispose] called on it when
1332 /// these shortcuts are no longer needed. This will remove them from the
1333 /// registry, and invalidate the entry.
1334 ///
1335 /// This method will assert in debug mode if another entry exists (i.e. hasn't
1336 /// been disposed of) that has already added a given shortcut.
1337 ///
1338 /// If two equivalent, but different, [ShortcutActivator]s are added, all of
1339 /// them will be executed when triggered. For example, if both
1340 /// `SingleActivator(LogicalKeyboardKey.keyA)` and `CharacterActivator('a')`
1341 /// are added, then both will be executed when an "a" key is pressed.
1342 ///
1343 /// See also:
1344 ///
1345 /// * [ShortcutRegistryEntry.replaceAll], a function used to replace the set of
1346 /// shortcuts associated with a particular entry.
1347 /// * [ShortcutRegistryEntry.dispose], a function used to remove the set of
1348 /// shortcuts associated with a particular entry.
1349 ShortcutRegistryEntry addAll(Map<ShortcutActivator, Intent> value) {
1350 assert(ChangeNotifier.debugAssertNotDisposed(this));
1351 assert(value.isNotEmpty, 'Cannot register an empty map of shortcuts');
1352 final ShortcutRegistryEntry entry = ShortcutRegistryEntry._(this);
1353 _registeredShortcuts[entry] = value;
1354 assert(_debugCheckForDuplicates());
1355 _notifyListenersNextFrame();
1356 return entry;
1357 }
1358
1359 // Subscriber notification has to happen in the next frame because shortcuts
1360 // are often registered that affect things in the overlay or different parts
1361 // of the tree, and so can cause build ordering issues if notifications happen
1362 // during the build. The _notificationScheduled check makes sure we only
1363 // notify once per frame.
1364 void _notifyListenersNextFrame() {
1365 if (!_notificationScheduled) {
1366 SchedulerBinding.instance.addPostFrameCallback((Duration _) {
1367 _notificationScheduled = false;
1368 if (!_disposed) {
1369 notifyListeners();
1370 }
1371 }, debugLabel: 'ShortcutRegistry.notifyListeners');
1372 _notificationScheduled = true;
1373 }
1374 }
1375
1376 /// Returns the [ShortcutRegistry] that belongs to the [ShortcutRegistrar]
1377 /// which most tightly encloses the given [BuildContext].
1378 ///
1379 /// If no [ShortcutRegistrar] widget encloses the context given, [of] will
1380 /// throw an exception in debug mode.
1381 ///
1382 /// There is a default [ShortcutRegistrar] instance in [WidgetsApp], so if
1383 /// [WidgetsApp], [MaterialApp] or [CupertinoApp] are used, an additional
1384 /// [ShortcutRegistrar] isn't needed.
1385 ///
1386 /// See also:
1387 ///
1388 /// * [maybeOf], which is similar to this function, but will return null if
1389 /// it doesn't find a [ShortcutRegistrar] ancestor.
1390 static ShortcutRegistry of(BuildContext context) {
1391 final _ShortcutRegistrarScope? inherited =
1392 context.dependOnInheritedWidgetOfExactType<_ShortcutRegistrarScope>();
1393 assert(() {
1394 if (inherited == null) {
1395 throw FlutterError(
1396 'Unable to find a $ShortcutRegistrar widget in the context.\n'
1397 '$ShortcutRegistrar.of() was called with a context that does not contain a '
1398 '$ShortcutRegistrar widget.\n'
1399 'No $ShortcutRegistrar ancestor could be found starting from the context that was '
1400 'passed to $ShortcutRegistrar.of().\n'
1401 'The context used was:\n'
1402 ' $context',
1403 );
1404 }
1405 return true;
1406 }());
1407 return inherited!.registry;
1408 }
1409
1410 /// Returns [ShortcutRegistry] of the [ShortcutRegistrar] that most tightly
1411 /// encloses the given [BuildContext].
1412 ///
1413 /// If no [ShortcutRegistrar] widget encloses the given context, [maybeOf]
1414 /// will return null.
1415 ///
1416 /// There is a default [ShortcutRegistrar] instance in [WidgetsApp], so if
1417 /// [WidgetsApp], [MaterialApp] or [CupertinoApp] are used, an additional
1418 /// [ShortcutRegistrar] isn't needed.
1419 ///
1420 /// See also:
1421 ///
1422 /// * [of], which is similar to this function, but returns a non-nullable
1423 /// result, and will throw an exception if it doesn't find a
1424 /// [ShortcutRegistrar] ancestor.
1425 static ShortcutRegistry? maybeOf(BuildContext context) {
1426 final _ShortcutRegistrarScope? inherited =
1427 context.dependOnInheritedWidgetOfExactType<_ShortcutRegistrarScope>();
1428 return inherited?.registry;
1429 }
1430
1431 // Replaces all the shortcuts associated with the given entry from this
1432 // registry.
1433 void _replaceAll(ShortcutRegistryEntry entry, Map<ShortcutActivator, Intent> value) {
1434 assert(ChangeNotifier.debugAssertNotDisposed(this));
1435 assert(_debugCheckEntryIsValid(entry));
1436 _registeredShortcuts[entry] = value;
1437 assert(_debugCheckForDuplicates());
1438 _notifyListenersNextFrame();
1439 }
1440
1441 // Removes all the shortcuts associated with the given entry from this
1442 // registry.
1443 void _disposeEntry(ShortcutRegistryEntry entry) {
1444 assert(_debugCheckEntryIsValid(entry));
1445 if (_registeredShortcuts.remove(entry) != null) {
1446 _notifyListenersNextFrame();
1447 }
1448 }
1449
1450 bool _debugCheckEntryIsValid(ShortcutRegistryEntry entry) {
1451 if (!_registeredShortcuts.containsKey(entry)) {
1452 if (entry.registry == this) {
1453 throw FlutterError(
1454 'entry ${describeIdentity(entry)} is invalid.\n'
1455 'The entry has already been disposed of. Tokens are not valid after '
1456 'dispose is called on them, and should no longer be used.',
1457 );
1458 } else {
1459 throw FlutterError(
1460 'Foreign entry ${describeIdentity(entry)} used.\n'
1461 'This entry was not created by this registry, it was created by '
1462 '${describeIdentity(entry.registry)}, and should be used with that '
1463 'registry instead.',
1464 );
1465 }
1466 }
1467 return true;
1468 }
1469
1470 bool _debugCheckForDuplicates() {
1471 final Map<ShortcutActivator, ShortcutRegistryEntry?> previous =
1472 <ShortcutActivator, ShortcutRegistryEntry?>{};
1473 for (final MapEntry<ShortcutRegistryEntry, Map<ShortcutActivator, Intent>> tokenEntry
1474 in _registeredShortcuts.entries) {
1475 for (final ShortcutActivator shortcut in tokenEntry.value.keys) {
1476 if (previous.containsKey(shortcut)) {
1477 throw FlutterError(
1478 '$ShortcutRegistry: Received a duplicate registration for the '
1479 'shortcut $shortcut in ${describeIdentity(tokenEntry.key)} and ${previous[shortcut]}.',
1480 );
1481 }
1482 previous[shortcut] = tokenEntry.key;
1483 }
1484 }
1485 return true;
1486 }
1487}
1488
1489/// A widget that holds a [ShortcutRegistry] which allows descendants to add,
1490/// remove, or replace shortcuts.
1491///
1492/// This widget holds a [ShortcutRegistry] so that its descendants can find it
1493/// with [ShortcutRegistry.of] or [ShortcutRegistry.maybeOf].
1494///
1495/// The registered shortcuts are valid whenever a widget below this one in the
1496/// hierarchy has focus.
1497///
1498/// To add shortcuts to the registry, call [ShortcutRegistry.of] or
1499/// [ShortcutRegistry.maybeOf] to get the [ShortcutRegistry], and then add them
1500/// using [ShortcutRegistry.addAll], which will return a [ShortcutRegistryEntry]
1501/// which must be disposed by calling [ShortcutRegistryEntry.dispose] when the
1502/// shortcuts are no longer needed.
1503///
1504/// To replace or update the shortcuts in the registry, call
1505/// [ShortcutRegistryEntry.replaceAll].
1506///
1507/// To remove previously added shortcuts from the registry, call
1508/// [ShortcutRegistryEntry.dispose] on the entry returned by
1509/// [ShortcutRegistry.addAll].
1510class ShortcutRegistrar extends StatefulWidget {
1511 /// Creates a const [ShortcutRegistrar].
1512 ///
1513 /// The [child] parameter is required.
1514 const ShortcutRegistrar({super.key, required this.child});
1515
1516 /// The widget below this widget in the tree.
1517 ///
1518 /// {@macro flutter.widgets.ProxyWidget.child}
1519 final Widget child;
1520
1521 @override
1522 State<ShortcutRegistrar> createState() => _ShortcutRegistrarState();
1523}
1524
1525class _ShortcutRegistrarState extends State<ShortcutRegistrar> {
1526 final ShortcutRegistry registry = ShortcutRegistry();
1527 final ShortcutManager manager = ShortcutManager();
1528
1529 @override
1530 void initState() {
1531 super.initState();
1532 registry.addListener(_shortcutsChanged);
1533 }
1534
1535 void _shortcutsChanged() {
1536 // This shouldn't need to update the widget, and avoids calling setState
1537 // during build phase.
1538 manager.shortcuts = registry.shortcuts;
1539 }
1540
1541 @override
1542 void dispose() {
1543 registry.removeListener(_shortcutsChanged);
1544 registry.dispose();
1545 manager.dispose();
1546 super.dispose();
1547 }
1548
1549 @override
1550 Widget build(BuildContext context) {
1551 return _ShortcutRegistrarScope(
1552 registry: registry,
1553 child: Shortcuts.manager(manager: manager, child: widget.child),
1554 );
1555 }
1556}
1557
1558class _ShortcutRegistrarScope extends InheritedWidget {
1559 const _ShortcutRegistrarScope({required this.registry, required super.child});
1560
1561 final ShortcutRegistry registry;
1562
1563 @override
1564 bool updateShouldNotify(covariant _ShortcutRegistrarScope oldWidget) {
1565 return registry != oldWidget.registry;
1566 }
1567}
1568