blob: 480f790d53c1cea7594722df4e5c74ddbbbd8714 [file] [log] [blame]
// Copyright 2013 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'dart:html' as html;
import 'package:ui/ui.dart' as ui;
import '../engine.dart' show registerHotRestartListener;
import 'browser_detection.dart';
import 'key_map.dart';
import 'platform_dispatcher.dart';
import 'semantics.dart';
typedef _VoidCallback = void Function();
typedef ValueGetter<T> = T Function();
typedef _ModifierGetter = bool Function(FlutterHtmlKeyboardEvent event);
// Set this flag to true to see all the fired events in the console.
const bool _debugLogKeyEvents = false;
const int _kLocationLeft = 1;
const int _kLocationRight = 2;
final int _kLogicalAltLeft = kWebLogicalLocationMap['Alt']![_kLocationLeft]!;
final int _kLogicalAltRight = kWebLogicalLocationMap['Alt']![_kLocationRight]!;
final int _kLogicalControlLeft = kWebLogicalLocationMap['Control']![_kLocationLeft]!;
final int _kLogicalControlRight = kWebLogicalLocationMap['Control']![_kLocationRight]!;
final int _kLogicalShiftLeft = kWebLogicalLocationMap['Shift']![_kLocationLeft]!;
final int _kLogicalShiftRight = kWebLogicalLocationMap['Shift']![_kLocationRight]!;
final int _kLogicalMetaLeft = kWebLogicalLocationMap['Meta']![_kLocationLeft]!;
final int _kLogicalMetaRight = kWebLogicalLocationMap['Meta']![_kLocationRight]!;
// Map logical keys for modifier keys to the functions that can get their
// modifier flag out of an event.
final Map<int, _ModifierGetter> _kLogicalKeyToModifierGetter = <int, _ModifierGetter>{
_kLogicalAltLeft: (FlutterHtmlKeyboardEvent event) => event.altKey,
_kLogicalAltRight: (FlutterHtmlKeyboardEvent event) => event.altKey,
_kLogicalControlLeft: (FlutterHtmlKeyboardEvent event) => event.ctrlKey,
_kLogicalControlRight: (FlutterHtmlKeyboardEvent event) => event.ctrlKey,
_kLogicalShiftLeft: (FlutterHtmlKeyboardEvent event) => event.shiftKey,
_kLogicalShiftRight: (FlutterHtmlKeyboardEvent event) => event.shiftKey,
_kLogicalMetaLeft: (FlutterHtmlKeyboardEvent event) => event.metaKey,
_kLogicalMetaRight: (FlutterHtmlKeyboardEvent event) => event.metaKey,
};
// After a keydown is received, this is the duration we wait for a repeat event
// before we decide to synthesize a keyup event.
//
// On Linux and Windows, the typical ranges for keyboard repeat delay go up to
// 1000ms. On Mac, the range goes up to 2000ms.
const Duration _kKeydownCancelDurationNormal = Duration(milliseconds: 1000);
const Duration _kKeydownCancelDurationMacOs = Duration(milliseconds: 2000);
// ASCII for a, z, A, and Z
const int _kCharLowerA = 0x61;
const int _kCharLowerZ = 0x7a;
const int _kCharUpperA = 0x41;
const int _kCharUpperZ = 0x5a;
bool isAlphabet(int charCode) {
return (charCode >= _kCharLowerA && charCode <= _kCharLowerZ)
|| (charCode >= _kCharUpperA && charCode <= _kCharUpperZ);
}
const String _kPhysicalCapsLock = 'CapsLock';
const String _kLogicalDead = 'Dead';
const int _kWebKeyIdPlane = 0x1700000000;
// Bits in a Flutter logical event to generate the logical key for dead keys.
//
// Logical keys for dead keys are generated by annotating physical keys with
// modifiers (see `_getLogicalCode`).
const int _kDeadKeyCtrl = 0x10000000;
const int _kDeadKeyShift = 0x20000000;
const int _kDeadKeyAlt = 0x40000000;
const int _kDeadKeyMeta = 0x80000000;
const ui.KeyData _emptyKeyData = ui.KeyData(
type: ui.KeyEventType.down,
timeStamp: Duration.zero,
logical: 0,
physical: 0,
character: null,
synthesized: false,
);
typedef DispatchKeyData = bool Function(ui.KeyData data);
/// Converts a floating number timestamp (in milliseconds) to a [Duration] by
/// splitting it into two integer components: milliseconds + microseconds.
Duration _eventTimeStampToDuration(num milliseconds) {
final int ms = milliseconds.toInt();
final int micro = ((milliseconds - ms) * Duration.microsecondsPerMillisecond).toInt();
return Duration(milliseconds: ms, microseconds: micro);
}
class KeyboardBinding {
/// The singleton instance of this object.
static KeyboardBinding? get instance => _instance;
static KeyboardBinding? _instance;
static void initInstance(html.Element glassPaneElement) {
if (_instance == null) {
_instance = KeyboardBinding._(glassPaneElement);
assert(() {
registerHotRestartListener(_instance!._reset);
return true;
}());
}
}
KeyboardBinding._(this.glassPaneElement) {
_setup();
}
final html.Element glassPaneElement;
late KeyboardConverter _converter;
final Map<String, html.EventListener> _listeners = <String, html.EventListener>{};
void _addEventListener(String eventName, html.EventListener handler) {
dynamic loggedHandler(html.Event event) {
if (_debugLogKeyEvents) {
print(event.type);
}
if (EngineSemanticsOwner.instance.receiveGlobalEvent(event)) {
return handler(event);
}
return null;
}
assert(!_listeners.containsKey(eventName));
_listeners[eventName] = loggedHandler;
html.window.addEventListener(eventName, loggedHandler, true);
}
/// Remove all active event listeners.
void _clearListeners() {
_listeners.forEach((String eventName, html.EventListener listener) {
html.window.removeEventListener(eventName, listener, true);
});
_listeners.clear();
}
bool _onKeyData(ui.KeyData data) {
bool? result;
// This callback is designed to be invoked synchronously. This is enforced
// by `result`, which starts null and is asserted non-null when returned.
EnginePlatformDispatcher.instance.invokeOnKeyData(data,
(bool handled) { result = handled; });
return result!;
}
void _setup() {
_addEventListener('keydown', (html.Event event) {
return _converter.handleEvent(FlutterHtmlKeyboardEvent(event as html.KeyboardEvent));
});
_addEventListener('keyup', (html.Event event) {
return _converter.handleEvent(FlutterHtmlKeyboardEvent(event as html.KeyboardEvent));
});
_converter = KeyboardConverter(_onKeyData, onMacOs: operatingSystem == OperatingSystem.macOs);
}
void _reset() {
_clearListeners();
_converter.dispose();
}
}
class AsyncKeyboardDispatching {
AsyncKeyboardDispatching({
required this.keyData,
this.callback,
});
final ui.KeyData keyData;
final _VoidCallback? callback;
}
// A wrapper of [html.KeyboardEvent] with reduced methods delegated to the event
// for the convenience of testing.
class FlutterHtmlKeyboardEvent {
FlutterHtmlKeyboardEvent(this._event);
final html.KeyboardEvent _event;
String get type => _event.type;
String? get code => _event.code;
String? get key => _event.key;
bool? get repeat => _event.repeat;
int? get location => _event.location;
num? get timeStamp => _event.timeStamp;
bool get altKey => _event.altKey;
bool get ctrlKey => _event.ctrlKey;
bool get shiftKey => _event.shiftKey;
bool get metaKey => _event.metaKey;
bool getModifierState(String key) => _event.getModifierState(key);
void preventDefault() => _event.preventDefault();
}
// Reads [html.KeyboardEvent], then [dispatches ui.KeyData] accordingly.
//
// The events are read through [handleEvent], and dispatched through the
// [dispatchKeyData] as given in the constructor. Some key data might be
// dispatched asynchronously.
class KeyboardConverter {
KeyboardConverter(this.performDispatchKeyData, {this.onMacOs = false});
final DispatchKeyData performDispatchKeyData;
final bool onMacOs;
// The `performDispatchKeyData` wrapped with tracking logic.
//
// It is non-null only during `handleEvent`. All events during `handleEvent`
// should be dispatched with `_dispatchKeyData`, others with
// `performDispatchKeyData`.
DispatchKeyData? _dispatchKeyData;
bool _disposed = false;
void dispose() {
_disposed = true;
}
// On macOS, CapsLock behaves differently in that, a keydown event occurs when
// the key is pressed and the light turns on, while a keyup event occurs when the
// key is pressed and the light turns off. Flutter considers both events as
// key down, and synthesizes immediate cancel events following them. The state
// of "whether CapsLock is on" should be accessed by "activeLocks".
bool _shouldSynthesizeCapsLockUp() {
return onMacOs;
}
Duration get _keydownCancelDuration => onMacOs ? _kKeydownCancelDurationMacOs : _kKeydownCancelDurationNormal;
static int _getPhysicalCode(String code) {
return kWebToPhysicalKey[code] ?? (code.hashCode + _kWebKeyIdPlane);
}
static int _getModifierMask(FlutterHtmlKeyboardEvent event) {
final bool altDown = event.altKey;
final bool ctrlDown = event.ctrlKey;
final bool shiftDown = event.shiftKey;
final bool metaDown = event.metaKey;
return (altDown ? _kDeadKeyAlt : 0) +
(ctrlDown ? _kDeadKeyCtrl : 0) +
(shiftDown ? _kDeadKeyShift : 0) +
(metaDown ? _kDeadKeyMeta : 0);
}
// Whether `event.key` should be considered a key name.
//
// The `event.key` can either be a key name or the printable character. If the
// first character is an alphabet, it must be either 'A' to 'Z' ( and return
// true), or be a key name (and return false). Otherwise, return true.
static bool _eventKeyIsKeyname(String key) {
assert(key.isNotEmpty);
return isAlphabet(key.codeUnitAt(0)) && key.length > 1;
}
static int _characterToLogicalKey(String key) {
// Assume the length being <= 2 to be sufficient in all cases. If not,
// extend the algorithm.
assert(key.length <= 2);
int result = key.codeUnitAt(0) & 0xffff;
if (key.length == 2) {
result += key.codeUnitAt(1) << 16;
}
// Convert upper letters to lower letters
if (result >= _kCharUpperA && result <= _kCharUpperZ) {
result = result + _kCharLowerA - _kCharUpperA;
}
return result;
}
static int _deadKeyToLogicalKey(int physicalKey, FlutterHtmlKeyboardEvent event) {
// 'Dead' is used to represent dead keys, such as a diacritic to the
// following base letter (such as Option-e results in ´).
//
// Assume they can be told apart with the physical key and the modifiers
// pressed.
return physicalKey + _getModifierMask(event) + _kWebKeyIdPlane;
}
static int _otherLogicalKey(String key) {
return kWebToLogicalKey[key] ?? (key.hashCode + _kWebKeyIdPlane);
}
// Map from pressed physical key to corresponding pressed logical key.
//
// Multiple physical keys can be mapped to the same logical key, usually due
// to positioned keys (left/right/numpad) or multiple keyboards.
final Map<int, int> _pressingRecords = <int, int>{};
// Schedule the dispatching of an event in the future. The `callback` will
// invoked before that.
//
// Returns a callback that cancels the schedule. Disposal of
// `KeyBoardConverter` also cancels the shedule automatically.
_VoidCallback _scheduleAsyncEvent(Duration duration, ValueGetter<ui.KeyData> getData, _VoidCallback callback) {
bool canceled = false;
Future<void>.delayed(duration).then<void>((_) {
if (!canceled && !_disposed) {
callback();
// This dispatch is performed asynchronously, therefore should not use
// `_dispatchKeyData`.
performDispatchKeyData(getData());
}
});
return () { canceled = true; };
}
// ## About Key guards
//
// When the user enters a browser/system shortcut (e.g. `cmd+alt+i`) the
// browser doesn't send a keyup for it. This puts the framework in a corrupt
// state because it thinks the key was never released.
//
// To avoid this, we rely on the fact that browsers send repeat events
// while the key is held down by the user. If we don't receive a repeat
// event within a specific duration ([_keydownCancelDuration]) we assume
// the user has released the key and we synthesize a keyup event.
final Map<int, _VoidCallback> _keyGuards = <int, _VoidCallback>{};
// Call this method on the down or repeated event of a non-modifier key.
void _startGuardingKey(int physicalKey, int logicalKey, Duration currentTimeStamp) {
final _VoidCallback cancelingCallback = _scheduleAsyncEvent(
_keydownCancelDuration,
() => ui.KeyData(
timeStamp: currentTimeStamp + _keydownCancelDuration,
type: ui.KeyEventType.up,
physical: physicalKey,
logical: logicalKey,
character: null,
synthesized: true,
),
() {
_pressingRecords.remove(physicalKey);
}
);
_keyGuards.remove(physicalKey)?.call();
_keyGuards[physicalKey] = cancelingCallback;
}
// Call this method on an up event event of a non-modifier key.
void _stopGuardingKey(int physicalKey) {
_keyGuards.remove(physicalKey)?.call();
}
void _handleEvent(FlutterHtmlKeyboardEvent event) {
final Duration timeStamp = _eventTimeStampToDuration(event.timeStamp!);
final String eventKey = event.key!;
final int physicalKey = _getPhysicalCode(event.code!);
final bool logicalKeyIsCharacter = !_eventKeyIsKeyname(eventKey);
final String? character = logicalKeyIsCharacter ? eventKey : null;
final int logicalKey = () {
if (kWebLogicalLocationMap.containsKey(event.key!)) {
final int? result = kWebLogicalLocationMap[event.key!]?[event.location!];
assert(result != null, 'Invalid modifier location: ${event.key}, ${event.location}');
return result!;
}
if (character != null)
return _characterToLogicalKey(character);
if (eventKey == _kLogicalDead)
return _deadKeyToLogicalKey(physicalKey, event);
return _otherLogicalKey(eventKey);
}();
assert(event.type == 'keydown' || event.type == 'keyup');
final bool isPhysicalDown = event.type == 'keydown' ||
// On macOS, both keydown and keyup events of CapsLock should be considered keydown,
// followed by an immediate cancel event.
(_shouldSynthesizeCapsLockUp() && event.code! == _kPhysicalCapsLock);
final int? lastLogicalRecord = _pressingRecords[physicalKey];
ui.KeyEventType type;
if (_shouldSynthesizeCapsLockUp() && event.code! == _kPhysicalCapsLock) {
// Case 1: Handle CapsLock on macOS
//
// On macOS, both keydown and keyup events of CapsLock are considered
// keydown, followed by an immediate synchronized up event.
_scheduleAsyncEvent(
Duration.zero,
() => ui.KeyData(
timeStamp: timeStamp,
type: ui.KeyEventType.up,
physical: physicalKey,
logical: logicalKey,
character: null,
synthesized: true,
),
() {
_pressingRecords.remove(physicalKey);
}
);
type = ui.KeyEventType.down;
} else if (isPhysicalDown) {
// Case 2: Handle key down of normal keys
type = ui.KeyEventType.down;
if (lastLogicalRecord != null) {
// This physical key is being pressed according to the record.
if (event.repeat ?? false) {
// A normal repeated key.
type = ui.KeyEventType.repeat;
} else {
// A non-repeated key has been pressed that has the exact physical key as
// a currently pressed one, usually indicating multiple keyboards are
// pressing keys with the same physical key, or the up event was lost
// during a loss of focus. The down event is ignored.
event.preventDefault();
return;
}
} else {
// This physical key is not being pressed according to the record. It's a
// normal down event, whether the system event is a repeat or not.
}
} else { // isPhysicalDown is false and not CapsLock
// Case 2: Handle key up of normal keys
if (lastLogicalRecord == null) {
// The physical key has been released before. It indicates multiple
// keyboards pressed keys with the same physical key. Ignore the up event.
event.preventDefault();
return;
}
type = ui.KeyEventType.up;
}
final int? nextLogicalRecord;
switch (type) {
case ui.KeyEventType.down:
assert(lastLogicalRecord == null);
nextLogicalRecord = logicalKey;
break;
case ui.KeyEventType.up:
assert(lastLogicalRecord != null);
nextLogicalRecord = null;
break;
case ui.KeyEventType.repeat:
assert(lastLogicalRecord != null);
nextLogicalRecord = lastLogicalRecord;
break;
}
if (nextLogicalRecord == null) {
_pressingRecords.remove(physicalKey);
} else {
_pressingRecords[physicalKey] = nextLogicalRecord;
}
// After updating _pressingRecords, synchronize modifier states. The
// `event.***Key` fields can be used to reduce some omitted modifier key
// events. We can deduce key cancel events if they are false. Key sync
// events can not be deduced since we don't know which physical key they
// represent.
_kLogicalKeyToModifierGetter.forEach((int logicalKey, _ModifierGetter getModifier) {
if (_pressingRecords.containsValue(logicalKey) && !getModifier(event)) {
_pressingRecords.removeWhere((int physicalKey, int logicalRecord) {
if (logicalRecord != logicalKey)
return false;
_dispatchKeyData!(ui.KeyData(
timeStamp: timeStamp,
type: ui.KeyEventType.up,
physical: physicalKey,
logical: logicalKey,
character: null,
synthesized: true,
));
return true;
});
}
});
// Update key guards
if (logicalKeyIsCharacter) {
if (nextLogicalRecord != null) {
_startGuardingKey(physicalKey, logicalKey, timeStamp);
} else {
_stopGuardingKey(physicalKey);
}
}
final ui.KeyData keyData = ui.KeyData(
timeStamp: timeStamp,
type: type,
physical: physicalKey,
logical: lastLogicalRecord ?? logicalKey,
character: type == ui.KeyEventType.up ? null : character,
synthesized: false,
);
final bool primaryHandled = _dispatchKeyData!(keyData);
if (primaryHandled) {
event.preventDefault();
}
}
// Parse the HTML event, update states, and dispatch Flutter key data through
// [performDispatchKeyData].
//
// * The method might dispatch some synthesized key data first to update states,
// results discarded.
// * Then it dispatches exactly one non-synthesized key data that corresponds
// to the `event`, i.e. the primary key data. If this dispatching returns
// true, then this event will be invoked `preventDefault`.
// * Some key data might be synthesized to update states after the main key
// data. They are always scheduled asynchronously with results discarded.
void handleEvent(FlutterHtmlKeyboardEvent event) {
assert(_dispatchKeyData == null);
bool sentAnyEvents = false;
_dispatchKeyData = (ui.KeyData data) {
sentAnyEvents = true;
return performDispatchKeyData(data);
};
try {
_handleEvent(event);
} finally {
if (!sentAnyEvents) {
_dispatchKeyData!(_emptyKeyData);
}
_dispatchKeyData = null;
}
}
}