a73x

docs/superpowers/plans/2026-04-09-visible-selection-implementation.md

Ref:   Size: 19.8 KiB

# Visible Selection Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Add XTerm-style visible-grid text selection so users can drag-highlight text with the left mouse button and copy it with `Ctrl+Shift+C`.

**Architecture:** Keep selection as UI state in `src/main.zig`, sourced from visible-grid coordinates derived from Wayland pointer events. Extend `src/wayland.zig` to expose pointer events and clipboard ownership, then thread the normalized selection span into existing row rebuild and copy paths without moving selection logic into the VT wrapper.

**Tech Stack:** Zig 0.15, Wayland client bindings, `ghostty-vt`, existing `src/main.zig` render cache path, existing `src/wayland.zig` clipboard receive path

---

## File Structure

- Modify: `src/wayland.zig`
  - Add pointer setup/event queue and clipboard-source support for serving copied UTF-8 text.
- Modify: `src/main.zig`
  - Add visible selection state, pointer-to-grid mapping, selection-aware highlighting, copy extraction, and `Ctrl+Shift+C` copy handling.
- Modify: `src/vt.zig`
  - Only if needed to expose a tiny helper for extracting printable text from visible render cells.
- Test: `src/wayland.zig`
  - Add coverage for pointer queue primitives or clipboard-source helper logic that can be tested without a compositor.
- Test: `src/main.zig`
  - Add coverage for selection normalization, inclusion checks, visible-text extraction, copy shortcut detection, and resize clamping behavior.

### Task 1: Add selection primitives and tests in `main.zig`

**Files:**
- Modify: `src/main.zig`
- Test: `src/main.zig`

- [ ] **Step 1: Write the failing selection-helper tests**

Add these tests near the existing pure-function tests in `src/main.zig`:

```zig
test "SelectionSpan.normalized orders endpoints in reading order" {
    const span = SelectionSpan{
        .start = .{ .col = 7, .row = 4 },
        .end = .{ .col = 2, .row = 1 },
    }.normalized();

    try std.testing.expectEqual(@as(u32, 2), span.start.col);
    try std.testing.expectEqual(@as(u32, 1), span.start.row);
    try std.testing.expectEqual(@as(u32, 7), span.end.col);
    try std.testing.expectEqual(@as(u32, 4), span.end.row);
}

test "SelectionSpan.containsCell includes the normalized endpoints" {
    const span = SelectionSpan{
        .start = .{ .col = 3, .row = 2 },
        .end = .{ .col = 1, .row = 1 },
    };

    try std.testing.expect(span.containsCell(1, 1));
    try std.testing.expect(span.containsCell(2, 1));
    try std.testing.expect(span.containsCell(0, 2));
    try std.testing.expect(span.containsCell(3, 2));
    try std.testing.expect(!span.containsCell(0, 0));
    try std.testing.expect(!span.containsCell(4, 2));
}

test "clampSelectionSpan clears fully offscreen spans and trims resized spans" {
    try std.testing.expect(clampSelectionSpan(.{
        .start = .{ .col = 5, .row = 30 },
        .end = .{ .col = 10, .row = 40 },
    }, 80, 24) == null);

    const clamped = clampSelectionSpan(.{
        .start = .{ .col = 78, .row = 22 },
        .end = .{ .col = 99, .row = 30 },
    }, 80, 24).?;

    try std.testing.expectEqual(@as(u32, 78), clamped.start.col);
    try std.testing.expectEqual(@as(u32, 22), clamped.start.row);
    try std.testing.expectEqual(@as(u32, 79), clamped.end.col);
    try std.testing.expectEqual(@as(u32, 23), clamped.end.row);
}

test "clampSelectionSpan preserves a larger span that collapses to one visible cell" {
    const clamped = clampSelectionSpan(.{
        .start = .{ .col = 0, .row = 0 },
        .end = .{ .col = 120, .row = 80 },
    }, 1, 1).?;

    try std.testing.expectEqual(@as(u32, 0), clamped.start.col);
    try std.testing.expectEqual(@as(u32, 0), clamped.start.row);
    try std.testing.expectEqual(@as(u32, 0), clamped.end.col);
    try std.testing.expectEqual(@as(u32, 0), clamped.end.row);
    try std.testing.expect(clamped.containsCell(0, 0));
}
```

