a73x

30b495f7

feat(wayland): keyboard + xkbcommon + key repeat

a73x   2026-04-08 08:46

Implements Tasks 5.4 and 5.5: Keyboard struct with xkbcommon keymap
loading via mmap, press/release event queuing, modifier state tracking,
and client-side key repeat via tickRepeat().

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

diff --git a/build.zig b/build.zig
index 29b5596..7ed1acd 100644
--- a/build.zig
+++ b/build.zig
@@ -32,6 +32,7 @@ pub fn build(b: *std.Build) void {
    });
    wayland_mod.addImport("wayland", wayland_generated_mod);
    wayland_mod.linkSystemLibrary("wayland-client", .{});
    wayland_mod.linkSystemLibrary("xkbcommon", .{});
    _ = wayland_dep; // referenced via Scanner

    // vt module — wraps ghostty-vt
diff --git a/src/wayland.zig b/src/wayland.zig
index 6affd67..0b9d5fa 100644
--- a/src/wayland.zig
+++ b/src/wayland.zig
@@ -4,6 +4,107 @@ const wayland = @import("wayland");
const wl = wayland.client.wl;
const xdg = wayland.client.xdg;

const c = @cImport({
    @cInclude("xkbcommon/xkbcommon.h");
    @cInclude("sys/mman.h");
    @cInclude("unistd.h");
});

pub const KeyboardEvent = struct {
    keysym: u32,
    modifiers: Modifiers,
    action: Action,
    utf8: [8]u8,
    utf8_len: u8,

    pub const Action = enum { press, release, repeat };
};

pub const Modifiers = struct {
    ctrl: bool = false,
    shift: bool = false,
    alt: bool = false,
    super: bool = false,
};

pub const Keyboard = struct {
    alloc: std.mem.Allocator,
    wl_keyboard: *wl.Keyboard,
    xkb_ctx: ?*c.xkb_context,
    xkb_keymap: ?*c.xkb_keymap = null,
    xkb_state: ?*c.xkb_state = null,
    event_queue: std.ArrayList(KeyboardEvent),
    current_mods: Modifiers = .{},
    // repeat info
    repeat_rate: u32 = 25,
    repeat_delay: u32 = 500,
    last_keycode: ?u32 = null,
    last_key_time_ns: i128 = 0,
    next_repeat_time_ns: i128 = 0,
    has_focus: bool = false,

    pub fn init(alloc: std.mem.Allocator, seat: *wl.Seat) !*Keyboard {
        const kb = try alloc.create(Keyboard);
        errdefer alloc.destroy(kb);

        var event_queue: std.ArrayList(KeyboardEvent) = .empty;
        errdefer event_queue.deinit(alloc);
        try event_queue.ensureTotalCapacity(alloc, 64);

        kb.* = .{
            .alloc = alloc,
            .wl_keyboard = try seat.getKeyboard(),
            .xkb_ctx = c.xkb_context_new(c.XKB_CONTEXT_NO_FLAGS),
            .event_queue = event_queue,
        };
        kb.wl_keyboard.setListener(*Keyboard, keyboardListener, kb);
        return kb;
    }

    pub fn deinit(self: *Keyboard) void {
        if (self.xkb_state) |s| c.xkb_state_unref(s);
        if (self.xkb_keymap) |m| c.xkb_keymap_unref(m);
        if (self.xkb_ctx) |ctx| c.xkb_context_unref(ctx);
        self.wl_keyboard.release();
        self.event_queue.deinit(self.alloc);
        self.alloc.destroy(self);
    }

    /// Called from the main loop. If a key is currently held and the repeat timer
    /// has expired, push synthetic repeat events to the queue.
    pub fn tickRepeat(self: *Keyboard) void {
        const keycode = self.last_keycode orelse return;
        const state = self.xkb_state orelse return;

        const now = std.time.nanoTimestamp();
        if (now < self.next_repeat_time_ns) return;

        const keysym = c.xkb_state_key_get_one_sym(state, keycode);
        var utf8: [8]u8 = undefined;
        const len = c.xkb_state_key_get_utf8(state, keycode, &utf8, utf8.len);

        const ev = KeyboardEvent{
            .keysym = keysym,
            .modifiers = self.current_mods,
            .action = .repeat,
            .utf8 = utf8,
            .utf8_len = @intCast(@min(len, 8)),
        };

        self.event_queue.append(self.alloc, ev) catch |err| {
            std.log.err("keyboard repeat append failed: {any}", .{err});
            return;
        };

        // Schedule next repeat at 1/rate seconds from now
        const interval_ms: i128 = if (self.repeat_rate > 0)
            @divTrunc(1000, @as(i128, self.repeat_rate))
        else
            40;
        self.next_repeat_time_ns = now + interval_ms * std.time.ns_per_ms;
    }
};

pub const Globals = struct {
    compositor: ?*wl.Compositor = null,
    wm_base: ?*xdg.WmBase = null,
@@ -94,6 +195,101 @@ pub const Connection = struct {
    }
};

