danos/library/xkeyboard-config/xkeyboard-config.zig

154 lines
6.7 KiB
Zig

//! xkeyboard-config — keyboard layouts, compiled from the X11 xkeyboard-config database
//! into native Zig. It turns a physical key (a USB HID usage, as the input module delivers)
//! plus a modifier state into a **keysym** and, when the key produces one, a **character**
//! (a Unicode scalar). This is the piece that lets a `KeyEvent.keycode` become a
//! `KeyEvent.character`, without shipping an X11 runtime.
//!
//! The layout tables in `generated/layouts.zig` are produced by
//! `tools/make-xkeyboard-config.py` (see ./README.md to regenerate). Those tables are
//! deliberately pure data — each key carries its up-to-four levels and an XKB *type*. The
//! type -> level selection semantics (which modifier picks which level) live here, so the
//! data and the policy are separable.
//!
//! Scope (documented in README.md): group 1 only, no dead-key/compose composition (a dead
//! key returns its keysym with no character), and a curated set of key types. Layouts:
//! us, gb, de, fr, es, dvorak.
//!
//! Upstream xkeyboard-config and keysymdef.h are MIT/X11 licensed; see vendor/COPYING and
//! vendor/PROVENANCE.md.
const std = @import("std");
const generated = @import("layouts");
pub const Level = generated.Level;
pub const KeyType = generated.KeyType;
pub const Key = generated.Key;
pub const Layout = generated.Layout;
/// The generated layouts, by name — as pointers, so they share identity with `all` and
/// `byName` (and match the `*const Layout` that `map` takes).
pub const us: *const Layout = &generated.us;
pub const gb: *const Layout = &generated.gb;
pub const de: *const Layout = &generated.de;
pub const fr: *const Layout = &generated.fr;
pub const es: *const Layout = &generated.es;
pub const dvorak: *const Layout = &generated.dvorak;
/// Every generated layout, for enumeration (e.g. a settings UI).
pub const all = generated.all;
/// The modifier state that selects a key's level. `level3` is AltGr (ISO Level3 Shift);
/// `control` is accepted for completeness but does not affect level selection here.
pub const Modifiers = struct {
shift: bool = false,
caps_lock: bool = false,
level3: bool = false,
control: bool = false,
};
/// The result of a lookup: the X11 `keysym`, and the `character` it produces (a Unicode
/// scalar) when it is a printable key — null for keys that produce none (Return, F1, a
/// bare dead key, an unmapped key).
pub const Mapping = struct {
keysym: u32,
character: ?u21,
};
/// Which level (0..3) a key of `kind` selects under `mods`. XKB's canonical semantics:
/// Shift picks the odd level, AltGr (level3) adds 2, and Caps acts like Shift for the
/// alphabetic types. See the XKB "key types" — this covers the ones the vendored layouts
/// use; anything else falls back to shift-or-not.
fn selectLevel(kind: KeyType, mods: Modifiers) usize {
const shift_or_caps = mods.shift != mods.caps_lock; // XOR: Caps behaves like Shift
const low: usize = if (mods.shift) 1 else 0;
const high: usize = if (mods.level3) 2 else 0;
return switch (kind) {
.one_level => 0,
.two_level, .keypad, .other => low,
.alphabetic => if (shift_or_caps) 1 else 0,
.four_level => low + high,
.four_level_alphabetic => (if (shift_or_caps) @as(usize, 1) else 0) + high,
// Caps affects only the base pair, not the AltGr pair.
.four_level_semialphabetic => if (mods.level3) 2 + low else (if (shift_or_caps) @as(usize, 1) else 0),
};
}
/// Map a physical key (`hid_usage`, a USB HID keyboard-page usage) under `mods` on
/// `layout` to its keysym and character. Falls back gracefully when the selected level is
/// undefined for the key: it drops the AltGr component, then the shift component, so a key
/// with only a base/shift pair still yields something sensible under AltGr.
pub fn map(layout: *const Layout, hid_usage: u8, mods: Modifiers) Mapping {
const key = &layout.keys[hid_usage];
var level = selectLevel(key.kind, mods);
// Fall back to a defined level: full -> without AltGr -> base.
if (key.levels[level].keysym == 0 and key.levels[level].unicode == 0) {
const candidates = [_]usize{ level & 1, 0 };
for (candidates) |candidate| {
if (key.levels[candidate].keysym != 0 or key.levels[candidate].unicode != 0) {
level = candidate;
break;
}
}
}
const chosen = key.levels[level];
return .{
.keysym = chosen.keysym,
.character = if (chosen.unicode != 0) @intCast(chosen.unicode) else null,
};
}
/// Look up a layout by its name (`"us"`, `"gb"`, ...), or null if unknown.
pub fn byName(name: []const u8) ?*const Layout {
for (all) |layout| {
if (std.mem.eql(u8, layout.name, name)) return layout;
}
return null;
}
// --- tests (host-run via `zig build test`) ---------------------------------
const testing = std.testing;
// USB HID usages used in the tests (keyboard page 0x07).
const hid_a: u8 = 0x04;
const hid_1: u8 = 0x1e;
const hid_3: u8 = 0x20;
test "us: letters obey shift and caps" {
try testing.expectEqual(@as(?u21, 'a'), map(us, hid_a, .{}).character);
try testing.expectEqual(@as(?u21, 'A'), map(us, hid_a, .{ .shift = true }).character);
try testing.expectEqual(@as(?u21, 'A'), map(us, hid_a, .{ .caps_lock = true }).character);
// Shift + Caps cancels for an alphabetic key.
try testing.expectEqual(@as(?u21, 'a'), map(us, hid_a, .{ .shift = true, .caps_lock = true }).character);
}
test "us: digits and their shifted symbols" {
try testing.expectEqual(@as(?u21, '1'), map(us, hid_1, .{}).character);
try testing.expectEqual(@as(?u21, '!'), map(us, hid_1, .{ .shift = true }).character);
try testing.expectEqual(@as(?u21, '3'), map(us, hid_3, .{}).character);
try testing.expectEqual(@as(?u21, '#'), map(us, hid_3, .{ .shift = true }).character);
// A digit is not alphabetic: Caps alone must not shift it.
try testing.expectEqual(@as(?u21, '3'), map(us, hid_3, .{ .caps_lock = true }).character);
}
test "layouts differ: GB pound vs US hash on shift+3" {
try testing.expectEqual(@as(?u21, '#'), map(us, hid_3, .{ .shift = true }).character);
try testing.expectEqual(@as(?u21, '£'), map(gb, hid_3, .{ .shift = true }).character);
}
test "french azerty places q where us has a" {
try testing.expectEqual(@as(?u21, 'q'), map(fr, hid_a, .{}).character);
try testing.expectEqual(@as(?u21, 'Q'), map(fr, hid_a, .{ .shift = true }).character);
}
test "byName resolves and rejects" {
try testing.expect(byName("us") == us);
try testing.expect(byName("gb") == gb);
try testing.expect(byName("nonsense") == null);
}
test "unmapped key yields no character" {
// HID 0x00 is not a key; every level is empty.
try testing.expectEqual(@as(?u21, null), map(us, 0x00, .{}).character);
}