a73x

9a329e5a

Fix selection grapheme and wide cell extraction

a73x   2026-04-09 18:10

Commit message
Fix selection grapheme and wide cell extraction

src/main.zig
Old New
@@ -583,37 +583,71 @@ fn appendSelectedRowText(
583 span: SelectionSpan, 583 span: SelectionSpan,
584 row_idx: usize, 584 row_idx: usize,
585 ) !void { 585 ) !void {
586 const cells = row_cells.items(.raw); 586 const bounds = canonicalizeSelectedRowBounds(row_cells, span, row_idx) orelse return;
587 if (cells.len == 0) return;
588 587
589 const start_col: usize = if (row_idx == span.start.row) 588 var end_col = bounds.end_col;
589 while (end_col >= bounds.start_col) {
590 const cell = row_cells.get(end_col);
591 if (!isTrailingBlankCell(cell)) break;
592 if (end_col == 0) return;
593 end_col -= 1;
594 }
595
596 var col = bounds.start_col;
597 while (col <= end_col) : (col += 1) {
598 const cell = row_cells.get(col);
599 try appendSelectedCellText(alloc, out, cell);
600 }
601 }
602
603 const SelectedRowBounds = struct {
604 start_col: usize,
605 end_col: usize,
606 };
607
608 fn canonicalizeSelectedRowBounds(
609 row_cells: anytype,
610 span: SelectionSpan,
611 row_idx: usize,
612 ) ?SelectedRowBounds {
613 if (row_cells.len == 0) return null;
614
615 var start_col: usize = if (row_idx == span.start.row)
590 @intCast(span.start.col) 616 @intCast(span.start.col)
591 else 617 else
592 0; 618 0;
593 if (start_col >= cells.len) return; 619 if (start_col >= row_cells.len) return null;
594 620
595 var end_col: usize = if (row_idx == span.end.row) 621 var end_col: usize = if (row_idx == span.end.row)
596 @intCast(span.end.col) 622 @intCast(span.end.col)
597 else 623 else
598 cells.len - 1; 624 row_cells.len - 1;
599 if (end_col >= cells.len) end_col = cells.len - 1; 625 if (end_col >= row_cells.len) end_col = row_cells.len - 1;
600 626
601 while (end_col >= start_col) { 627 start_col = canonicalizeSpacerBoundary(row_cells, start_col) orelse return null;
602 const cell = cells[end_col]; 628 end_col = canonicalizeSpacerBoundary(row_cells, end_col) orelse return null;
603 if (!isTrailingBlankCell(cell)) break; 629 if (end_col < start_col) return null;
604 if (end_col == 0) return;
605 end_col -= 1;
606 }
607 630
608 var col = start_col; 631 return .{
609 while (col <= end_col) : (col += 1) { 632 .start_col = start_col,
610 const cell = cells[col]; 633 .end_col = end_col,
611 try appendSelectedCellText(alloc, out, cell); 634 };
635 }
636
637 fn canonicalizeSpacerBoundary(row_cells: anytype, col: usize) ?usize {
638 if (col >= row_cells.len) return null;
639
640 const cell = row_cells.get(col);
641 if (cell.raw.wide == .spacer_tail or cell.raw.wide == .spacer_head) {
642 if (col == 0) return null;
643 return col - 1;
612 } 644 }
645
646 return col;
613 } 647 }
614 648
615 fn isTrailingBlankCell(cell: anytype) bool { 649 fn isTrailingBlankCell(cell: anytype) bool {
616 return !cell.hasText() and cell.wide != .spacer_tail and cell.wide != .spacer_head; 650 return !cell.raw.hasText() and cell.raw.wide != .spacer_tail and cell.raw.wide != .spacer_head;
617 } 651 }
618 652
619 fn appendSelectedCellText( 653 fn appendSelectedCellText(
@@ -621,14 +655,26 @@ fn appendSelectedCellText(
621 out: *std.ArrayListUnmanaged(u8), 655 out: *std.ArrayListUnmanaged(u8),
622 cell: anytype, 656 cell: anytype,
623 ) !void { 657 ) !void {
624 if (cell.wide == .spacer_tail or cell.wide == .spacer_head) return; 658 if (cell.raw.wide == .spacer_tail or cell.raw.wide == .spacer_head) return;
625 659
626 if (!cell.hasText()) { 660 if (!cell.raw.hasText()) {
627 try out.append(alloc, ' '); 661 try out.append(alloc, ' ');
628 return; 662 return;
629 } 663 }
630 664
631 const cp = cell.codepoint(); 665 try appendCodepoint(alloc, out, cell.raw.codepoint());
666 if (cell.raw.hasGrapheme()) {
667 for (cell.grapheme) |cp| {
668 try appendCodepoint(alloc, out, cp);
669 }
670 }
671 }
672
673 fn appendCodepoint(
674 alloc: std.mem.Allocator,
675 out: *std.ArrayListUnmanaged(u8),
676 cp: u21,
677 ) !void {
632 if (cp == 0) return; 678 if (cp == 0) return;
633 679
634 var utf8_buf: [4]u8 = undefined; 680 var utf8_buf: [4]u8 = undefined;
@@ -2444,6 +2490,30 @@ test "extractSelectedText preserves interior spaces while trimming trailing blan
2444 try std.testing.expectEqualStrings("a b c ", text); 2490 try std.testing.expectEqualStrings("a b c ", text);
2445 } 2491 }
2446 2492
2493 test "extractSelectedText copies a wide glyph when selection lands on its spacer cell" {
2494 var term = try vt.Terminal.init(std.testing.allocator, .{
2495 .cols = 4,
2496 .rows = 1,
2497 });
2498 defer term.deinit();
2499
2500 term.write("表");
2501 try term.snapshot();
2502
2503 const row_cells = term.render_state.row_data.items(.cells)[0];
2504 try std.testing.expectEqual(.wide, row_cells.get(0).raw.wide);
2505 try std.testing.expectEqual(.spacer_tail, row_cells.get(1).raw.wide);
2506
2507 const span = SelectionSpan{
2508 .start = .{ .col = 1, .row = 0 },
2509 .end = .{ .col = 1, .row = 0 },
2510 };
2511 const text = try extractSelectedText(std.testing.allocator, term.render_state.row_data.items(.cells), span);
2512 defer std.testing.allocator.free(text);
2513
2514 try std.testing.expectEqualStrings("表", text);
2515 }
2516
2447 test "extractSelectedText respects partial first and last rows" { 2517 test "extractSelectedText respects partial first and last rows" {
2448 var term = try vt.Terminal.init(std.testing.allocator, .{ 2518 var term = try vt.Terminal.init(std.testing.allocator, .{
2449 .cols = 8, 2519 .cols = 8,
@@ -2464,6 +2534,31 @@ test "extractSelectedText respects partial first and last rows" {
2464 try std.testing.expectEqualStrings("bc\nde", text); 2534 try std.testing.expectEqualStrings("bc\nde", text);
2465 } 2535 }
2466 2536
2537 test "extractSelectedText preserves grapheme clusters from render state" {
2538 var term = try vt.Terminal.init(std.testing.allocator, .{
2539 .cols = 4,
2540 .rows = 1,
2541 });
2542 defer term.deinit();
2543
2544 term.write("e\xcc\x81");
2545 try term.snapshot();
2546
2547 const row_cells = term.render_state.row_data.items(.cells)[0];
2548 const cell = row_cells.get(0);
2549 try std.testing.expect(cell.raw.hasGrapheme());
2550 try std.testing.expectEqualSlices(u21, &.{0x0301}, cell.grapheme);
2551
2552 const span = SelectionSpan{
2553 .start = .{ .col = 0, .row = 0 },
2554 .end = .{ .col = 0, .row = 0 },
2555 };
2556 const text = try extractSelectedText(std.testing.allocator, term.render_state.row_data.items(.cells), span);
2557 defer std.testing.allocator.free(text);
2558
2559 try std.testing.expectEqualStrings("e\xcc\x81", text);
2560 }
2561
2467 test "extractSelectedText clamps an offscreen end row to visible rows" { 2562 test "extractSelectedText clamps an offscreen end row to visible rows" {
2468 var term = try vt.Terminal.init(std.testing.allocator, .{ 2563 var term = try vt.Terminal.init(std.testing.allocator, .{
2469 .cols = 8, 2564 .cols = 8,