fn keyboardListener(_: *wl.Keyboard, event: wl.Keyboard.Event, kb: *Keyboard) void {
    switch (event) {
        .keymap => |k| {
            if (k.format != .xkb_v1) {
                _ = c.close(k.fd);
                return;
            }
            const map_mem = c.mmap(null, k.size, c.PROT_READ, c.MAP_PRIVATE, k.fd, 0);
            if (map_mem == c.MAP_FAILED) {
                _ = c.close(k.fd);
                return;
            }
            defer _ = c.munmap(map_mem, k.size);
            defer _ = c.close(k.fd);

            const new_keymap = c.xkb_keymap_new_from_string(
                kb.xkb_ctx,
                @ptrCast(@alignCast(map_mem.?)),
                c.XKB_KEYMAP_FORMAT_TEXT_V1,
                c.XKB_KEYMAP_COMPILE_NO_FLAGS,
            ) orelse return;

            const new_state = c.xkb_state_new(new_keymap) orelse {
                c.xkb_keymap_unref(new_keymap);
                return;
            };

            if (kb.xkb_state) |s| c.xkb_state_unref(s);
            if (kb.xkb_keymap) |m| c.xkb_keymap_unref(m);
            kb.xkb_keymap = new_keymap;
            kb.xkb_state = new_state;
        },
        .enter => {
            kb.has_focus = true;
        },
        .leave => {
            kb.has_focus = false;
            kb.last_keycode = null;
        },
        .key => |k| {
            const state = kb.xkb_state orelse return;
            const keycode: u32 = k.key + 8; // evdev -> xkb offset
            const keysym = c.xkb_state_key_get_one_sym(state, keycode);

            var utf8: [8]u8 = undefined;
            const len = c.xkb_state_key_get_utf8(state, keycode, &utf8, utf8.len);

            const action: KeyboardEvent.Action = if (k.state == .pressed) .press else .release;

            const ev = KeyboardEvent{
                .keysym = keysym,
                .modifiers = kb.current_mods,
                .action = action,
                .utf8 = utf8,
                .utf8_len = @intCast(@min(len, 8)),
            };

            kb.event_queue.append(kb.alloc, ev) catch |err| {
                std.log.err("keyboard event queue append failed: {any}", .{err});
                return;
            };

            if (action == .press) {
                kb.last_keycode = keycode;
                kb.last_key_time_ns = std.time.nanoTimestamp();
                kb.next_repeat_time_ns = kb.last_key_time_ns + @as(i128, kb.repeat_delay) * std.time.ns_per_ms;
            } else if (kb.last_keycode == keycode) {
                kb.last_keycode = null;
            }
        },
        .modifiers => |m| {
            const state = kb.xkb_state orelse return;
            _ = c.xkb_state_update_mask(
                state,
                m.mods_depressed,
                m.mods_latched,
                m.mods_locked,
                0,
                0,
                m.group,
            );
            kb.current_mods = .{
                .ctrl = c.xkb_state_mod_name_is_active(state, "Control", c.XKB_STATE_MODS_EFFECTIVE) > 0,
                .shift = c.xkb_state_mod_name_is_active(state, "Shift", c.XKB_STATE_MODS_EFFECTIVE) > 0,
                .alt = c.xkb_state_mod_name_is_active(state, "Mod1", c.XKB_STATE_MODS_EFFECTIVE) > 0,
                .super = c.xkb_state_mod_name_is_active(state, "Mod4", c.XKB_STATE_MODS_EFFECTIVE) > 0,
            };
        },
        .repeat_info => |r| {
            kb.repeat_rate = @intCast(r.rate);
            kb.repeat_delay = @intCast(r.delay);
        },
    }
}

fn wmBaseListener(wm_base: *xdg.WmBase, event: xdg.WmBase.Event, _: *xdg.WmBase) void {
    switch (event) {
        .ping => |p| wm_base.pong(p.serial),
@@ -102,8 +298,8 @@ fn wmBaseListener(wm_base: *xdg.WmBase, event: xdg.WmBase.Event, _: *xdg.WmBase)

fn xdgSurfaceListener(surface: *xdg.Surface, event: xdg.Surface.Event, window: *Window) void {
    switch (event) {
        .configure => |c| {
            surface.ackConfigure(c.serial);
        .configure => |cfg| {
            surface.ackConfigure(cfg.serial);
            window.configured = true;
        },
    }
@@ -111,9 +307,9 @@ fn xdgSurfaceListener(surface: *xdg.Surface, event: xdg.Surface.Event, window: *

fn xdgToplevelListener(_: *xdg.Toplevel, event: xdg.Toplevel.Event, window: *Window) void {
    switch (event) {
        .configure => |c| {
            if (c.width > 0) window.width = @intCast(c.width);
            if (c.height > 0) window.height = @intCast(c.height);
        .configure => |cfg| {
            if (cfg.width > 0) window.width = @intCast(cfg.width);
            if (cfg.height > 0) window.height = @intCast(cfg.height);
        },
        .close => window.should_close = true,
        .configure_bounds => {},