448e8bb8
Add clipboard paste support
a73x 2026-04-08 13:18
diff --git a/build.zig b/build.zig index 1fb9743..9274716 100644 --- a/build.zig +++ b/build.zig @@ -15,6 +15,7 @@ pub fn build(b: *std.Build) void { scanner.addSystemProtocol("stable/xdg-shell/xdg-shell.xml"); scanner.generate("wl_compositor", 6); scanner.generate("wl_seat", 9); scanner.generate("wl_data_device_manager", 3); scanner.generate("xdg_wm_base", 6); // wayland module — generated bindings + our Connection wrapper diff --git a/src/main.zig b/src/main.zig index e057439..ede23cc 100644 --- a/src/main.zig +++ b/src/main.zig @@ -95,6 +95,13 @@ fn runTerminal(alloc: std.mem.Allocator) !void { var keyboard = try wayland_client.Keyboard.init(alloc, conn.globals.seat.?); defer keyboard.deinit(); // === clipboard === const clipboard: ?*wayland_client.Clipboard = if (conn.globals.data_device_manager) |manager| try wayland_client.Clipboard.init(alloc, conn.display, manager, conn.globals.seat.?) else null; defer if (clipboard) |cb| cb.deinit(); // === renderer === var ctx = try renderer.Context.init( alloc, @@ -187,6 +194,19 @@ fn runTerminal(alloc: std.mem.Allocator) !void { keyboard.tickRepeat(); for (keyboard.event_queue.items) |ev| { if (ev.action == .release) continue; if (isClipboardPasteEvent(ev)) { if (clipboard) |cb| { if (try cb.receiveSelectionText(alloc)) |text| { defer alloc.free(text); const encoded = term.encodePaste(text); for (encoded) |chunk| { if (chunk.len == 0) continue; _ = try p.write(chunk); } } } continue; } if (ev.utf8_len > 0) { _ = try p.write(ev.utf8[0..ev.utf8_len]); } else if (try encodeKeyboardEvent(&term, ev, &key_buf)) |encoded| { @@ -308,6 +328,13 @@ fn gridSizeForWindow(window_w: u32, window_h: u32, cell_w: u32, cell_h: u32) Gri }; } fn isClipboardPasteEvent(ev: wayland_client.KeyboardEvent) bool { return ev.action == .press and ev.modifiers.ctrl and ev.modifiers.shift and ev.keysym == c.XKB_KEY_V; } fn appendCellInstances( alloc: std.mem.Allocator, instances: *std.ArrayListUnmanaged(renderer.Instance), @@ -515,6 +542,30 @@ test "gridSizeForWindow clamps to at least one cell" { try std.testing.expectEqual(@as(u16, 1), grid.rows); } test "isClipboardPasteEvent matches Ctrl-Shift-V press" { try std.testing.expect(isClipboardPasteEvent(.{ .keysym = c.XKB_KEY_V, .modifiers = .{ .ctrl = true, .shift = true }, .action = .press, .utf8 = [_]u8{0} ** 8, .utf8_len = 0, })); try std.testing.expect(!isClipboardPasteEvent(.{ .keysym = c.XKB_KEY_V, .modifiers = .{ .ctrl = true, .shift = true }, .action = .repeat, .utf8 = [_]u8{0} ** 8, .utf8_len = 0, })); try std.testing.expect(!isClipboardPasteEvent(.{ .keysym = c.XKB_KEY_v, .modifiers = .{ .ctrl = true, .shift = true }, .action = .press, .utf8 = [_]u8{0} ** 8, .utf8_len = 0, })); } test "appendCellInstances emits a background quad for colored space" { var instances: std.ArrayListUnmanaged(renderer.Instance) = .empty; defer instances.deinit(std.testing.allocator); diff --git a/src/vt.zig b/src/vt.zig index a320058..f50bb2e 100644 --- a/src/vt.zig +++ b/src/vt.zig @@ -181,6 +181,13 @@ pub const Terminal = struct { return writer.buffered(); } pub fn encodePaste( self: *const Terminal, data: []u8, ) [3][]const u8 { return ghostty_vt.input.encodePaste(data, .fromTerminal(&self.inner)); } pub fn cellColors( self: *const Terminal, cell: ghostty_vt.RenderState.Cell, @@ -481,3 +488,20 @@ test "Terminal reports default device attributes queries" { try std.testing.expectEqualStrings("\x1b[?62;22c", S.buf[0..S.written_len]); } test "Terminal encodes bracketed paste based on terminal mode" { var term = try Terminal.init(std.testing.allocator, .{ .cols = 80, .rows = 24, }); defer term.deinit(); term.write("\x1b[?2004h"); const data = try std.testing.allocator.dupe(u8, "hello"); defer std.testing.allocator.free(data); const encoded = term.encodePaste(data); try std.testing.expectEqualStrings("\x1b[200~", encoded[0]); try std.testing.expectEqualStrings("hello", encoded[1]); try std.testing.expectEqualStrings("\x1b[201~", encoded[2]); } diff --git a/src/wayland.zig b/src/wayland.zig index 6e4b2cf..5b80a87 100644 --- a/src/wayland.zig +++ b/src/wayland.zig @@ -109,8 +109,99 @@ 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([]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(); } }; pub const Clipboard = struct { alloc: std.mem.Allocator, display: *wl.Display, data_device: *wl.DataDevice, pending_offer: ?ClipboardOffer = null, selection_offer: ?ClipboardOffer = 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 = 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); 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; var pipefds: [2]std.posix.fd_t = undefined; try std.posix.pipe(&pipefds); defer std.posix.close(pipefds[0]); offer.offer.receive(mime.ptr, pipefds[1]); _ = self.display.flush(); _ = self.display.roundtrip(); std.posix.close(pipefds[1]); var out = std.ArrayList(u8).empty; defer out.deinit(alloc); var buf: [4096]u8 = undefined; while (true) { const n = std.posix.read(pipefds[0], &buf) catch |err| switch (err) { error.WouldBlock => break, else => return err, }; if (n == 0) break; try out.appendSlice(alloc, buf[0..n]); } return try out.toOwnedSlice(alloc); } }; fn choosePreferredMime(mime_types: []const []const u8) ?[]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, @@ -294,6 +385,46 @@ fn keyboardListener(_: *wl.Keyboard, event: wl.Keyboard.Event, kb: *Keyboard) vo } } 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.dupe(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 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 => {}, } } fn wmBaseListener(wm_base: *xdg.WmBase, event: xdg.WmBase.Event, _: *xdg.WmBase) void { switch (event) { .ping => |p| wm_base.pong(p.serial), @@ -331,6 +462,8 @@ fn registryListener( const iface = std.mem.span(g.interface); if (std.mem.eql(u8, iface, std.mem.span(wl.Compositor.interface.name))) { 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))) { 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))) { 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))) { @@ -340,3 +473,21 @@ fn registryListener( .global_remove => {}, } } 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); }