a73x

448e8bb8

Add clipboard paste support

a73x   2026-04-08 13:18

Commit message
Add clipboard paste support

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 }