- [ ] **Step 2: Run test to verify it fails**

Run: `zig build test --summary all`
Expected:
- FAIL because `SelectionSpan` and `clampSelectionSpan` do not exist yet.

- [ ] **Step 3: Implement the minimal selection primitives**

Add these helpers in `src/main.zig` near the other render-loop helper structs/functions:

```zig
const GridPoint = struct {
    col: u32,
    row: u32,
};

const SelectionSpan = struct {
    start: GridPoint,
    end: GridPoint,

    fn normalized(self: SelectionSpan) SelectionSpan {
        if (self.start.row < self.end.row) return self;
        if (self.start.row == self.end.row and self.start.col <= self.end.col) return self;
        return .{ .start = self.end, .end = self.start };
    }

    fn containsCell(self: SelectionSpan, col: u32, row: u32) bool {
        const span = self.normalized();
        if (row < span.start.row or row > span.end.row) return false;
        if (span.start.row == span.end.row) {
            return row == span.start.row and col >= span.start.col and col <= span.end.col;
        }
        if (row == span.start.row) return col >= span.start.col;
        if (row == span.end.row) return col <= span.end.col;
        return true;
    }
};

fn clampSelectionSpan(span: SelectionSpan, cols: u16, rows: u16) ?SelectionSpan {
    if (cols == 0 or rows == 0) return null;
    const max_col = @as(u32, cols) - 1;
    const max_row = @as(u32, rows) - 1;
    const normalized = span.normalized();
    if (normalized.start.row > max_row) return null;
    if (normalized.start.row == normalized.end.row and normalized.start.col > max_col) return null;
    return SelectionSpan{
        .start = .{
            .col = @min(normalized.start.col, max_col),
            .row = @min(normalized.start.row, max_row),
        },
        .end = .{
            .col = @min(normalized.end.col, max_col),
            .row = @min(normalized.end.row, max_row),
        },
    };
}
```

- [ ] **Step 4: Run test to verify it passes**

Run: `zig build test --summary all`
Expected:
- PASS for the new selection-helper tests.

Note:
- `SelectionSpan` is an inclusive visible span and may legitimately collapse to one visible cell after resize.
- Click-without-drag emptiness will be handled later by `SelectionState` in Task 4 rather than by the span primitive itself.

- [ ] **Step 5: Commit**

```bash
git add src/main.zig
git commit -m "Add visible selection helpers"
```

### Task 2: Add visible-text extraction and copy shortcut coverage

**Files:**
- Modify: `src/main.zig`
- Modify: `src/vt.zig`
- Test: `src/main.zig`

- [ ] **Step 1: Write the failing extraction and copy tests**

Add the shortcut test next to `isClipboardPasteEvent`, and add focused extraction tests:

```zig
test "isClipboardCopyEvent matches Ctrl-Shift-C press" {
    try std.testing.expect(isClipboardCopyEvent(.{
        .keysym = c.XKB_KEY_C,
        .modifiers = .{ .ctrl = true, .shift = true },
        .action = .press,
        .utf8 = [_]u8{0} ** 8,
        .utf8_len = 0,
    }));
    try std.testing.expect(!isClipboardCopyEvent(.{
        .keysym = c.XKB_KEY_C,
        .modifiers = .{ .ctrl = true, .shift = true },
        .action = .repeat,
        .utf8 = [_]u8{0} ** 8,
        .utf8_len = 0,
    }));
}
```

```zig
test "extractSelectedText trims trailing blanks on each visible row" {
    var term = try vt.Terminal.init(std.testing.allocator, .{ .cols = 6, .rows = 2 });
    defer term.deinit();

    term.write("abc   \r\ndef   ");
    try term.snapshot();

    const text = try extractSelectedText(
        std.testing.allocator,
        &term.render_state.row_data,
        SelectionSpan{
            .start = .{ .col = 0, .row = 0 },
            .end = .{ .col = 5, .row = 1 },
        },
    );
    defer std.testing.allocator.free(text);

    try std.testing.expectEqualStrings("abc\ndef", text);
}

test "extractSelectedText respects partial first and last rows" {
    var term = try vt.Terminal.init(std.testing.allocator, .{ .cols = 6, .rows = 2 });
    defer term.deinit();

    term.write("abcdef\r\nuvwxyz");
    try term.snapshot();

    const text = try extractSelectedText(
        std.testing.allocator,
        &term.render_state.row_data,
        SelectionSpan{
            .start = .{ .col = 2, .row = 0 },
            .end = .{ .col = 3, .row = 1 },
        },
    );
    defer std.testing.allocator.free(text);

    try std.testing.expectEqualStrings("cdef\nuvwx", text);
}
```

