a73x

bf07276c

Highlight visible text selection

a73x   2026-04-09 18:43

Commit message
Highlight visible text selection

Adds pointer-driven selection state machine (SelectionState,
handlePointerSelectionEvent) and selection-aware rendering via
selectionColors; wires Pointer into runTerminal and drains the event
queue each frame.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

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