#!/usr/bin/env python3 """Vendor xkeyboard-config and compile it to a native Zig keymap library. danos does not ship an X11 runtime, but it wants X11's keyboard *data*: the layout tables (US, UK, German, ...) that turn a physical key + modifiers into a character. So we compile xkeyboard-config down to Zig at build time, the same way the initial ramdisk is packed by a host-side Python tool. Two subcommands: fetch Download the pinned xkeyboard-config release, verify its sha256, and copy the transitively-needed data (the symbols files for the configured layouts plus everything they `include`) into library/xkeyboard-config/vendor/, alongside a vendored keysymdef.h, the upstream COPYING, and a PROVENANCE.md. This is the one step that needs the network; run it when bumping the version. generate Parse the vendored data and emit library/xkeyboard-config/generated/layouts.zig (deterministic, offline). Run whenever the layout list or emitter changes. The pipeline per key: our events carry a USB HID usage; HID_TO_NAME maps it to an XKB key name (); the layout's symbols give the keysyms per level; keysymdef.h resolves each keysym name to its value and Unicode scalar. Level *selection* (which modifier picks which level) is deliberately left to the Zig side (xkeyboard-config.zig) — we only emit the per-key type and its up-to-4 keysyms here. """ import argparse import hashlib import io import os import re import sys import tarfile import urllib.request # --- pinned upstream ------------------------------------------------------- VERSION = "2.44" TARBALL_URL = ( "https://gitlab.freedesktop.org/xkeyboard-config/xkeyboard-config/-/archive/" f"xkeyboard-config-{VERSION}/xkeyboard-config-{VERSION}.tar.gz" ) TARBALL_SHA256 = "35e34edeaf4e8da8d0696ff6b241ee11ddb1b8c6730bac7252d4d0a88ea5f05b" # keysymdef.h is xorgproto, not xkeyboard-config; fetch copies it from the build host. KEYSYMDEF_CANDIDATES = [ "/opt/homebrew/include/X11/keysymdef.h", "/usr/include/X11/keysymdef.h", "/usr/X11/include/X11/keysymdef.h", "/usr/X11R6/include/X11/keysymdef.h", ] # The layouts we generate: (zig name, symbols file, section or None=default). TARGETS = [ ("us", "us", None), ("gb", "gb", None), ("de", "de", None), ("fr", "fr", None), ("es", "es", None), ("dvorak", "us", "dvorak"), ] # USB HID usage (keyboard page 0x07) -> XKB key name. The physical keys we can turn into # characters; positions are the ANSI standard shared by HID usages and XKB names. HID_TO_NAME = { 0x04: "AC01", 0x05: "AB05", 0x06: "AB03", 0x07: "AC03", 0x08: "AD03", 0x09: "AC04", 0x0A: "AC05", 0x0B: "AC06", 0x0C: "AD08", 0x0D: "AC07", 0x0E: "AC08", 0x0F: "AC09", 0x10: "AB07", 0x11: "AB06", 0x12: "AD09", 0x13: "AD10", 0x14: "AD01", 0x15: "AD04", 0x16: "AC02", 0x17: "AD05", 0x18: "AD07", 0x19: "AB04", 0x1A: "AD02", 0x1B: "AB02", 0x1C: "AD06", 0x1D: "AB01", 0x1E: "AE01", 0x1F: "AE02", 0x20: "AE03", 0x21: "AE04", 0x22: "AE05", 0x23: "AE06", 0x24: "AE07", 0x25: "AE08", 0x26: "AE09", 0x27: "AE10", 0x2C: "SPCE", 0x2D: "AE11", 0x2E: "AE12", 0x2F: "AD11", 0x30: "AD12", 0x31: "BKSL", 0x33: "AC10", 0x34: "AC11", 0x35: "TLDE", 0x36: "AB08", 0x37: "AB09", 0x38: "AB10", 0x64: "LSGT", # the extra key on ISO keyboards (102nd key) } # XKB type name -> the KeyType enum tag emitted for the Zig side. TYPE_TO_TAG = { "ONE_LEVEL": "one_level", "TWO_LEVEL": "two_level", "ALPHABETIC": "alphabetic", "FOUR_LEVEL": "four_level", "FOUR_LEVEL_ALPHABETIC": "four_level_alphabetic", "FOUR_LEVEL_SEMIALPHABETIC": "four_level_semialphabetic", "FOUR_LEVEL_MIXED_KEYPAD": "keypad", "FOUR_LEVEL_KEYPAD": "keypad", "KEYPAD": "keypad", "PC_SYSRQ": "other", } REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) LIB = os.path.join(REPO, "library", "xkeyboard-config") VENDOR = os.path.join(LIB, "vendor") # --- keysymdef.h: keysym name -> (value, unicode scalar or None) ------------ def parse_keysymdef(path): table = {} line_re = re.compile(r"#define\s+XK_(\w+)\s+0x([0-9a-fA-F]+)\s*(?:/\*\s*U\+([0-9A-Fa-f]+)|/\* (keysym value, unicode scalar or 0).""" if name in ("NoSymbol", "VoidSymbol", ""): return (0, 0) # Unicode escape forms: U00E9 / u00E9. m = re.fullmatch(r"[Uu]([0-9A-Fa-f]{4,6})", name) if m: cp = int(m.group(1), 16) return (0x01000000 + cp, cp) # Raw hex keysym value: 0x1000161 (a Unicode keysym) or a plain keysym number. m = re.fullmatch(r"0x([0-9A-Fa-f]+)", name) if m: value = int(m.group(1), 16) if 0x01000100 <= value <= 0x0110FFFF: return (value, value - 0x01000000) if 0x20 <= value <= 0x7E or 0xA0 <= value <= 0xFF: return (value, value) return (value, 0) entry = keysymdef.get(name) if entry is None: return (0, 0) # unknown named keysym (dead_*, ISO_*, rare) -> no character value, unicode_scalar = entry return (value, unicode_scalar or 0) # --- symbols parsing -------------------------------------------------------- def strip_comments(text): text = re.sub(r"/\*.*?\*/", "", text, flags=re.S) text = re.sub(r"//[^\n]*", "", text) return text class KeyDef: __slots__ = ("levels", "type_name") def __init__(self, levels, type_name): self.levels = levels # list of keysym-name strings (group 1) self.type_name = type_name # explicit XKB type name, or None class Symbols: """Loads and resolves xkb_symbols sections from a directory of symbols files.""" def __init__(self, symbols_dir): self.dir = symbols_dir self._files = {} # filename -> {section_name: body, "__default__": name} self.visited_files = set() def _load(self, fname): if fname in self._files: return self._files[fname] path = os.path.join(self.dir, fname) self.visited_files.add(fname) with open(path, encoding="latin-1") as f: text = strip_comments(f.read()) sections = {} default = None # Find each `[flags] xkb_symbols "NAME" {` and brace-match its body. for m in re.finditer(r'(\w[\w\s]*?)?\bxkb_symbols\s+"([^"]+)"\s*\{', text): flags = m.group(1) or "" name = m.group(2) body, _ = self._brace_match(text, m.end() - 1) sections[name] = body if "default" in flags.split() and default is None: default = name if default is None and sections: default = next(iter(sections)) sections["__default__"] = default self._files[fname] = sections return sections @staticmethod def _brace_match(text, open_index): depth = 0 for i in range(open_index, len(text)): c = text[i] if c == "{": depth += 1 elif c == "}": depth -= 1 if depth == 0: return text[open_index + 1:i], i raise ValueError("unbalanced braces") def resolve(self, fname, section=None, stack=()): """Merged {key name -> KeyDef} for (fname, section), following includes.""" sections = self._load(fname) if section is None: section = sections["__default__"] key = (fname, section) if key in stack: return {} body = sections.get(section) if body is None: return {} keys = {} default_type = None for stmt in self._statements(body): kind = stmt[0] if kind == "include": augment, inc_file, inc_section = stmt[1], stmt[2], stmt[3] inc = self.resolve(inc_file, inc_section, stack + (key,)) for n, kd in inc.items(): if augment and n in keys: continue keys[n] = kd elif kind == "default_type": default_type = stmt[1] elif kind == "key": augment, name, kd = stmt[1], stmt[2], stmt[3] if augment and name in keys: continue keys[name] = kd if default_type is not None: for kd in keys.values(): if kd.type_name is None: kd.type_name = default_type return keys def _statements(self, body): """Yield ('include', augment, file, section) / ('default_type', name) / ('key', augment, name, KeyDef) in source order.""" # Recognise the three statement heads and step through the body in order. head = re.compile( r'(?P(?:(?Paugment|override|replace)\s+)?include\s+"(?P[^"]+)")' r'|(?Pkey\.type(?:\[[^\]]*\])?\s*=\s*"(?P[^"]+)")' r'|(?P(?:(?Paugment|override|replace)\s+)?key\s+<(?P[^>]+)>\s*\{)' ) i = 0 while i < len(body): m = head.search(body, i) if not m: break if m.group("inc"): inc_file, inc_section = self._parse_include(m.group("incarg")) yield ("include", m.group("augi") == "augment", inc_file, inc_section) i = m.end() elif m.group("dtype"): yield ("default_type", m.group("dtypearg")) i = m.end() else: # a key definition; brace-match its body kbody, end = self._brace_match(body, m.end() - 1) kd = self._parse_key_body(kbody) if kd is not None: yield ("key", m.group("augk") == "augment", m.group("kname"), kd) i = end + 1 @staticmethod def _parse_include(arg): m = re.fullmatch(r"([^()]+)(?:\(([^)]+)\))?", arg.strip()) return (m.group(1), m.group(2)) @staticmethod def _parse_key_body(kbody): type_name = None mt = re.search(r'type(?:\[[^\]]*\])?\s*=\s*"([^"]+)"', kbody) if mt: type_name = mt.group(1) groups = re.findall(r"\[([^\]]*)\]", kbody) if not groups: return None levels = [tok.strip() for tok in groups[0].split(",")] levels = [t for t in levels if t != ""] if not levels: return None return KeyDef(levels, type_name) # --- type inference + emission --------------------------------------------- def is_case_pair(a, b): return len(a) == 1 and len(b) == 1 and a.isalpha() and a.islower() and b == a.upper() def key_type_tag(kd): if kd.type_name is not None: return TYPE_TO_TAG.get(kd.type_name, "other") n = len(kd.levels) if n <= 1: return "one_level" if n == 2: return "alphabetic" if is_case_pair(kd.levels[0], kd.levels[1]) else "two_level" return "four_level_alphabetic" if is_case_pair(kd.levels[0], kd.levels[1]) else "four_level" def build_layout(symbols, keysymdef, fname, section): keys = symbols.resolve(fname, section) table = [] # 256 entries: (tag, [(keysym, unicode) x4]) for hid in range(256): name = HID_TO_NAME.get(hid) kd = keys.get(name) if name else None if kd is None: table.append(("one_level", [(0, 0)] * 4)) continue levels = [resolve_keysym(kd.levels[i], keysymdef) if i < len(kd.levels) else (0, 0) for i in range(4)] table.append((key_type_tag(kd), levels)) return table def emit_zig(layouts): out = io.StringIO() out.write("// GENERATED by tools/make-xkeyboard-config.py from xkeyboard-config " f"{VERSION}. Do not edit.\n") out.write("// Keyboard layout tables compiled from the X11 xkeyboard-config database\n") out.write("// (MIT/X11 licensed; see ../vendor/COPYING and ../vendor/PROVENANCE.md).\n\n") out.write("pub const Level = struct { keysym: u32 = 0, unicode: u21 = 0 };\n\n") out.write("pub const KeyType = enum {\n") out.write(" one_level,\n two_level,\n alphabetic,\n four_level,\n" " four_level_alphabetic,\n four_level_semialphabetic,\n keypad,\n other,\n};\n\n") out.write("pub const Key = struct { kind: KeyType = .one_level, levels: [4]Level = [_]Level{.{}} ** 4 };\n\n") out.write("pub const Layout = struct { name: []const u8, keys: [256]Key };\n\n") names = [] for zig_name, fname, section in TARGETS: table = layouts[zig_name] names.append(zig_name) out.write(f"pub const {zig_name}: Layout = .{{\n") out.write(f' .name = "{zig_name}",\n') out.write(" .keys = .{\n") for hid, (tag, levels) in enumerate(table): if tag == "one_level" and all(k == 0 and u == 0 for k, u in levels): out.write(" .{},\n") continue parts = ", ".join(f".{{ .keysym = {k}, .unicode = {u} }}" for k, u in levels) out.write(f" .{{ .kind = .{tag}, .levels = .{{ {parts} }} }},\n") out.write(" },\n};\n\n") out.write("pub const all = [_]*const Layout{ " + ", ".join("&" + n for n in names) + " };\n") return out.getvalue() # --- fetch ------------------------------------------------------------------ def collect_needed(symbols): for _, fname, section in TARGETS: symbols.resolve(fname, section) return set(symbols.visited_files) def find_keysymdef(): env = os.environ.get("KEYSYMDEF") if env and os.path.isfile(env): return env for p in KEYSYMDEF_CANDIDATES: if os.path.isfile(p): return p sys.exit("error: keysymdef.h not found; install xorgproto or set KEYSYMDEF=/path/to/keysymdef.h") def cmd_fetch(_args): local = os.environ.get("XKB_TARBALL") if local: data = open(local, "rb").read() else: print(f"downloading {TARBALL_URL}") data = urllib.request.urlopen(TARBALL_URL).read() digest = hashlib.sha256(data).hexdigest() if digest != TARBALL_SHA256: sys.exit(f"error: sha256 mismatch\n expected {TARBALL_SHA256}\n got {digest}") tar = tarfile.open(fileobj=io.BytesIO(data), mode="r:gz") members = tar.getnames() root = members[0].split("/")[0] # Extract symbols/ + COPYING to a temp view, resolve includes, keep only what's needed. tmp = os.path.join(VENDOR, ".upstream") for m in tar.getmembers(): if m.name.startswith(f"{root}/symbols/") or m.name == f"{root}/COPYING": m.name = m.name[len(root) + 1:] tar.extract(m, tmp) tar.close() needed = collect_needed(Symbols(os.path.join(tmp, "symbols"))) os.makedirs(os.path.join(VENDOR, "symbols"), exist_ok=True) for fname in sorted(needed): src = os.path.join(tmp, "symbols", fname) dst = os.path.join(VENDOR, "symbols", fname) os.makedirs(os.path.dirname(dst), exist_ok=True) with open(src, "rb") as s, open(dst, "wb") as d: d.write(s.read()) _copy(os.path.join(tmp, "COPYING"), os.path.join(VENDOR, "COPYING")) keysymdef = find_keysymdef() _copy(keysymdef, os.path.join(VENDOR, "keysymdef.h")) _rmtree(tmp) with open(os.path.join(VENDOR, "PROVENANCE.md"), "w") as f: f.write("# Vendored xkeyboard-config subset\n\n") f.write(f"- **Package**: xkeyboard-config {VERSION}\n") f.write(f"- **Source**: {TARBALL_URL}\n") f.write(f"- **sha256**: `{TARBALL_SHA256}`\n") f.write(f"- **keysymdef.h**: xorgproto, copied from `{keysymdef}`\n") f.write("- **License**: MIT/X11 (see COPYING)\n\n") f.write("Only the symbols files reachable from the generated layouts " "(tools/make-xkeyboard-config.py `TARGETS`) are vendored; regenerate with\n" "`python3 tools/make-xkeyboard-config.py fetch` then `... generate`.\n\n") f.write("Vendored symbols files:\n\n") for fname in sorted(needed): f.write(f"- `symbols/{fname}`\n") print(f"vendored {len(needed)} symbols files + keysymdef.h + COPYING into {VENDOR}") def cmd_generate(args): symbols_dir = args.symbols or os.path.join(VENDOR, "symbols") keysymdef_path = args.keysymdef or os.path.join(VENDOR, "keysymdef.h") keysymdef = parse_keysymdef(keysymdef_path) symbols = Symbols(symbols_dir) layouts = {zig_name: build_layout(symbols, keysymdef, fname, section) for zig_name, fname, section in TARGETS} out_dir = os.path.join(LIB, "generated") os.makedirs(out_dir, exist_ok=True) out_path = os.path.join(out_dir, "layouts.zig") with open(out_path, "w") as f: f.write(emit_zig(layouts)) print(f"wrote {out_path} ({len(TARGETS)} layouts)") def _copy(src, dst): os.makedirs(os.path.dirname(dst), exist_ok=True) with open(src, "rb") as s, open(dst, "wb") as d: d.write(s.read()) def _rmtree(path): for root, dirs, files in os.walk(path, topdown=False): for name in files: os.remove(os.path.join(root, name)) for name in dirs: os.rmdir(os.path.join(root, name)) if os.path.isdir(path): os.rmdir(path) def main(): parser = argparse.ArgumentParser(description=__doc__) sub = parser.add_subparsers(dest="command", required=True) sub.add_parser("fetch", help="download + vendor the needed xkeyboard-config data") g = sub.add_parser("generate", help="emit generated/layouts.zig from the vendored data") g.add_argument("--symbols", help="override the vendored symbols/ dir (for development)") g.add_argument("--keysymdef", help="override the vendored keysymdef.h (for development)") args = parser.parse_args() {"fetch": cmd_fetch, "generate": cmd_generate}[args.command](args) if __name__ == "__main__": main()