a73x

src/wayland.zig

Ref:   Size: 38.3 KiB

const std = @import("std");
const posix = std.posix;
const wayland = @import("wayland");
const wl = wayland.client.wl;
const xdg = wayland.client.xdg;
const ScaleTracker = @import("scale_tracker").ScaleTracker;

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

pub const Output = struct {
    wl_output: *wl.Output,
    name: u32,
    tracker: *ScaleTracker,
    pending_scale: i32 = 1,
};

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

    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 PointerEvent = union(enum) {
    enter: struct {
        serial: u32,
        x: f64,
        y: f64,
    },
    leave: struct {
        serial: u32,
    },
    motion: struct {
        time: u32,
        x: f64,
        y: f64,
    },
    button_press: struct {
        serial: u32,
        time: u32,
        button: u32,
    },
    button_release: struct {
        serial: u32,
        time: u32,
        button: u32,
    },
};

const PointerState = struct {
    has_focus: bool = false,
    surface_x: f64 = 0,
    surface_y: f64 = 0,
    last_serial: u32 = 0,

    fn pushEnter(
        self: *PointerState,
        alloc: std.mem.Allocator,
        queue: *std.ArrayList(PointerEvent),
        serial: u32,
        x: f64,
        y: f64,
    ) !void {
        self.has_focus = true;
        self.surface_x = x;
        self.surface_y = y;
        self.last_serial = serial;
        try queue.append(alloc, .{
            .enter = .{
                .serial = serial,
                .x = x,
                .y = y,
            },
        });
    }

    fn pushLeave(
        self: *PointerState,
        alloc: std.mem.Allocator,
        queue: *std.ArrayList(PointerEvent),
        serial: u32,
    ) !void {
        self.has_focus = false;
        self.last_serial = serial;
        try queue.append(alloc, .{
            .leave = .{
                .serial = serial,
            },
        });
    }

    fn pushMotion(
        self: *PointerState,
        alloc: std.mem.Allocator,
        queue: *std.ArrayList(PointerEvent),
        time: u32,
        x: f64,
        y: f64,
    ) !void {
        self.surface_x = x;
        self.surface_y = y;
        try queue.append(alloc, .{
            .motion = .{
                .time = time,
                .x = x,
                .y = y,
            },
        });
    }

    fn pushButton(
        self: *PointerState,
        alloc: std.mem.Allocator,
        queue: *std.ArrayList(PointerEvent),
        serial: u32,
        time: u32,
        button: u32,
        state: wl.Pointer.ButtonState,
    ) !void {
        self.last_serial = serial;
        try queue.append(alloc, switch (state) {
            .pressed => .{
                .button_press = .{
                    .serial = serial,
                    .time = time,
                    .button = button,
                },
            },
            .released => .{
                .button_release = .{
                    .serial = serial,
                    .time = time,
                    .button = button,
                },
            },
            else => unreachable,
        });
    }
};

pub const Pointer = struct {
    alloc: std.mem.Allocator,
    wl_pointer: *wl.Pointer,
    event_queue: std.ArrayList(PointerEvent),
    state: PointerState = .{},

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

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

        pointer.* = .{
            .alloc = alloc,
            .wl_pointer = try seat.getPointer(),
            .event_queue = event_queue,
        };
        pointer.wl_pointer.setListener(*Pointer, pointerListener, pointer);
        return pointer;
    }

    pub fn deinit(self: *Pointer) void {
        self.wl_pointer.release();
        self.event_queue.deinit(self.alloc);
        self.alloc.destroy(self);
    }
};

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 fn nextRepeatDeadlineNs(self: *const Keyboard) ?i128 {
        if (!self.has_focus) return null;
        _ = self.xkb_state orelse return null;
        _ = self.last_keycode orelse return null;
        if (self.repeat_rate == 0) return null;
        return self.next_repeat_time_ns;
    }
};

pub const Globals = struct {
    compositor: ?*wl.Compositor = null,
    wm_base: ?*xdg.WmBase = null,
    seat: ?*wl.Seat = null,
    data_device_manager: ?*wl.DataDeviceManager = null,
};

const ClipboardOffer = struct {
    offer: *wl.DataOffer,
    mime_types: std.ArrayList([:0]u8),

    fn init(offer: *wl.DataOffer) ClipboardOffer {
        return .{
            .offer = offer,
            .mime_types = .empty,
        };
    }

    fn deinit(self: *ClipboardOffer, alloc: std.mem.Allocator) void {
        for (self.mime_types.items) |mime| alloc.free(mime);
        self.mime_types.deinit(alloc);
        self.offer.destroy();
    }
};

const OwnedSelectionState = struct {
    text: ?[]u8 = null,

    fn deinit(self: *OwnedSelectionState, alloc: std.mem.Allocator) void {
        self.clear(alloc);
    }

    fn replaceText(
        self: *OwnedSelectionState,
        alloc: std.mem.Allocator,
        text: []const u8,
    ) !void {
        const dup = try alloc.dupe(u8, text);
        self.clear(alloc);
        self.text = dup;
    }

    fn clear(self: *OwnedSelectionState, alloc: std.mem.Allocator) void {
        if (self.text) |text| alloc.free(text);
        self.text = null;
    }

    fn canServeMime(self: *const OwnedSelectionState, mime: []const u8) bool {
        return self.text != null and
            (std.mem.eql(u8, mime, "text/plain;charset=utf-8") or
                std.mem.eql(u8, mime, "text/plain"));
    }
};

const CancelSelectionDecision = struct {
    destroy_source: *wl.DataSource,
    cleared_active_selection: bool,
};

fn replaceSelectionSource(
    slot: *?*wl.DataSource,
    new_source: *wl.DataSource,
) ?*wl.DataSource {
    const old_source = slot.*;
    slot.* = new_source;
    return old_source;
}

fn cancelSelectionSource(
    slot: *?*wl.DataSource,
    source: *wl.DataSource,
) CancelSelectionDecision {
    if (slot.* == source) {
        slot.* = null;
        return .{
            .destroy_source = source,
            .cleared_active_selection = true,
        };
    }
    return .{
        .destroy_source = source,
        .cleared_active_selection = false,
    };
}

fn writeClipboardTransfer(payload: []const u8, fd: i32) !void {
    defer _ = c.close(fd);

    var written: usize = 0;
    while (written < payload.len) {
        const n = try posix.write(fd, payload[written..]);
        if (n == 0) return error.BrokenPipe;
        written += n;
    }
}

const ClipboardTransfer = struct {
    alloc: std.mem.Allocator,
    payload: []u8,
    fd: i32,

    fn init(alloc: std.mem.Allocator, payload: []const u8, fd: i32) !ClipboardTransfer {
        return .{
            .alloc = alloc,
            .payload = try alloc.dupe(u8, payload),
            .fd = fd,
        };
    }

    fn run(self: ClipboardTransfer) void {
        defer self.alloc.free(self.payload);
        writeClipboardTransfer(self.payload, self.fd) catch |err| {
            std.log.err("clipboard transfer failed: {any}", .{err});
        };
    }
};

fn serveOwnedSelectionRequest(
    alloc: std.mem.Allocator,
    text: ?[]const u8,
    mime_type: []const u8,
    fd: i32,
) !bool {
    var handed_off = false;
    defer {
        if (!handed_off) _ = c.close(fd);
    }

    if (text == null) return false;
    if (!(std.mem.eql(u8, mime_type, "text/plain;charset=utf-8") or
        std.mem.eql(u8, mime_type, "text/plain"))) return false;

    const transfer = try ClipboardTransfer.init(alloc, text.?, fd);
    errdefer alloc.free(transfer.payload);
    const thread = try std.Thread.spawn(.{}, ClipboardTransfer.run, .{transfer});
    handed_off = true;
    thread.detach();
    return true;
}

