209 lines
6.2 KiB
Dart
209 lines
6.2 KiB
Dart
/// Generates lib/data/hid_key_labels.dart from the curated offline HID catalog.
|
|
///
|
|
/// Usage:
|
|
/// dart run scripts/generate_hid_key_labels.dart [input.json] [output.dart]
|
|
library;
|
|
|
|
import 'dart:convert';
|
|
import 'dart:io';
|
|
|
|
const defaultHidKeyLabelsInput = 'scripts/data/hid_key_labels.json';
|
|
const defaultHidKeyLabelsOutput = 'lib/data/hid_key_labels.dart';
|
|
|
|
final class HidKeyLabel {
|
|
const HidKeyLabel({required this.id, required this.label});
|
|
|
|
final String id;
|
|
final String label;
|
|
|
|
int get numericId => int.parse(id, radix: 16);
|
|
}
|
|
|
|
final class HidKeyGroup {
|
|
const HidKeyGroup({required this.name, required this.keys});
|
|
|
|
final String name;
|
|
final List<HidKeyLabel> keys;
|
|
}
|
|
|
|
final class HidKeyCatalog {
|
|
const HidKeyCatalog({required this.groups});
|
|
|
|
final List<HidKeyGroup> groups;
|
|
}
|
|
|
|
typedef AtomicFileWriter = Future<void> Function(String path, String contents);
|
|
|
|
HidKeyCatalog parseHidKeyLabelsCatalog(String source) {
|
|
final Object? decoded;
|
|
try {
|
|
decoded = jsonDecode(source);
|
|
} on FormatException catch (error) {
|
|
throw FormatException('Invalid HID catalog JSON: ${error.message}');
|
|
}
|
|
|
|
final root = _expectMap(decoded, 'catalog');
|
|
if (root['schemaVersion'] != 1) {
|
|
throw const FormatException('HID catalog schemaVersion must be 1');
|
|
}
|
|
|
|
final rawGroups = _expectList(root['groups'], 'groups');
|
|
if (rawGroups.isEmpty) {
|
|
throw const FormatException('HID catalog groups must not be empty');
|
|
}
|
|
|
|
final groups = <HidKeyGroup>[];
|
|
final groupNames = <String>{};
|
|
final ids = <String>{};
|
|
var previousId = -1;
|
|
|
|
for (var groupIndex = 0; groupIndex < rawGroups.length; groupIndex++) {
|
|
final rawGroup = _expectMap(rawGroups[groupIndex], 'groups[$groupIndex]');
|
|
final name = _expectNonemptyString(rawGroup['name'], 'groups[$groupIndex].name');
|
|
if (!groupNames.add(name)) {
|
|
throw FormatException('Duplicate HID group name: $name');
|
|
}
|
|
|
|
final rawKeys = _expectList(rawGroup['keys'], 'groups[$groupIndex].keys');
|
|
if (rawKeys.isEmpty) {
|
|
throw FormatException('HID group "$name" must contain at least one key');
|
|
}
|
|
|
|
final keys = <HidKeyLabel>[];
|
|
for (var keyIndex = 0; keyIndex < rawKeys.length; keyIndex++) {
|
|
final path = 'groups[$groupIndex].keys[$keyIndex]';
|
|
final rawKey = _expectMap(rawKeys[keyIndex], path);
|
|
final id = _expectString(rawKey['id'], '$path.id');
|
|
if (!RegExp(r'^[0-9a-f]{8}$').hasMatch(id)) {
|
|
throw FormatException('$path.id must be exactly eight lowercase hexadecimal digits');
|
|
}
|
|
if (!ids.add(id)) {
|
|
throw FormatException('Duplicate HID key ID: $id');
|
|
}
|
|
|
|
final numericId = int.parse(id, radix: 16);
|
|
if (numericId <= previousId) {
|
|
throw FormatException('HID key IDs must be in strictly increasing numeric order: $id');
|
|
}
|
|
previousId = numericId;
|
|
|
|
final label = _expectNonemptyString(rawKey['label'], '$path.label');
|
|
keys.add(HidKeyLabel(id: id, label: label));
|
|
}
|
|
groups.add(HidKeyGroup(name: name, keys: List.unmodifiable(keys)));
|
|
}
|
|
|
|
return HidKeyCatalog(groups: List.unmodifiable(groups));
|
|
}
|
|
|
|
String renderHidKeyLabels(HidKeyCatalog catalog) {
|
|
final output = StringBuffer()
|
|
..writeln(
|
|
'// Generated by dart run scripts/generate_hid_key_labels.dart from '
|
|
'scripts/data/hid_key_labels.json; do not edit by hand.',
|
|
)
|
|
..writeln()
|
|
..writeln('/// Human-readable labels for physical keyboard keys, keyed by USB HID usage code.')
|
|
..writeln('const hidKeyLabels = <int, String>{');
|
|
|
|
for (final group in catalog.groups) {
|
|
output.writeln(' // ${group.name}');
|
|
for (final key in group.keys) {
|
|
output.writeln(' 0x${key.id}: ${_dartString(key.label)},');
|
|
}
|
|
}
|
|
|
|
output.writeln('};');
|
|
return output.toString();
|
|
}
|
|
|
|
Future<void> writeFileAtomically(String path, String contents) async {
|
|
final output = File(path);
|
|
await output.parent.create(recursive: true);
|
|
final temporary = File('$path.tmp.$pid.${DateTime.now().microsecondsSinceEpoch}');
|
|
try {
|
|
await temporary.writeAsString(contents, flush: true);
|
|
await temporary.rename(path);
|
|
} finally {
|
|
if (await temporary.exists()) {
|
|
await temporary.delete();
|
|
}
|
|
}
|
|
}
|
|
|
|
Future<void> generateHidKeyLabels(
|
|
String inputPath,
|
|
String outputPath, {
|
|
AtomicFileWriter writer = writeFileAtomically,
|
|
}) async {
|
|
final source = await File(inputPath).readAsString();
|
|
final catalog = parseHidKeyLabelsCatalog(source);
|
|
final rendered = renderHidKeyLabels(catalog);
|
|
await writer(outputPath, rendered);
|
|
}
|
|
|
|
Future<void> main(List<String> arguments) async {
|
|
if (arguments.length > 2) {
|
|
stderr.writeln('Usage: dart run scripts/generate_hid_key_labels.dart [input.json] [output.dart]');
|
|
exitCode = 64;
|
|
return;
|
|
}
|
|
|
|
final inputPath = arguments.isEmpty ? defaultHidKeyLabelsInput : arguments[0];
|
|
final outputPath = arguments.length < 2 ? defaultHidKeyLabelsOutput : arguments[1];
|
|
await generateHidKeyLabels(inputPath, outputPath);
|
|
}
|
|
|
|
Map<String, Object?> _expectMap(Object? value, String path) {
|
|
if (value is! Map<String, Object?>) {
|
|
throw FormatException('$path must be a JSON object');
|
|
}
|
|
return value;
|
|
}
|
|
|
|
List<Object?> _expectList(Object? value, String path) {
|
|
if (value is! List<Object?>) {
|
|
throw FormatException('$path must be a JSON array');
|
|
}
|
|
return value;
|
|
}
|
|
|
|
String _expectString(Object? value, String path) {
|
|
if (value is! String) {
|
|
throw FormatException('$path must be a string');
|
|
}
|
|
return value;
|
|
}
|
|
|
|
String _expectNonemptyString(Object? value, String path) {
|
|
final string = _expectString(value, path);
|
|
if (string.trim().isEmpty) {
|
|
throw FormatException('$path must not be empty');
|
|
}
|
|
return string;
|
|
}
|
|
|
|
String _dartString(String value) {
|
|
if (value == r'\') return r"r'\'";
|
|
|
|
if (value.contains("'")) {
|
|
final escaped = value
|
|
.replaceAll(r'\', r'\\')
|
|
.replaceAll('"', r'\"')
|
|
.replaceAll(r'$', r'\$')
|
|
.replaceAll('\r', r'\r')
|
|
.replaceAll('\n', r'\n')
|
|
.replaceAll('\t', r'\t');
|
|
return '"$escaped"';
|
|
}
|
|
|
|
final escaped = value
|
|
.replaceAll(r'\', r'\\')
|
|
.replaceAll("'", r"\'")
|
|
.replaceAll(r'$', r'\$')
|
|
.replaceAll('\r', r'\r')
|
|
.replaceAll('\n', r'\n')
|
|
.replaceAll('\t', r'\t');
|
|
return "'$escaped'";
|
|
}
|