Unverified Commit f6c3ee31 authored by Greg Spencer's avatar Greg Spencer Committed by GitHub

Add ShortcutsRegistry (#103456)

This adds a ShortcutsRegistry for ShortcutActivator to Intent mappings that can be modified from its descendants.

This is so that descendants can make shortcuts dynamically available to a larger portion of the app than just their descendants. This is a precursor needed by the new MenuBar, for instance, so that the menu bar itself can be placed where it likes, but the shortcuts it defines can be in effect for most, if not all, of the UI surface in the app. For example, the "Ctrl-Q" quit binding would need to work even if the focused widget wasn't a child of the MenuBar.

This just provides the shortcut to intent mapping, the actions activated by the intent are described in the context where they make sense. For example, defining a "Ctrl-C" shortcut mapped to a "CopyIntent" should perform different functions if it happens while a TextField has focus vs when a drawing has focus, so those different areas would need to define different actions mapped to "CopyIntent". A hypothetical "QuitIntent" would probably be active for the entire app, so would be mapped in an Actions widget near the top of the hierarchy.
parent d2ba83d4
......@@ -119,7 +119,24 @@ class ChangeNotifier implements Listenable {
int _reentrantlyRemovedListeners = 0;
bool _debugDisposed = false;
bool _debugAssertNotDisposed() {
/// Used by subclasses to assert that the [ChangeNotifier] has not yet been
/// disposed.
///
/// {@tool snippet}
/// The `debugAssertNotDisposed` function should only be called inside of an
/// assert, as in this example.
///
/// ```dart
/// class MyNotifier with ChangeNotifier {
/// void doUpdate() {
/// assert(debugAssertNotDisposed());
/// // ...
/// }
/// }
/// ```
/// {@end-tool}
@protected
bool debugAssertNotDisposed() {
assert(() {
if (_debugDisposed) {
throw FlutterError(
......@@ -149,7 +166,7 @@ class ChangeNotifier implements Listenable {
/// so, stopping that same work.
@protected
bool get hasListeners {
assert(_debugAssertNotDisposed());
assert(debugAssertNotDisposed());
return _count > 0;
}
......@@ -181,7 +198,7 @@ class ChangeNotifier implements Listenable {
/// the list of closures that are notified when the object changes.
@override
void addListener(VoidCallback listener) {
assert(_debugAssertNotDisposed());
assert(debugAssertNotDisposed());
if (_count == _listeners.length) {
if (_count == 0) {
_listeners = List<VoidCallback?>.filled(1, null);
......@@ -273,7 +290,7 @@ class ChangeNotifier implements Listenable {
/// This method should only be called by the object's owner.
@mustCallSuper
void dispose() {
assert(_debugAssertNotDisposed());
assert(debugAssertNotDisposed());
assert(() {
_debugDisposed = true;
return true;
......@@ -301,7 +318,7 @@ class ChangeNotifier implements Listenable {
@visibleForTesting
@pragma('vm:notify-debugger-on-exception')
void notifyListeners() {
assert(_debugAssertNotDisposed());
assert(debugAssertNotDisposed());
if (_count == 0)
return;
......
......@@ -473,7 +473,7 @@ abstract class RestorableProperty<T> extends ChangeNotifier {
@override
void dispose() {
assert(_debugAssertNotDisposed());
assert(debugAssertNotDisposed()); // FYI, This uses ChangeNotifier's _debugDisposed, not _disposed.
_owner?._unregister(this);
super.dispose();
_disposed = true;
......@@ -483,14 +483,14 @@ abstract class RestorableProperty<T> extends ChangeNotifier {
String? _restorationId;
RestorationMixin? _owner;
void _register(String restorationId, RestorationMixin owner) {
assert(_debugAssertNotDisposed());
assert(debugAssertNotDisposed());
assert(restorationId != null);
assert(owner != null);
_restorationId = restorationId;
_owner = owner;
}
void _unregister() {
assert(_debugAssertNotDisposed());
assert(debugAssertNotDisposed());
assert(_restorationId != null);
assert(_owner != null);
_restorationId = null;
......@@ -503,29 +503,16 @@ abstract class RestorableProperty<T> extends ChangeNotifier {
@protected
State get state {
assert(isRegistered);
assert(_debugAssertNotDisposed());
assert(debugAssertNotDisposed());
return _owner!;
}
/// Whether this property is currently registered with a [RestorationMixin].
@protected
bool get isRegistered {
assert(_debugAssertNotDisposed());
assert(debugAssertNotDisposed());
return _restorationId != null;
}
bool _debugAssertNotDisposed() {
assert(() {
if (_disposed) {
throw FlutterError(
'A $runtimeType was used after being disposed.\n'
'Once you have called dispose() on a $runtimeType, it can no longer be used.',
);
}
return true;
}());
return true;
}
}
/// Manages the restoration data for a [State] object of a [StatefulWidget].
......
......@@ -11,7 +11,6 @@ import 'actions.dart';
import 'focus_manager.dart';
import 'focus_scope.dart';
import 'framework.dart';
import 'inherited_notifier.dart';
import 'platform_menu_bar.dart';
/// A set of [KeyboardKey]s that can be used as the keys in a [Map].
......@@ -639,9 +638,12 @@ class _ActivatorIntentPair with Diagnosticable {
/// A manager of keyboard shortcut bindings.
///
/// A [ShortcutManager] is obtained by calling [Shortcuts.of] on the context of
/// 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 {
///
/// The manager may be listened to (with [addListener]/[removeListener]) for
/// change notifications when the shortcuts change.
class ShortcutManager with Diagnosticable, ChangeNotifier {
/// Constructs a [ShortcutManager].
ShortcutManager({
Map<ShortcutActivator, Intent> shortcuts = const <ShortcutActivator, Intent>{},
......@@ -690,9 +692,11 @@ class ShortcutManager extends ChangeNotifier with Diagnosticable {
});
return result;
}
Map<LogicalKeyboardKey?, List<_ActivatorIntentPair>> get _indexedShortcuts {
return _indexedShortcutsCache ??= _indexShortcuts(_shortcuts);
return _indexedShortcutsCache ??= _indexShortcuts(shortcuts);
}
Map<LogicalKeyboardKey?, List<_ActivatorIntentPair>>? _indexedShortcutsCache;
/// Returns the [Intent], if any, that matches the current set of pressed
......@@ -757,7 +761,7 @@ class ShortcutManager extends ChangeNotifier with Diagnosticable {
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties.add(DiagnosticsProperty<Map<ShortcutActivator, Intent>>('shortcuts', _shortcuts));
properties.add(DiagnosticsProperty<Map<ShortcutActivator, Intent>>('shortcuts', shortcuts));
properties.add(FlagProperty('modal', value: modal, ifTrue: 'modal', defaultValue: false));
}
}
......@@ -961,15 +965,19 @@ class _ShortcutsState extends State<Shortcuts> {
}
}
class _ShortcutsMarker extends InheritedNotifier<ShortcutManager> {
class _ShortcutsMarker extends InheritedWidget {
const _ShortcutsMarker({
required ShortcutManager manager,
required this.manager,
required super.child,
}) : assert(manager != null),
assert(child != null),
super(notifier: manager);
assert(child != null);
final ShortcutManager manager;
ShortcutManager get manager => super.notifier!;
@override
bool updateShouldNotify(_ShortcutsMarker oldWidget) {
return manager != oldWidget.manager;
}
}
/// A widget that provides an uncomplicated mechanism for binding a key
......@@ -1058,3 +1066,292 @@ class CallbackShortcuts extends StatelessWidget {
);
}
}
/// A entry returned by [ShortcutRegistry.addAll] that allows the caller to
/// identify the shortcuts they registered with the [ShortcutRegistry] through
/// the [ShortcutRegistrar].
///
/// When the entry is no longer needed, [dispose] should be called, and the
/// entry should no longer be used.
class ShortcutRegistryEntry {
// Tokens can only be created by the ShortcutRegistry.
const ShortcutRegistryEntry._(this.registry);
/// The [ShortcutRegistry] that this entry was issued by.
final ShortcutRegistry registry;
/// Replaces the given shortcut bindings in the [ShortcutRegistry] that this
/// entry was created from.
///
/// This method will assert in debug mode if another [ShortcutRegistryEntry]
/// exists (i.e. hasn't been disposed of) that has already added a given
/// shortcut.
///
/// It will also assert if this entry has already been disposed.
///
/// If two equivalent, but different, [ShortcutActivator]s are added, all of
/// them will be executed when triggered. For example, if both
/// `SingleActivator(LogicalKeyboardKey.keyA)` and `CharacterActivator('a')`
/// are added, then both will be executed when an "a" key is pressed.
void replaceAll(Map<ShortcutActivator, Intent> value) {
registry._replaceAll(this, value);
}
/// Called when the entry is no longer needed.
///
/// Call this will remove all shortcuts associated with this
/// [ShortcutRegistryEntry] from the [registry].
@mustCallSuper
void dispose() {
registry._disposeToken(this);
}
}
/// A class used by [ShortcutRegistrar] that allows adding or removing shortcut
/// bindings by descendants of the [ShortcutRegistrar].
///
/// You can reach the nearest [ShortcutRegistry] using [of] and [maybeOf].
///
/// The registry may be listened to (with [addListener]/[removeListener]) for
/// change notifications when the registered shortcuts change.
class ShortcutRegistry with ChangeNotifier {
/// Gets the combined shortcut bindings from all contexts that are registered
/// with this [ShortcutRegistry], in addition to the bindings passed to
/// [ShortcutRegistry].
///
/// Listeners will be notified when the value returned by this getter changes.
///
/// Returns a copy: modifying the returned map will have no effect.
Map<ShortcutActivator, Intent> get shortcuts {
assert(debugAssertNotDisposed());
return <ShortcutActivator, Intent>{
for (final MapEntry<ShortcutRegistryEntry, Map<ShortcutActivator, Intent>> entry in _tokenShortcuts.entries)
...entry.value,
};
}
final Map<ShortcutRegistryEntry, Map<ShortcutActivator, Intent>> _tokenShortcuts =
<ShortcutRegistryEntry, Map<ShortcutActivator, Intent>>{};
/// Adds all the given shortcut bindings to this [ShortcutRegistry], and
/// returns a entry for managing those bindings.
///
/// The entry should have [ShortcutRegistryEntry.dispose] called on it when
/// these shortcuts are no longer needed. This will remove them from the
/// registry, and invalidate the entry.
///
/// This method will assert in debug mode if another entry exists (i.e. hasn't
/// been disposed of) that has already added a given shortcut.
///
/// If two equivalent, but different, [ShortcutActivator]s are added, all of
/// them will be executed when triggered. For example, if both
/// `SingleActivator(LogicalKeyboardKey.keyA)` and `CharacterActivator('a')`
/// are added, then both will be executed when an "a" key is pressed.
///
/// See also:
///
/// * [ShortcutRegistryEntry.replaceAll], a function used to replace the set of
/// shortcuts associated with a particular entry.
/// * [ShortcutRegistryEntry.dispose], a function used to remove the set of
/// shortcuts associated with a particular entry.
ShortcutRegistryEntry addAll(Map<ShortcutActivator, Intent> value) {
assert(debugAssertNotDisposed());
final ShortcutRegistryEntry entry = ShortcutRegistryEntry._(this);
_tokenShortcuts[entry] = value;
assert(_debugCheckForDuplicates());
notifyListeners();
return entry;
}
/// Returns the [ShortcutRegistry] that belongs to the [ShortcutRegistrar]
/// which most tightly encloses the given [BuildContext].
///
/// If no [ShortcutRegistrar] widget encloses the context given, `of` will
/// throw an exception in debug mode.
///
/// There is a default [ShortcutRegistrar] instance in [WidgetsApp], so if
/// [WidgetsApp], [MaterialApp] or [CupertinoApp] are used, an additional
/// [ShortcutRegistrar] isn't needed.
///
/// See also:
///
/// * [maybeOf], which is similar to this function, but will return null if
/// it doesn't find a [ShortcutRegistrar] ancestor.
static ShortcutRegistry of(BuildContext context) {
assert(context != null);
final _ShortcutRegistrarMarker? inherited =
context.dependOnInheritedWidgetOfExactType<_ShortcutRegistrarMarker>();
assert(() {
if (inherited == null) {
throw FlutterError(
'Unable to find a $ShortcutRegistrar widget in the context.\n'
'$ShortcutRegistrar.of() was called with a context that does not contain a '
'$ShortcutRegistrar widget.\n'
'No $ShortcutRegistrar ancestor could be found starting from the context that was '
'passed to $ShortcutRegistrar.of().\n'
'The context used was:\n'
' $context',
);
}
return true;
}());
return inherited!.registry;
}
/// Returns [ShortcutRegistry] of the [ShortcutRegistrar] that most tightly
/// encloses the given [BuildContext].
///
/// If no [ShortcutRegistrar] widget encloses the given context, `maybeOf`
/// will return null.
///
/// There is a default [ShortcutRegistrar] instance in [WidgetsApp], so if
/// [WidgetsApp], [MaterialApp] or [CupertinoApp] are used, an additional
/// [ShortcutRegistrar] isn't needed.
///
/// 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
/// [ShortcutRegistrar] ancestor.
static ShortcutRegistry? maybeOf(BuildContext context) {
assert(context != null);
final _ShortcutRegistrarMarker? inherited =
context.dependOnInheritedWidgetOfExactType<_ShortcutRegistrarMarker>();
return inherited?.registry;
}
// Replaces all the shortcuts associated with the given entry from this
// registry.
void _replaceAll(ShortcutRegistryEntry entry, Map<ShortcutActivator, Intent> value) {
assert(debugAssertNotDisposed());
assert(_debugCheckTokenIsValid(entry));
_tokenShortcuts[entry] = value;
assert(_debugCheckForDuplicates());
notifyListeners();
}
// Removes all the shortcuts associated with the given entry from this
// registry.
void _disposeToken(ShortcutRegistryEntry entry) {
assert(_debugCheckTokenIsValid(entry));
if (_tokenShortcuts.remove(entry) != null) {
notifyListeners();
}
}
bool _debugCheckTokenIsValid(ShortcutRegistryEntry entry) {
if (!_tokenShortcuts.containsKey(entry)) {
if (entry.registry == this) {
throw FlutterError('entry ${describeIdentity(entry)} is invalid.\n'
'The entry has already been disposed of. Tokens are not valid after '
'dispose is called on them, and should no longer be used.');
} else {
throw FlutterError('Foreign entry ${describeIdentity(entry)} used.\n'
'This entry was not created by this registry, it was created by '
'${describeIdentity(entry.registry)}, and should be used with that '
'registry instead.');
}
}
return true;
}
bool _debugCheckForDuplicates() {
final Map<ShortcutActivator, ShortcutRegistryEntry?> previous = <ShortcutActivator, ShortcutRegistryEntry?>{};
for (final MapEntry<ShortcutRegistryEntry, Map<ShortcutActivator, Intent>> tokenEntry in _tokenShortcuts.entries) {
for (final ShortcutActivator shortcut in tokenEntry.value.keys) {
if (previous.containsKey(shortcut)) {
throw FlutterError(
'$ShortcutRegistry: Received a duplicate registration for the '
'shortcut $shortcut in ${describeIdentity(tokenEntry.key)} and ${previous[shortcut]}.');
}
previous[shortcut] = tokenEntry.key;
}
}
return true;
}
}
/// A widget that holds a [ShortcutRegistry] which allows descendants to add,
/// remove, or replace shortcuts.
///
/// This widget holds a [ShortcutRegistry] so that its descendants can find it
/// with [ShortcutRegistry.of] or [ShortcutRegistry.maybeOf].
///
/// The registered shortcuts are valid whenever a widget below this one in the
/// hierarchy has focus.
///
/// To add shortcuts to the registry, call [ShortcutRegistry.of] or
/// [ShortcutRegistry.maybeOf] to get the [ShortcutRegistry], and then add them
/// using [ShortcutRegistry.addAll], which will return a [ShortcutRegistryEntry]
/// which must be disposed by calling [ShortcutRegistryEntry.dispose] when the
/// shortcuts are no longer needed.
///
/// To replace or update the shortcuts in the registry, call
/// [ShortcutRegistryEntry.replaceAll].
///
/// To remove previously added shortcuts from the registry, call
/// [ShortcutRegistryEntry.dispose] on the entry returned by
/// [ShortcutRegistry.addAll].
class ShortcutRegistrar extends StatefulWidget {
/// Creates a const [ShortcutRegistrar].
///
/// The [child] parameter is required.
const ShortcutRegistrar({super.key, required this.child});
/// The widget below this widget in the tree.
///
/// {@macro flutter.widgets.ProxyWidget.child}
final Widget child;
@override
State<ShortcutRegistrar> createState() => _ShortcutRegistrarState();
}
class _ShortcutRegistrarState extends State<ShortcutRegistrar> {
final ShortcutRegistry registry = ShortcutRegistry();
final ShortcutManager manager = ShortcutManager();
@override
void initState() {
super.initState();
registry.addListener(_shortcutsChanged);
}
void _shortcutsChanged() {
// This shouldn't need to update the widget, and avoids calling setState
// during build phase.
manager.shortcuts = registry.shortcuts;
}
@override
void dispose() {
registry.removeListener(_shortcutsChanged);
registry.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Shortcuts(
shortcuts: registry.shortcuts,
manager: manager,
child: _ShortcutRegistrarMarker(
registry: registry,
child: widget.child,
),
);
}
}
class _ShortcutRegistrarMarker extends InheritedWidget {
const _ShortcutRegistrarMarker({
required this.registry,
required super.child,
});
final ShortcutRegistry registry;
@override
bool updateShouldNotify(covariant _ShortcutRegistrarMarker oldWidget) {
return registry != oldWidget.registry;
}
}
......@@ -7,118 +7,6 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
typedef PostInvokeCallback = void Function({Action<Intent> action, Intent intent, BuildContext? context, ActionDispatcher dispatcher});
class TestAction extends CallbackAction<Intent> {
TestAction({
required super.onInvoke,
}) : assert(onInvoke != null);
static const LocalKey key = ValueKey<Type>(TestAction);
}
class TestDispatcher extends ActionDispatcher {
const TestDispatcher({this.postInvoke});
final PostInvokeCallback? postInvoke;
@override
Object? invokeAction(Action<TestIntent> action, Intent intent, [BuildContext? context]) {
final Object? result = super.invokeAction(action, intent, context);
postInvoke?.call(action: action, intent: intent, context: context, dispatcher: this);
return result;
}
}
/// An activator that accepts down events that has [key] as the logical key.
///
/// This class is used only to tests. It is intentionally designed poorly by
/// returning null in [triggers], and checks [key] in [accepts].
class DumbLogicalActivator extends ShortcutActivator {
const DumbLogicalActivator(this.key);
final LogicalKeyboardKey key;
@override
Iterable<LogicalKeyboardKey>? get triggers => null;
@override
bool accepts(RawKeyEvent event, RawKeyboard state) {
return event is RawKeyDownEvent
&& event.logicalKey == key;
}
/// Returns a short and readable description of the key combination.
///
/// Intended to be used in debug mode for logging purposes. In release mode,
/// [debugDescribeKeys] returns an empty string.
@override
String debugDescribeKeys() {
String result = '';
assert(() {
result = key.keyLabel;
return true;
}());
return result;
}
}
class TestIntent extends Intent {
const TestIntent();
}
class TestIntent2 extends Intent {
const TestIntent2();
}
class TestShortcutManager extends ShortcutManager {
TestShortcutManager(this.keys);
List<LogicalKeyboardKey> keys;
@override
KeyEventResult handleKeypress(BuildContext context, RawKeyEvent event) {
if (event is RawKeyDownEvent) {
keys.add(event.logicalKey);
}
return super.handleKeypress(context, event);
}
}
Widget activatorTester(
ShortcutActivator activator,
ValueSetter<Intent> onInvoke, [
ShortcutActivator? activator2,
ValueSetter<Intent>? onInvoke2,
]) {
final bool hasSecond = activator2 != null && onInvoke2 != null;
return Actions(
key: GlobalKey(),
actions: <Type, Action<Intent>>{
TestIntent: TestAction(onInvoke: (Intent intent) {
onInvoke(intent);
return true;
}),
if (hasSecond)
TestIntent2: TestAction(onInvoke: (Intent intent) {
onInvoke2(intent);
return null;
}),
},
child: Shortcuts(
shortcuts: <ShortcutActivator, Intent>{
activator: const TestIntent(),
if (hasSecond)
activator2: const TestIntent2(),
},
child: const Focus(
autofocus: true,
child: SizedBox(width: 100, height: 100),
),
),
);
}
void main() {
group(LogicalKeySet, () {
test('LogicalKeySet passes parameters correctly.', () {
......@@ -1419,4 +1307,499 @@ void main() {
await tester.sendKeyUpEvent(LogicalKeyboardKey.keyB);
});
});
group('ShortcutRegistrar', () {
testWidgets('trigger ShortcutRegistrar on key events', (WidgetTester tester) async {
int invokedA = 0;
int invokedB = 0;
await tester.pumpWidget(
ShortcutRegistrar(
child: TestCallbackRegistration(
shortcuts: <ShortcutActivator, Intent>{
const SingleActivator(LogicalKeyboardKey.keyA): VoidCallbackIntent(() {
invokedA += 1;
}),
const SingleActivator(LogicalKeyboardKey.keyB): VoidCallbackIntent(() {
invokedB += 1;
}),
},
child: Actions(
actions: <Type, Action<Intent>>{
VoidCallbackIntent: VoidCallbackAction(),
},
child: const Focus(
autofocus: true,
child: Placeholder(),
),
),
),
),
);
await tester.pump();
await tester.sendKeyDownEvent(LogicalKeyboardKey.keyA);
await tester.pump();
expect(invokedA, equals(1));
expect(invokedB, equals(0));
await tester.sendKeyUpEvent(LogicalKeyboardKey.keyA);
expect(invokedA, equals(1));
expect(invokedB, equals(0));
invokedA = 0;
invokedB = 0;
await tester.sendKeyDownEvent(LogicalKeyboardKey.keyB);
expect(invokedA, equals(0));
expect(invokedB, equals(1));
await tester.sendKeyUpEvent(LogicalKeyboardKey.keyB);
expect(invokedA, equals(0));
expect(invokedB, equals(1));
});
testWidgets("doesn't override text field shortcuts", (WidgetTester tester) async {
final TextEditingController controller = TextEditingController();
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: ShortcutRegistrar(
child: TestCallbackRegistration(
shortcuts: const <ShortcutActivator, Intent>{
SingleActivator(LogicalKeyboardKey.keyA, control: true): SelectAllTextIntent(SelectionChangedCause.keyboard),
},
child: TextField(
autofocus: true,
controller: controller,
),
),
),
),
),
);
controller.text = 'Testing';
await tester.pump();
// Send a "Ctrl-A", which should be bound to select all by default.
await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft);
await tester.sendKeyEvent(LogicalKeyboardKey.keyA);
await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft);
await tester.pump();
expect(controller.selection.baseOffset, equals(0));
expect(controller.selection.extentOffset, equals(7));
});
testWidgets('nested ShortcutRegistrars stop propagation', (WidgetTester tester) async {
int invokedOuter = 0;
int invokedInner = 0;
await tester.pumpWidget(
ShortcutRegistrar(
child: TestCallbackRegistration(
shortcuts: <ShortcutActivator, Intent>{
const SingleActivator(LogicalKeyboardKey.keyA): VoidCallbackIntent(() {
invokedOuter += 1;
}),
},
child: ShortcutRegistrar(
child: TestCallbackRegistration(
shortcuts: <ShortcutActivator, Intent>{
const SingleActivator(LogicalKeyboardKey.keyA): VoidCallbackIntent(() {
invokedInner += 1;
}),
},
child: Actions(
actions: <Type, Action<Intent>>{
VoidCallbackIntent: VoidCallbackAction(),
},
child: const Focus(
autofocus: true,
child: Placeholder(),
),
),),
),
),
),
);
await tester.pump();
await tester.sendKeyDownEvent(LogicalKeyboardKey.keyA);
expect(invokedOuter, equals(0));
expect(invokedInner, equals(1));
await tester.sendKeyUpEvent(LogicalKeyboardKey.keyA);
expect(invokedOuter, equals(0));
expect(invokedInner, equals(1));
});
testWidgets('non-overlapping nested ShortcutRegistrars fire appropriately', (WidgetTester tester) async {
int invokedOuter = 0;
int invokedInner = 0;
await tester.pumpWidget(
ShortcutRegistrar(
child: TestCallbackRegistration(
shortcuts: <ShortcutActivator, Intent>{
const CharacterActivator('b'): VoidCallbackIntent(() {
invokedOuter += 1;
}),
},
child: ShortcutRegistrar(
child: TestCallbackRegistration(
shortcuts: <ShortcutActivator, Intent>{
const CharacterActivator('a'): VoidCallbackIntent(() {
invokedInner += 1;
}),
},
child: Actions(
actions: <Type, Action<Intent>>{
VoidCallbackIntent: VoidCallbackAction(),
},
child: const Focus(
autofocus: true,
child: Placeholder(),
),
),
),
),
),
),
);
await tester.pump();
await tester.sendKeyDownEvent(LogicalKeyboardKey.keyA);
expect(invokedOuter, equals(0));
expect(invokedInner, equals(1));
await tester.sendKeyDownEvent(LogicalKeyboardKey.keyB);
expect(invokedOuter, equals(1));
expect(invokedInner, equals(1));
await tester.sendKeyUpEvent(LogicalKeyboardKey.keyA);
await tester.sendKeyUpEvent(LogicalKeyboardKey.keyB);
expect(invokedOuter, equals(1));
expect(invokedInner, equals(1));
});
testWidgets('Works correctly with Shortcuts too', (WidgetTester tester) async {
int invokedCallbackA = 0;
int invokedCallbackB = 0;
int invokedActionA = 0;
int invokedActionB = 0;
void clear() {
invokedCallbackA = 0;
invokedCallbackB = 0;
invokedActionA = 0;
invokedActionB = 0;
}
await tester.pumpWidget(
Actions(
actions: <Type, Action<Intent>>{
TestIntent: TestAction(
onInvoke: (Intent intent) {
invokedActionA += 1;
return true;
},
),
TestIntent2: TestAction(
onInvoke: (Intent intent) {
invokedActionB += 1;
return true;
},
),
VoidCallbackIntent: VoidCallbackAction(),
},
child: ShortcutRegistrar(
child: TestCallbackRegistration(
shortcuts: <ShortcutActivator, Intent>{
const CharacterActivator('b'): VoidCallbackIntent(() {
invokedCallbackB += 1;
}),
},
child: Shortcuts(
shortcuts: const <ShortcutActivator, Intent>{
SingleActivator(LogicalKeyboardKey.keyA): TestIntent(),
SingleActivator(LogicalKeyboardKey.keyB): TestIntent2(),
},
child: ShortcutRegistrar(
child: TestCallbackRegistration(
shortcuts: <ShortcutActivator, Intent>{
const CharacterActivator('a'): VoidCallbackIntent(() {
invokedCallbackA += 1;
}),
},
child: const Focus(
autofocus: true,
child: Placeholder(),
),
),
),
),
),
),
),
);
await tester.pump();
await tester.sendKeyDownEvent(LogicalKeyboardKey.keyA);
expect(invokedCallbackA, equals(1));
expect(invokedCallbackB, equals(0));
expect(invokedActionA, equals(0));
expect(invokedActionB, equals(0));
await tester.sendKeyUpEvent(LogicalKeyboardKey.keyA);
clear();
await tester.sendKeyDownEvent(LogicalKeyboardKey.keyB);
expect(invokedCallbackA, equals(0));
expect(invokedCallbackB, equals(0));
expect(invokedActionA, equals(0));
expect(invokedActionB, equals(1));
await tester.sendKeyUpEvent(LogicalKeyboardKey.keyB);
});
testWidgets('Updating shortcuts triggers dependency rebuild', (WidgetTester tester) async {
final List<Map<ShortcutActivator, Intent>> shortcutsChanged = <Map<ShortcutActivator, Intent>>[];
void dependenciesUpdated(Map<ShortcutActivator, Intent> shortcuts) {
shortcutsChanged.add(shortcuts);
}
await tester.pumpWidget(
ShortcutRegistrar(
child: TestCallbackRegistration(
onDependencyUpdate: dependenciesUpdated,
shortcuts: const <ShortcutActivator, Intent>{
SingleActivator(LogicalKeyboardKey.keyA): SelectAllTextIntent(SelectionChangedCause.keyboard),
SingleActivator(LogicalKeyboardKey.keyB): ActivateIntent(),
},
child: Actions(
actions: <Type, Action<Intent>>{
VoidCallbackIntent: VoidCallbackAction(),
},
child: const Focus(
autofocus: true,
child: Placeholder(),
),
),
),
),
);
await tester.pumpWidget(
ShortcutRegistrar(
child: TestCallbackRegistration(
onDependencyUpdate: dependenciesUpdated,
shortcuts: const <ShortcutActivator, Intent>{
SingleActivator(LogicalKeyboardKey.keyA): SelectAllTextIntent(SelectionChangedCause.keyboard),
},
child: Actions(
actions: <Type, Action<Intent>>{
VoidCallbackIntent: VoidCallbackAction(),
},
child: const Focus(
autofocus: true,
child: Placeholder(),
),
),
),
),
);
await tester.pumpWidget(
ShortcutRegistrar(
child: TestCallbackRegistration(
onDependencyUpdate: dependenciesUpdated,
shortcuts: const <ShortcutActivator, Intent>{
SingleActivator(LogicalKeyboardKey.keyA): SelectAllTextIntent(SelectionChangedCause.keyboard),
SingleActivator(LogicalKeyboardKey.keyB): ActivateIntent(),
},
child: Actions(
actions: <Type, Action<Intent>>{
VoidCallbackIntent: VoidCallbackAction(),
},
child: const Focus(
autofocus: true,
child: Placeholder(),
),
),
),
),
);
expect(shortcutsChanged.length, equals(2));
expect(shortcutsChanged.last, equals(const <ShortcutActivator, Intent>{
SingleActivator(LogicalKeyboardKey.keyA): SelectAllTextIntent(SelectionChangedCause.keyboard),
SingleActivator(LogicalKeyboardKey.keyB): ActivateIntent(),
}));
});
testWidgets('using a disposed token asserts', (WidgetTester tester) async {
final ShortcutRegistry registry = ShortcutRegistry();
final ShortcutRegistryEntry token = registry.addAll(<ShortcutActivator, Intent>{
const SingleActivator(LogicalKeyboardKey.keyA): DoNothingIntent(),
});
token.dispose();
expect(() {token.replaceAll(<ShortcutActivator, Intent>{}); }, throwsFlutterError);
});
testWidgets('setting duplicate bindings asserts', (WidgetTester tester) async {
final ShortcutRegistry registry = ShortcutRegistry();
final ShortcutRegistryEntry token = registry.addAll(<ShortcutActivator, Intent>{
const SingleActivator(LogicalKeyboardKey.keyA): DoNothingIntent(),
});
expect(() {
final ShortcutRegistryEntry token2 = registry.addAll(const <ShortcutActivator, Intent>{
SingleActivator(LogicalKeyboardKey.keyA): ActivateIntent(),
});
token2.dispose();
}, throwsAssertionError);
token.dispose();
});
});
}
class TestCallbackRegistration extends StatefulWidget {
const TestCallbackRegistration({super.key, required this.shortcuts, this.onDependencyUpdate, required this.child});
final Map<ShortcutActivator, Intent> shortcuts;
final void Function(Map<ShortcutActivator, Intent> shortcuts)? onDependencyUpdate;
final Widget child;
@override
State<TestCallbackRegistration> createState() => _TestCallbackRegistrationState();
}
class _TestCallbackRegistrationState extends State<TestCallbackRegistration> {
ShortcutRegistryEntry? _registryToken;
@override
void didChangeDependencies() {
super.didChangeDependencies();
_registryToken?.dispose();
_registryToken = ShortcutRegistry.of(context).addAll(widget.shortcuts);
}
@override
void didUpdateWidget(TestCallbackRegistration oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.shortcuts != oldWidget.shortcuts || _registryToken == null) {
_registryToken?.dispose();
_registryToken = ShortcutRegistry.of(context).addAll(widget.shortcuts);
}
widget.onDependencyUpdate?.call(ShortcutRegistry.of(context).shortcuts);
}
@override
void dispose() {
_registryToken?.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return widget.child;
}
}
typedef PostInvokeCallback = void Function({Action<Intent> action, Intent intent, BuildContext? context, ActionDispatcher dispatcher});
class TestAction extends CallbackAction<Intent> {
TestAction({
required super.onInvoke,
}) : assert(onInvoke != null);
static const LocalKey key = ValueKey<Type>(TestAction);
}
class TestDispatcher extends ActionDispatcher {
const TestDispatcher({this.postInvoke});
final PostInvokeCallback? postInvoke;
@override
Object? invokeAction(Action<TestIntent> action, Intent intent, [BuildContext? context]) {
final Object? result = super.invokeAction(action, intent, context);
postInvoke?.call(action: action, intent: intent, context: context, dispatcher: this);
return result;
}
}
/// An activator that accepts down events that has [key] as the logical key.
///
/// This class is used only to tests. It is intentionally designed poorly by
/// returning null in [triggers], and checks [key] in [accepts].
class DumbLogicalActivator extends ShortcutActivator {
const DumbLogicalActivator(this.key);
final LogicalKeyboardKey key;
@override
Iterable<LogicalKeyboardKey>? get triggers => null;
@override
bool accepts(RawKeyEvent event, RawKeyboard state) {
return event is RawKeyDownEvent
&& event.logicalKey == key;
}
/// Returns a short and readable description of the key combination.
///
/// Intended to be used in debug mode for logging purposes. In release mode,
/// [debugDescribeKeys] returns an empty string.
@override
String debugDescribeKeys() {
String result = '';
assert(() {
result = key.keyLabel;
return true;
}());
return result;
}
}
class TestIntent extends Intent {
const TestIntent();
}
class TestIntent2 extends Intent {
const TestIntent2();
}
class TestShortcutManager extends ShortcutManager {
TestShortcutManager(this.keys);
List<LogicalKeyboardKey> keys;
@override
KeyEventResult handleKeypress(BuildContext context, RawKeyEvent event) {
if (event is RawKeyDownEvent) {
keys.add(event.logicalKey);
}
return super.handleKeypress(context, event);
}
}
Widget activatorTester(
ShortcutActivator activator,
ValueSetter<Intent> onInvoke, [
ShortcutActivator? activator2,
ValueSetter<Intent>? onInvoke2,
]) {
final bool hasSecond = activator2 != null && onInvoke2 != null;
return Actions(
key: GlobalKey(),
actions: <Type, Action<Intent>>{
TestIntent: TestAction(onInvoke: (Intent intent) {
onInvoke(intent);
return true;
}),
if (hasSecond)
TestIntent2: TestAction(onInvoke: (Intent intent) {
onInvoke2(intent);
return null;
}),
},
child: Shortcuts(
shortcuts: <ShortcutActivator, Intent>{
activator: const TestIntent(),
if (hasSecond)
activator2: const TestIntent2(),
},
child: const Focus(
autofocus: true,
child: SizedBox(width: 100, height: 100),
),
),
);
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment