XKB keymaps, keyboard state and Compose sequences in Zig, for Wayland clients, with no libxkbcommon.
  • Zig 92.9%
  • C 3.8%
  • Nix 3.3%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Jeffrey C. Ollie 1fd7af423f
Some checks failed
test / test (push) Failing after 5m52s
test / docs (push) Has been skipped
Build for 32-bit targets
Index the key table with a usize rather than the u64 difference of two
keycodes, which is already known to be below max_keycode_span. Make
`zig build check` compile both test binaries for -Dtarget, so that a
target the tests cannot run on is still compiled for; the unit tests
alone never reach the keymap compiler.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W3dz2xoroVgeNuLkoAjqTu
2026-10-07 19:35:40 -05:00
.forgejo/workflows zig-xkb: XKB keymaps, keyboard state and Compose in Zig 2026-09-25 11:23:04 -05:00
LICENSES Drop the licenses of test data no longer kept here 2026-09-26 02:15:15 -05:00
src Build for 32-bit targets 2026-10-07 19:35:40 -05:00
tests Build with Zig 0.17.0 2026-10-07 01:21:51 -05:00
tools Build with Zig 0.17.0 2026-10-07 01:21:51 -05:00
.gitignore zig-xkb: XKB keymaps, keyboard state and Compose in Zig 2026-09-25 11:23:04 -05:00
build.zig Build for 32-bit targets 2026-10-07 19:35:40 -05:00
build.zig.zon Build with Zig 0.17.0 2026-10-07 01:21:51 -05:00
build.zig.zon.nix Build with Zig 0.17.0 2026-10-07 01:21:51 -05:00
flake.lock Build with Zig 0.17.0 2026-10-07 01:21:51 -05:00
flake.nix Build with Zig 0.17.0 2026-10-07 01:21:51 -05:00
README.md Build with Zig 0.17.0 2026-10-07 01:21:51 -05:00
REUSE.toml Build with Zig 0.17.0 2026-10-07 01:21:51 -05:00

zig-xkb

XKB keymaps, keyboard state and Compose sequences in Zig, for a Wayland client, with no libxkbcommon.

A Wayland compositor tells its clients how the keyboard is laid out by sending a keymap as text, and tells them which modifiers are down and which layout is in use as six numbers. Turning a key press into a keysym and a character from those takes a keymap compiler and a model of the keyboard's state; turning dead_acute then e into é takes a Compose table. This library is all three, written in Zig, and it answers the way libxkbcommon 1.13.2 does. Its tests check that key by key against libxkbcommon itself.

The API documentation is generated from the doc comments and published at https://jeff.jcollie.page/zig-xkb/.

const xkb = @import("xkb");

// wl_keyboard.keymap: the text the compositor mapped for us.
var keymap = try xkb.Keymap.fromText(gpa, text, null);
defer keymap.deinit();
var state: xkb.State = .init(&keymap);

// wl_keyboard.modifiers
_ = state.updateMask(depressed, latched, locked, 0, 0, group);

// wl_keyboard.key: evdev codes are XKB keycodes less eight.
const keycode = key + 8;
const sym = state.keyGetOneSym(keycode);
if (state.keyGetUtf32(keycode)) |char| typed(char);
const ctrl = state.modNameIsActive("Control", .effective_mods) orelse false;

What it does

Keymaps. Keymap.fromText compiles the XKB_KEYMAP_FORMAT_TEXT_V1 text of wl_keyboard.keymap: one xkb_keymap block with its keycodes, types, compatibility and symbols sections written out in full. It parses the whole language, reading both the compact form compositors send (keysyms as hexadecimal) and the named form xkbcli compile-keymap prints. It makes the same decisions libxkbcommon leaves to the compiler:

  • the automatic key type for a key that names none (ALPHABETIC, FOUR_LEVEL_SEMIALPHABETIC and the rest, decided by what the first levels hold and their case);
  • the symbol interpretations, which give each key its virtual modifiers and decide whether it repeats, tried from the most specific to the least;
  • what each virtual modifier maps to, which is the real modifiers of every key that sets it plus any mapping written out explicitly;
  • and which key-type entries are dead because they name a virtual modifier that maps to nothing.

An include is refused rather than followed, since a compiled keymap has nothing left to include. A geometry section is skipped. When compilation fails, a Diagnostic gives the line, column and reason.

State. State.updateMask takes the six numbers of wl_keyboard.modifiers. Virtual modifiers in the masks are resolved to real ones, and the layout wraps into range as libxkbcommon wraps it. After that, for any key:

  • keyGetLayout and keyGetLevel say where the key is;
  • keyGetSyms and keyGetOneSym say what it produces, with Caps Lock applied unless the key's type used Lock to choose the level;
  • keyGetUtf32 and keyGetUtf8 say what it types, including the Control transformation (Control+a is 1, and on a Cyrillic layout Control takes the key's Latin letter);
  • keyGetConsumedMods says which modifiers the key used up choosing its level, counted the XKB way or the GTK way;
  • modIndexIsActive and modNameIsActive say whether a modifier is on.

Keysyms. Keysym is every keysym libxkbcommon knows by name. Keysym.named("Shift_L") is checked when compiled and Keysym.fromName works at run time; both also take U20AC and 0x... forms. A keysym converts to its name, its character (toUtf32, toUtf8) and its upper and lower case. The tables are read at build time from the libxkbcommon release named in build.zig.zon. That tarball is only read: nothing from it is compiled or linked.

Compose. compose.Table.fromLocale finds the Compose file the way libX11 does: $XCOMPOSEFILE, then $XDG_CONFIG_HOME/XCompose (or ~/.config/XCompose), then ~/.XCompose, and failing those the locale's own file, which compose.dir in the X locale directory names after locale.alias has had its say. include "%L", %H and %S work as in libX11. A compose.State walks the table a keysym at a time and says when a sequence is composing, composed or cancelled, and what it produced.

The environment is passed in as a compose.Environment rather than read by the library. Environment.fromMap and localeFromMap fill it from a process's environment map.

The locale data comes with the library. zig build makes an X locale directory from the libX11 release named in build.zig.zon, with the same preprocessing libX11's own build does. The files are not kept in this repository. A program installs the directory beside itself as share/X11/locale, and fromLocale looks there when XLOCALEDIR is not set, before the places libX11 is usually installed:

const xkb_dep = b.dependency("xkb", .{});
exe.root_module.addImport("xkb", xkb_dep.module("xkb"));
b.installDirectory(.{
    .source_dir = xkb_dep.namedLazyPath("x11_locale"),
    .install_dir = .prefix,
    .install_subdir = "share/X11/locale",
});

zig build locale installs it into zig-out on its own. Its Compose files match libX11's byte for byte, with one exception: an include of another locale's file is written relative to the locale directory (%S) rather than as an absolute path, so the directory works wherever it is installed.

What it does not do

It is a client's half of XKB. Actions are parsed and set aside: the compositor runs them and sends the result, so there is no update_key here. There are no keyboard LEDs and no RMLVO names (us, pc105, grp:alt_shift_toggle), and it does not read xkeyboard-config's directories. A keymap made from names is the compositor's to compile; this library reads what it compiled.

The text format is version 1, the only one Wayland sends. Keycodes are held in a single array, so a keymap whose keys span more than 65,536 codes is refused.

When compose.dir has no entry for a locale, the en_US.UTF-8 file is used, as libxkbcommon does for a locale that is installed. libxkbcommon also asks the C library whether the locale is valid and fails if not; this library does not have that check.

How it is checked

zig build test runs the unit tests and then compares this library with libxkbcommon itself. The comparison data is made during the build, not kept in the repository:

  • libxkbcommon 1.13.2 is compiled from the release tarball the keysym tables come from. Its keymap parser is generated from that release's parser.y by bison, which the devshell provides; only these tests need it, so a program depending on zig-xkb never does.
  • The three small C programs in tests/reference are linked against it and run.
  • Their output becomes a module the tests embed.

The tests check:

  • Keysyms. Every named keysym, and every Unicode keysym that has a case: its name, character, upper and lower case, and whether it counts as lower or upper case.
  • Keymaps. Five keymaps compiled from xkeyboard-config 2.48, which is also a build dependency: us, de, fr, us(intl), and us,ru with a layout toggle. Each is checked in both its compact and named forms. Under 23 modifier and layout states, the test checks every key's layout, level, keysyms, UTF-8, UTF-32 and consumed modifiers, plus the modifiers and layouts in effect.
  • Compose. Every sequence in libX11's en_US.UTF-8 Compose file, from the locale directory the build makes, both as the table holds it and as a state composes it.

Moving to a new libxkbcommon, xkeyboard-config or libX11 release is a matter of changing build.zig.zon. The comparison data follows automatically.

Keymaps and Compose files come from outside the program, so both parsers are fuzzed. Each target in tests/fuzz.zig runs once against a smoke input as part of zig build test. zig build test --fuzz runs them under Zig's own coverage-guided fuzzer, and zig build fuzz-run under a loop of our own that mutates a corpus of real keymaps and Compose files and checks every input for leaks as well as crashes; tools/fuzz.zig explains it.

nix develop -c zig build test --fuzz=1M
nix develop -c zig build fuzz-run -- --seconds 600
nix develop -c zig build fuzz-run -- --target keymap --seconds 60

Using it

zig fetch --save git+https://git.jcollie.dev/jeff/zig-xkb.git
const xkb = b.dependency("xkb", .{}).module("xkb");
exe.root_module.addImport("xkb", xkb);

A Nix build of something that depends on this library needs the libxkbcommon and libX11 tarballs in its --system directory along with the library itself. zon2nix finds all three, since it follows transitive dependencies.

Building

zig-xkb needs Zig 0.17.0. Everything needed is in the flake's devshell:

nix develop -c zig build test --summary all
nix develop -c zig build check       # the fuzz driver and docs server too
nix develop -c zig build docs-serve  # the API documentation, on port 8000
nix flake check                      # the tests in the Nix sandbox

For Zig 0.16.0, use the zig-0.16 branch, which holds the last of zig-xkb to build with it:

git clone -b zig-0.16 https://git.jcollie.dev/jeff/zig-xkb.git

or, as a dependency:

zig fetch --save https://git.jcollie.dev/jeff/zig-xkb/archive/zig-0.16.tar.gz

The v0.1.0 tag also predates the move to 0.17 and builds with 0.16.0.

Repository

The code lives at https://git.jcollie.dev/jeff/zig-xkb:

git clone https://git.jcollie.dev/jeff/zig-xkb.git

It is mirrored on Tangled at https://tangled.org/jcollie.dev/zig-xkb, and is on Radicle as rad:z2pfZFSnMWawK275QEtWMfXGtTvUH:

rad clone rad:z2pfZFSnMWawK275QEtWMfXGtTvUH

References cited

XKB

Wayland

Compose

Test data

License

MIT. The test data in tests/data is under its sources' licenses, which REUSE.toml records. The project follows the REUSE specification, and reuse lint checks it.