pub const Clipboard = struct {
    alloc: std.mem.Allocator,
    display: *wl.Display,
    data_device_manager: *wl.DataDeviceManager,
    data_device: *wl.DataDevice,
    pending_offer: ?ClipboardOffer = null,
    selection_offer: ?ClipboardOffer = null,
    owned_selection: OwnedSelectionState = .{},
    selection_source: ?*wl.DataSource = null,

    pub fn init(
        alloc: std.mem.Allocator,
        display: *wl.Display,
        manager: *wl.DataDeviceManager,
        seat: *wl.Seat,
    ) !*Clipboard {
        const clipboard = try alloc.create(Clipboard);
        errdefer alloc.destroy(clipboard);

        clipboard.* = .{
            .alloc = alloc,
            .display = display,
            .data_device_manager = manager,
            .data_device = try manager.getDataDevice(seat),
        };
        clipboard.data_device.setListener(*Clipboard, dataDeviceListener, clipboard);
        return clipboard;
    }

    pub fn deinit(self: *Clipboard) void {
        if (self.pending_offer) |*offer| offer.deinit(self.alloc);
        if (self.selection_offer) |*offer| offer.deinit(self.alloc);
        if (self.selection_source) |source| source.destroy();
        self.owned_selection.deinit(self.alloc);
        self.data_device.release();
        self.alloc.destroy(self);
    }

    pub fn receiveSelectionText(self: *Clipboard, alloc: std.mem.Allocator) !?[]u8 {
        const offer = &(self.selection_offer orelse return null);
        const mime = choosePreferredMime(offer.mime_types.items) orelse return null;

        const pipefds = try std.posix.pipe();
        defer std.posix.close(pipefds[0]);
        var write_fd_closed = false;
        defer if (!write_fd_closed) std.posix.close(pipefds[1]);

        offer.offer.receive(mime.ptr, pipefds[1]);
        _ = self.display.flush();
        std.posix.close(pipefds[1]);
        write_fd_closed = true;

        var roundtrip_ctx = struct {
            display: *wl.Display,

            fn roundtrip(ctx: *@This()) !void {
                _ = ctx.display.roundtrip();
            }
        }{ .display = self.display };

        return try drainSelectionPipeThenRoundtrip(alloc, pipefds[0], &roundtrip_ctx);
    }

    pub fn setSelectionText(self: *Clipboard, text: []const u8, serial: u32) !void {
        const source = try self.data_device_manager.createDataSource();
        errdefer source.destroy();

        try self.owned_selection.replaceText(self.alloc, text);

        source.setListener(*Clipboard, dataSourceListener, self);
        source.offer("text/plain;charset=utf-8");
        source.offer("text/plain");

        if (replaceSelectionSource(&self.selection_source, source)) |old_source| {
            old_source.destroy();
        }
        self.data_device.setSelection(source, serial);
        _ = self.display.flush();
    }

    fn handleCancelledSource(self: *Clipboard, source: *wl.DataSource) void {
        const decision = cancelSelectionSource(&self.selection_source, source);
        if (decision.cleared_active_selection) {
            self.owned_selection.clear(self.alloc);
        }
        decision.destroy_source.destroy();
    }

    fn sendOwnedSelection(self: *Clipboard, mime_type: [*:0]const u8, fd: i32) void {
        _ = serveOwnedSelectionRequest(
            self.alloc,
            self.owned_selection.text,
            std.mem.span(mime_type),
            fd,
        ) catch |err| {
            std.log.err("clipboard transfer setup failed: {any}", .{err});
            return;
        };
    }
};

pub fn drainSelectionPipeThenRoundtrip(
    alloc: std.mem.Allocator,
    read_fd: std.posix.fd_t,
    roundtrip_ctx: anytype,
) ![]u8 {
    var out = std.ArrayList(u8).empty;
    defer out.deinit(alloc);

    var buf: [4096]u8 = undefined;
    while (true) {
        const n = std.posix.read(read_fd, &buf) catch |err| switch (err) {
            error.WouldBlock => break,
            else => return err,
        };
        if (n == 0) break;
        try out.appendSlice(alloc, buf[0..n]);
    }

    try roundtrip_ctx.roundtrip();
    return try out.toOwnedSlice(alloc);
}