- [ ] **Step 2: Run test to verify it fails**

Run: `zig build test --summary all`
Expected:
- FAIL because `isClipboardCopyEvent` and `extractSelectedText` do not exist yet, or because cell text extraction is not wired.

- [ ] **Step 3: Implement minimal visible-text extraction**

In `src/main.zig`, add:

```zig
fn isClipboardCopyEvent(ev: wayland_client.KeyboardEvent) bool {
    return ev.action == .press and
        ev.modifiers.ctrl and
        ev.modifiers.shift and
        ev.keysym == c.XKB_KEY_C;
}
```

Add a row/cell extraction path that walks `term.render_state.row_data` in reading order:

```zig
fn extractSelectedText(
    alloc: std.mem.Allocator,
    row_data: anytype,
    span: SelectionSpan,
) ![]u8 {
    const normalized = span.normalized();
    var out = std.ArrayList(u8).empty;
    defer out.deinit(alloc);

    const rows = row_data.items(.cells);
    var row_idx = normalized.start.row;
    while (row_idx <= normalized.end.row and row_idx < rows.len) : (row_idx += 1) {
        const row = rows[row_idx];
        const first_col: u32 = if (row_idx == normalized.start.row) normalized.start.col else 0;
        const last_col: u32 = if (row_idx == normalized.end.row) normalized.end.col else @intCast(row.items(.raw).len - 1);
        try appendSelectedRowText(alloc, &out, row, first_col, last_col);
        if (row_idx != normalized.end.row) try out.append(alloc, '\n');
    }

    return try out.toOwnedSlice(alloc);
}
```

If `src/vt.zig` needs a helper to expose printable text for a render-state cell, add only that helper, for example:

```zig
pub fn cellCodepoint(cell: ghostty_vt.RenderState.Cell) u21 {
    return cell.raw.codepoint();
}
```

- [ ] **Step 4: Run test to verify it passes**

Run: `zig build test --summary all`
Expected:
- PASS for the new copy-shortcut and selected-text extraction tests.

- [ ] **Step 5: Commit**

```bash
git add src/main.zig src/vt.zig
git commit -m "Add visible selection copy extraction"
```

### Task 3: Add Wayland pointer events and clipboard ownership plumbing

**Files:**
- Modify: `src/wayland.zig`
- Test: `src/wayland.zig`

- [ ] **Step 1: Write the failing Wayland helper tests**

Add focused tests for logic that can run without a compositor:

```zig
test "clampSurfacePointToGrid caps pointer coordinates to visible cells" {
    const point = clampSurfacePointToGrid(9999.0, 9999.0, 8, 16, 80, 24).?;
    try std.testing.expectEqual(@as(u32, 79), point.col);
    try std.testing.expectEqual(@as(u32, 23), point.row);
}

test "ClipboardSelection.init stores offered UTF-8 bytes" {
    const selection = ClipboardSelection.init("hello");
    try std.testing.expectEqualStrings("hello", selection.text);
}
```

- [ ] **Step 2: Run test to verify it fails**

Run: `zig build test --summary all`
Expected:
- FAIL because pointer/grid helpers and clipboard-source state do not exist yet.

- [ ] **Step 3: Implement pointer queue and clipboard source support**

In `src/wayland.zig`, add pointer types alongside `KeyboardEvent`:

```zig
pub const PointerEvent = union(enum) {
    enter: struct { surface_x: f64, surface_y: f64 },
    leave: void,
    motion: struct { surface_x: f64, surface_y: f64 },
    button_press: struct { button: u32 },
    button_release: struct { button: u32 },
};
```

Add a `Pointer` wrapper parallel to `Keyboard`:

