| // 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); |
| } |