bf07276c
Highlight visible text selection
a73x 2026-04-09 18:43
Commit message
src/main.zig
| Old | New | ||
|---|---|---|---|
| @@ -140,6 +140,13 @@ fn runTerminal(alloc: std.mem.Allocator) !void { | |||
| 140 | var keyboard = try wayland_client.Keyboard.init(alloc, conn.globals.seat.?); | 140 | var keyboard = try wayland_client.Keyboard.init(alloc, conn.globals.seat.?); |
| 141 | defer keyboard.deinit(); | 141 | defer keyboard.deinit(); |
| 142 | 142 | ||
| 143 | // === pointer === | ||
| 144 | var pointer = try wayland_client.Pointer.init(alloc, conn.globals.seat.?); | ||
| 145 | defer pointer.deinit(); | ||
| 146 | |||
| 147 | // === selection UI state === | ||
| 148 | var selection: SelectionState = .{}; | ||
| 149 | |||
| 143 | // === clipboard === | 150 | // === clipboard === |
| 144 | const clipboard: ?*wayland_client.Clipboard = if (conn.globals.data_device_manager) |manager| | 151 | const clipboard: ?*wayland_client.Clipboard = if (conn.globals.data_device_manager) |manager| |
| 145 | try wayland_client.Clipboard.init(alloc, conn.display, manager, conn.globals.seat.?) | 152 | try wayland_client.Clipboard.init(alloc, conn.display, manager, conn.globals.seat.?) |
| @@ -238,6 +245,17 @@ fn runTerminal(alloc: std.mem.Allocator) !void { | |||
| 238 | } | 245 | } |
| 239 | } | 246 | } |
| 240 | 247 | ||
| 248 | // Pointer events → update selection state | ||
| 249 | const ptr_cell_w = cell_w / @as(u32, @intCast(geom.buffer_scale)); | ||
| 250 | const ptr_cell_h = cell_h / @as(u32, @intCast(geom.buffer_scale)); | ||
| 251 | for (pointer.event_queue.items) |ev| { | ||
| 252 | handlePointerSelectionEvent(&selection, ev, ptr_cell_w, ptr_cell_h, cols, rows); | ||
| 253 | } | ||
| 254 | if (pointer.event_queue.items.len > 0) { | ||
| 255 | pointer.event_queue.clearRetainingCapacity(); | ||
| 256 | render_pending = true; | ||
| 257 | } | ||
| 258 | |||
| 241 | // Keyboard events → write to pty | 259 | // Keyboard events → write to pty |
| 242 | keyboard.tickRepeat(); | 260 | keyboard.tickRepeat(); |
| 243 | for (keyboard.event_queue.items) |ev| { | 261 | for (keyboard.event_queue.items) |ev| { |
| @@ -315,6 +333,10 @@ fn runTerminal(alloc: std.mem.Allocator) !void { | |||
| 315 | .cell_width = cell_w, | 333 | .cell_width = cell_w, |
| 316 | .cell_height = cell_h, | 334 | .cell_height = cell_h, |
| 317 | }); | 335 | }); |
| 336 | selection.committed = if (selection.committed) |span| clampSelectionSpan(span, cols, rows) else null; | ||
| 337 | selection.active = if (selection.active) |span| clampSelectionSpan(span, cols, rows) else null; | ||
| 338 | selection.anchor = if (selection.anchor) |point| clampGridPoint(point, cols, rows) else null; | ||
| 339 | selection.hover = if (selection.hover) |point| clampGridPoint(point, cols, rows) else null; | ||
| 318 | } else { | 340 | } else { |
| 319 | _ = try ctx.vkd.deviceWaitIdle(ctx.device); | 341 | _ = try ctx.vkd.deviceWaitIdle(ctx.device); |
| 320 | try ctx.recreateSwapchain(buf_w, buf_h); | 342 | try ctx.recreateSwapchain(buf_w, buf_h); |
| @@ -354,6 +376,7 @@ fn runTerminal(alloc: std.mem.Allocator) !void { | |||
| 354 | 376 | ||
| 355 | var rows_rebuilt: usize = 0; | 377 | var rows_rebuilt: usize = 0; |
| 356 | var row_idx: usize = 0; | 378 | var row_idx: usize = 0; |
| 379 | const current_selection = activeSelectionSpan(selection); | ||
| 357 | while (row_idx < term_rows.len) : (row_idx += 1) { | 380 | while (row_idx < term_rows.len) : (row_idx += 1) { |
| 358 | if (!refresh_plan.full_rebuild and !refresh_plan.rows_to_rebuild.isSet(row_idx)) continue; | 381 | if (!refresh_plan.full_rebuild and !refresh_plan.rows_to_rebuild.isSet(row_idx)) continue; |
| 359 | 382 | ||
| @@ -372,6 +395,7 @@ fn runTerminal(alloc: std.mem.Allocator) !void { | |||
| 372 | baseline, | 395 | baseline, |
| 373 | default_bg, | 396 | default_bg, |
| 374 | bg_uv, | 397 | bg_uv, |
| 398 | current_selection, | ||
| 375 | ); | 399 | ); |
| 376 | if (rebuilt.len_changed) { | 400 | if (rebuilt.len_changed) { |
| 377 | render_cache.layout_dirty = true; | 401 | render_cache.layout_dirty = true; |
| @@ -647,11 +671,103 @@ fn appendCodepoint( | |||
| 647 | try out.appendSlice(alloc, utf8_buf[0..utf8_len]); | 671 | try out.appendSlice(alloc, utf8_buf[0..utf8_len]); |
| 648 | } | 672 | } |
| 649 | 673 | ||
| 674 | const BTN_LEFT: u32 = 0x110; | ||
| 675 | |||
| 650 | const GridPoint = struct { | 676 | const GridPoint = struct { |
| 651 | col: u32, | 677 | col: u32, |
| 652 | row: u32, | 678 | row: u32, |
| 653 | }; | 679 | }; |
| 654 | 680 | ||
| 681 | const SelectionState = struct { | ||
| 682 | hover: ?GridPoint = null, | ||
| 683 | anchor: ?GridPoint = null, | ||
| 684 | active: ?SelectionSpan = null, | ||
| 685 | committed: ?SelectionSpan = null, | ||
| 686 | }; | ||
| 687 | |||
| 688 | fn clampGridPoint(point: GridPoint, cols: u16, rows: u16) ?GridPoint { | ||
| 689 | if (cols == 0 or rows == 0) return null; | ||
| 690 | const max_col = @as(u32, cols) - 1; | ||
| 691 | const max_row = @as(u32, rows) - 1; | ||
| 692 | if (point.row > max_row) return null; | ||
| 693 | return .{ | ||
| 694 | .col = @min(point.col, max_col), | ||
| 695 | .row = point.row, | ||
| 696 | }; | ||
| 697 | } | ||
| 698 | |||
| 699 | fn surfacePointToGrid( | ||
| 700 | surface_x: f64, | ||
| 701 | surface_y: f64, | ||
| 702 | cell_w: u32, | ||
| 703 | cell_h: u32, | ||
| 704 | cols: u16, | ||
| 705 | rows: u16, | ||
| 706 | ) ?GridPoint { | ||
| 707 | if (cell_w == 0 or cell_h == 0 or cols == 0 or rows == 0) return null; | ||
| 708 | if (surface_x < 0 or surface_y < 0) return null; | ||
| 709 | const col: u32 = @intFromFloat(@floor(surface_x / @as(f64, @floatFromInt(cell_w)))); | ||
| 710 | const row: u32 = @intFromFloat(@floor(surface_y / @as(f64, @floatFromInt(cell_h)))); | ||
| 711 | if (col >= cols or row >= rows) return null; | ||
| 712 | return .{ .col = col, .row = row }; | ||
| 713 | } | ||
| 714 | |||
| 715 | fn handlePointerSelectionEvent( | ||
| 716 | state: *SelectionState, | ||
| 717 | ev: wayland_client.PointerEvent, | ||
| 718 | cell_w: u32, | ||
| 719 | cell_h: u32, | ||
| 720 | cols: u16, | ||
| 721 | rows: u16, | ||
| 722 | ) void { | ||
| 723 | switch (ev) { | ||
| 724 | .motion => |m| { | ||
| 725 | state.hover = surfacePointToGrid(m.x, m.y, cell_w, cell_h, cols, rows); | ||
| 726 | if (state.anchor) |anchor| { | ||
| 727 | if (state.hover) |hover| { | ||
| 728 | state.active = .{ .start = anchor, .end = hover }; | ||
| 729 | } | ||
| 730 | } | ||
| 731 | }, | ||
| 732 | .button_press => |b| { | ||
| 733 | if (b.button == BTN_LEFT) { | ||
| 734 | if (state.hover) |hover| { | ||
| 735 | state.anchor = hover; | ||
| 736 | state.active = .{ .start = hover, .end = hover }; | ||
| 737 | state.committed = null; | ||
| 738 | } | ||
| 739 | } | ||
| 740 | }, | ||
| 741 | .button_release => |b| { | ||
| 742 | if (b.button == BTN_LEFT) { | ||
| 743 | if (state.active) |span| { | ||
| 744 | state.committed = span; | ||
| 745 | } | ||
| 746 | state.active = null; | ||
| 747 | state.anchor = null; | ||
| 748 | } | ||
| 749 | }, | ||
| 750 | .enter => |e| { | ||
| 751 | state.hover = surfacePointToGrid(e.x, e.y, cell_w, cell_h, cols, rows); | ||
| 752 | }, | ||
| 753 | .leave => { | ||
| 754 | state.hover = null; | ||
| 755 | }, | ||
| 756 | } | ||
| 757 | } | ||
| 758 | |||
| 759 | fn activeSelectionSpan(state: SelectionState) ?SelectionSpan { | ||
| 760 | return state.active orelse state.committed; | ||
| 761 | } | ||
| 762 | |||
| 763 | fn selectionColors(base: vt.CellColors, selected: bool) vt.CellColors { | ||
| 764 | if (!selected) return base; | ||
| 765 | return .{ | ||
| 766 | .fg = .{ 0.08, 0.08, 0.08, 1.0 }, | ||
| 767 | .bg = .{ 0.78, 0.82, 0.88, 1.0 }, | ||
| 768 | }; | ||
| 769 | } | ||
| 770 | |||
| 655 | const SelectionSpan = struct { | 771 | const SelectionSpan = struct { |
| 656 | start: GridPoint, | 772 | start: GridPoint, |
| 657 | end: GridPoint, | 773 | end: GridPoint, |
| @@ -816,6 +932,26 @@ test "clampSelectionSpan preserves a larger span that collapses to one visible c | |||
| 816 | try std.testing.expect(clamped.containsCell(0, 0)); | 932 | try std.testing.expect(clamped.containsCell(0, 0)); |
| 817 | } | 933 | } |
| 818 | 934 | ||
| 935 | test "SelectionState starts drag on left-button press and commits on release" { | ||
| 936 | var state = SelectionState{}; | ||
| 937 | handlePointerSelectionEvent(&state, .{ .motion = .{ .time = 0, .x = 24.0, .y = 16.0 } }, 8, 16, 80, 24); | ||
| 938 | handlePointerSelectionEvent(&state, .{ .button_press = .{ .serial = 0, .time = 0, .button = BTN_LEFT } }, 8, 16, 80, 24); | ||
| 939 | handlePointerSelectionEvent(&state, .{ .motion = .{ .time = 0, .x = 56.0, .y = 16.0 } }, 8, 16, 80, 24); | ||
| 940 | handlePointerSelectionEvent(&state, .{ .button_release = .{ .serial = 0, .time = 0, .button = BTN_LEFT } }, 8, 16, 80, 24); | ||
| 941 | |||
| 942 | try std.testing.expect(state.active == null); | ||
| 943 | try std.testing.expect(state.committed != null); | ||
| 944 | } | ||
| 945 | |||
| 946 | test "selectionColors overrides terminal colors for selected cells" { | ||
| 947 | const selected = selectionColors(.{ | ||
| 948 | .fg = .{ 1.0, 1.0, 1.0, 1.0 }, | ||
| 949 | .bg = .{ 0.0, 0.0, 0.0, 1.0 }, | ||
| 950 | }, true); | ||
| 951 | try std.testing.expectEqualDeep([4]f32{ 0.08, 0.08, 0.08, 1.0 }, selected.fg); | ||
| 952 | try std.testing.expectEqualDeep([4]f32{ 0.78, 0.82, 0.88, 1.0 }, selected.bg); | ||
| 953 | } | ||
| 954 | |||
| 819 | const ComparisonVariant = struct { | 955 | const ComparisonVariant = struct { |
| 820 | label: []const u8, | 956 | label: []const u8, |
| 821 | coverage: [2]f32, | 957 | coverage: [2]f32, |
| @@ -1611,6 +1747,7 @@ test "rebuildRowInstances emits expected instances for a colored glyph row" { | |||
| 1611 | face.baseline(), | 1747 | face.baseline(), |
| 1612 | default_bg, | 1748 | default_bg, |
| 1613 | atlas.cursorUV(), | 1749 | atlas.cursorUV(), |
| 1750 | null, | ||
| 1614 | ); | 1751 | ); |
| 1615 | 1752 | ||
| 1616 | try std.testing.expect(rebuilt.len_changed); | 1753 | try std.testing.expect(rebuilt.len_changed); |
| @@ -1680,6 +1817,7 @@ test "rebuildRowInstances replaces stale cached contents without layout dirtines | |||
| 1680 | face.baseline(), | 1817 | face.baseline(), |
| 1681 | term.backgroundColor(), | 1818 | term.backgroundColor(), |
| 1682 | atlas.cursorUV(), | 1819 | atlas.cursorUV(), |
| 1820 | null, | ||
| 1683 | ); | 1821 | ); |
| 1684 | 1822 | ||
| 1685 | try std.testing.expect(!rebuilt.len_changed); | 1823 | try std.testing.expect(!rebuilt.len_changed); |
| @@ -2016,6 +2154,7 @@ fn rebuildRowInstances( | |||
| 2016 | baseline: u32, | 2154 | baseline: u32, |
| 2017 | default_bg: [4]f32, | 2155 | default_bg: [4]f32, |
| 2018 | bg_uv: font.GlyphUV, | 2156 | bg_uv: font.GlyphUV, |
| 2157 | selection: ?SelectionSpan, | ||
| 2019 | ) !RowRebuildResult { | 2158 | ) !RowRebuildResult { |
| 2020 | const old_len = cache.instances.items.len; | 2159 | const old_len = cache.instances.items.len; |
| 2021 | cache.instances.clearRetainingCapacity(); | 2160 | cache.instances.clearRetainingCapacity(); |
| @@ -2026,7 +2165,9 @@ fn rebuildRowInstances( | |||
| 2026 | var col_idx: u32 = 0; | 2165 | var col_idx: u32 = 0; |
| 2027 | while (col_idx < raw_cells.len) : (col_idx += 1) { | 2166 | while (col_idx < raw_cells.len) : (col_idx += 1) { |
| 2028 | const cp = raw_cells[col_idx].codepoint(); | 2167 | const cp = raw_cells[col_idx].codepoint(); |
| 2029 | const colors = term.cellColors(row_cells.get(col_idx)); | 2168 | const base_colors = term.cellColors(row_cells.get(col_idx)); |
| 2169 | const is_selected = if (selection) |span| span.containsCell(col_idx, row_idx) else false; | ||
| 2170 | const colors = selectionColors(base_colors, is_selected); | ||
| 2030 | const glyph_uv = if (cp == 0 or cp == ' ') | 2171 | const glyph_uv = if (cp == 0 or cp == ' ') |
| 2031 | null | 2172 | null |
| 2032 | else | 2173 | else |