```zig
pub const Pointer = struct {
    alloc: std.mem.Allocator,
    wl_pointer: *wl.Pointer,
    event_queue: std.ArrayList(PointerEvent),

    pub fn init(alloc: std.mem.Allocator, seat: *wl.Seat) !*Pointer { ... }
    pub fn deinit(self: *Pointer) void { ... }
};
```

Register the listener with `seat.getPointer()` and append `.enter`, `.leave`, `.motion`, `.button_press`, and `.button_release` events in `pointerListener`.

Extend `Clipboard` to hold source-side state and export text:

```zig
const ClipboardSelection = struct {
    text: []const u8,

    fn init(text: []const u8) ClipboardSelection {
        return .{ .text = text };
    }
};
```

Add a setter on `Clipboard`:

```zig
pub fn setSelectionText(self: *Clipboard, text: []const u8) !void { ... }
```

That method should:
- duplicate the copied text into owned storage
- create a `wl_data_source`
- offer `text/plain;charset=utf-8` and `text/plain`
- install a listener that writes the stored bytes to the requester fd on `.send`
- call `data_device.setSelection(...)`
- clean up the previous source on replacement or cancellation

- [ ] **Step 4: Run test to verify it passes**

Run: `zig build test --summary all`
Expected:
- PASS for the new pure/helper tests in `src/wayland.zig`.

- [ ] **Step 5: Commit**

```bash
git add src/wayland.zig
git commit -m "Add Wayland pointer and clipboard source support"
```

### Task 4: Wire pointer-driven visible selection into the main loop and rendering

**Files:**
- Modify: `src/main.zig`
- Modify: `src/wayland.zig`
- Test: `src/main.zig`

- [ ] **Step 1: Write the failing interaction/render tests**

Add tests for the state machine and selected color override:

```zig
test "SelectionState starts drag on left-button press and commits on release" {
    var state = SelectionState{};
    handlePointerSelectionEvent(&state, .{ .motion = .{ .surface_x = 24.0, .surface_y = 16.0 } }, 8, 16, 80, 24);
    handlePointerSelectionEvent(&state, .{ .button_press = .{ .button = BTN_LEFT } }, 8, 16, 80, 24);
    handlePointerSelectionEvent(&state, .{ .motion = .{ .surface_x = 56.0, .surface_y = 16.0 } }, 8, 16, 80, 24);
    handlePointerSelectionEvent(&state, .{ .button_release = .{ .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);
}
```

- [ ] **Step 2: Run test to verify it fails**

Run: `zig build test --summary all`
Expected:
- FAIL because `SelectionState`, `handlePointerSelectionEvent`, and `selectionColors` do not exist yet.

- [ ] **Step 3: Implement pointer-driven selection and selection-aware rendering**

In `src/main.zig`, add UI state:

```zig
const SelectionState = struct {
    hover: ?GridPoint = null,
    anchor: ?GridPoint = null,
    active: ?SelectionSpan = null,
    committed: ?SelectionSpan = null,
};
```

Create a small helper that maps pointer coordinates to cells using surface-space cell dimensions:

```zig
fn surfacePointToGrid(
    surface_x: f64,
    surface_y: f64,
    cell_w: u32,
    cell_h: u32,
    cols: u16,
    rows: u16,
) ?GridPoint { ... }
```

Drain the pointer queue in the main loop after `dispatchPending()` and before rendering:

```zig
for (pointer.event_queue.items) |ev| {
    handlePointerSelectionEvent(&selection, ev, surf_cell_w, surf_cell_h, cols, rows);
}
pointer.event_queue.clearRetainingCapacity();
```

Thread the current committed-or-active span into row rebuilds:

```zig
const current_selection = activeSelectionSpan(selection, cols, rows);
```

Update `rebuildRowInstances` to accept `selection: ?SelectionSpan` and use `selectionColors(...)` when `selection.containsCell(col_idx, row_idx)` is true before calling `appendCellInstances`.

On grid resize:

```zig
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;
```

- [ ] **Step 4: Run test to verify it passes**

Run: `zig build test --summary all`
Expected:
- PASS for the selection interaction and render-color tests.

- [ ] **Step 5: Commit**

