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`.