Unverified Commit 01c98fa9 authored by Tong Mu's avatar Tong Mu Committed by GitHub

Character activator (#81807)

parent 7cdd33fe
......@@ -160,6 +160,8 @@ class KeySet<T extends KeyboardKey> {
///
/// * [SingleActivator], an implementation that represents a single key combined
/// with modifiers (control, shift, alt, meta).
/// * [CharacterActivator], an implementation that represents key combinations
/// that result in the specified character, such as question mark.
/// * [LogicalKeySet], an implementation that requires one or more
/// [LogicalKeyboardKey]s to be pressed at the same time. Prefer
/// [SingleActivator] when possible.
......@@ -179,7 +181,13 @@ abstract class ShortcutActivator {
/// [Intent]s are stored in a [Map] and indexed by trigger keys. Subclasses
/// should make sure that the return value of this method does not change
/// throughout the lifespan of this object.
Iterable<LogicalKeyboardKey> get triggers;
///
/// This method might also return null, which means this activator declares
/// all keys as the trigger key. All activators whose [triggers] returns null
/// will be tested with [accepts] on every event. Since this becomes a
/// linear search, and having too many might impact performance, it is
/// preferred to return non-null [triggers] whenever possible.
Iterable<LogicalKeyboardKey>? get triggers;
/// Whether the triggering `event` and the keyboard `state` at the time of the
/// event meet required conditions, providing that the event is a triggering
......@@ -194,6 +202,9 @@ abstract class ShortcutActivator {
/// this is only used to query whether [RawKeyboard.keysPressed] contains
/// a key.
///
/// Since [ShortcutActivator] accepts all event types, subclasses might want
/// to check the event type in [accepts].
///
/// See also:
///
/// * [LogicalKeyboardKey.collapseSynonyms], which helps deciding whether a
......@@ -323,9 +334,6 @@ class LogicalKeySet extends KeySet<LogicalKeyboardKey> with Diagnosticable
LogicalKeyboardKey.meta: <LogicalKeyboardKey>[LogicalKeyboardKey.metaLeft, LogicalKeyboardKey.metaRight],
};
/// Returns a description of the key set that is short and readable.
///
/// Intended to be used in debug mode for logging purposes.
@override
String debugDescribeKeys() {
final List<LogicalKeyboardKey> sortedKeys = keys.toList()..sort(
......@@ -387,7 +395,7 @@ class ShortcutMapProperty extends DiagnosticsProperty<Map<ShortcutActivator, Int
/// A shortcut key combination of a single key and modifiers.
///
/// This [ShortcutActivator] implements typical shortcuts such as:
/// The [SingleActivator] implements typical shortcuts such as:
///
/// * ArrowLeft
/// * Shift + Delete
......@@ -412,6 +420,11 @@ class ShortcutMapProperty extends DiagnosticsProperty<Map<ShortcutActivator, Int
/// * [SingleActivator]s do not consider modifiers to be a trigger key. For
/// example, pressing ControlLeft while holding key X *will not* activate a
/// `SingleActivator(LogicalKeyboardKey.keyX, control: true)`.
///
/// See also:
///
/// * [CharacterActivator], an activator that represents key combinations
/// that result in the specified character, such as question mark.
class SingleActivator with Diagnosticable implements ShortcutActivator {
/// Create an activator of a trigger key and modifiers.
///
......@@ -474,8 +487,8 @@ class SingleActivator with Diagnosticable implements ShortcutActivator {
this.meta = false,
}) : // The enumerated check with `identical` is cumbersome but the only way
// since const constructors can not call functions such as `==` or
// `Set.contains`. Checking with `identical` is sufficient since
// `LogicalKeyboardKey` only provides cached values.
// `Set.contains`. Checking with `identical` might not work when the
// key object is created from ID, but it covers common cases.
assert(
!identical(trigger, LogicalKeyboardKey.control) &&
!identical(trigger, LogicalKeyboardKey.controlLeft) &&
......@@ -585,6 +598,110 @@ class SingleActivator with Diagnosticable implements ShortcutActivator {
}
}
/// A shortcut combination that is triggered by a key event that produces a
/// specific character.
///
/// Keys often produce different characters when combined with modifiers. For
/// example, it might be helpful for the user to bring up a help menu by
/// pressing the question mark ('?'). However, there is no logical key that
/// directly represents a question mark. Althouh 'Shift+Slash' produces a '?'
/// character on a US keyboard, its logical key is still considered a Slash key,
/// and hard-coding 'Shift+Slash' in this situation is unfriendly to other
/// keyboard layouts.
///
/// For example, `CharacterActivator('?')` is triggered when a key combination
/// results in a question mark, which is 'Shift+Slash' on a US keyboard, but
/// 'Shift+Comma' on a French keyboard.
///
/// {@tool dartpad --template=stateful_widget_scaffold_center}
/// In the following example, when a key combination results in a question mark,
/// the counter is increased:
///
/// ```dart preamble
/// class HelpMenuIntent extends Intent {
/// const HelpMenuIntent();
/// }
/// ```
///
/// ```dart
/// @override
/// Widget build(BuildContext context) {
/// return Shortcuts(
/// shortcuts: const <ShortcutActivator, Intent>{
/// CharacterActivator('?'): HelpMenuIntent(),
/// },
/// child: Actions(
/// actions: <Type, Action<Intent>>{
/// HelpMenuIntent: CallbackAction<HelpMenuIntent>(
/// onInvoke: (HelpMenuIntent intent) {
/// ScaffoldMessenger.of(context).showSnackBar(
/// const SnackBar(content: Text('Keep calm and carry on!')),
/// );
/// return null;
/// },
/// ),
/// },
/// child: Focus(
/// autofocus: true,
/// child: Column(
/// children: const <Widget>[
/// Text('Press question mark for help'),
/// ],
/// ),
/// ),
/// ),
/// );
/// }
/// ```
/// {@end-tool}
///
/// See also:
///
/// * [SingleActivator], an activator that represents a single key combined
/// with modifiers, such as `Ctrl+C`.
class CharacterActivator with Diagnosticable implements ShortcutActivator {
/// Create a [CharacterActivator] from the triggering character.
const CharacterActivator(this.character);
/// The character of the triggering event.
///
/// This is typically a single-character string, such as '?' or 'œ', although
/// [CharacterActivator] doesn't check the length of [character] or whether it
/// can be matched by any key combination at all. It is case-sensitive, since
/// the [character] is directly compared by `==` to the character reported by
/// the platform.
///
/// See also:
///
/// * [RawKeyEvent.character], the character of a key event.
final String character;
@override
Iterable<LogicalKeyboardKey>? get triggers => null;
@override
bool accepts(RawKeyEvent event, RawKeyboard state) {
return event is RawKeyDownEvent
&& event.character == character;
}
@override
String debugDescribeKeys() {
String result = '';
assert(() {
result = "'$character'";
return true;
}());
return result;
}
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties.add(StringProperty('character', character));
}
}
class _ActivatorIntentPair with Diagnosticable {
const _ActivatorIntentPair(this.activator, this.intent);
final ShortcutActivator activator;
......@@ -639,20 +756,22 @@ class ShortcutManager extends ChangeNotifier with Diagnosticable {
}
}
static Map<LogicalKeyboardKey, List<_ActivatorIntentPair>> _indexShortcuts(Map<ShortcutActivator, Intent> source) {
final Map<LogicalKeyboardKey, List<_ActivatorIntentPair>> result = <LogicalKeyboardKey, List<_ActivatorIntentPair>>{};
static Map<LogicalKeyboardKey?, List<_ActivatorIntentPair>> _indexShortcuts(Map<ShortcutActivator, Intent> source) {
final Map<LogicalKeyboardKey?, List<_ActivatorIntentPair>> result = <LogicalKeyboardKey?, List<_ActivatorIntentPair>>{};
source.forEach((ShortcutActivator activator, Intent intent) {
for (final LogicalKeyboardKey trigger in activator.triggers) {
// This intermediate variable is necessary to comply with Dart analyzer.
final Iterable<LogicalKeyboardKey?>? nullableTriggers = activator.triggers;
for (final LogicalKeyboardKey? trigger in nullableTriggers ?? <LogicalKeyboardKey?>[null]) {
result.putIfAbsent(trigger, () => <_ActivatorIntentPair>[])
.add(_ActivatorIntentPair(activator, intent));
}
});
return result;
}
Map<LogicalKeyboardKey, List<_ActivatorIntentPair>> get _indexedShortcuts {
Map<LogicalKeyboardKey?, List<_ActivatorIntentPair>> get _indexedShortcuts {
return _indexedShortcutsCache ??= _indexShortcuts(_shortcuts);
}
Map<LogicalKeyboardKey, List<_ActivatorIntentPair>>? _indexedShortcutsCache;
Map<LogicalKeyboardKey?, List<_ActivatorIntentPair>>? _indexedShortcutsCache;
/// Returns the [Intent], if any, that matches the current set of pressed
/// keys.
......@@ -662,9 +781,12 @@ class ShortcutManager extends ChangeNotifier with Diagnosticable {
/// Defaults to a set derived from [RawKeyboard.keysPressed] if `keysPressed`
/// is not supplied.
Intent? _find(RawKeyEvent event, RawKeyboard state) {
final List<_ActivatorIntentPair>? candidates = _indexedShortcuts[event.logicalKey];
if (candidates == null)
return null;
final List<_ActivatorIntentPair>? candidatesByKey = _indexedShortcuts[event.logicalKey];
final List<_ActivatorIntentPair>? candidatesByNull = _indexedShortcuts[null];
final List<_ActivatorIntentPair> candidates = <_ActivatorIntentPair>[
if (candidatesByKey != null) ...candidatesByKey,
if (candidatesByNull != null) ...candidatesByNull,
];
for (final _ActivatorIntentPair activatorIntent in candidates) {
if (activatorIntent.activator.accepts(event, state)) {
return activatorIntent.intent;
......
......@@ -9,7 +9,7 @@ import 'package:flutter_test/flutter_test.dart';
typedef PostInvokeCallback = void Function({Action<Intent> action, Intent intent, BuildContext? context, ActionDispatcher dispatcher});
class TestAction extends CallbackAction<TestIntent> {
class TestAction extends CallbackAction<Intent> {
TestAction({
required OnInvokeCallback onInvoke,
}) : assert(onInvoke != null),
......@@ -31,10 +31,47 @@ class TestDispatcher extends ActionDispatcher {
}
}
/// 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);
......@@ -49,7 +86,13 @@ class TestShortcutManager extends ShortcutManager {
}
}
Widget activatorTester(ShortcutActivator activator, ValueSetter<Intent> onInvoke) {
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>>{
......@@ -57,10 +100,16 @@ Widget activatorTester(ShortcutActivator activator, ValueSetter<Intent> onInvoke
onInvoke(intent);
return true;
}),
if (hasSecond)
TestIntent2: TestAction(onInvoke: (Intent intent) {
onInvoke2(intent);
}),
},
child: Shortcuts(
shortcuts: <ShortcutActivator, Intent>{
activator: const TestIntent(),
if (hasSecond)
activator2: const TestIntent2(),
},
child: const Focus(
autofocus: true,
......@@ -967,5 +1016,65 @@ void main() {
expect(value, isTrue);
expect(controller.position.pixels, 0.0);
});
testWidgets('Shortcuts support activators that returns null in triggers', (WidgetTester tester) async {
int invoked = 0;
await tester.pumpWidget(activatorTester(
const DumbLogicalActivator(LogicalKeyboardKey.keyC),
(Intent intent) { invoked += 1; },
const SingleActivator(LogicalKeyboardKey.keyC, control: true),
(Intent intent) { invoked += 10; },
));
await tester.pump();
// Press KeyC: Accepted by DumbLogicalActivator
await tester.sendKeyDownEvent(LogicalKeyboardKey.keyC);
expect(invoked, 1);
await tester.sendKeyUpEvent(LogicalKeyboardKey.keyC);
expect(invoked, 1);
invoked = 0;
// Press ControlLeft + KeyC: Accepted by SingleActivator
await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft);
expect(invoked, 0);
await tester.sendKeyDownEvent(LogicalKeyboardKey.keyC);
expect(invoked, 10);
await tester.sendKeyUpEvent(LogicalKeyboardKey.keyC);
await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft);
expect(invoked, 10);
invoked = 0;
// Press ControlLeft + ShiftLeft + KeyC: Accepted by DumbLogicalActivator
await tester.sendKeyDownEvent(LogicalKeyboardKey.shiftLeft);
await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft);
expect(invoked, 0);
await tester.sendKeyDownEvent(LogicalKeyboardKey.keyC);
expect(invoked, 1);
await tester.sendKeyUpEvent(LogicalKeyboardKey.keyC);
await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft);
await tester.sendKeyUpEvent(LogicalKeyboardKey.shiftLeft);
expect(invoked, 1);
invoked = 0;
});
});
group('CharacterActivator', () {
testWidgets('is triggered on events with correct character', (WidgetTester tester) async {
int invoked = 0;
await tester.pumpWidget(activatorTester(
const CharacterActivator('?'),
(Intent intent) { invoked += 1; },
));
await tester.pump();
// Press KeyC: Accepted by DumbLogicalActivator
await tester.sendKeyDownEvent(LogicalKeyboardKey.shiftLeft);
await tester.sendKeyDownEvent(LogicalKeyboardKey.slash, character: '?');
expect(invoked, 1);
await tester.sendKeyUpEvent(LogicalKeyboardKey.slash);
await tester.sendKeyUpEvent(LogicalKeyboardKey.shiftLeft);
expect(invoked, 1);
invoked = 0;
});
});
}
......@@ -1021,10 +1021,10 @@ abstract class WidgetController {
///
/// - [sendKeyUpEvent] to simulate the corresponding key up event.
/// - [sendKeyEvent] to simulate both the key up and key down in the same call.
Future<bool> sendKeyDownEvent(LogicalKeyboardKey key, { String platform = _defaultPlatform }) async {
Future<bool> sendKeyDownEvent(LogicalKeyboardKey key, { String? character, String platform = _defaultPlatform }) async {
assert(platform != null);
// Internally wrapped in async guard.
return simulateKeyDownEvent(key, platform: platform);
return simulateKeyDownEvent(key, character: character, platform: platform);
}
/// Simulates sending a physical key up event through the system channel.
......
......@@ -194,6 +194,7 @@ class KeyEventSimulator {
required String platform,
bool isDown = true,
PhysicalKeyboardKey? physicalKey,
String? character,
}) {
assert(_osIsSupported(platform), 'Platform $platform not supported for key simulation');
......@@ -211,27 +212,31 @@ class KeyEventSimulator {
'keymap': platform,
};
if (kIsWeb) {
final String resultCharacter = character ?? _keyLabel(key);
void assignWeb() {
result['code'] = _getWebKeyCode(key);
result['key'] = _keyLabel(key);
result['key'] = resultCharacter;
result['metaState'] = _getWebModifierFlags(key, isDown);
}
if (kIsWeb) {
assignWeb();
return result;
}
switch (platform) {
case 'android':
result['keyCode'] = keyCode;
if (_keyLabel(key).isNotEmpty) {
result['codePoint'] = _keyLabel(key).codeUnitAt(0);
result['character'] = _keyLabel(key);
if (resultCharacter.isNotEmpty) {
result['codePoint'] = resultCharacter.codeUnitAt(0);
result['character'] = resultCharacter;
}
result['scanCode'] = scanCode;
result['metaState'] = _getAndroidModifierFlags(key, isDown);
break;
case 'fuchsia':
result['hidUsage'] = physicalKey.usbHidUsage;
if (_keyLabel(key).isNotEmpty) {
result['codePoint'] = _keyLabel(key).codeUnitAt(0);
if (resultCharacter.isNotEmpty) {
result['codePoint'] = resultCharacter.codeUnitAt(0);
}
result['modifiers'] = _getFuchsiaModifierFlags(key, isDown);
break;
......@@ -240,34 +245,33 @@ class KeyEventSimulator {
result['keyCode'] = keyCode;
result['scanCode'] = scanCode;
result['modifiers'] = _getGlfwModifierFlags(key, isDown);
result['unicodeScalarValues'] = _keyLabel(key).isNotEmpty ? _keyLabel(key).codeUnitAt(0) : 0;
result['unicodeScalarValues'] = resultCharacter.isNotEmpty ? resultCharacter.codeUnitAt(0) : 0;
break;
case 'macos':
result['keyCode'] = scanCode;
if (_keyLabel(key).isNotEmpty) {
result['characters'] = _keyLabel(key);
result['charactersIgnoringModifiers'] = _keyLabel(key);
if (resultCharacter.isNotEmpty) {
result['characters'] = resultCharacter;
result['charactersIgnoringModifiers'] = resultCharacter;
}
result['modifiers'] = _getMacOsModifierFlags(key, isDown);
break;
case 'ios':
result['keyCode'] = scanCode;
result['characters'] = _keyLabel(key);
result['charactersIgnoringModifiers'] = _keyLabel(key);
result['characters'] = resultCharacter;
result['charactersIgnoringModifiers'] = resultCharacter;
result['modifiers'] = _getIOSModifierFlags(key, isDown);
break;
case 'web':
result['code'] = _getWebKeyCode(key);
result['key'] = _keyLabel(key);
result['metaState'] = _getWebModifierFlags(key, isDown);
break;
case 'windows':
result['keyCode'] = keyCode;
result['scanCode'] = scanCode;
if (_keyLabel(key).isNotEmpty) {
result['characterCodePoint'] = _keyLabel(key).codeUnitAt(0);
if (resultCharacter.isNotEmpty) {
result['characterCodePoint'] = resultCharacter.codeUnitAt(0);
}
result['modifiers'] = _getWindowsModifierFlags(key, isDown);
break;
case 'web':
assignWeb();
break;
}
return result;
}
......@@ -631,12 +635,12 @@ class KeyEventSimulator {
/// See also:
///
/// - [simulateKeyUpEvent] to simulate the corresponding key up event.
static Future<bool> simulateKeyDownEvent(LogicalKeyboardKey key, {String? platform, PhysicalKeyboardKey? physicalKey}) async {
static Future<bool> simulateKeyDownEvent(LogicalKeyboardKey key, {String? platform, PhysicalKeyboardKey? physicalKey, String? character}) async {
return TestAsyncUtils.guard<bool>(() async {
platform ??= Platform.operatingSystem;
assert(_osIsSupported(platform!), 'Platform $platform not supported for key simulation');
final Map<String, dynamic> data = getKeyData(key, platform: platform!, isDown: true, physicalKey: physicalKey);
final Map<String, dynamic> data = getKeyData(key, platform: platform!, isDown: true, physicalKey: physicalKey, character: character);
bool result = false;
await ServicesBinding.instance!.defaultBinaryMessenger.handlePlatformMessage(
SystemChannels.keyEvent.name,
......@@ -715,8 +719,8 @@ class KeyEventSimulator {
/// See also:
///
/// - [simulateKeyUpEvent] to simulate the corresponding key up event.
Future<bool> simulateKeyDownEvent(LogicalKeyboardKey key, {String? platform, PhysicalKeyboardKey? physicalKey}) {
return KeyEventSimulator.simulateKeyDownEvent(key, platform: platform, physicalKey: physicalKey);
Future<bool> simulateKeyDownEvent(LogicalKeyboardKey key, {String? platform, PhysicalKeyboardKey? physicalKey, String? character}) {
return KeyEventSimulator.simulateKeyDownEvent(key, platform: platform, physicalKey: physicalKey, character: character);
}
/// Simulates sending a hardware key up event through the system channel.
......
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