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