// 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 Android's `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. /// /// 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); }