448e8bb8
Add clipboard paste support
a73x 2026-04-08 13:18
Commit message
build.zig
| Old | New | ||
|---|---|---|---|
| @@ -15,6 +15,7 @@ pub fn build(b: *std.Build) void { | |||
| 15 | scanner.addSystemProtocol("stable/xdg-shell/xdg-shell.xml"); | 15 | scanner.addSystemProtocol("stable/xdg-shell/xdg-shell.xml"); |
| 16 | scanner.generate("wl_compositor", 6); | 16 | scanner.generate("wl_compositor", 6); |
| 17 | scanner.generate("wl_seat", 9); | 17 | scanner.generate("wl_seat", 9); |
| 18 | scanner.generate("wl_data_device_manager", 3); | ||
| 18 | scanner.generate("xdg_wm_base", 6); | 19 | scanner.generate("xdg_wm_base", 6); |
| 19 | 20 | ||
| 20 | // wayland module — generated bindings + our Connection wrapper | 21 | // wayland module — generated bindings + our Connection wrapper |
src/main.zig
| Old | New | ||
|---|---|---|---|
| @@ -95,6 +95,13 @@ fn runTerminal(alloc: std.mem.Allocator) !void { | |||
| 95 | var keyboard = try wayland_client.Keyboard.init(alloc, conn.globals.seat.?); | 95 | var keyboard = try wayland_client.Keyboard.init(alloc, conn.globals.seat.?); |
| 96 | defer keyboard.deinit(); | 96 | defer keyboard.deinit(); |
| 97 | 97 | ||
| 98 | // === clipboard === | ||
| 99 | const clipboard: ?*wayland_client.Clipboard = if (conn.globals.data_device_manager) |manager| | ||
| 100 | try wayland_client.Clipboard.init(alloc, conn.display, manager, conn.globals.seat.?) | ||
| 101 | else | ||
| 102 | null; | ||
| 103 | defer if (clipboard) |cb| cb.deinit(); | ||
| 104 | |||
| 98 | // === renderer === | 105 | // === renderer === |
| 99 | var ctx = try renderer.Context.init( | 106 | var ctx = try renderer.Context.init( |
| 100 | alloc, | 107 | alloc, |
| @@ -187,6 +194,19 @@ fn runTerminal(alloc: std.mem.Allocator) !void { | |||
| 187 | keyboard.tickRepeat(); | 194 | keyboard.tickRepeat(); |
| 188 | for (keyboard.event_queue.items) |ev| { | 195 | for (keyboard.event_queue.items) |ev| { |
| 189 | if (ev.action == .release) continue; | 196 | if (ev.action == .release) continue; |
| 197 | if (isClipboardPasteEvent(ev)) { | ||
| 198 | if (clipboard) |cb| { | ||
| 199 | if (try cb.receiveSelectionText(alloc)) |text| { | ||
| 200 | defer alloc.free(text); | ||
| 201 | const encoded = term.encodePaste(text); | ||
| 202 | for (encoded) |chunk| { | ||
| 203 | if (chunk.len == 0) continue; | ||
| 204 | _ = try p.write(chunk); | ||
| 205 | } | ||
| 206 | } | ||
| 207 | } | ||
| 208 | continue; | ||
| 209 | } | ||
| 190 | if (ev.utf8_len > 0) { | 210 | if (ev.utf8_len > 0) { |
| 191 | _ = try p.write(ev.utf8[0..ev.utf8_len]); | 211 | _ = try p.write(ev.utf8[0..ev.utf8_len]); |
| 192 | } else if (try encodeKeyboardEvent(&term, ev, &key_buf)) |encoded| { | 212 | } 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 | |||
| 308 | }; | 328 | }; |
| 309 | } | 329 | } |
| 310 | 330 | ||
| 331 | fn isClipboardPasteEvent(ev: wayland_client.KeyboardEvent) bool { | ||
| 332 | return ev.action == .press and | ||
| 333 | ev.modifiers.ctrl and | ||
| 334 | ev.modifiers.shift and | ||
| 335 | ev.keysym == c.XKB_KEY_V; | ||
| 336 | } | ||
| 337 | |||
| 311 | fn appendCellInstances( | 338 | fn appendCellInstances( |
| 312 | alloc: std.mem.Allocator, | 339 | alloc: std.mem.Allocator, |
| 313 | instances: *std.ArrayListUnmanaged(renderer.Instance), | 340 | instances: *std.ArrayListUnmanaged(renderer.Instance), |
| @@ -515,6 +542,30 @@ test "gridSizeForWindow clamps to at least one cell" { | |||
| 515 | try std.testing.expectEqual(@as(u16, 1), grid.rows); | 542 | try std.testing.expectEqual(@as(u16, 1), grid.rows); |
| 516 | } | 543 | } |
| 517 | 544 | ||
| 545 | test "isClipboardPasteEvent matches Ctrl-Shift-V press" { | ||
| 546 | try std.testing.expect(isClipboardPasteEvent(.{ | ||
| 547 | .keysym = c.XKB_KEY_V, | ||
| 548 | .modifiers = .{ .ctrl = true, .shift = true }, | ||
| 549 | .action = .press, | ||
| 550 | .utf8 = [_]u8{0} ** 8, | ||
| 551 | .utf8_len = 0, | ||
| 552 | })); | ||
| 553 | try std.testing.expect(!isClipboardPasteEvent(.{ | ||
| 554 | .keysym = c.XKB_KEY_V, | ||
| 555 | .modifiers = .{ .ctrl = true, .shift = true }, | ||
| 556 | .action = .repeat, | ||
| 557 | .utf8 = [_]u8{0} ** 8, | ||
| 558 | .utf8_len = 0, | ||
| 559 | })); | ||
| 560 | try std.testing.expect(!isClipboardPasteEvent(.{ | ||
| 561 | .keysym = c.XKB_KEY_v, | ||
| 562 | .modifiers = .{ .ctrl = true, .shift = true }, | ||
| 563 | .action = .press, | ||
| 564 | .utf8 = [_]u8{0} ** 8, | ||
| 565 | .utf8_len = 0, | ||
| 566 | })); | ||
| 567 | } | ||
| 568 | |||
| 518 | test "appendCellInstances emits a background quad for colored space" { | 569 | test "appendCellInstances emits a background quad for colored space" { |
| 519 | var instances: std.ArrayListUnmanaged(renderer.Instance) = .empty; | 570 | var instances: std.ArrayListUnmanaged(renderer.Instance) = .empty; |
| 520 | defer instances.deinit(std.testing.allocator); | 571 | defer instances.deinit(std.testing.allocator); |
src/vt.zig
| Old | New | ||
|---|---|---|---|
| @@ -181,6 +181,13 @@ pub const Terminal = struct { | |||
| 181 | return writer.buffered(); | 181 | return writer.buffered(); |
| 182 | } | 182 | } |
| 183 | 183 | ||
| 184 | pub fn encodePaste( | ||
| 185 | self: *const Terminal, | ||
| 186 | data: []u8, | ||
| 187 | ) [3][]const u8 { | ||
| 188 | return ghostty_vt.input.encodePaste(data, .fromTerminal(&self.inner)); | ||
| 189 | } | ||
| 190 | |||
| 184 | pub fn cellColors( | 191 | pub fn cellColors( |
| 185 | self: *const Terminal, | 192 | self: *const Terminal, |
| 186 | cell: ghostty_vt.RenderState.Cell, | 193 | cell: ghostty_vt.RenderState.Cell, |
| @@ -481,3 +488,20 @@ test "Terminal reports default device attributes queries" { | |||
| 481 | 488 | ||
| 482 | try std.testing.expectEqualStrings("\x1b[?62;22c", S.buf[0..S.written_len]); | 489 | try std.testing.expectEqualStrings("\x1b[?62;22c", S.buf[0..S.written_len]); |
| 483 | } | 490 | } |
| 491 | |||
| 492 | test "Terminal encodes bracketed paste based on terminal mode" { | ||
| 493 | var term = try Terminal.init(std.testing.allocator, .{ | ||
| 494 | .cols = 80, | ||
| 495 | .rows = 24, | ||
| 496 | }); | ||
| 497 | defer term.deinit(); | ||
| 498 | |||
| 499 | term.write("\x1b[?2004h"); | ||
| 500 | const data = try std.testing.allocator.dupe(u8, "hello"); | ||
| 501 | defer std.testing.allocator.free(data); | ||
| 502 | |||
| 503 | const encoded = term.encodePaste(data); | ||
| 504 | try std.testing.expectEqualStrings("\x1b[200~", encoded[0]); | ||
| 505 | try std.testing.expectEqualStrings("hello", encoded[1]); | ||
| 506 | try std.testing.expectEqualStrings("\x1b[201~", encoded[2]); | ||
| 507 | } | ||
src/wayland.zig
| Old | New | ||
|---|---|---|---|
| @@ -109,8 +109,99 @@ pub const Globals = struct { | |||
| 109 | compositor: ?*wl.Compositor = null, | 109 | compositor: ?*wl.Compositor = null, |
| 110 | wm_base: ?*xdg.WmBase = null, | 110 | wm_base: ?*xdg.WmBase = null, |
| 111 | seat: ?*wl.Seat = null, | 111 | seat: ?*wl.Seat = null, |
| 112 | data_device_manager: ?*wl.DataDeviceManager = null, | ||
| 112 | }; | 113 | }; |
| 113 | 114 | ||
| 115 | const ClipboardOffer = struct { | ||
| 116 | offer: *wl.DataOffer, | ||
| 117 | mime_types: std.ArrayList([]u8), | ||
| 118 | |||
| 119 | fn init(offer: *wl.DataOffer) ClipboardOffer { | ||
| 120 | return .{ | ||
| 121 | .offer = offer, | ||
| 122 | .mime_types = .empty, | ||
| 123 | }; | ||
| 124 | } | ||
| 125 | |||
| 126 | fn deinit(self: *ClipboardOffer, alloc: std.mem.Allocator) void { | ||
| 127 | for (self.mime_types.items) |mime| alloc.free(mime); | ||
| 128 | self.mime_types.deinit(alloc); | ||
| 129 | self.offer.destroy(); | ||
| 130 | } | ||
| 131 | }; | ||
| 132 | |||
| 133 | pub const Clipboard = struct { | ||
| 134 | alloc: std.mem.Allocator, | ||
| 135 | display: *wl.Display, | ||
| 136 | data_device: *wl.DataDevice, | ||
| 137 | pending_offer: ?ClipboardOffer = null, | ||
| 138 | selection_offer: ?ClipboardOffer = null, | ||
| 139 | |||
| 140 | pub fn init( | ||
| 141 | alloc: std.mem.Allocator, | ||
| 142 | display: *wl.Display, | ||
| 143 | manager: *wl.DataDeviceManager, | ||
| 144 | seat: *wl.Seat, | ||
| 145 | ) !*Clipboard { | ||
| 146 | const clipboard = try alloc.create(Clipboard); | ||
| 147 | errdefer alloc.destroy(clipboard); | ||
| 148 | |||
| 149 | clipboard.* = .{ | ||
| 150 | .alloc = alloc, | ||
| 151 | .display = display, | ||
| 152 | .data_device = try manager.getDataDevice(seat), | ||
| 153 | }; | ||
| 154 | clipboard.data_device.setListener(*Clipboard, dataDeviceListener, clipboard); | ||
| 155 | return clipboard; | ||
| 156 | } | ||
| 157 | |||
| 158 | pub fn deinit(self: *Clipboard) void { | ||
| 159 | if (self.pending_offer) |*offer| offer.deinit(self.alloc); | ||
| 160 | if (self.selection_offer) |*offer| offer.deinit(self.alloc); | ||
| 161 | self.data_device.release(); | ||
| 162 | self.alloc.destroy(self); | ||
| 163 | } | ||
| 164 | |||
| 165 | pub fn receiveSelectionText(self: *Clipboard, alloc: std.mem.Allocator) !?[]u8 { | ||
| 166 | const offer = &(self.selection_offer orelse return null); | ||
| 167 | const mime = choosePreferredMime(offer.mime_types.items) orelse return null; | ||
| 168 | |||
| 169 | var pipefds: [2]std.posix.fd_t = undefined; | ||
| 170 | try std.posix.pipe(&pipefds); | ||
| 171 | defer std.posix.close(pipefds[0]); | ||
| 172 | |||
| 173 | offer.offer.receive(mime.ptr, pipefds[1]); | ||
| 174 | _ = self.display.flush(); | ||
| 175 | _ = self.display.roundtrip(); | ||
| 176 | std.posix.close(pipefds[1]); | ||
| 177 | |||
| 178 | var out = std.ArrayList(u8).empty; | ||
| 179 | defer out.deinit(alloc); | ||
| 180 | |||
| 181 | var buf: [4096]u8 = undefined; | ||
| 182 | while (true) { | ||
| 183 | const n = std.posix.read(pipefds[0], &buf) catch |err| switch (err) { | ||
| 184 | error.WouldBlock => break, | ||
| 185 | else => return err, | ||
| 186 | }; | ||
| 187 | if (n == 0) break; | ||
| 188 | try out.appendSlice(alloc, buf[0..n]); | ||
| 189 | } | ||
| 190 | |||
| 191 | return try out.toOwnedSlice(alloc); | ||
| 192 | } | ||
| 193 | }; | ||
| 194 | |||
| 195 | fn choosePreferredMime(mime_types: []const []const u8) ?[]const u8 { | ||
| 196 | for (mime_types) |mime| { | ||
| 197 | if (std.mem.eql(u8, mime, "text/plain;charset=utf-8")) return mime; | ||
| 198 | } | ||
| 199 | for (mime_types) |mime| { | ||
| 200 | if (std.mem.eql(u8, mime, "text/plain")) return mime; | ||
| 201 | } | ||
| 202 | return null; | ||
| 203 | } | ||
| 204 | |||
| 114 | pub const Window = struct { | 205 | pub const Window = struct { |
| 115 | alloc: std.mem.Allocator, | 206 | alloc: std.mem.Allocator, |
| 116 | surface: *wl.Surface, | 207 | surface: *wl.Surface, |
| @@ -294,6 +385,46 @@ fn keyboardListener(_: *wl.Keyboard, event: wl.Keyboard.Event, kb: *Keyboard) vo | |||
| 294 | } | 385 | } |
| 295 | } | 386 | } |
| 296 | 387 | ||
| 388 | fn dataOfferListener(_: *wl.DataOffer, event: wl.DataOffer.Event, clipboard: *Clipboard) void { | ||
| 389 | switch (event) { | ||
| 390 | .offer => |mime| { | ||
| 391 | var offer = &(clipboard.pending_offer orelse return); | ||
| 392 | const dup = clipboard.alloc.dupe(u8, std.mem.span(mime.mime_type)) catch return; | ||
| 393 | offer.mime_types.append(clipboard.alloc, dup) catch { | ||
| 394 | clipboard.alloc.free(dup); | ||
| 395 | }; | ||
| 396 | }, | ||
| 397 | .source_actions => {}, | ||
| 398 | .action => {}, | ||
| 399 | } | ||
| 400 | } | ||
| 401 | |||
| 402 | fn dataDeviceListener(_: *wl.DataDevice, event: wl.DataDevice.Event, clipboard: *Clipboard) void { | ||
| 403 | switch (event) { | ||
| 404 | .data_offer => |offer| { | ||
| 405 | if (clipboard.pending_offer) |*old| old.deinit(clipboard.alloc); | ||
| 406 | clipboard.pending_offer = ClipboardOffer.init(offer.id); | ||
| 407 | clipboard.pending_offer.?.offer.setListener(*Clipboard, dataOfferListener, clipboard); | ||
| 408 | }, | ||
| 409 | .selection => |selection| { | ||
| 410 | if (clipboard.selection_offer) |*old| old.deinit(clipboard.alloc); | ||
| 411 | clipboard.selection_offer = null; | ||
| 412 | if (selection.id) |offer| { | ||
| 413 | if (clipboard.pending_offer) |pending| { | ||
| 414 | if (pending.offer == offer) { | ||
| 415 | clipboard.selection_offer = pending; | ||
| 416 | clipboard.pending_offer = null; | ||
| 417 | } | ||
| 418 | } | ||
| 419 | } | ||
| 420 | }, | ||
| 421 | .enter => {}, | ||
| 422 | .leave => {}, | ||
| 423 | .motion => {}, | ||
| 424 | .drop => {}, | ||
| 425 | } | ||
| 426 | } | ||
| 427 | |||
| 297 | fn wmBaseListener(wm_base: *xdg.WmBase, event: xdg.WmBase.Event, _: *xdg.WmBase) void { | 428 | fn wmBaseListener(wm_base: *xdg.WmBase, event: xdg.WmBase.Event, _: *xdg.WmBase) void { |
| 298 | switch (event) { | 429 | switch (event) { |
| 299 | .ping => |p| wm_base.pong(p.serial), | 430 | .ping => |p| wm_base.pong(p.serial), |
| @@ -331,6 +462,8 @@ fn registryListener( | |||
| 331 | const iface = std.mem.span(g.interface); | 462 | const iface = std.mem.span(g.interface); |
| 332 | if (std.mem.eql(u8, iface, std.mem.span(wl.Compositor.interface.name))) { | 463 | if (std.mem.eql(u8, iface, std.mem.span(wl.Compositor.interface.name))) { |
| 333 | globals.compositor = registry.bind(g.name, wl.Compositor, 6) catch return; | 464 | globals.compositor = registry.bind(g.name, wl.Compositor, 6) catch return; |
| 465 | } else if (std.mem.eql(u8, iface, std.mem.span(wl.DataDeviceManager.interface.name))) { | ||
| 466 | globals.data_device_manager = registry.bind(g.name, wl.DataDeviceManager, 3) catch return; | ||
| 334 | } else if (std.mem.eql(u8, iface, std.mem.span(xdg.WmBase.interface.name))) { | 467 | } else if (std.mem.eql(u8, iface, std.mem.span(xdg.WmBase.interface.name))) { |
| 335 | globals.wm_base = registry.bind(g.name, xdg.WmBase, 5) catch return; | 468 | globals.wm_base = registry.bind(g.name, xdg.WmBase, 5) catch return; |
| 336 | } else if (std.mem.eql(u8, iface, std.mem.span(wl.Seat.interface.name))) { | 469 | } else if (std.mem.eql(u8, iface, std.mem.span(wl.Seat.interface.name))) { |
| @@ -340,3 +473,21 @@ fn registryListener( | |||
| 340 | .global_remove => {}, | 473 | .global_remove => {}, |
| 341 | } | 474 | } |
| 342 | } | 475 | } |
| 476 | |||
| 477 | test "choosePreferredMime prefers UTF-8 plain text" { | ||
| 478 | try std.testing.expectEqualStrings( | ||
| 479 | "text/plain;charset=utf-8", | ||
| 480 | choosePreferredMime(&.{ | ||
| 481 | "text/plain", | ||
| 482 | "text/plain;charset=utf-8", | ||
| 483 | }).?, | ||
| 484 | ); | ||
| 485 | try std.testing.expectEqualStrings( | ||
| 486 | "text/plain", | ||
| 487 | choosePreferredMime(&.{ | ||
| 488 | "text/plain", | ||
| 489 | "application/octet-stream", | ||
| 490 | }).?, | ||
| 491 | ); | ||
| 492 | try std.testing.expect(choosePreferredMime(&.{"application/octet-stream"}) == null); | ||
| 493 | } | ||