fn choosePreferredMime(mime_types: []const [:0]const u8) ?[:0]const u8 {
    for (mime_types) |mime| {
        if (std.mem.eql(u8, mime, "text/plain;charset=utf-8")) return mime;
    }
    for (mime_types) |mime| {
        if (std.mem.eql(u8, mime, "text/plain")) return mime;
    }
    return null;
}

pub const Window = struct {
    alloc: std.mem.Allocator,
    surface: *wl.Surface,
    xdg_surface: *xdg.Surface,
    xdg_toplevel: *xdg.Toplevel,
    tracker: *ScaleTracker,
    outputs: *std.ArrayListUnmanaged(*Output),
    scale_generation: u64 = 0,
    applied_buffer_scale: i32 = 1,
    configured: bool = false,
    should_close: bool = false,
    width: u32 = 800,
    height: u32 = 600,

    pub fn deinit(self: *Window) void {
        self.xdg_toplevel.destroy();
        self.xdg_surface.destroy();
        self.surface.destroy();
        self.alloc.destroy(self);
    }

    pub fn setTitle(self: *Window, title: ?[:0]const u8) void {
        self.xdg_toplevel.setTitle((title orelse "waystty"));
    }

    pub fn bufferScale(self: *const Window) i32 {
        return self.tracker.bufferScale();
    }

    pub fn handleSurfaceEnter(self: *Window, wl_out: *wl.Output) void {
        for (self.outputs.items) |out| {
            if (out.wl_output == wl_out) {
                self.tracker.enterOutput(out.name) catch {};
                return;
            }
        }
    }

    pub fn handleSurfaceLeave(self: *Window, wl_out: *wl.Output) void {
        for (self.outputs.items) |out| {
            if (out.wl_output == wl_out) {
                self.tracker.leaveOutput(out.name);
                return;
            }
        }
    }
};