```bash
git add src/main.zig src/wayland.zig
git commit -m "Highlight visible text selection"
```

### Task 5: Connect `Ctrl+Shift+C` to Wayland clipboard export

**Files:**
- Modify: `src/main.zig`
- Modify: `src/wayland.zig`
- Test: `src/main.zig`

- [ ] **Step 1: Write the failing copy integration test**

Add a small testable helper around the copy path:

```zig
test "copySelection returns false for empty visible selection" {
    var term = try vt.Terminal.init(std.testing.allocator, .{ .cols = 4, .rows = 1 });
    defer term.deinit();

    try std.testing.expect(!try copySelectionText(
        std.testing.allocator,
        null,
        &term,
        null,
    ));
}
```

- [ ] **Step 2: Run test to verify it fails**

Run: `zig build test --summary all`
Expected:
- FAIL because the copy helper does not exist yet.

- [ ] **Step 3: Implement the explicit copy path**

In `src/main.zig`, factor the copy path into a helper:

```zig
fn copySelectionText(
    alloc: std.mem.Allocator,
    clipboard: ?*wayland_client.Clipboard,
    term: *vt.Terminal,
    selection: ?SelectionSpan,
) !bool {
    const cb = clipboard orelse return false;
    const span = selection orelse return false;
    const text = try extractSelectedText(alloc, &term.render_state.row_data, span);
    defer alloc.free(text);
    if (text.len == 0) return false;
    try cb.setSelectionText(text);
    return true;
}
```

Wire it into keyboard handling after `term.snapshot()` has produced current visible rows:

```zig
if (isClipboardCopyEvent(ev)) {
    _ = try copySelectionText(alloc, clipboard, term, activeSelectionSpan(selection, cols, rows));
    continue;
}
```

Keep the existing `Ctrl+Shift+V` paste path unchanged.

- [ ] **Step 4: Run test to verify it passes**

Run: `zig build test --summary all`
Expected:
- PASS for the new empty-selection copy helper test and all prior tests.

- [ ] **Step 5: Commit**

```bash
git add src/main.zig src/wayland.zig
git commit -m "Copy visible selection to clipboard"
```

### Task 6: Full verification

**Files:**
- Modify: none
- Test: `src/main.zig`, `src/wayland.zig`, `src/vt.zig`

- [ ] **Step 1: Run the full test suite**

Run: `rm -rf /tmp/zig-global-cache-selection-plan && cp -a /home/xanderle/.cache/zig /tmp/zig-global-cache-selection-plan && ZIG_GLOBAL_CACHE_DIR=/tmp/zig-global-cache-selection-plan zig build test --summary all`
Expected:
- PASS

- [ ] **Step 2: Run a build verification**

Run: `rm -rf /tmp/zig-global-cache-selection-build && cp -a /home/xanderle/.cache/zig /tmp/zig-global-cache-selection-build && ZIG_GLOBAL_CACHE_DIR=/tmp/zig-global-cache-selection-build zig build`
Expected:
- PASS

- [ ] **Step 3: Run a manual Wayland verification**

Run: `zig build run`
Expected:
- Left-button drag highlights visible text.
- Right-to-left drag normalizes correctly.
- `Ctrl+Shift+C` copies the highlighted text.
- `Ctrl+Shift+V` paste still works.
- Resize does not crash or leave out-of-bounds selection state.

- [ ] **Step 4: Commit**

```bash
git add src/main.zig src/wayland.zig src/vt.zig
git commit -m "Verify visible text selection flow"
```

## Self-Review

- Spec coverage:
  - Visible-grid drag selection: Task 4
  - Persistent highlight after release: Task 4
  - `Ctrl+Shift+C` clipboard export: Task 5
  - Wayland clipboard ownership: Task 3
  - Resize-safe behavior: Task 1 and Task 4
  - Tests and manual verification: Tasks 1, 2, 3, 4, 5, and 6
- Placeholder scan:
  - No `TODO`, `TBD`, or deferred “figure it out later” steps remain.
- Type consistency:
  - The plan consistently uses `GridPoint`, `SelectionSpan`, `SelectionState`, `extractSelectedText`, `isClipboardCopyEvent`, and `setSelectionText`.