mirror of
https://github.com/flutter/flutter.git
synced 2025-06-03 00:51:18 +00:00
352 lines
14 KiB
Dart
352 lines
14 KiB
Dart
// 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.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);
|
|
}
|