Skip to content

Instantly share code, notes, and snippets.

@venkatd
Created July 16, 2021 15:20
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save venkatd/fbbf17eda538538e9ff3ed92a3ca29de to your computer and use it in GitHub Desktop.
Save venkatd/fbbf17eda538538e9ff3ed92a3ca29de to your computer and use it in GitHub Desktop.
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
/// This class smooths over the platform differences for a RawKeyEvent,
/// encapsulates some workarounds for Flutter keyboard handling bugs,
/// and makes it easier to match up [LogicalKeyEvent] to a keyboard shortcut
/// combo.
///
/// It accounts for edge cases & bugs such as:
/// - Ensuring the event is only triggered on a non-modifier key. For example
/// cmd+x would trigger a cut event while x+cmd would not
/// - Normalizing modifier key such as shiftLeft and shiftRight to mean just mean shift
/// - On Flutter web ensure modifier keys don't get stuck. On this platform,
/// modifier keys are not marked as released immediately.
/// - Normalize the [LogicalKeyEvent.character] to correspond to the actual
/// character that is intended to be typed. This means filtering out control
/// character, on web UI event keys, and ensuring the quote character is properly
/// interpreted as '"' on international keyboards.
/// - On MacOS ensure the shift-key doesn't modify the case of letter trigger keys
/// For example cmd+shift+v on Mac gets interpreted as cmd+shift+V (upper case)
/// while on other platforms it is correctly interpreted as cmd+shift+v
class LogicalKeyEvent {
LogicalKeyEvent({
required this.trigger,
required Set<LogicalKeyboardKey> keysPressed,
this.character,
}) : _keySet = LogicalKeySet.fromSet(keysPressed);
factory LogicalKeyEvent.fromRawKeyEvent(
RawKeyEvent keyEvent, Set<LogicalKeyboardKey> keysPressed) {
final adjustedKeysPressed = _adjustKeysPressed(
_collapseKeyboardKeySynonyms(keysPressed),
keyEvent,
);
return LogicalKeyEvent(
trigger: keyEvent.logicalKey,
keysPressed: adjustedKeysPressed,
character: _logicalCharacterForKeyEvent(
trigger: keyEvent.logicalKey,
keysPressed: adjustedKeysPressed,
character: keyEvent.character,
),
);
}
/// The non-modifier key that possibly triggers the event
/// For example, if trigger is [LogicalKeyboardKey.keyX], cmd+x would be triggered
/// while x+cmd would not trigger anything.
final LogicalKeyboardKey trigger;
Set<LogicalKeyboardKey> get keysPressed => _keySet.keys;
final String? character;
final LogicalKeySet _keySet;
bool isKeyPressed(LogicalKeyboardKey key) => keysPressed.contains(key);
LogicalKeySet toKeySet() => _keySet;
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
if (other is! LogicalKeyEvent) return false;
return trigger == other.trigger && _keySet == other._keySet;
}
@override
int get hashCode => trigger.hashCode;
@override
String toString() {
return toKeySet().toString();
}
}
String? _logicalCharacterForKeyEvent({
required LogicalKeyboardKey trigger,
required Set<LogicalKeyboardKey> keysPressed,
required String? character,
}) {
if (_isQuoteKey(trigger)) {
return keysPressed.contains(LogicalKeyboardKey.shift) ? '"' : "'";
}
if (character == null || character == '') return null;
if (keysPressed.contains(LogicalKeyboardKey.meta) ||
keysPressed.contains(LogicalKeyboardKey.control)) {
return null;
}
if (LogicalKeyboardKey.isControlCharacter(character)) return null;
if (kIsWeb && _isWebUIEventKey(character)) return null;
return character;
}
const _kMacDeadKey = LogicalKeyboardKey(0x100070034);
// This is the accent key aka dead key (https://en.wikipedia.org/wiki/Dead_key)
// also doubles as a quote in some keyboard layouts. To account for this, we have
// to manually generate a single or double quote
bool _isQuoteKey(LogicalKeyboardKey key) {
return key == LogicalKeyboardKey.quote || key == _kMacDeadKey;
}
// Limitation of Flutter web which returns chars that map to UI events
// Further reading: https://www.w3.org/TR/uievents-key
// The equivalent regex would look like [A-Z][A-Za-z0-9]+
bool _isWebUIEventKey(String chars) {
const _kA = 0x41;
const _kZ = 0x5a;
const _ka = 0x61;
const _kz = 0x7a;
const _k0 = 0x30;
const _k9 = 0x39;
bool isUpperAlpha(int codeUnit) => codeUnit >= _kA && codeUnit <= _kZ;
bool isAlphaNumeric(int codeUnit) {
return codeUnit >= _kA && codeUnit <= _kZ ||
codeUnit >= _ka && codeUnit <= _kz ||
codeUnit >= _k0 && codeUnit <= _k9;
}
if (chars.length <= 1) return false;
final codeUnits = chars.codeUnits;
if (!isUpperAlpha(codeUnits[0])) return false;
for (var i = 1; i < codeUnits.length; i++) {
if (!isAlphaNumeric(codeUnits[i])) return false;
}
return true;
}
/// This is a workaround to a bug on some platforms
///
/// Bug 1: Sticky web keys (kIsWeb):
/// Modifier keys are "sticky". Meaning they are incorrectly marked as pressed
/// for some delay after they have already been released.
/// For example hitting cmd+right, then cmd+left, incorrectly reports
/// [RawKeyboard.instance.keysPressed] to be cmd+left+right when really right
/// is no longer pressed.
/// The workaround assumes we only have 1 non-modifier key.
/// On a [RawKeyDownEvent], we filter out any previous non-modifier keys and
/// replace them with the most recent non modifier key.
///
/// Bug 2: Shift keys affecting MacOS key case
Set<LogicalKeyboardKey> _adjustKeysPressed(
Set<LogicalKeyboardKey> keys, RawKeyEvent keyEvent) {
if (keyEvent is! RawKeyDownEvent) return keys;
if (keyEvent.logicalKey.isModifierKey) return keys;
final modifierKeys = keys.where((k) => k.isModifierKey);
// In some systems, letters gets transformed to the upper case variant due to
// the shift modifier key. Here we normalize it so all letters are lower-case/
// Meanwhile in most platforms, the lower case letter come through.
final triggerKey = modifierKeys.contains(LogicalKeyboardKey.shift)
? keyEvent.logicalKey.toLowerCase()
: keyEvent.logicalKey;
return {triggerKey, ...modifierKeys};
}
LogicalKeyboardKey _collapseKeyboardSynonym(LogicalKeyboardKey key) {
return _keyboardKeySynonyms[key] ?? key;
}
Set<LogicalKeyboardKey> _collapseKeyboardKeySynonyms(
Set<LogicalKeyboardKey> keys) {
final collapsed = <LogicalKeyboardKey>{};
for (final k in keys) {
collapsed.add(_collapseKeyboardSynonym(k));
}
return collapsed;
}
extension LogicalKeyboardKeyExt on LogicalKeyboardKey {
bool get isModifierKey => _modifierKeys.contains(this);
}
final _modifierKeys = {
LogicalKeyboardKey.shiftLeft,
LogicalKeyboardKey.shiftRight,
LogicalKeyboardKey.shift,
LogicalKeyboardKey.metaLeft,
LogicalKeyboardKey.metaRight,
LogicalKeyboardKey.meta,
LogicalKeyboardKey.altLeft,
LogicalKeyboardKey.altRight,
LogicalKeyboardKey.alt,
LogicalKeyboardKey.controlLeft,
LogicalKeyboardKey.controlRight,
LogicalKeyboardKey.control,
LogicalKeyboardKey.fn,
};
final _keyboardKeySynonyms = <LogicalKeyboardKey, LogicalKeyboardKey>{
LogicalKeyboardKey.shiftLeft: LogicalKeyboardKey.shift,
LogicalKeyboardKey.shiftRight: LogicalKeyboardKey.shift,
LogicalKeyboardKey.metaLeft: LogicalKeyboardKey.meta,
LogicalKeyboardKey.metaRight: LogicalKeyboardKey.meta,
LogicalKeyboardKey.altLeft: LogicalKeyboardKey.alt,
LogicalKeyboardKey.altRight: LogicalKeyboardKey.alt,
LogicalKeyboardKey.controlLeft: LogicalKeyboardKey.control,
LogicalKeyboardKey.controlRight: LogicalKeyboardKey.control,
LogicalKeyboardKey.numpadEnter: LogicalKeyboardKey.enter,
};
extension _LogicalKeyboardKeyCaseExt on LogicalKeyboardKey {
static final _kUpperToLowerDist = 0x20;
static final _kLowerCaseA = LogicalKeyboardKey.keyA.keyId;
static final _kLowerCaseZ = LogicalKeyboardKey.keyZ.keyId;
static final _kUpperCaseA = _kLowerCaseA - _kUpperToLowerDist;
static final _kUpperCaseZ = _kLowerCaseZ - _kUpperToLowerDist;
LogicalKeyboardKey toLowerCase() {
if (keyId < _kUpperCaseA || keyId > _kUpperCaseZ) return this;
return LogicalKeyboardKey(keyId + _kUpperToLowerDist);
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment