diff options
Diffstat (limited to 'src/util/genkeymap.py')
| -rwxr-xr-x | src/util/genkeymap.py | 451 |
1 files changed, 451 insertions, 0 deletions
diff --git a/src/util/genkeymap.py b/src/util/genkeymap.py new file mode 100755 index 000000000..b70ce5f72 --- /dev/null +++ b/src/util/genkeymap.py @@ -0,0 +1,451 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2022 Michael Brown <mbrown@fensystems.co.uk>. +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License as +# published by the Free Software Foundation; either version 2 of the +# License, or any later version. +# +# This program is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA +# 02110-1301, USA. + +"""Generate iPXE keymaps""" + +from __future__ import annotations + +import argparse +from collections import UserDict +from collections.abc import Sequence, Mapping, MutableMapping +from dataclasses import dataclass +from enum import Flag, IntEnum +import re +import subprocess +from struct import Struct +import textwrap +from typing import ClassVar, Optional + + +BACKSPACE = chr(0x7f) +"""Backspace character""" + + +class KeyType(IntEnum): + """Key types""" + + LATIN = 0 + FN = 1 + SPEC = 2 + PAD = 3 + DEAD = 4 + CONS = 5 + CUR = 6 + SHIFT = 7 + META = 8 + ASCII = 9 + LOCK = 10 + LETTER = 11 + SLOCK = 12 + DEAD2 = 13 + BRL = 14 + UNKNOWN = 0xf0 + + +class DeadKey(IntEnum): + """Dead keys""" + + GRAVE = 0 + CIRCUMFLEX = 2 + TILDE = 3 + + +class KeyModifiers(Flag): + """Key modifiers""" + + NONE = 0 + SHIFT = 1 + ALTGR = 2 + CTRL = 4 + ALT = 8 + SHIFTL = 16 + SHIFTR = 32 + CTRLL = 64 + CTRLR = 128 + + @property + def complexity(self) -> int: + """Get complexity value of applied modifiers""" + if self == self.NONE: + return 0 + if self == self.SHIFT: + return 1 + if self == self.CTRL: + return 2 + return 3 + bin(self.value).count('1') + + +@dataclass(frozen=True) +class Key: + """A single key definition""" + + keycode: int + """Opaque keycode""" + + keysym: int + """Key symbol""" + + modifiers: KeyModifiers + """Applied modifiers""" + + ASCII_TYPES: ClassVar[set[KeyType]] = {KeyType.LATIN, KeyType.ASCII, + KeyType.LETTER} + """Key types with direct ASCII values""" + + DEAD_KEYS: ClassVar[Mapping[int, str]] = { + DeadKey.GRAVE: '`', + DeadKey.CIRCUMFLEX: '^', + DeadKey.TILDE: '~', + } + """Dead key replacement ASCII values""" + + @property + def keytype(self) -> Optional[KeyType]: + """Key type""" + try: + return KeyType(self.keysym >> 8) + except ValueError: + return None + + @property + def value(self) -> int: + """Key value""" + return self.keysym & 0xff + + @property + def ascii(self) -> Optional[str]: + """ASCII character""" + keytype = self.keytype + value = self.value + if keytype in self.ASCII_TYPES: + char = chr(value) + if value and char.isascii(): + return char + if keytype == KeyType.DEAD: + return self.DEAD_KEYS.get(value) + return None + + +class KeyLayout(UserDict[KeyModifiers, Sequence[Key]]): + """A keyboard layout""" + + BKEYMAP_MAGIC: ClassVar[bytes] = b'bkeymap' + """Magic signature for output produced by 'loadkeys -b'""" + + MAX_NR_KEYMAPS: ClassVar[int] = 256 + """Maximum number of keymaps produced by 'loadkeys -b'""" + + NR_KEYS: ClassVar[int] = 128 + """Number of keys in each keymap produced by 'loadkeys -b'""" + + KEY_BACKSPACE: ClassVar[int] = 14 + """Key code for backspace + + Keyboard maps seem to somewhat arbitrarily pick an interpretation + for the backspace key and its various modifiers, according to the + personal preference of the keyboard map transcriber. + """ + + KEY_NON_US: ClassVar[int] = 86 + """Key code 86 + + Key code 86 is somewhat bizarre. It doesn't physically exist on + most US keyboards. The database used by "loadkeys" defines it as + "<>", while most other databases either define it as a duplicate + "\\|" or omit it entirely. + """ + + FIXUPS: ClassVar[Mapping[str, Mapping[KeyModifiers, + Sequence[tuple[int, int]]]]] = { + 'us': { + # Redefine erroneous key 86 as generating "\\|" + KeyModifiers.NONE: [(KEY_NON_US, ord('\\'))], + KeyModifiers.SHIFT: [(KEY_NON_US, ord('|'))], + # Treat Ctrl-Backspace as producing Backspace rather than Ctrl-H + KeyModifiers.CTRL: [(KEY_BACKSPACE, ord(BACKSPACE))], + }, + 'il': { + # Redefine some otherwise unreachable ASCII characters + # using the closest available approximation + KeyModifiers.ALTGR: [(0x28, ord('\'')), (0x2b, ord('`')), + (0x35, ord('/'))], + }, + 'mt': { + # Redefine erroneous key 86 as generating "\\|" + KeyModifiers.NONE: [(KEY_NON_US, ord('\\'))], + KeyModifiers.SHIFT: [(KEY_NON_US, ord('|'))], + }, + } + """Fixups for erroneous keymappings produced by 'loadkeys -b'""" + + @property + def unshifted(self): + """Basic unshifted keyboard layout""" + return self[KeyModifiers.NONE] + + @property + def shifted(self): + """Basic shifted keyboard layout""" + return self[KeyModifiers.SHIFT] + + @classmethod + def load(cls, name: str) -> KeyLayout: + """Load keymap using 'loadkeys -b'""" + bkeymap = subprocess.check_output(["loadkeys", "-u", "-b", name]) + if not bkeymap.startswith(cls.BKEYMAP_MAGIC): + raise ValueError("Invalid bkeymap magic signature") + bkeymap = bkeymap[len(cls.BKEYMAP_MAGIC):] + included = bkeymap[:cls.MAX_NR_KEYMAPS] + if len(included) != cls.MAX_NR_KEYMAPS: + raise ValueError("Invalid bkeymap inclusion list") + bkeymap = bkeymap[cls.MAX_NR_KEYMAPS:] + keys = {} + for modifiers in map(KeyModifiers, range(cls.MAX_NR_KEYMAPS)): + if included[modifiers.value]: + fmt = Struct('<%dH' % cls.NR_KEYS) + bkeylist = bkeymap[:fmt.size] + if len(bkeylist) != fmt.size: + raise ValueError("Invalid bkeymap map %#x" % + modifiers.value) + keys[modifiers] = [ + Key(modifiers=modifiers, keycode=keycode, keysym=keysym) + for keycode, keysym in enumerate(fmt.unpack(bkeylist)) + ] + bkeymap = bkeymap[len(bkeylist):] + if bkeymap: + raise ValueError("Trailing bkeymap data") + for modifiers, fixups in cls.FIXUPS.get(name, {}).items(): + for keycode, keysym in fixups: + keys[modifiers][keycode] = Key(modifiers=modifiers, + keycode=keycode, keysym=keysym) + return cls(keys) + + @property + def inverse(self) -> MutableMapping[str, Key]: + """Construct inverse mapping from ASCII value to key""" + return { + key.ascii: key + # Give priority to simplest modifier for a given ASCII code + for modifiers in sorted(self.keys(), reverse=True, + key=lambda x: (x.complexity, x.value)) + # Give priority to lowest keycode for a given ASCII code + for key in reversed(self[modifiers]) + # Ignore keys with no ASCII value + if key.ascii + } + + +class BiosKeyLayout(KeyLayout): + """Keyboard layout as used by the BIOS + + To allow for remappings of the somewhat interesting key 86, we + arrange for our keyboard drivers to generate this key as "\\|" + with the high bit set. + """ + + KEY_PSEUDO: ClassVar[int] = 0x80 + """Flag used to indicate a fake ASCII value""" + + KEY_NON_US_UNSHIFTED: ClassVar[str] = chr(KEY_PSEUDO | ord('\\')) + """Fake ASCII value generated for unshifted key code 86""" + + KEY_NON_US_SHIFTED: ClassVar[str] = chr(KEY_PSEUDO | ord('|')) + """Fake ASCII value generated for shifted key code 86""" + + @property + def inverse(self) -> MutableMapping[str, Key]: + inverse = super().inverse + assert len(inverse) == 0x7f + inverse[self.KEY_NON_US_UNSHIFTED] = self.unshifted[self.KEY_NON_US] + inverse[self.KEY_NON_US_SHIFTED] = self.shifted[self.KEY_NON_US] + assert all(x.modifiers in {KeyModifiers.NONE, KeyModifiers.SHIFT, + KeyModifiers.CTRL} + for x in inverse.values()) + return inverse + + +class KeymapKeys(UserDict[str, Optional[str]]): + """An ASCII character remapping""" + + @classmethod + def ascii_name(cls, char: str) -> str: + """ASCII character name""" + if char == '\\': + name = "'\\\\'" + elif char == '\'': + name = "'\\\''" + elif ord(char) & BiosKeyLayout.KEY_PSEUDO: + name = "Pseudo-%s" % cls.ascii_name( + chr(ord(char) & ~BiosKeyLayout.KEY_PSEUDO) + ) + elif char.isprintable(): + name = "'%s'" % char + elif ord(char) <= 0x1a: + name = "Ctrl-%c" % (ord(char) + 0x40) + else: + name = "0x%02x" % ord(char) + return name + + @property + def code(self): + """Generated source code for C array""" + return '{\n' + ''.join( + '\t{ 0x%02x, 0x%02x },\t/* %s => %s */\n' % ( + ord(source), ord(target), + self.ascii_name(source), self.ascii_name(target) + ) + for source, target in self.items() + if target + and ord(source) & ~BiosKeyLayout.KEY_PSEUDO != ord(target) + ) + '\t{ 0, 0 }\n}' + + +@dataclass +class Keymap: + """An iPXE keyboard mapping""" + + name: str + """Mapping name""" + + source: KeyLayout + """Source keyboard layout""" + + target: KeyLayout + """Target keyboard layout""" + + @property + def basic(self) -> KeymapKeys: + """Basic remapping table""" + # Construct raw mapping from source ASCII to target ASCII + raw = {source: self.target[key.modifiers][key.keycode].ascii + for source, key in self.source.inverse.items()} + # Eliminate any identity mappings, or mappings that attempt to + # remap the backspace key + table = {source: target for source, target in raw.items() + if source != target + and source != BACKSPACE + and target != BACKSPACE} + # Recursively delete any mappings that would produce + # unreachable alphanumerics (e.g. the "il" keymap, which maps + # away the whole lower-case alphabet) + while True: + unreachable = set(table.keys()) - set(table.values()) + delete = {x for x in unreachable if x.isascii() and x.isalnum()} + if not delete: + break + table = {k: v for k, v in table.items() if k not in delete} + # Sanity check: ensure that all numerics are reachable using + # the same shift state + digits = '1234567890' + unshifted = ''.join(table.get(x) or x for x in '1234567890') + shifted = ''.join(table.get(x) or x for x in '!@#$%^&*()') + if digits not in (shifted, unshifted): + raise ValueError("Inconsistent numeric remapping %s / %s" % + (unshifted, shifted)) + return KeymapKeys(dict(sorted(table.items()))) + + @property + def altgr(self) -> KeymapKeys: + """AltGr remapping table""" + # Construct raw mapping from source ASCII to target ASCII + raw = { + source: next((self.target[x][key.keycode].ascii + for x in (key.modifiers | KeyModifiers.ALTGR, + KeyModifiers.ALTGR, key.modifiers) + if x in self.target + and self.target[x][key.keycode].ascii), None) + for source, key in self.source.inverse.items() + } + # Identify printable keys that are unreachable via the basic map + basic = self.basic + unmapped = set(x for x in basic.keys() + if x.isascii() and x.isprintable()) + remapped = set(basic.values()) + unreachable = unmapped - remapped + # Eliminate any mappings for unprintable characters, or + # mappings for characters that are reachable via the basic map + table = {source: target for source, target in raw.items() + if source.isprintable() + and target in unreachable} + # Check that all characters are now reachable + unreachable -= set(table.values()) + if unreachable: + raise ValueError("Unreachable characters: %s" % ', '.join( + KeymapKeys.ascii_name(x) for x in sorted(unreachable) + )) + return KeymapKeys(dict(sorted(table.items()))) + + def cname(self, suffix: str) -> str: + """C variable name""" + return re.sub(r'\W', '_', (self.name + '_' + suffix)) + + @property + def code(self) -> str: + """Generated source code""" + keymap_name = self.cname("keymap") + basic_name = self.cname("basic") + altgr_name = self.cname("altgr") + attribute = "__keymap_default" if self.name == "us" else "__keymap" + code = textwrap.dedent(f""" + /** @file + * + * "{self.name}" keyboard mapping + * + * This file is automatically generated; do not edit + * + */ + + FILE_LICENCE ( PUBLIC_DOMAIN ); + + #include <ipxe/keymap.h> + + /** "{self.name}" basic remapping */ + static struct keymap_key {basic_name}[] = %s; + + /** "{self.name}" AltGr remapping */ + static struct keymap_key {altgr_name}[] = %s; + + /** "{self.name}" keyboard map */ + struct keymap {keymap_name} {attribute} = {{ + \t.name = "{self.name}", + \t.basic = {basic_name}, + \t.altgr = {altgr_name}, + }}; + """).strip() % (self.basic.code, self.altgr.code) + return code + + +if __name__ == '__main__': + + # Parse command-line arguments + parser = argparse.ArgumentParser(description="Generate iPXE keymaps") + parser.add_argument('--verbose', '-v', action='count', default=0, + help="Increase verbosity") + parser.add_argument('layout', help="Target keyboard layout") + args = parser.parse_args() + + # Load source and target keyboard layouts + source = BiosKeyLayout.load('us') + target = KeyLayout.load(args.layout) + + # Construct keyboard mapping + keymap = Keymap(name=args.layout, source=source, target=target) + + # Output generated code + print(keymap.code) |
