a73x

ce9c391f

Add Wayland pointer and clipboard source support

a73x   2026-04-09 18:21


diff --git a/src/wayland.zig b/src/wayland.zig
index 476ec45..dcf5d2d 100644
--- a/src/wayland.zig
+++ b/src/wayland.zig
@@ -35,6 +35,153 @@ pub const Modifiers = struct {
    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,
@@ -146,12 +293,48 @@ const ClipboardOffer = struct {
    }
};

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

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

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

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

    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"));
    }
};

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,
@@ -165,6 +348,7 @@ pub const Clipboard = struct {
        clipboard.* = .{
            .alloc = alloc,
            .display = display,
            .data_device_manager = manager,
            .data_device = try manager.getDataDevice(seat),
        };
        clipboard.data_device.setListener(*Clipboard, dataDeviceListener, clipboard);
@@ -174,6 +358,8 @@ pub const Clipboard = struct {
    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);
    }
@@ -202,6 +388,49 @@ pub const Clipboard = struct {

        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, serial);

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

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

    fn handleCancelledSource(self: *Clipboard, source: *wl.DataSource) void {
        if (self.selection_source == source) {
            self.selection_source = null;
            source.destroy();
            self.owned_selection.clear(self.alloc);
            return;
        }
        source.destroy();
    }

    fn sendOwnedSelection(self: *Clipboard, mime_type: [*:0]const u8, fd: i32) void {
        defer _ = c.close(fd);

        if (!self.owned_selection.canServeMime(std.mem.span(mime_type))) return;
        const text = self.owned_selection.text orelse return;

        var written: usize = 0;
        while (written < text.len) {
            const n = posix.write(fd, text[written..]) catch |err| {
                std.log.err("clipboard write failed: {any}", .{err});
                return;
            };
            if (n == 0) return;
            written += n;
        }
    }
};

pub fn drainSelectionPipeThenRoundtrip(
@@ -482,6 +711,51 @@ fn keyboardListener(_: *wl.Keyboard, event: wl.Keyboard.Event, kb: *Keyboard) vo
    }
}

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| {
@@ -496,6 +770,14 @@ fn dataOfferListener(_: *wl.DataOffer, event: wl.DataOffer.Event, clipboard: *Cl
    }
}

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| {
@@ -534,6 +816,64 @@ test "choosePreferredMime preserves sentinel-terminated mime type" {
    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", 17);
    try std.testing.expectEqualStrings("first", state.text.?);
    try std.testing.expectEqual(@as(u32, 17), state.serial);
    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", 21);
    try std.testing.expectEqualStrings("second", state.text.?);
    try std.testing.expectEqual(@as(u32, 21), state.serial);

    state.clear(std.testing.allocator);
    try std.testing.expect(state.text == null);
    try std.testing.expectEqual(@as(u32, 0), state.serial);
}

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