a73x

ec887038

Add visible selection copy extraction

a73x   2026-04-09 18:00


diff --git a/src/main.zig b/src/main.zig
index 5a4a7dd..c5ac0cf 100644
--- a/src/main.zig
+++ b/src/main.zig
@@ -524,6 +524,13 @@ fn isClipboardPasteEvent(ev: wayland_client.KeyboardEvent) bool {
        ev.keysym == c.XKB_KEY_V;
}

fn isClipboardCopyEvent(ev: wayland_client.KeyboardEvent) bool {
    return ev.action == .press and
        ev.modifiers.ctrl and
        ev.modifiers.shift and
        ev.keysym == c.XKB_KEY_C;
}

fn remainingRepeatTimeoutMs(deadline_ns: ?i128) ?i32 {
    const deadline = deadline_ns orelse return null;
    const now = std.time.nanoTimestamp();
@@ -541,6 +548,80 @@ fn shouldRenderFrame(terminal_dirty: bool, window_dirty: bool, forced: bool) boo
    return terminal_dirty or window_dirty or forced;
}

fn extractSelectedText(
    alloc: std.mem.Allocator,
    row_data: anytype,
    span: SelectionSpan,
) ![]u8 {
    const normalized = span.normalized();
    var out: std.ArrayListUnmanaged(u8) = .empty;
    errdefer out.deinit(alloc);

    const start_row: usize = @intCast(normalized.start.row);
    const end_row: usize = @intCast(normalized.end.row);
    var row_idx = start_row;
    while (row_idx <= end_row) : (row_idx += 1) {
        if (row_idx > start_row) try out.append(alloc, '\n');
        try appendSelectedRowText(alloc, &out, row_data[row_idx], normalized, row_idx);
    }

    return out.toOwnedSlice(alloc);
}

fn appendSelectedRowText(
    alloc: std.mem.Allocator,
    out: *std.ArrayListUnmanaged(u8),
    row_cells: anytype,
    span: SelectionSpan,
    row_idx: usize,
) !void {
    const cells = row_cells.items(.raw);
    if (cells.len == 0) return;

    const start_col: usize = if (row_idx == span.start.row)
        @intCast(span.start.col)
    else
        0;
    if (start_col >= cells.len) return;

    var end_col: usize = if (row_idx == span.end.row)
        @intCast(span.end.col)
    else
        cells.len - 1;
    if (end_col >= cells.len) end_col = cells.len - 1;

    while (end_col >= start_col) {
        const cell = cells[end_col];
        if (cellContributesVisibleText(cell)) break;
        if (end_col == 0) return;
        end_col -= 1;
    }

    var col = start_col;
    while (col <= end_col) : (col += 1) {
        const cell = cells[col];
        if (!cellContributesVisibleText(cell)) continue;
        try appendCellText(alloc, out, cell);
    }
}

fn cellContributesVisibleText(cell: anytype) bool {
    return cell.hasText() and cell.wide != .spacer_tail and cell.wide != .spacer_head;
}

fn appendCellText(
    alloc: std.mem.Allocator,
    out: *std.ArrayListUnmanaged(u8),
    cell: anytype,
) !void {
    const cp = cell.codepoint();
    if (cp == 0) return;

    var utf8_buf: [4]u8 = undefined;
    const utf8_len = try std.unicode.utf8Encode(cp, &utf8_buf);
    try out.appendSlice(alloc, utf8_buf[0..utf8_len]);
}

const GridPoint = struct {
    col: u32,
    row: u32,
@@ -2285,6 +2366,70 @@ test "isClipboardPasteEvent matches Ctrl-Shift-V press" {
    }));
}

test "isClipboardCopyEvent matches Ctrl-Shift-C press" {
    try std.testing.expect(isClipboardCopyEvent(.{
        .keysym = c.XKB_KEY_C,
        .modifiers = .{ .ctrl = true, .shift = true },
        .action = .press,
        .utf8 = [_]u8{0} ** 8,
        .utf8_len = 0,
    }));
    try std.testing.expect(!isClipboardCopyEvent(.{
        .keysym = c.XKB_KEY_C,
        .modifiers = .{ .ctrl = true, .shift = true },
        .action = .repeat,
        .utf8 = [_]u8{0} ** 8,
        .utf8_len = 0,
    }));
    try std.testing.expect(!isClipboardCopyEvent(.{
        .keysym = c.XKB_KEY_c,
        .modifiers = .{ .ctrl = true, .shift = true },
        .action = .press,
        .utf8 = [_]u8{0} ** 8,
        .utf8_len = 0,
    }));
}

test "extractSelectedText trims trailing blanks on each visible row" {
    var term = try vt.Terminal.init(std.testing.allocator, .{
        .cols = 8,
        .rows = 2,
    });
    defer term.deinit();

    term.write("ab\r\nc");
    try term.snapshot();

    const span = SelectionSpan{
        .start = .{ .col = 0, .row = 0 },
        .end = .{ .col = 7, .row = 1 },
    };
    const text = try extractSelectedText(std.testing.allocator, term.render_state.row_data.items(.cells), span);
    defer std.testing.allocator.free(text);

    try std.testing.expectEqualStrings("ab\nc", text);
}

test "extractSelectedText respects partial first and last rows" {
    var term = try vt.Terminal.init(std.testing.allocator, .{
        .cols = 8,
        .rows = 2,
    });
    defer term.deinit();

    term.write("abc\r\ndef");
    try term.snapshot();

    const span = SelectionSpan{
        .start = .{ .col = 1, .row = 0 },
        .end = .{ .col = 1, .row = 1 },
    };
    const text = try extractSelectedText(std.testing.allocator, term.render_state.row_data.items(.cells), span);
    defer std.testing.allocator.free(text);

    try std.testing.expectEqualStrings("bc\nde", text);
}

test "drainSelectionPipeThenRoundtrip drains large paste before roundtrip" {
    const payload_len: usize = 8192;
    const payload = try std.testing.allocator.alloc(u8, payload_len);