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,