a73x

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