ec887038
Add visible selection copy extraction
a73x 2026-04-09 18:00
Commit message
src/main.zig
| Old | New | ||
|---|---|---|---|
| @@ -524,6 +524,13 @@ fn isClipboardPasteEvent(ev: wayland_client.KeyboardEvent) bool { | |||
| 524 | ev.keysym == c.XKB_KEY_V; | 524 | ev.keysym == c.XKB_KEY_V; |
| 525 | } | 525 | } |
| 526 | 526 | ||
| 527 | fn isClipboardCopyEvent(ev: wayland_client.KeyboardEvent) bool { | ||
| 528 | return ev.action == .press and | ||
| 529 | ev.modifiers.ctrl and | ||
| 530 | ev.modifiers.shift and | ||
| 531 | ev.keysym == c.XKB_KEY_C; | ||
| 532 | } | ||
| 533 | |||
| 527 | fn remainingRepeatTimeoutMs(deadline_ns: ?i128) ?i32 { | 534 | fn remainingRepeatTimeoutMs(deadline_ns: ?i128) ?i32 { |
| 528 | const deadline = deadline_ns orelse return null; | 535 | const deadline = deadline_ns orelse return null; |
| 529 | const now = std.time.nanoTimestamp(); | 536 | const now = std.time.nanoTimestamp(); |
| @@ -541,6 +548,80 @@ fn shouldRenderFrame(terminal_dirty: bool, window_dirty: bool, forced: bool) boo | |||
| 541 | return terminal_dirty or window_dirty or forced; | 548 | return terminal_dirty or window_dirty or forced; |
| 542 | } | 549 | } |
| 543 | 550 | ||
| 551 | fn extractSelectedText( | ||
| 552 | alloc: std.mem.Allocator, | ||
| 553 | row_data: anytype, | ||
| 554 | span: SelectionSpan, | ||
| 555 | ) ![]u8 { | ||
| 556 | const normalized = span.normalized(); | ||
| 557 | var out: std.ArrayListUnmanaged(u8) = .empty; | ||
| 558 | errdefer out.deinit(alloc); | ||
| 559 | |||
| 560 | const start_row: usize = @intCast(normalized.start.row); | ||
| 561 | const end_row: usize = @intCast(normalized.end.row); | ||
| 562 | var row_idx = start_row; | ||
| 563 | while (row_idx <= end_row) : (row_idx += 1) { | ||
| 564 | if (row_idx > start_row) try out.append(alloc, '\n'); | ||
| 565 | try appendSelectedRowText(alloc, &out, row_data[row_idx], normalized, row_idx); | ||
| 566 | } | ||
| 567 | |||
| 568 | return out.toOwnedSlice(alloc); | ||
| 569 | } | ||
| 570 | |||
| 571 | fn appendSelectedRowText( | ||
| 572 | alloc: std.mem.Allocator, | ||
| 573 | out: *std.ArrayListUnmanaged(u8), | ||
| 574 | row_cells: anytype, | ||
| 575 | span: SelectionSpan, | ||
| 576 | row_idx: usize, | ||
| 577 | ) !void { | ||
| 578 | const cells = row_cells.items(.raw); | ||
| 579 | if (cells.len == 0) return; | ||
| 580 | |||
| 581 | const start_col: usize = if (row_idx == span.start.row) | ||
| 582 | @intCast(span.start.col) | ||
| 583 | else | ||
| 584 | 0; | ||
| 585 | if (start_col >= cells.len) return; | ||
| 586 | |||
| 587 | var end_col: usize = if (row_idx == span.end.row) | ||
| 588 | @intCast(span.end.col) | ||
| 589 | else | ||
| 590 | cells.len - 1; | ||
| 591 | if (end_col >= cells.len) end_col = cells.len - 1; | ||
| 592 | |||
| 593 | while (end_col >= start_col) { | ||
| 594 | const cell = cells[end_col]; | ||
| 595 | if (cellContributesVisibleText(cell)) break; | ||
| 596 | if (end_col == 0) return; | ||
| 597 | end_col -= 1; | ||
| 598 | } | ||
| 599 | |||
| 600 | var col = start_col; | ||
| 601 | while (col <= end_col) : (col += 1) { | ||
| 602 | const cell = cells[col]; | ||
| 603 | if (!cellContributesVisibleText(cell)) continue; | ||
| 604 | try appendCellText(alloc, out, cell); | ||
| 605 | } | ||
| 606 | } | ||
| 607 | |||
| 608 | fn cellContributesVisibleText(cell: anytype) bool { | ||
| 609 | return cell.hasText() and cell.wide != .spacer_tail and cell.wide != .spacer_head; | ||
| 610 | } | ||
| 611 | |||
| 612 | fn appendCellText( | ||
| 613 | alloc: std.mem.Allocator, | ||
| 614 | out: *std.ArrayListUnmanaged(u8), | ||
| 615 | cell: anytype, | ||
| 616 | ) !void { | ||
| 617 | const cp = cell.codepoint(); | ||
| 618 | if (cp == 0) return; | ||
| 619 | |||
| 620 | var utf8_buf: [4]u8 = undefined; | ||
| 621 | const utf8_len = try std.unicode.utf8Encode(cp, &utf8_buf); | ||
| 622 | try out.appendSlice(alloc, utf8_buf[0..utf8_len]); | ||
| 623 | } | ||
| 624 | |||
| 544 | const GridPoint = struct { | 625 | const GridPoint = struct { |
| 545 | col: u32, | 626 | col: u32, |
| 546 | row: u32, | 627 | row: u32, |
| @@ -2285,6 +2366,70 @@ test "isClipboardPasteEvent matches Ctrl-Shift-V press" { | |||
| 2285 | })); | 2366 | })); |
| 2286 | } | 2367 | } |
| 2287 | 2368 | ||
| 2369 | test "isClipboardCopyEvent matches Ctrl-Shift-C press" { | ||
| 2370 | try std.testing.expect(isClipboardCopyEvent(.{ | ||
| 2371 | .keysym = c.XKB_KEY_C, | ||
| 2372 | .modifiers = .{ .ctrl = true, .shift = true }, | ||
| 2373 | .action = .press, | ||
| 2374 | .utf8 = [_]u8{0} ** 8, | ||
| 2375 | .utf8_len = 0, | ||
| 2376 | })); | ||
| 2377 | try std.testing.expect(!isClipboardCopyEvent(.{ | ||
| 2378 | .keysym = c.XKB_KEY_C, | ||
| 2379 | .modifiers = .{ .ctrl = true, .shift = true }, | ||
| 2380 | .action = .repeat, | ||
| 2381 | .utf8 = [_]u8{0} ** 8, | ||
| 2382 | .utf8_len = 0, | ||
| 2383 | })); | ||
| 2384 | try std.testing.expect(!isClipboardCopyEvent(.{ | ||
| 2385 | .keysym = c.XKB_KEY_c, | ||
| 2386 | .modifiers = .{ .ctrl = true, .shift = true }, | ||
| 2387 | .action = .press, | ||
| 2388 | .utf8 = [_]u8{0} ** 8, | ||
| 2389 | .utf8_len = 0, | ||
| 2390 | })); | ||
| 2391 | } | ||
| 2392 | |||
| 2393 | test "extractSelectedText trims trailing blanks on each visible row" { | ||
| 2394 | var term = try vt.Terminal.init(std.testing.allocator, .{ | ||
| 2395 | .cols = 8, | ||
| 2396 | .rows = 2, | ||
| 2397 | }); | ||
| 2398 | defer term.deinit(); | ||
| 2399 | |||
| 2400 | term.write("ab\r\nc"); | ||
| 2401 | try term.snapshot(); | ||
| 2402 | |||
| 2403 | const span = SelectionSpan{ | ||
| 2404 | .start = .{ .col = 0, .row = 0 }, | ||
| 2405 | .end = .{ .col = 7, .row = 1 }, | ||
| 2406 | }; | ||
| 2407 | const text = try extractSelectedText(std.testing.allocator, term.render_state.row_data.items(.cells), span); | ||
| 2408 | defer std.testing.allocator.free(text); | ||
| 2409 | |||
| 2410 | try std.testing.expectEqualStrings("ab\nc", text); | ||
| 2411 | } | ||
| 2412 | |||
| 2413 | test "extractSelectedText respects partial first and last rows" { | ||
| 2414 | var term = try vt.Terminal.init(std.testing.allocator, .{ | ||
| 2415 | .cols = 8, | ||
| 2416 | .rows = 2, | ||
| 2417 | }); | ||
| 2418 | defer term.deinit(); | ||
| 2419 | |||
| 2420 | term.write("abc\r\ndef"); | ||
| 2421 | try term.snapshot(); | ||
| 2422 | |||
| 2423 | const span = SelectionSpan{ | ||
| 2424 | .start = .{ .col = 1, .row = 0 }, | ||
| 2425 | .end = .{ .col = 1, .row = 1 }, | ||
| 2426 | }; | ||
| 2427 | const text = try extractSelectedText(std.testing.allocator, term.render_state.row_data.items(.cells), span); | ||
| 2428 | defer std.testing.allocator.free(text); | ||
| 2429 | |||
| 2430 | try std.testing.expectEqualStrings("bc\nde", text); | ||
| 2431 | } | ||
| 2432 | |||
| 2288 | test "drainSelectionPipeThenRoundtrip drains large paste before roundtrip" { | 2433 | test "drainSelectionPipeThenRoundtrip drains large paste before roundtrip" { |
| 2289 | const payload_len: usize = 8192; | 2434 | const payload_len: usize = 8192; |
| 2290 | const payload = try std.testing.allocator.alloc(u8, payload_len); | 2435 | const payload = try std.testing.allocator.alloc(u8, payload_len); |