// 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 'package:gen_keycodes/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, String glfwHeaderFile, String glfwNameMap, ) { final Map> nameToAndroidScanCodes = _readAndroidScanCodes(androidKeyboardLayout, androidNameMap); final Map> nameToGlfwKeyCodes = _readGlfwKeyCodes(glfwHeaderFile, glfwNameMap); final Map data = _readHidEntries( chromiumHidCodes, nameToAndroidScanCodes, nameToGlfwKeyCodes, ); final List> sortedEntries = data.entries.toList()..sort( (MapEntry a, MapEntry 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 contentMap) { final Map data = {}; for (final MapEntry jsonEntry in contentMap.entries) { final PhysicalKeyEntry entry = PhysicalKeyEntry.fromJsonMapEntry(jsonEntry.value as Map); 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 get entries => _data.values; // Keys mapped from their names. final Map _data; /// Converts the data structure into a JSON structure that can be parsed by /// [PhysicalKeyData.fromJson]. Map toJson() { final Map outputMap = {}; 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 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> _readAndroidScanCodes(String keyboardLayout, String nameMap) { final RegExp keyEntry = RegExp( r'#?\s*' // Optional comment mark r'key\s+' // Literal "key" r'(?[0-9]+)\s*' // ID section r'"?(?:KEY_)?(?[0-9A-Z_]+|\(undefined\))"?\s*' // Name section r'(?FUNCTION)?' // Optional literal "FUNCTION" ); final Map> androidNameToScanCodes = >{}; 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, () => []) .add(int.parse(match.namedGroup('id')!)); } // Cast Android dom map final Map> nameToAndroidNames = (json.decode(nameMap) as Map) .cast>() .map>((String key, List value) { return MapEntry>(key, value.cast()); }); final Map> result = nameToAndroidNames.map((String name, List androidNames) { final Set scanCodes = {}; for (final String androidName in androidNames) { scanCodes.addAll(androidNameToScanCodes[androidName] ?? []); } return MapEntry>(name, scanCodes.toList()..sort()); }); return result; } /// Parses entries from GLFW's keycodes.h key code data file. /// /// Lines in this file look like this (without the ///): /// /** Space key. */ /// #define GLFW_KEY_SPACE 32, /// #define GLFW_KEY_LAST GLFW_KEY_MENU static Map> _readGlfwKeyCodes(String headerFile, String nameMap) { // Only get the KEY definitions, ignore the rest (mouse, joystick, etc). final RegExp definedCodes = RegExp( r'define\s+' r'GLFW_KEY_(?[A-Z0-9_]+)\s+' r'(?[A-Z0-9_]+),?', ); final Map replaced = {}; for (final RegExpMatch match in definedCodes.allMatches(headerFile)) { final String name = match.namedGroup('name')!; final String value = match.namedGroup('value')!; replaced[name] = int.tryParse(value) ?? value.replaceAll('GLFW_KEY_', ''); } final Map glfwNameToKeyCode = {}; replaced.forEach((String key, dynamic value) { // Some definition values point to other definitions (e.g #define GLFW_KEY_LAST GLFW_KEY_MENU). if (value is String) { glfwNameToKeyCode[key] = replaced[value] as int; } else { glfwNameToKeyCode[key] = value as int; } }); final Map> nameToGlfwNames = (json.decode(nameMap) as Map) .cast>() .map>((String key, List value) { return MapEntry>(key, value.cast()); }); final Map> result = nameToGlfwNames.map((String name, List glfwNames) { final Set keyCodes = {}; for (final String glfwName in glfwNames) { if (glfwNameToKeyCode[glfwName] != null) keyCodes.add(glfwNameToKeyCode[glfwName]!); } return MapEntry>(name, keyCodes.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 _readHidEntries( String input, Map> nameToAndroidScanCodes, Map> nameToGlfwKeyCodes, ) { final Map entries = {}; final RegExp usbMapRegExp = RegExp( r'DOM_CODE\s*\(\s*' r'0[xX](?[a-fA-F0-9]+),\s*' r'0[xX](?[a-fA-F0-9]+),\s*' r'0[xX](?[a-fA-F0-9]+),\s*' r'0[xX](?[a-fA-F0-9]+),\s*' r'0[xX](?[a-fA-F0-9]+),\s*' r'(?:"(?[^\s]+)")?[^")]*?,' r'\s*(?[^\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 linuxScanCode = 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') { // Skip key that is not actually generated by any keyboard. continue; } final PhysicalKeyEntry newEntry = PhysicalKeyEntry( usbHidCode: usbHidCode, androidScanCodes: nameToAndroidScanCodes[name] ?? [], glfwKeyCodes: nameToGlfwKeyCodes[name] ?? [], linuxScanCode: linuxScanCode == 0 ? null : linuxScanCode, 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, ); // Remove duplicates: last one wins, so that supplemental codes // override. if (entries.containsKey(newEntry.usbHidCode)) { // This is expected for Fn. Warn for other keys. if (newEntry.name != 'Fn') { print('Duplicate usbHidCode ${newEntry.usbHidCode} of key ${newEntry.name} ' 'conflicts with existing ${entries[newEntry.usbHidCode]!.name}. Keeping the new one.'); } } entries[newEntry.usbHidCode] = newEntry; } return entries.map((int code, PhysicalKeyEntry entry) => MapEntry(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.linuxScanCode, required this.xKbScanCode, required this.windowsScanCode, required this.macOsScanCode, required this.iosScanCode, required this.chromiumCode, required this.glfwKeyCodes, }); /// Populates the key from a JSON map. factory PhysicalKeyEntry.fromJsonMapEntry(Map map) { return PhysicalKeyEntry( name: map['names']['name'] as String, chromiumCode: map['names']['chromium'] as String?, usbHidCode: map['scanCodes']['usb'] as int, androidScanCodes: (map['scanCodes']['android'] as List?)?.cast() ?? [], linuxScanCode: map['scanCodes']['linux'] as int?, xKbScanCode: map['scanCodes']['xkb'] as int?, windowsScanCode: map['scanCodes']['windows'] as int?, macOsScanCode: map['scanCodes']['macos'] as int?, iosScanCode: map['scanCodes']['ios'] as int?, glfwKeyCodes: (map['keyCodes']?['glfw'] as List?)?.cast() ?? [], ); } /// The USB HID code of the key final int usbHidCode; /// The Linux scan code of the key, from Chromium's header file. final int? linuxScanCode; /// 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 androidScanCodes; /// The list of GLFW key codes matching this key, created by looking up the /// Linux name in the Chromium data, and substituting the GLFW key code /// value. final List glfwKeyCodes; /// 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; /// Creates a JSON map from the key data. Map toJson() { return removeEmptyValues({ 'names': { 'name': name, 'chromium': chromiumCode, }, 'scanCodes': { 'android': androidScanCodes, 'usb': usbHidCode, 'linux': linuxScanCode, 'xkb': xKbScanCode, 'windows': windowsScanCode, 'macos': macOsScanCode, 'ios': iosScanCode, }, 'keyCodes': >{ 'glfw': glfwKeyCodes, }, }); } 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.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() { return """'$constantName': (name: "$name", usbHidCode: ${toHex(usbHidCode)}, """ 'linuxScanCode: ${toHex(linuxScanCode)}, xKbScanCode: ${toHex(xKbScanCode)}, ' 'windowsKeyCode: ${toHex(windowsScanCode)}, macOsScanCode: ${toHex(macOsScanCode)}, ' 'windowsScanCode: ${toHex(windowsScanCode)}, chromiumSymbolName: $chromiumCode ' 'iOSScanCode: ${toHex(iosScanCode)})'; } static int compareByUsbHidCode(PhysicalKeyEntry a, PhysicalKeyEntry b) => a.usbHidCode.compareTo(b.usbHidCode); }