pub const Connection = struct {
    display: *wl.Display,
    registry: *wl.Registry,
    globals: Globals,
    alloc: std.mem.Allocator,
    scale_tracker: ScaleTracker,
    outputs: std.ArrayListUnmanaged(*Output),

    // Heap-allocated so the pointer passed to `registry.setListener` remains stable
    // across the init call boundary. With wl_output hotplug support the listener
    // can fire after init returns, so we need a stable address — a stack-local
    // Connection would dangle.
    pub fn init(alloc: std.mem.Allocator) !*Connection {
        const display = try wl.Display.connect(null);
        errdefer display.disconnect();

        const registry = try display.getRegistry();
        errdefer registry.destroy();

        const conn = try alloc.create(Connection);
        errdefer alloc.destroy(conn);

        conn.* = .{
            .display = display,
            .registry = registry,
            .globals = Globals{},
            .alloc = alloc,
            .scale_tracker = ScaleTracker.init(alloc),
            .outputs = .empty,
        };
        errdefer {
            for (conn.outputs.items) |out| {
                out.wl_output.release();
                alloc.destroy(out);
            }
            conn.outputs.deinit(alloc);
            conn.scale_tracker.deinit();
        }

        registry.setListener(*Connection, registryListener, conn);

        if (display.roundtrip() != .SUCCESS) return error.RoundtripFailed;
        // Second roundtrip so each wl_output's initial scale/done events are
        // received after the output's own listener is attached in the first pass.
        if (display.roundtrip() != .SUCCESS) return error.RoundtripFailed;

        if (conn.globals.compositor == null) return error.NoCompositor;
        if (conn.globals.wm_base == null) return error.NoXdgWmBase;
        if (conn.globals.seat == null) return error.NoSeat;

        return conn;
    }

    pub fn deinit(self: *Connection) void {
        const alloc = self.alloc;
        for (self.outputs.items) |out| {
            out.wl_output.release();
            alloc.destroy(out);
        }
        self.outputs.deinit(alloc);
        self.scale_tracker.deinit();
        self.display.disconnect();
        alloc.destroy(self);
    }

    pub fn createWindow(self: *Connection, alloc: std.mem.Allocator, title: [*:0]const u8) !*Window {
        const compositor = self.globals.compositor orelse return error.NoCompositor;
        const wm_base = self.globals.wm_base orelse return error.NoXdgWmBase;

        const window = try alloc.create(Window);
        errdefer alloc.destroy(window);

        window.* = .{
            .alloc = alloc,
            .surface = try compositor.createSurface(),
            .xdg_surface = undefined,
            .xdg_toplevel = undefined,
            .tracker = &self.scale_tracker,
            .outputs = &self.outputs,
        };
        errdefer window.surface.destroy();

        window.surface.setListener(*Window, surfaceListener, window);

        window.xdg_surface = try wm_base.getXdgSurface(window.surface);
        errdefer window.xdg_surface.destroy();

        window.xdg_toplevel = try window.xdg_surface.getToplevel();

        window.xdg_toplevel.setTitle(title);
        window.xdg_toplevel.setAppId("waystty");

        window.xdg_surface.setListener(*Window, xdgSurfaceListener, window);
        window.xdg_toplevel.setListener(*Window, xdgToplevelListener, window);
        wm_base.setListener(*xdg.WmBase, wmBaseListener, wm_base);

        window.surface.commit();
        _ = self.display.roundtrip();

        return window;
    }
};

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)),
                .serial = k.serial,
            };

            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 pointerListener(_: *wl.Pointer, event: wl.Pointer.Event, pointer: *Pointer) void {
    switch (event) {
        .enter => |e| {
            pointer.state.pushEnter(
                pointer.alloc,
                &pointer.event_queue,
                e.serial,
                e.surface_x.toDouble(),
                e.surface_y.toDouble(),
            ) catch |err| {
                std.log.err("pointer enter queue append failed: {any}", .{err});
            };
        },
        .leave => |e| {
            pointer.state.pushLeave(pointer.alloc, &pointer.event_queue, e.serial) catch |err| {
                std.log.err("pointer leave queue append failed: {any}", .{err});
            };
        },
        .motion => |m| {
            pointer.state.pushMotion(
                pointer.alloc,
                &pointer.event_queue,
                m.time,
                m.surface_x.toDouble(),
                m.surface_y.toDouble(),
            ) catch |err| {
                std.log.err("pointer motion queue append failed: {any}", .{err});
            };
        },
        .button => |b| {
            pointer.state.pushButton(
                pointer.alloc,
                &pointer.event_queue,
                b.serial,
                b.time,
                b.button,
                b.state,
            ) catch |err| {
                std.log.err("pointer button queue append failed: {any}", .{err});
            };
        },
        .axis, .frame, .axis_source, .axis_stop, .axis_discrete, .axis_value120, .axis_relative_direction => {},
    }
}

fn dataOfferListener(_: *wl.DataOffer, event: wl.DataOffer.Event, clipboard: *Clipboard) void {
    switch (event) {
        .offer => |mime| {
            var offer = &(clipboard.pending_offer orelse return);
            const dup = clipboard.alloc.dupeZ(u8, std.mem.span(mime.mime_type)) catch return;
            offer.mime_types.append(clipboard.alloc, dup) catch {
                clipboard.alloc.free(dup);
            };
        },
        .source_actions => {},
        .action => {},
    }
}

fn dataSourceListener(source: *wl.DataSource, event: wl.DataSource.Event, clipboard: *Clipboard) void {
    switch (event) {
        .send => |send| clipboard.sendOwnedSelection(send.mime_type, send.fd),
        .cancelled => clipboard.handleCancelledSource(source),
        .target, .dnd_drop_performed, .dnd_finished, .action => {},
    }
}

fn dataDeviceListener(_: *wl.DataDevice, event: wl.DataDevice.Event, clipboard: *Clipboard) void {
    switch (event) {
        .data_offer => |offer| {
            if (clipboard.pending_offer) |*old| old.deinit(clipboard.alloc);
            clipboard.pending_offer = ClipboardOffer.init(offer.id);
            clipboard.pending_offer.?.offer.setListener(*Clipboard, dataOfferListener, clipboard);
        },
        .selection => |selection| {
            if (clipboard.selection_offer) |*old| old.deinit(clipboard.alloc);
            clipboard.selection_offer = null;
            if (selection.id) |offer| {
                if (clipboard.pending_offer) |pending| {
                    if (pending.offer == offer) {
                        clipboard.selection_offer = pending;
                        clipboard.pending_offer = null;
                    }
                }
            }
        },
        .enter => {},
        .leave => {},
        .motion => {},
        .drop => {},
    }
}

test "choosePreferredMime preserves sentinel-terminated mime type" {
    const mime_types = [_][:0]const u8{
        "text/html",
        "text/plain;charset=utf-8",
        "text/plain",
    };

    const mime = choosePreferredMime(&mime_types) orelse return error.TestUnexpectedResult;
    try std.testing.expectEqualStrings("text/plain;charset=utf-8", mime);
    try std.testing.expect(mime[mime.len] == 0);
}

test "PointerState queues enter motion and button transitions" {
    var state = PointerState{};
    var queue: std.ArrayList(PointerEvent) = .empty;
    defer queue.deinit(std.testing.allocator);

    try state.pushEnter(std.testing.allocator, &queue, 11, 12.5, 9.25);
    try state.pushMotion(std.testing.allocator, &queue, 27, 18.0, 4.0);
    try state.pushButton(std.testing.allocator, &queue, 11, 33, 0x110, .pressed);
    try state.pushButton(std.testing.allocator, &queue, 11, 34, 0x110, .released);
    try state.pushLeave(std.testing.allocator, &queue, 12);

    try std.testing.expectEqual(@as(usize, 5), queue.items.len);
    try std.testing.expect(!state.has_focus);
    try std.testing.expectEqual(@as(u32, 12), state.last_serial);
    try std.testing.expectApproxEqAbs(18.0, state.surface_x, 0.001);
    try std.testing.expectApproxEqAbs(4.0, state.surface_y, 0.001);

    switch (queue.items[0]) {
        .enter => |ev| {
            try std.testing.expectEqual(@as(u32, 11), ev.serial);
            try std.testing.expectApproxEqAbs(12.5, ev.x, 0.001);
            try std.testing.expectApproxEqAbs(9.25, ev.y, 0.001);
        },
        else => return error.TestUnexpectedResult,
    }
    switch (queue.items[1]) {
        .motion => |ev| {
            try std.testing.expectEqual(@as(u32, 27), ev.time);
            try std.testing.expectApproxEqAbs(18.0, ev.x, 0.001);
            try std.testing.expectApproxEqAbs(4.0, ev.y, 0.001);
        },
        else => return error.TestUnexpectedResult,
    }
    try std.testing.expect(queue.items[2] == .button_press);
    try std.testing.expect(queue.items[3] == .button_release);
    try std.testing.expect(queue.items[4] == .leave);
}

test "OwnedSelectionState replaces text and recognizes offered mime types" {
    var state = OwnedSelectionState{};
    defer state.deinit(std.testing.allocator);

    try state.replaceText(std.testing.allocator, "first");
    try std.testing.expectEqualStrings("first", state.text.?);
    try std.testing.expect(state.canServeMime("text/plain;charset=utf-8"));
    try std.testing.expect(state.canServeMime("text/plain"));
    try std.testing.expect(!state.canServeMime("text/html"));

    try state.replaceText(std.testing.allocator, "second");
    try std.testing.expectEqualStrings("second", state.text.?);

    state.clear(std.testing.allocator);
    try std.testing.expect(state.text == null);
}

test "replaceSelectionSource returns prior source to destroy" {
    const old_source: *wl.DataSource = @ptrFromInt(0x1000);
    const new_source: *wl.DataSource = @ptrFromInt(0x2000);

    var slot: ?*wl.DataSource = null;
    try std.testing.expect(replaceSelectionSource(&slot, old_source) == null);
    try std.testing.expect(slot == old_source);

    const replaced = replaceSelectionSource(&slot, new_source);
    try std.testing.expect(replaced == old_source);
    try std.testing.expect(slot == new_source);
}

test "cancelledSelectionSource clears active source and ignores stale source" {
    const stale_source: *wl.DataSource = @ptrFromInt(0x1000);
    const active_source: *wl.DataSource = @ptrFromInt(0x2000);

    var slot: ?*wl.DataSource = active_source;

    const stale = cancelSelectionSource(&slot, stale_source);
    try std.testing.expect(stale.destroy_source == stale_source);
    try std.testing.expect(!stale.cleared_active_selection);
    try std.testing.expect(slot == active_source);

    const active = cancelSelectionSource(&slot, active_source);
    try std.testing.expect(active.destroy_source == active_source);
    try std.testing.expect(active.cleared_active_selection);
    try std.testing.expect(slot == null);
}

test "writeClipboardTransfer writes payload and closes fd" {
    const pipefds = try std.posix.pipe();
    defer std.posix.close(pipefds[0]);

    try writeClipboardTransfer("clipboard payload", pipefds[1]);

    var out = std.ArrayList(u8).empty;
    defer out.deinit(std.testing.allocator);

    var buf: [64]u8 = undefined;
    while (true) {
        const n = try std.posix.read(pipefds[0], &buf);
        if (n == 0) break;
        try out.appendSlice(std.testing.allocator, buf[0..n]);
    }

    try std.testing.expectEqualStrings("clipboard payload", out.items);
}

test "serveOwnedSelectionRequest closes fd for unsupported mime and missing text" {
    {
        const pipefds = try std.posix.pipe();
        defer std.posix.close(pipefds[0]);

        try std.testing.expect(!try serveOwnedSelectionRequest(
            std.testing.allocator,
            null,
            "text/plain",
            pipefds[1],
        ));

        var buf: [1]u8 = undefined;
        const n = try std.posix.read(pipefds[0], &buf);
        try std.testing.expectEqual(@as(usize, 0), n);
    }

    {
        const pipefds = try std.posix.pipe();
        defer std.posix.close(pipefds[0]);

        try std.testing.expect(!try serveOwnedSelectionRequest(
            std.testing.allocator,
            "payload",
            "text/html",
            pipefds[1],
        ));

        var buf: [1]u8 = undefined;
        const n = try std.posix.read(pipefds[0], &buf);
        try std.testing.expectEqual(@as(usize, 0), n);
    }
}

fn wmBaseListener(wm_base: *xdg.WmBase, event: xdg.WmBase.Event, _: *xdg.WmBase) void {
    switch (event) {
        .ping => |p| wm_base.pong(p.serial),
    }
}

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

fn surfaceListener(_: *wl.Surface, event: wl.Surface.Event, window: *Window) void {
    switch (event) {
        .enter => |e| {
            const wl_out = e.output orelse return;
            window.handleSurfaceEnter(wl_out);
            window.scale_generation += 1;
        },
        .leave => |e| {
            const wl_out = e.output orelse return;
            window.handleSurfaceLeave(wl_out);
            window.scale_generation += 1;
        },
        .preferred_buffer_scale => {},
        .preferred_buffer_transform => {},
    }
}

fn xdgToplevelListener(_: *xdg.Toplevel, event: xdg.Toplevel.Event, window: *Window) void {
    switch (event) {
        .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 => {},
        .wm_capabilities => {},
    }
}

