blob: aef81d0d673ce1ad75b860cf32a73d9fafc3b563 [file] [log] [blame]
// Copyright 2014 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:convert';
import 'utils.dart';
/// The data structure used to manage keyboard key entries.
///
/// The main constructor parses the given input data into the data structure.
///
/// The data structure can be also loaded and saved to JSON, with the
/// [PhysicalKeyData.fromJson] constructor and [toJson] method, respectively.
class PhysicalKeyData {
factory PhysicalKeyData(
String chromiumHidCodes,
String androidKeyboardLayout,
String androidNameMap,
) {
final Map<String, List<int>> nameToAndroidScanCodes = _readAndroidScanCodes(androidKeyboardLayout, androidNameMap);
final Map<String, PhysicalKeyEntry> data = _readHidEntries(
chromiumHidCodes,
nameToAndroidScanCodes,
);
final List<MapEntry<String, PhysicalKeyEntry>> sortedEntries = data.entries.toList()..sort(
(MapEntry<String, PhysicalKeyEntry> a, MapEntry<String, PhysicalKeyEntry> b) =>
PhysicalKeyEntry.compareByUsbHidCode(a.value, b.value),
);
data
..clear()
..addEntries(sortedEntries);
return PhysicalKeyData._(data);
}
/// Parses the given JSON data and populates the data structure from it.
factory PhysicalKeyData.fromJson(Map<String, dynamic> contentMap) {
final Map<String, PhysicalKeyEntry> data = <String, PhysicalKeyEntry>{};
for (final MapEntry<String, dynamic> jsonEntry in contentMap.entries) {
final PhysicalKeyEntry entry = PhysicalKeyEntry.fromJsonMapEntry(jsonEntry.value as Map<String, dynamic>);
data[entry.name] = entry;
}
return PhysicalKeyData._(data);
}
PhysicalKeyData._(this._data);
/// Find an entry from name, or null if not found.
PhysicalKeyEntry? tryEntryByName(String name) {
return _data[name];
}
/// Find an entry from name.
///
/// Asserts if the name is not found.
PhysicalKeyEntry entryByName(String name) {
final PhysicalKeyEntry? entry = tryEntryByName(name);
assert(entry != null,
'Unable to find logical entry by name $name.');
return entry!;
}
/// All entries.
Iterable<PhysicalKeyEntry> get entries => _data.values;
// Keys mapped from their names.
final Map<String, PhysicalKeyEntry> _data;
/// Converts the data structure into a JSON structure that can be parsed by
/// [PhysicalKeyData.fromJson].
Map<String, dynamic> toJson() {
final Map<String, dynamic> outputMap = <String, dynamic>{};
for (final PhysicalKeyEntry entry in _data.values) {
outputMap[entry.name] = entry.toJson();
}
return outputMap;
}
/// Parses entries from Androids Generic.kl scan code data file.
///
/// Lines in this file look like this (without the ///):
/// key 100 ALT_RIGHT
/// # key 101 "KEY_LINEFEED"
/// key 477 F12 FUNCTION
///
/// We parse the commented out lines as well as the non-commented lines, so
/// that we can get names for all of the available scan codes, not just ones
/// defined for the generic profile.
///
/// Also, note that some keys (notably MEDIA_EJECT) can be mapped to more than
/// one scan code, so the mapping can't just be 1:1, it has to be 1:many.
static Map<String, List<int>> _readAndroidScanCodes(String keyboardLayout, String nameMap) {
final RegExp keyEntry = RegExp(
r'#?\s*' // Optional comment mark
r'key\s+' // Literal "key"
r'(?<id>[0-9]+)\s*' // ID section
r'"?(?:KEY_)?(?<name>[0-9A-Z_]+|\(undefined\))"?\s*' // Name section
r'(?<function>FUNCTION)?' // Optional literal "FUNCTION"
);
final Map<String, List<int>> androidNameToScanCodes = <String, List<int>>{};
for (final RegExpMatch match in keyEntry.allMatches(keyboardLayout)) {
if (match.namedGroup('function') == 'FUNCTION') {
// Skip odd duplicate Android FUNCTION keys (F1-F12 are already defined).
continue;
}
final String name = match.namedGroup('name')!;
if (name == '(undefined)') {
// Skip undefined scan codes.
continue;
}
androidNameToScanCodes.putIfAbsent(name, () => <int>[])
.add(int.parse(match.namedGroup('id')!));
}
// Cast Android dom map
final Map<String, List<String>> nameToAndroidNames = (json.decode(nameMap) as Map<String, dynamic>)
.cast<String, List<dynamic>>()
.map<String, List<String>>((String key, List<dynamic> value) {
return MapEntry<String, List<String>>(key, value.cast<String>());
});
final Map<String, List<int>> result = nameToAndroidNames.map((String name, List<String> androidNames) {
final Set<int> scanCodes = <int>{};
for (final String androidName in androidNames) {
scanCodes.addAll(androidNameToScanCodes[androidName] ?? <int>[]);
}
return MapEntry<String, List<int>>(name, scanCodes.toList()..sort());
});
return result;
}
/// Parses entries from Chromium's HID code mapping header file.
///
/// Lines in this file look like this (without the ///):
/// USB evdev XKB Win Mac Code Enum
/// DOM_CODE(0x000010, 0x0000, 0x0000, 0x0000, 0xffff, "Hyper", HYPER),
static Map<String, PhysicalKeyEntry> _readHidEntries(
String input,
Map<String, List<int>> nameToAndroidScanCodes,
) {
final Map<int, PhysicalKeyEntry> entries = <int, PhysicalKeyEntry>{};
final RegExp usbMapRegExp = RegExp(
r'DOM_CODE\s*\(\s*'
r'0[xX](?<usb>[a-fA-F0-9]+),\s*'
r'0[xX](?<evdev>[a-fA-F0-9]+),\s*'
r'0[xX](?<xkb>[a-fA-F0-9]+),\s*'
r'0[xX](?<win>[a-fA-F0-9]+),\s*'
r'0[xX](?<mac>[a-fA-F0-9]+),\s*'
r'(?:"(?<code>[^\s]+)")?[^")]*?,'
r'\s*(?<enum>[^\s]+?)\s*'
r'\)',
// Multiline is necessary because some definitions spread across
// multiple lines.
multiLine: true,
);
final RegExp commentRegExp = RegExp(r'//.*$', multiLine: true);
input = input.replaceAll(commentRegExp, '');
for (final RegExpMatch match in usbMapRegExp.allMatches(input)) {
final int usbHidCode = getHex(match.namedGroup('usb')!);
final int evdevCode = getHex(match.namedGroup('evdev')!);
final int xKbScanCode = getHex(match.namedGroup('xkb')!);
final int windowsScanCode = getHex(match.namedGroup('win')!);
final int macScanCode = getHex(match.namedGroup('mac')!);
final String? chromiumCode = match.namedGroup('code');
// The input data has a typo...
final String enumName = match.namedGroup('enum')!.replaceAll('MINIMIUM', 'MINIMUM');
final String name = chromiumCode ?? shoutingToUpperCamel(enumName);
if (name == 'IntlHash' || name == 'None') {
// Skip key that is not actually generated by any keyboard.
continue;
}
final PhysicalKeyEntry? existing = entries[usbHidCode];
// Allow duplicate entries for Fn, which overwrites.
if (existing != null && existing.name != 'Fn') {
// If it's an existing entry, the only thing we currently support is
// to insert an extra DOMKey. The other entries must be empty.
assert(evdevCode == 0
&& xKbScanCode == 0
&& windowsScanCode == 0
&& macScanCode == 0xffff
&& chromiumCode != null
&& chromiumCode.isNotEmpty,
'Duplicate usbHidCode ${existing.usbHidCode} of key ${existing.name} '
'conflicts with existing ${entries[existing.usbHidCode]!.name}.');
existing.otherWebCodes.add(chromiumCode!);
continue;
}
final PhysicalKeyEntry newEntry = PhysicalKeyEntry(
usbHidCode: usbHidCode,
androidScanCodes: nameToAndroidScanCodes[name] ?? <int>[],
evdevCode: evdevCode == 0 ? null : evdevCode,
xKbScanCode: xKbScanCode == 0 ? null : xKbScanCode,
windowsScanCode: windowsScanCode == 0 ? null : windowsScanCode,
macOSScanCode: macScanCode == 0xffff ? null : macScanCode,
iOSScanCode: (usbHidCode & 0x070000) == 0x070000 ? (usbHidCode ^ 0x070000) : null,
name: name,
chromiumCode: chromiumCode,
);
entries[newEntry.usbHidCode] = newEntry;
}
return entries.map((int code, PhysicalKeyEntry entry) =>
MapEntry<String, PhysicalKeyEntry>(entry.name, entry));
}
}
/// A single entry in the key data structure.
///
/// Can be read from JSON with the [PhysicalKeyEntry.fromJsonMapEntry] constructor, or
/// written with the [toJson] method.
class PhysicalKeyEntry {
/// Creates a single key entry from available data.
///
/// The [usbHidCode] and [chromiumName] parameters must not be null.
PhysicalKeyEntry({
required this.usbHidCode,
required this.name,
required this.androidScanCodes,
required this.evdevCode,
required this.xKbScanCode,
required this.windowsScanCode,
required this.macOSScanCode,
required this.iOSScanCode,
required this.chromiumCode,
List<String>? otherWebCodes,
}) : otherWebCodes = otherWebCodes ?? <String>[];
/// Populates the key from a JSON map.
factory PhysicalKeyEntry.fromJsonMapEntry(Map<String, dynamic> map) {
final Map<String, dynamic> names = map['names'] as Map<String, dynamic>;
final Map<String, dynamic> scanCodes = map['scanCodes'] as Map<String, dynamic>;
return PhysicalKeyEntry(
name: names['name'] as String,
chromiumCode: names['chromium'] as String?,
usbHidCode: scanCodes['usb'] as int,
androidScanCodes: (scanCodes['android'] as List<dynamic>?)?.cast<int>() ?? <int>[],
evdevCode: scanCodes['linux'] as int?,
xKbScanCode: scanCodes['xkb'] as int?,
windowsScanCode: scanCodes['windows'] as int?,
macOSScanCode: scanCodes['macos'] as int?,
iOSScanCode: scanCodes['ios'] as int?,
otherWebCodes: (map['otherWebCodes'] as List<dynamic>?)?.cast<String>(),
);
}
/// The USB HID code of the key
final int usbHidCode;
/// The Evdev scan code of the key, from Chromium's header file.
final int? evdevCode;
/// The XKb scan code of the key from Chromium's header file.
final int? xKbScanCode;
/// The Windows scan code of the key from Chromium's header file.
final int? windowsScanCode;
/// The macOS scan code of the key from Chromium's header file.
final int? macOSScanCode;
/// The iOS scan code of the key from UIKey's documentation (USB Hid table)
final int? iOSScanCode;
/// The list of Android scan codes matching this key, created by looking up
/// the Android name in the Chromium data, and substituting the Android scan
/// code value.
final List<int> androidScanCodes;
/// The name of the key, mostly derived from the DomKey name in Chromium,
/// but where there was no DomKey representation, derived from the Chromium
/// symbol name.
final String name;
/// The Chromium event code for the key.
final String? chromiumCode;
/// Other codes used by Web besides chromiumCode.
final List<String> otherWebCodes;
Iterable<String> webCodes() sync* {
if (chromiumCode != null) {
yield chromiumCode!;
}
yield* otherWebCodes;
}
/// Creates a JSON map from the key data.
Map<String, dynamic> toJson() {
return removeEmptyValues(<String, dynamic>{
'names': <String, dynamic>{
'name': name,
'chromium': chromiumCode,
},
'otherWebCodes': otherWebCodes,
'scanCodes': <String, dynamic>{
'android': androidScanCodes,
'usb': usbHidCode,
'linux': evdevCode,
'xkb': xKbScanCode,
'windows': windowsScanCode,
'macos': macOSScanCode,
'ios': iOSScanCode,
},
});
}
static String getCommentName(String constantName) {
String upperCamel = lowerCamelToUpperCamel(constantName);
upperCamel = upperCamel.replaceAllMapped(
RegExp(r'(Digit|Numpad|Lang|Button|Left|Right)([0-9]+)'),
(Match match) => '${match.group(1)} ${match.group(2)}',
);
return upperCamel.replaceAllMapped(RegExp(r'([A-Z])'), (Match match) => ' ${match.group(1)}').trim();
}
/// Gets the name of the key suitable for placing in comments.
///
/// Takes the [constantName] and converts it from lower camel case to capitalized
/// separate words (e.g. "wakeUp" converts to "Wake Up").
String get commentName => getCommentName(constantName);
/// Gets the named used for the key constant in the definitions in
/// keyboard_key.g.dart.
///
/// If set by the constructor, returns the name set, but otherwise constructs
/// the name from the various different names available, making sure that the
/// name isn't a Dart reserved word (if it is, then it adds the word "Key" to
/// the end of the name).
late final String constantName = (() {
String? result;
if (name.isEmpty) {
// If it doesn't have a DomKey name then use the Chromium symbol name.
result = chromiumCode;
} else {
result = upperCamelToLowerCamel(name);
}
result ??= 'Key${toHex(usbHidCode)}';
if (kDartReservedWords.contains(result)) {
return '${result}Key';
}
return result;
})();
@override
String toString() {
final String otherWebStr = otherWebCodes.isEmpty
? ''
: ', otherWebCodes: [${otherWebCodes.join(', ')}]';
return """'$constantName': (name: "$name", usbHidCode: ${toHex(usbHidCode)}, """
'linuxScanCode: ${toHex(evdevCode)}, xKbScanCode: ${toHex(xKbScanCode)}, '
'windowsKeyCode: ${toHex(windowsScanCode)}, macOSScanCode: ${toHex(macOSScanCode)}, '
'windowsScanCode: ${toHex(windowsScanCode)}, chromiumSymbolName: $chromiumCode '
'iOSScanCode: ${toHex(iOSScanCode)})$otherWebStr';
}
static int compareByUsbHidCode(PhysicalKeyEntry a, PhysicalKeyEntry b) =>
a.usbHidCode.compareTo(b.usbHidCode);
}