bf07276c
Highlight visible text selection
a73x 2026-04-09 18:43
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>
diff --git a/src/main.zig b/src/main.zig index dd823a7..94fafe6 100644 --- a/src/main.zig +++ b/src/main.zig @@ -140,6 +140,13 @@ fn runTerminal(alloc: std.mem.Allocator) !void { var keyboard = try wayland_client.Keyboard.init(alloc, conn.globals.seat.?); defer keyboard.deinit(); // === pointer === var pointer = try wayland_client.Pointer.init(alloc, conn.globals.seat.?); defer pointer.deinit(); // === selection UI state === var selection: SelectionState = .{}; // === clipboard === const clipboard: ?*wayland_client.Clipboard = if (conn.globals.data_device_manager) |manager| try wayland_client.Clipboard.init(alloc, conn.display, manager, conn.globals.seat.?) @@ -238,6 +245,17 @@ fn runTerminal(alloc: std.mem.Allocator) !void { } } // Pointer events → update selection state const ptr_cell_w = cell_w / @as(u32, @intCast(geom.buffer_scale)); const ptr_cell_h = cell_h / @as(u32, @intCast(geom.buffer_scale)); for (pointer.event_queue.items) |ev| { handlePointerSelectionEvent(&selection, ev, ptr_cell_w, ptr_cell_h, cols, rows); } if (pointer.event_queue.items.len > 0) { pointer.event_queue.clearRetainingCapacity(); render_pending = true; } // Keyboard events → write to pty keyboard.tickRepeat(); for (keyboard.event_queue.items) |ev| { @@ -315,6 +333,10 @@ fn runTerminal(alloc: std.mem.Allocator) !void { .cell_width = cell_w, .cell_height = cell_h, }); selection.committed = if (selection.committed) |span| clampSelectionSpan(span, cols, rows) else null; selection.active = if (selection.active) |span| clampSelectionSpan(span, cols, rows) else null; selection.anchor = if (selection.anchor) |point| clampGridPoint(point, cols, rows) else null; selection.hover = if (selection.hover) |point| clampGridPoint(point, cols, rows) else null; } else { _ = try ctx.vkd.deviceWaitIdle(ctx.device); try ctx.recreateSwapchain(buf_w, buf_h); @@ -354,6 +376,7 @@ fn runTerminal(alloc: std.mem.Allocator) !void { var rows_rebuilt: usize = 0; var row_idx: usize = 0; const current_selection = activeSelectionSpan(selection); while (row_idx < term_rows.len) : (row_idx += 1) { if (!refresh_plan.full_rebuild and !refresh_plan.rows_to_rebuild.isSet(row_idx)) continue; @@ -372,6 +395,7 @@ fn runTerminal(alloc: std.mem.Allocator) !void { baseline, default_bg, bg_uv, current_selection, ); if (rebuilt.len_changed) { render_cache.layout_dirty = true; @@ -647,11 +671,103 @@ fn appendCodepoint( try out.appendSlice(alloc, utf8_buf[0..utf8_len]); } const BTN_LEFT: u32 = 0x110; const GridPoint = struct { col: u32, row: u32, }; const SelectionState = struct { hover: ?GridPoint = null, anchor: ?GridPoint = null, active: ?SelectionSpan = null, committed: ?SelectionSpan = null, }; fn clampGridPoint(point: GridPoint, cols: u16, rows: u16) ?GridPoint { if (cols == 0 or rows == 0) return null; const max_col = @as(u32, cols) - 1; const max_row = @as(u32, rows) - 1; if (point.row > max_row) return null; return .{ .col = @min(point.col, max_col), .row = point.row, }; } fn surfacePointToGrid( surface_x: f64, surface_y: f64, cell_w: u32, cell_h: u32, cols: u16, rows: u16, ) ?GridPoint { if (cell_w == 0 or cell_h == 0 or cols == 0 or rows == 0) return null; if (surface_x < 0 or surface_y < 0) return null; const col: u32 = @intFromFloat(@floor(surface_x / @as(f64, @floatFromInt(cell_w)))); const row: u32 = @intFromFloat(@floor(surface_y / @as(f64, @floatFromInt(cell_h)))); if (col >= cols or row >= rows) return null; return .{ .col = col, .row = row }; } fn handlePointerSelectionEvent( state: *SelectionState, ev: wayland_client.PointerEvent, cell_w: u32, cell_h: u32, cols: u16, rows: u16, ) void { switch (ev) { .motion => |m| { state.hover = surfacePointToGrid(m.x, m.y, cell_w, cell_h, cols, rows); if (state.anchor) |anchor| { if (state.hover) |hover| { state.active = .{ .start = anchor, .end = hover }; } } }, .button_press => |b| { if (b.button == BTN_LEFT) { if (state.hover) |hover| { state.anchor = hover; state.active = .{ .start = hover, .end = hover }; state.committed = null; } } }, .button_release => |b| { if (b.button == BTN_LEFT) { if (state.active) |span| { state.committed = span; } state.active = null; state.anchor = null; } }, .enter => |e| { state.hover = surfacePointToGrid(e.x, e.y, cell_w, cell_h, cols, rows); }, .leave => { state.hover = null; }, } } fn activeSelectionSpan(state: SelectionState) ?SelectionSpan { return state.active orelse state.committed; } fn selectionColors(base: vt.CellColors, selected: bool) vt.CellColors { if (!selected) return base; return .{ .fg = .{ 0.08, 0.08, 0.08, 1.0 }, .bg = .{ 0.78, 0.82, 0.88, 1.0 }, }; } const SelectionSpan = struct { start: GridPoint, end: GridPoint, @@ -816,6 +932,26 @@ test "clampSelectionSpan preserves a larger span that collapses to one visible c try std.testing.expect(clamped.containsCell(0, 0)); } test "SelectionState starts drag on left-button press and commits on release" { var state = SelectionState{}; handlePointerSelectionEvent(&state, .{ .motion = .{ .time = 0, .x = 24.0, .y = 16.0 } }, 8, 16, 80, 24); handlePointerSelectionEvent(&state, .{ .button_press = .{ .serial = 0, .time = 0, .button = BTN_LEFT } }, 8, 16, 80, 24); handlePointerSelectionEvent(&state, .{ .motion = .{ .time = 0, .x = 56.0, .y = 16.0 } }, 8, 16, 80, 24); handlePointerSelectionEvent(&state, .{ .button_release = .{ .serial = 0, .time = 0, .button = BTN_LEFT } }, 8, 16, 80, 24); try std.testing.expect(state.active == null); try std.testing.expect(state.committed != null); } test "selectionColors overrides terminal colors for selected cells" { const selected = selectionColors(.{ .fg = .{ 1.0, 1.0, 1.0, 1.0 }, .bg = .{ 0.0, 0.0, 0.0, 1.0 }, }, true); try std.testing.expectEqualDeep([4]f32{ 0.08, 0.08, 0.08, 1.0 }, selected.fg); try std.testing.expectEqualDeep([4]f32{ 0.78, 0.82, 0.88, 1.0 }, selected.bg); } const ComparisonVariant = struct { label: []const u8, coverage: [2]f32, @@ -1611,6 +1747,7 @@ test "rebuildRowInstances emits expected instances for a colored glyph row" { face.baseline(), default_bg, atlas.cursorUV(), null, ); try std.testing.expect(rebuilt.len_changed); @@ -1680,6 +1817,7 @@ test "rebuildRowInstances replaces stale cached contents without layout dirtines face.baseline(), term.backgroundColor(), atlas.cursorUV(), null, ); try std.testing.expect(!rebuilt.len_changed); @@ -2016,6 +2154,7 @@ fn rebuildRowInstances( baseline: u32, default_bg: [4]f32, bg_uv: font.GlyphUV, selection: ?SelectionSpan, ) !RowRebuildResult { const old_len = cache.instances.items.len; cache.instances.clearRetainingCapacity(); @@ -2026,7 +2165,9 @@ fn rebuildRowInstances( var col_idx: u32 = 0; while (col_idx < raw_cells.len) : (col_idx += 1) { const cp = raw_cells[col_idx].codepoint(); const colors = term.cellColors(row_cells.get(col_idx)); const base_colors = term.cellColors(row_cells.get(col_idx)); const is_selected = if (selection) |span| span.containsCell(col_idx, row_idx) else false; const colors = selectionColors(base_colors, is_selected); const glyph_uv = if (cp == 0 or cp == ' ') null else