fn registryListener(
    registry: *wl.Registry,
    event: wl.Registry.Event,
    conn: *Connection,
) void {
    switch (event) {
        .global => |g| {
            const iface = std.mem.span(g.interface);
            if (std.mem.eql(u8, iface, std.mem.span(wl.Compositor.interface.name))) {
                conn.globals.compositor = registry.bind(g.name, wl.Compositor, 6) catch return;
            } else if (std.mem.eql(u8, iface, std.mem.span(wl.DataDeviceManager.interface.name))) {
                conn.globals.data_device_manager = registry.bind(g.name, wl.DataDeviceManager, 3) catch return;
            } else if (std.mem.eql(u8, iface, std.mem.span(xdg.WmBase.interface.name))) {
                conn.globals.wm_base = registry.bind(g.name, xdg.WmBase, 5) catch return;
            } else if (std.mem.eql(u8, iface, std.mem.span(wl.Seat.interface.name))) {
                conn.globals.seat = registry.bind(g.name, wl.Seat, 9) catch return;
            } else if (std.mem.eql(u8, iface, std.mem.span(wl.Output.interface.name))) {
                const wl_out = registry.bind(g.name, wl.Output, 4) catch return;
                const out = conn.alloc.create(Output) catch {
                    wl_out.release();
                    return;
                };
                out.* = .{
                    .wl_output = wl_out,
                    .name = g.name,
                    .tracker = &conn.scale_tracker,
                };
                conn.outputs.append(conn.alloc, out) catch {
                    wl_out.release();
                    conn.alloc.destroy(out);
                    return;
                };
                conn.scale_tracker.addOutput(g.name) catch {};
                wl_out.setListener(*Output, outputListener, out);
            }
        },
        .global_remove => |g| {
            var i: usize = 0;
            while (i < conn.outputs.items.len) : (i += 1) {
                const out = conn.outputs.items[i];
                if (out.name == g.name) {
                    conn.scale_tracker.removeOutput(out.name);
                    out.wl_output.release();
                    conn.alloc.destroy(out);
                    _ = conn.outputs.swapRemove(i);
                    return;
                }
            }
        },
    }
}

fn outputListener(
    _: *wl.Output,
    event: wl.Output.Event,
    out: *Output,
) void {
    switch (event) {
        .scale => |s| {
            out.pending_scale = s.factor;
        },
        .done => {
            out.tracker.setOutputScale(out.name, out.pending_scale);
        },
        .geometry, .mode, .name, .description => {},
    }
}

test "choosePreferredMime prefers UTF-8 plain text" {
    try std.testing.expectEqualStrings(
        "text/plain;charset=utf-8",
        choosePreferredMime(&.{
            "text/plain",
            "text/plain;charset=utf-8",
        }).?,
    );
    try std.testing.expectEqualStrings(
        "text/plain",
        choosePreferredMime(&.{
            "text/plain",
            "application/octet-stream",
        }).?,
    );
    try std.testing.expect(choosePreferredMime(&.{"application/octet-stream"}) == null);
}

test "drainSelectionPipeThenRoundtrip drains large payload before roundtrip" {
    const pipefds = try std.posix.pipe();
    defer std.posix.close(pipefds[0]);

    const payload = "clip" ** 20_000;
    const Writer = struct {
        fd: std.posix.fd_t,

        fn run(self: @This()) !void {
            defer std.posix.close(self.fd);

            var written: usize = 0;
            while (written < payload.len) {
                written += try std.posix.write(self.fd, payload[written..]);
            }
        }
    };

    var roundtrip_called = false;
    const RoundtripCtx = struct {
        called: *bool,

        pub fn roundtrip(self: *@This()) !void {
            self.called.* = true;
        }
    };

    const writer = Writer{ .fd = pipefds[1] };
    const thread = try std.Thread.spawn(.{}, Writer.run, .{writer});
    defer thread.join();

    var roundtrip_ctx = RoundtripCtx{ .called = &roundtrip_called };
    const text = try drainSelectionPipeThenRoundtrip(std.testing.allocator, pipefds[0], &roundtrip_ctx);
    defer std.testing.allocator.free(text);

    try std.testing.expect(roundtrip_called);
    try std.testing.expectEqualStrings(payload, text);
}

test "Window.bufferScale reflects ScaleTracker entered outputs" {
    var tracker = ScaleTracker.init(std.testing.allocator);
    defer tracker.deinit();

    try tracker.addOutput(1);
    try tracker.addOutput(2);
    tracker.setOutputScale(1, 1);
    tracker.setOutputScale(2, 2);

    // Simulate the bits Window.bufferScale delegates to.
    try tracker.enterOutput(2);
    try std.testing.expectEqual(@as(i32, 2), tracker.bufferScale());

    tracker.leaveOutput(2);
    try std.testing.expectEqual(@as(i32, 1), tracker.bufferScale());
}