a73x

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