a73x

9a329e5a

Fix selection grapheme and wide cell extraction

a73x   2026-04-09 18:10


diff --git a/src/main.zig b/src/main.zig
index b25ee96..c058953 100644
--- a/src/main.zig
+++ b/src/main.zig
@@ -583,37 +583,71 @@ fn appendSelectedRowText(
    span: SelectionSpan,
    row_idx: usize,
) !void {
    const cells = row_cells.items(.raw);
    if (cells.len == 0) return;
    const bounds = canonicalizeSelectedRowBounds(row_cells, span, row_idx) orelse return;

    const start_col: usize = if (row_idx == span.start.row)
    var end_col = bounds.end_col;
    while (end_col >= bounds.start_col) {
        const cell = row_cells.get(end_col);
        if (!isTrailingBlankCell(cell)) break;
        if (end_col == 0) return;
        end_col -= 1;
    }

    var col = bounds.start_col;
    while (col <= end_col) : (col += 1) {
        const cell = row_cells.get(col);
        try appendSelectedCellText(alloc, out, cell);
    }
}

const SelectedRowBounds = struct {
    start_col: usize,
    end_col: usize,
};

fn canonicalizeSelectedRowBounds(
    row_cells: anytype,
    span: SelectionSpan,
    row_idx: usize,
) ?SelectedRowBounds {
    if (row_cells.len == 0) return null;

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

    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;
        row_cells.len - 1;
    if (end_col >= row_cells.len) end_col = row_cells.len - 1;

    while (end_col >= start_col) {
        const cell = cells[end_col];
        if (!isTrailingBlankCell(cell)) break;
        if (end_col == 0) return;
        end_col -= 1;
    }
    start_col = canonicalizeSpacerBoundary(row_cells, start_col) orelse return null;
    end_col = canonicalizeSpacerBoundary(row_cells, end_col) orelse return null;
    if (end_col < start_col) return null;

    var col = start_col;
    while (col <= end_col) : (col += 1) {
        const cell = cells[col];
        try appendSelectedCellText(alloc, out, cell);
    return .{
        .start_col = start_col,
        .end_col = end_col,
    };
}

fn canonicalizeSpacerBoundary(row_cells: anytype, col: usize) ?usize {
    if (col >= row_cells.len) return null;

    const cell = row_cells.get(col);
    if (cell.raw.wide == .spacer_tail or cell.raw.wide == .spacer_head) {
        if (col == 0) return null;
        return col - 1;
    }

    return col;
}

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

fn appendSelectedCellText(
@@ -621,14 +655,26 @@ fn appendSelectedCellText(
    out: *std.ArrayListUnmanaged(u8),
    cell: anytype,
) !void {
    if (cell.wide == .spacer_tail or cell.wide == .spacer_head) return;
    if (cell.raw.wide == .spacer_tail or cell.raw.wide == .spacer_head) return;

    if (!cell.hasText()) {
    if (!cell.raw.hasText()) {
        try out.append(alloc, ' ');
        return;
    }

    const cp = cell.codepoint();
    try appendCodepoint(alloc, out, cell.raw.codepoint());
    if (cell.raw.hasGrapheme()) {
        for (cell.grapheme) |cp| {
            try appendCodepoint(alloc, out, cp);
        }
    }
}

fn appendCodepoint(
    alloc: std.mem.Allocator,
    out: *std.ArrayListUnmanaged(u8),
    cp: u21,
) !void {
    if (cp == 0) return;

    var utf8_buf: [4]u8 = undefined;
@@ -2444,6 +2490,30 @@ test "extractSelectedText preserves interior spaces while trimming trailing blan
    try std.testing.expectEqualStrings("a b  c  ", text);
}

test "extractSelectedText copies a wide glyph when selection lands on its spacer cell" {
    var term = try vt.Terminal.init(std.testing.allocator, .{
        .cols = 4,
        .rows = 1,
    });
    defer term.deinit();

    term.write("表");
    try term.snapshot();

    const row_cells = term.render_state.row_data.items(.cells)[0];
    try std.testing.expectEqual(.wide, row_cells.get(0).raw.wide);
    try std.testing.expectEqual(.spacer_tail, row_cells.get(1).raw.wide);

    const span = SelectionSpan{
        .start = .{ .col = 1, .row = 0 },
        .end = .{ .col = 1, .row = 0 },
    };
    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("表", text);
}

test "extractSelectedText respects partial first and last rows" {
    var term = try vt.Terminal.init(std.testing.allocator, .{
        .cols = 8,
@@ -2464,6 +2534,31 @@ test "extractSelectedText respects partial first and last rows" {
    try std.testing.expectEqualStrings("bc\nde", text);
}

test "extractSelectedText preserves grapheme clusters from render state" {
    var term = try vt.Terminal.init(std.testing.allocator, .{
        .cols = 4,
        .rows = 1,
    });
    defer term.deinit();

    term.write("e\xcc\x81");
    try term.snapshot();

    const row_cells = term.render_state.row_data.items(.cells)[0];
    const cell = row_cells.get(0);
    try std.testing.expect(cell.raw.hasGrapheme());
    try std.testing.expectEqualSlices(u21, &.{0x0301}, cell.grapheme);

    const span = SelectionSpan{
        .start = .{ .col = 0, .row = 0 },
        .end = .{ .col = 0, .row = 0 },
    };
    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("e\xcc\x81", text);
}

test "extractSelectedText clamps an offscreen end row to visible rows" {
    var term = try vt.Terminal.init(std.testing.allocator, .{
        .cols = 8,