diff --git a/packages/flutter/lib/src/widgets/shortcuts.dart b/packages/flutter/lib/src/widgets/shortcuts.dart index a51335c3a8..2ba89866de 100644 --- a/packages/flutter/lib/src/widgets/shortcuts.dart +++ b/packages/flutter/lib/src/widgets/shortcuts.dart @@ -393,12 +393,13 @@ class ShortcutMapProperty extends DiagnosticsProperty('keys', debugDescribeKeys())); + properties.add(MessageProperty('keys', debugDescribeKeys())); properties.add(FlagProperty('includeRepeats', value: includeRepeats, ifFalse: 'excluding repeats')); } } @@ -577,8 +582,54 @@ class SingleActivator with Diagnosticable, MenuSerializableShortcut implements S /// * [SingleActivator], an activator that represents a single key combined /// with modifiers, such as `Ctrl+C`. class CharacterActivator with Diagnosticable, MenuSerializableShortcut implements ShortcutActivator { - /// Create a [CharacterActivator] from the triggering character. - const CharacterActivator(this.character); + /// Triggered when the key event yields the given character. + /// + /// The [control] and [meta] flags represent whether the respect modifier + /// keys should be held (true) or released (false). They default to false. + /// [CharacterActivator] can not check Shift keys or Alt keys yet, and will + /// accept whether they are pressed or not. + /// + /// By default, the activator is checked on all [RawKeyDownEvent] events for + /// the [character]. If `includeRepeats` is false, only the [character] + /// events with a false [RawKeyDownEvent.repeat] attribute will be + /// considered. + const CharacterActivator(this.character, { + this.control = false, + this.meta = false, + this.includeRepeats = true, + }); + + /// Whether either (or both) control keys should be held for the [character] + /// to activate the shortcut. + /// + /// It defaults to false, meaning all Control keys must be released when the + /// event is received in order to activate the shortcut. If it's true, then + /// either or both Control keys must be pressed. + /// + /// See also: + /// + /// * [LogicalKeyboardKey.controlLeft], [LogicalKeyboardKey.controlRight]. + final bool control; + + /// Whether either (or both) meta keys should be held for the [character] to + /// activate the shortcut. + /// + /// It defaults to false, meaning all Meta keys must be released when the + /// event is received in order to activate the shortcut. If it's true, then + /// either or both Meta keys must be pressed. + /// + /// See also: + /// + /// * [LogicalKeyboardKey.metaLeft], [LogicalKeyboardKey.metaRight]. + final bool meta; + + /// Whether this activator accepts repeat events of the [character]. + /// + /// If [includeRepeats] is true, the activator is checked on all + /// [RawKeyDownEvent] events for the [character]. If `includeRepeats` is + /// false, only the [character] events with a false [RawKeyDownEvent.repeat] + /// attribute will be considered. + final bool includeRepeats; /// The character of the triggering event. /// @@ -598,15 +649,24 @@ class CharacterActivator with Diagnosticable, MenuSerializableShortcut implement @override bool accepts(RawKeyEvent event, RawKeyboard state) { + final Set pressed = state.keysPressed; return event is RawKeyDownEvent - && event.character == character; + && event.character == character + && (includeRepeats || !event.repeat) + && (control == (pressed.contains(LogicalKeyboardKey.controlLeft) || pressed.contains(LogicalKeyboardKey.controlRight))) + && (meta == (pressed.contains(LogicalKeyboardKey.metaLeft) || pressed.contains(LogicalKeyboardKey.metaRight))); } @override String debugDescribeKeys() { String result = ''; assert(() { - result = "'$character'"; + final List keys = [ + if (control) 'Control', + if (meta) 'Meta', + "'$character'", + ]; + result = keys.join(' + '); return true; }()); return result; @@ -620,7 +680,8 @@ class CharacterActivator with Diagnosticable, MenuSerializableShortcut implement @override void debugFillProperties(DiagnosticPropertiesBuilder properties) { super.debugFillProperties(properties); - properties.add(StringProperty('character', character)); + properties.add(MessageProperty('character', debugDescribeKeys())); + properties.add(FlagProperty('includeRepeats', value: includeRepeats, ifFalse: 'excluding repeats')); } } diff --git a/packages/flutter/test/widgets/shortcuts_test.dart b/packages/flutter/test/widgets/shortcuts_test.dart index d4214ca841..f4141f5f19 100644 --- a/packages/flutter/test/widgets/shortcuts_test.dart +++ b/packages/flutter/test/widgets/shortcuts_test.dart @@ -1112,7 +1112,7 @@ void main() { )); await tester.pump(); - // Press KeyC: Accepted by DumbLogicalActivator + // Press Shift + / await tester.sendKeyDownEvent(LogicalKeyboardKey.shiftLeft); await tester.sendKeyDownEvent(LogicalKeyboardKey.slash, character: '?'); expect(invoked, 1); @@ -1142,6 +1142,53 @@ void main() { invoked = 0; }, variant: KeySimulatorTransitModeVariant.all()); + testWidgets('rejects repeated events if requested', (WidgetTester tester) async { + int invoked = 0; + await tester.pumpWidget(activatorTester( + const CharacterActivator('?', includeRepeats: false), + (Intent intent) { invoked += 1; }, + )); + await tester.pump(); + + // Press Shift + / + await tester.sendKeyDownEvent(LogicalKeyboardKey.shiftLeft); + await tester.sendKeyDownEvent(LogicalKeyboardKey.slash, character: '?'); + expect(invoked, 1); + await tester.sendKeyRepeatEvent(LogicalKeyboardKey.slash, character: '?'); + expect(invoked, 1); + await tester.sendKeyUpEvent(LogicalKeyboardKey.slash); + await tester.sendKeyUpEvent(LogicalKeyboardKey.shiftLeft); + expect(invoked, 1); + invoked = 0; + }, variant: KeySimulatorTransitModeVariant.all()); + + testWidgets('handles Ctrl and Meta', (WidgetTester tester) async { + int invoked = 0; + await tester.pumpWidget(activatorTester( + const CharacterActivator('?', meta: true, control: true), + (Intent intent) { invoked += 1; }, + )); + await tester.pump(); + + // Press Shift + / + await tester.sendKeyDownEvent(LogicalKeyboardKey.shiftLeft); + await tester.sendKeyDownEvent(LogicalKeyboardKey.slash, character: '?'); + await tester.sendKeyUpEvent(LogicalKeyboardKey.slash); + expect(invoked, 0); + + // Press Ctrl + Meta + Shift + / + await tester.sendKeyDownEvent(LogicalKeyboardKey.metaLeft); + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + expect(invoked, 0); + await tester.sendKeyDownEvent(LogicalKeyboardKey.slash, character: '?'); + expect(invoked, 1); + await tester.sendKeyUpEvent(LogicalKeyboardKey.slash); + await tester.sendKeyUpEvent(LogicalKeyboardKey.shiftLeft); + await tester.sendKeyUpEvent(LogicalKeyboardKey.metaLeft); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + expect(invoked, 1); + invoked = 0; + }, variant: KeySimulatorTransitModeVariant.all()); testWidgets('isActivatedBy works as expected', (WidgetTester tester) async { // Collect some key events to use for testing. @@ -1163,6 +1210,52 @@ void main() { await tester.sendKeyUpEvent(LogicalKeyboardKey.keyA); expect(ShortcutActivator.isActivatedBy(characterActivator, events[0]), isTrue); }); + + group('diagnostics.', () { + test('single key', () { + final DiagnosticPropertiesBuilder builder = DiagnosticPropertiesBuilder(); + + const CharacterActivator('A').debugFillProperties(builder); + + final List description = builder.properties.where((DiagnosticsNode node) { + return !node.isFiltered(DiagnosticLevel.info); + }).map((DiagnosticsNode node) => node.toString()).toList(); + + expect(description.length, equals(1)); + expect(description[0], equals("character: 'A'")); + }); + + test('no repeats', () { + final DiagnosticPropertiesBuilder builder = DiagnosticPropertiesBuilder(); + + const CharacterActivator('A', includeRepeats: false) + .debugFillProperties(builder); + + final List description = builder.properties.where((DiagnosticsNode node) { + return !node.isFiltered(DiagnosticLevel.info); + }).map((DiagnosticsNode node) => node.toString()).toList(); + + expect(description.length, equals(2)); + expect(description[0], equals("character: 'A'")); + expect(description[1], equals('excluding repeats')); + }); + + test('combination', () { + final DiagnosticPropertiesBuilder builder = DiagnosticPropertiesBuilder(); + + const CharacterActivator('A', + control: true, + meta: true, + ).debugFillProperties(builder); + + final List description = builder.properties.where((DiagnosticsNode node) { + return !node.isFiltered(DiagnosticLevel.info); + }).map((DiagnosticsNode node) => node.toString()).toList(); + + expect(description.length, equals(1)); + expect(description[0], equals("character: Control + Meta + 'A'")); + }); + }); }); group('CallbackShortcuts', () {