9a329e5a
Fix selection grapheme and wide cell extraction
a73x 2026-04-09 18:10
Commit message
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, |