a73x

b0a68b12

refactor: share terminal selection gestures with native clients

a73x   2026-09-06 07:03

Commit message
refactor: share terminal selection gestures with native clients

CLAUDE.md
Old New
@@ -97,9 +97,9 @@ a symbol by its FILE stem (`wall_pump.askOn`) — a file, not a module.
97 |---|---| 97 |---|---|
98 | `src/engine/` | `term`(`term.zig`) — `protocol` `replica` `grid` · `engine`(`engine.zig`) — `delta` — the daemon's ghostty-vt; no client row imports it outside a test | 98 | `src/engine/` | `term`(`term.zig`) — `protocol` `replica` `grid` · `engine`(`engine.zig`) — `delta` — the daemon's ghostty-vt; no client row imports it outside a test |
99 | `src/server/` | `daemon`(`server.zig`) — `server_agent` `server_sessions` `cmd` `shellint` `quic_server` `upgrade` `server_test_*` · `pty` | 99 | `src/server/` | `daemon`(`server.zig`) — `server_agent` `server_sessions` `cmd` `shellint` `quic_server` `upgrade` `server_test_*` · `pty` |
100 | `src/client/` | `client` — `client_core` `hosts` `handoff` `layout` `keymap` `askpass` `session_pump` `buffered_wire` `open_wait` `resolver` `discovery` · `webhub` · `wasm_core` `client_core_wasm_check` (wasm roots the build wires outside the table) | 100 | `src/client/` | `client` — `client_core` `hosts` `handoff` `layout` `keymap` `askpass` `selection` `session_pump` `buffered_wire` `open_wait` `resolver` `discovery` · `webhub` · `wasm_core` `client_core_wasm_check` (wasm roots the build wires outside the table) |
101 | `src/gui/` | `native_core`(`native_core.zig`) — `workspace` `runtime` `picker` `persistence` `interaction` `font_options` `config` `theme` · `native`(`native.zig`) — `font` `atlas` `quads` `gl` `frame` `bench` | 101 | `src/gui/` | `native_core`(`native_core.zig`) — `workspace` `runtime` `picker` `persistence` `interaction` `font_options` `config` `theme` · `native`(`native.zig`) — `font` `atlas` `quads` `gl` `frame` `bench` |
102 | `src/tui/` | `wall`(`wallview.zig`) — `interact` `paint` `select` `predict` `wall_host` `wall_picker` `wall_pump` `wall_layout` `wall_test_*` | 102 | `src/tui/` | `wall`(`wallview.zig`) — `interact` `paint` `predict` `wall_host` `wall_picker` `wall_pump` `wall_layout` `wall_test_*` |
103 | `src/cli/` | `mux`(dispatch) — `main`(daemon) `mux_main`(client) `webhub_main`(hub) · `muxg`(native viewer) · `agent`(`muxa.zig`) · `cliflags`(`flags.zig`) | 103 | `src/cli/` | `mux`(dispatch) — `main`(daemon) `mux_main`(client) `webhub_main`(hub) · `muxg`(native viewer) · `agent`(`muxa.zig`) · `cliflags`(`flags.zig`) |
104 | `src/os/` | `server_os`(`server_os.zig`) — `server_os_linux` `server_os_macos` · `client_os`(`client_os.zig`) — `client_os_linux` `client_os_macos` · `spawn` — the platform layer, one row per side so the client never links a fork or a pty; imports nothing of ours (spec 2026-09-03) | 104 | `src/os/` | `server_os`(`server_os.zig`) — `server_os_linux` `server_os_macos` · `client_os`(`client_os.zig`) — `client_os_linux` `client_os_macos` · `spawn` — the platform layer, one row per side so the client never links a fork or a pty; imports nothing of ours (spec 2026-09-03) |
105 | `src/` | `xdg` `sockpath` `dial` `link` `serve` `proxy` `quic` `testtmp` — what both sides link; `dial` is the client side of a daemon socket and `link` the live connection under it whatever reached it (fd, handoff stdio, QUIC), `serve` the right to bind a socket path and the duty to unlink it, `term` and nothing else under them | 105 | `src/` | `xdg` `sockpath` `dial` `link` `serve` `proxy` `quic` `testtmp` — what both sides link; `dial` is the client side of a daemon socket and `link` the live connection under it whatever reached it (fd, handoff stdio, QUIC), `serve` the right to bind a socket path and the duty to unlink it, `term` and nothing else under them |
RETRO.md
Old New
@@ -1079,3 +1079,26 @@ and queue pre-encoded bytes. Keep history/live snapshot ownership and reply
1079 correlation explicit, share actual semantic helpers instead of copying the TUI 1079 correlation explicit, share actual semantic helpers instead of copying the TUI
1080 interaction core, and inject real SDL wheel events in the retained test adapter. 1080 interaction core, and inject real SDL wheel events in the retained test adapter.
1081 These are implementation constraints, not completed wheel behavior. 1081 These are implementation constraints, not completed wheel behavior.
1082
1083
1084 ## Text selection opening cleanup — 2026-09-06
1085
1086 The user selected text selection as the next sprint using the updated skill.
1087 Wheel behavior remains unimplemented; its plan is deferred. The reuse map in
1088 `docs/superpowers/plans/2026-09-06-native-text-selection.md` identifies existing
1089 drag state, daemon extraction and shared request correlation before feature edits.
1090
1091 Moved the pure TUI selection module and all its tests to `client.selection`;
1092 both frontends can consume one gesture model. Deleted the old TUI source path
1093 and updated existing callers without changing wall behavior. Luna implemented;
1094 Terra independently found no opening-cleanup defect. The first focused client
1095 test attempt was sandbox-blocked on local sockets; the required escalated
1096 `make check` covers those tests. Logs are in `dist/text-selection/`.
1097
1098 Feature work owns the remaining snapshot/request freshness checks and SDL
1099 clipboard adapter. Review identified a send-time race in addition to UI
1100 cancellation: the pump must compare the displayed sequence when sending and
1101 receiving. The existing protocol does not carry a server sequence precondition;
1102 observed output changes can be rejected, but atomic copying of a historical
1103 frame is not promised. A protocol-level version guard is retained for the next
1104 selection-consistency change if that stronger guarantee is required.
build.zig
Old New
@@ -288,8 +288,9 @@ const mod_table = [_]ModSpec{
288 // through. It sits beside webhub for the same reason — both are fronts 288 // through. It sits beside webhub for the same reason — both are fronts
289 // on client's Transport and `client.hosts`' grammar; neither imports the 289 // on client's Transport and `client.hosts`' grammar; neither imports the
290 // other; both resolve a spelling through `client.Target.fromSpec`. 290 // other; both resolve a spelling through `client.Target.fromSpec`.
291 // The interaction loop, the painter, the selector and the prediction 291 // The interaction loop, painter and prediction overlay are CHILD FILES of
292 // overlay are CHILD FILES of this root rather than modules of their own, 292 // this root rather than modules of their own. Selection policy is a public
293 // child of the client root so the native and terminal adapters share it,
293 // so nothing outside src/tui/ can name one: a second module claiming any 294 // so nothing outside src/tui/ can name one: a second module claiming any
294 // of those files is a file-in-multiple-modules compile error. `term` is 295 // of those files is a file-in-multiple-modules compile error. `term` is
295 // the children's as much as the root's: `term.replica` is the keyboard 296 // the children's as much as the root's: `term.replica` is the keyboard
docs/superpowers/plans/2026-09-06-native-text-selection.md
Old New
@@ -0,0 +1,81 @@
1 # Native parity — text selection
2
3 Status: planning and opening cleanup. User selected text selection as the next
4 sprint on 2026-09-06; wheel scrolling remains unimplemented and deferred.
5 Worktree branch: `gui-text-selection` (existing worktree directory retained).
6
7 ## Goal and trial behavior
8
9 Drag across visible terminal text, see the selected cells, and copy the daemon's
10 text to the desktop clipboard on release. Reuse the TUI's single-click versus
11 drag semantics and daemon extraction. A plain click continues to focus a pane.
12 The selection belongs to its starting pane; crossing another pane must never
13 select or copy that pane's text. A new press clears the previous highlight.
14
15 This slice covers visible text and copy on release. Wheel/history navigation,
16 edge autoscroll, double/triple click, rectangular selection, clipboard paste,
17 application mouse forwarding and ligatures remain separate work. The existing
18 GUI does not forward application mouse clicks; this slice preserves that scope.
19
20 ## Reuse and ownership map
21
22 | Behavior or rule | Existing implementation and callers | Intended owner | Reuse, refactor or add; what can be deleted |
23 | --- | --- | --- | --- |
24 | Click/drag distinction, direction, pane confinement, inclusive row spans | `src/tui/select.zig`: `Drag`, `Range.span`; `interact.zig` and `wallview.zig` callers | Shared `client.selection` | Move the pure module and its tests to `src/client/selection.zig`; delete the TUI source and update imports. No second drag state machine. |
25 | Text extraction, soft wraps, hard newlines, UTF-8/graphemes and wide cells | `Engine.extractSelection`, `Server.onSelectionReq`, protocol `SelectionReq`/`SelectionReply` | Daemon engine and existing wire | Reuse unchanged. Never reconstruct copied text from native grid cells. |
26 | Request ID correlation and reply validation | `ClientCore.beginSelection`/`receiveSelectionReply`; TUI `Core.requestSelection`/`selectionCopy` | Shared client core and session pump | Reuse core matching. Add bounded mailbox request/result ownership to `session_pump`; release borrowed frame text before the frame is freed by copying or transferring into owned storage. Do not import TUI `Core`. |
27 | Pane identity, lifetime and frozen displayed grid | `runtime.Live.capture`, `Runtime.accepts`, `workspace.Attachment` | Native runtime | Expose selection-relevant snapshot metadata alongside the grid through the existing boundary; associate requests with attachment and displayed coordinates. Avoid new unlocked reads of pump internals. |
28 | Pointer hit testing, modal routing and cancellation | `interaction.Controller.pointerDown`/`pointerMove`, `focusLost`, `updateGeometry` | Window-free native controller | Use shared drag state with native geometry; add release/copy intent and cancellation. Keep divider drag distinct from text drag. |
29 | Logical-to-physical input, mouse capture, clipboard IO | `frame.Events`, `physicalPoint`, `physicalSignedAxis`, existing pointer hooks | SDL frame adapter | Extend ordinary mouse routing and use SDL clipboard writing. SDL remains confined to frame. Test hooks observe actual clipboard state and inject existing SDL events. |
30 | Selection highlight | `quads.rowInstances`, existing themed cell colors | Native painter | Consume a supplied row span; select complete wide glyph cells and retain readable colors. Never mutate the authoritative grid for highlighting. |
31
32 The existing pure selection module already supplies the needed domain rule; a
33 new selection framework is unnecessary. Moving it to the shared client owner
34 allows both frontends to consume it without importing either window or terminal
35 UI libraries. The TUI's OSC 52 size cap is an adapter limit and must not become
36 the desktop clipboard's cap; the existing wire's 1 MiB bound still applies.
37
38 ## Observable acceptance
39
40 - Three panes across two real daemons, including off-origin panes: forward and
41 reverse drags highlight and copy only the starting pane. Headers, dividers,
42 blank padding and modal dialogs cannot start a terminal selection.
43 - A press/release in one cell changes focus without changing clipboard text.
44 Copy on release uses exact daemon text for soft wraps, hard newlines, trailing
45 spaces, non-ASCII text and wide-cell continuations. A selected wide glyph is
46 visibly complete. Other panes retain their text, PTYs and input behavior.
47 - A new selection, cancellation, resize/DPI transition, focus loss, attachment
48 replacement/reconnect or detach cannot allow a delayed reply to overwrite the
49 clipboard. Empty/invalid/unavailable/refused/oversized results leave its prior
50 contents intact; errors that need action get a bounded notice.
51 - Coordinate validity while output changes is explicit and tested. Favor
52 cancellation when the displayed coordinates no longer identify the selected
53 text, rather than copying newly arrived content under an old highlight.
54 - The painter highlights without changing replica cells. Core policy tests run
55 without GUI libraries. Real clipboard reads and actual framebuffer samples
56 are the independent integration oracles.
57 - Real NVIDIA Wayland drag/release at 200%, retained scale transitions, and
58 separate raw-output responsiveness gates. Linux evidence does not imply Mac
59 acceptance.
60
61 ## Delivery and retained actions
62
63 Luna owns opening shared-selection extraction and feature implementation;
64 Terra independently reviews correctness and structure. Root owns planning,
65 integration oracles, final gates, demonstration, documentation and commits.
66 Opening cleanup is reviewed, checked and committed before feature edits.
67
68 Follow `docs/native-sprint-workflow.md`, serializing builds against the source
69 ban probes in `make check`. Retain logs for full CI, native core/build/units,
70 native integration, NVIDIA scale and stress. Review production growth and remove
71 superseded paths before freezing source. Existing NVIDIA frame-time misses and
72 offscreen growth artifacts remain unresolved renderer follow-ups.
73
74 Assess the existing passive frame hooks as they are touched; add only observation
75 needed for clipboard evidence. Any broader hook extraction requires a concrete
76 benefit rather than an arbitrary file split. Reuse the existing Rig and recorder
77 assets with isolated XDG state and compositor addresses.
78
79 Deliver a private Tailscale review page with achievements, actual GUI recording,
80 controls, checks and limitations. Preserve previous pages. Record demo approval
81 only after the user gives it.
docs/superpowers/plans/2026-09-06-native-wheel-scrolling.md
Old New
@@ -1,7 +1,8 @@
1 # Native parity — wheel scrolling 1 # Native parity — wheel scrolling
2 2
3 Status: opening architecture cleanup implemented, reviewed and validated; 3 Status: opening architecture cleanup implemented, reviewed and validated;
4 wheel behavior has not been implemented. The user authorized a fresh worktree to begin closing GUI/TUI gaps. 4 wheel behavior has not been implemented. On 2026-09-06 the user selected text
5 selection as the next sprint; this wheel plan is deferred. The user authorized a fresh worktree to begin closing GUI/TUI gaps.
5 This is the first functional slice of git-collab issue `8b16e26b`. 6 This is the first functional slice of git-collab issue `8b16e26b`.
6 7
7 ## Sprint goal 8 ## Sprint goal
src/client/client.zig
Old New
@@ -34,6 +34,7 @@ pub const layoutfile = @import("layoutfile.zig");
34 pub const keymap = @import("keymap.zig"); 34 pub const keymap = @import("keymap.zig");
35 pub const askpass = @import("askpass.zig"); 35 pub const askpass = @import("askpass.zig");
36 pub const core = @import("client_core.zig"); 36 pub const core = @import("client_core.zig");
37 pub const selection = @import("selection.zig");
37 pub const discovery = @import("discovery.zig"); 38 pub const discovery = @import("discovery.zig");
38 pub const resolver = @import("resolver.zig"); 39 pub const resolver = @import("resolver.zig");
39 const open_wait = @import("open_wait.zig"); 40 const open_wait = @import("open_wait.zig");
src/client/selection.zig
Old New
@@ -0,0 +1,350 @@
1 //! The drag behind text selection, as pure state: press, motion, release, and
2 //! the span of columns highlighted on one line of one session.
3 //!
4 //! No tty, no transport, no engine, no allocation beyond its own struct — a
5 //! driver resolves a mouse report into `Hit` and this owns what a SEQUENCE of
6 //! those means, which is what lets two drivers in different layers share one
7 //! meaning. Resolving a terminal row to a session line stays with the layout.
8 //!
9 //! Rows are ABSOLUTE, counted from the oldest row the daemon retains: a drag
10 //! held while the session scrolls must keep naming the lines it started over,
11 //! and a terminal row renames itself the moment output moves the window.
12 const std = @import("std");
13
14 /// One place in one session, as a driver has resolved a report.
15 pub const Hit = struct {
16 tile: usize,
17 /// Absolute row: counted from the oldest row the daemon still retains.
18 row: u32,
19 /// Grid column, zero-based. Terminal column is the same number —
20 /// every painter emits from column 1 with no x-offset.
21 col: u16,
22 };
23
24 /// A zero-based terminal cell, which is not a `Hit`: it is where the POINTER
25 /// is, not what is under it. The two part company when the session scrolls, and
26 /// telling a click from a drag is the pointer's question.
27 pub const Cell = struct { row: u16, col: u16 };
28
29 /// Inclusive columns, the same convention `engine.extractSelection` and
30 /// ghostty's `Selection` use at the other end of the wire.
31 pub const Span = struct { from: u16, to: u16 };
32
33 /// A completed selection, normalized: `from` is at or before `to` in
34 /// reading order, whichever way the hand moved.
35 pub const Range = struct {
36 from: Hit,
37 to: Hit,
38
39 /// On `Range` rather than `Drag` because two get compared: a drag that moved
40 /// repaints the rows whose span CHANGED. `cols` is the GRID's width, and the
41 /// clamp is not decoration — a grid narrower than its terminal has columns a
42 /// pointer can reach with no cell behind them.
43 pub fn span(self: Range, tile: usize, row: u32, cols: u16) ?Span {
44 if (self.from.tile != tile) return null;
45 if (row < self.from.row or row > self.to.row) return null;
46 const from = if (row == self.from.row) self.from.col else 0;
47 const to = @min(if (row == self.to.row) self.to.col else cols -| 1, cols -| 1);
48 // A selection whose whole width fell off the grid's right edge
49 // highlights nothing on this row, rather than one clamped cell at
50 // the edge that nobody pointed at.
51 if (from > to) return null;
52 return .{ .from = from, .to = to };
53 }
54 };
55
56 /// What a button coming up meant.
57 pub const Release = union(enum) {
58 /// Nothing was down, or the press landed on nothing selectable.
59 nothing,
60 /// Press and release in one cell. Not a selection — tmux's
61 /// `MouseDown1Pane`, which a driver answers by moving its own
62 /// selection to the thing clicked.
63 click: Hit,
64 /// A drag that ended. The highlight STANDS after this: it is what a
65 /// copy is taken from, and what the next press or `clear` drops.
66 selection: Range,
67 };
68
69 /// One button's worth of drag. Four states, because a press is not yet a
70 /// selection and a release is not the end of one: `.down` may still turn out to
71 /// be a click, and `.held` is a finished selection still on screen.
72 pub const Drag = struct {
73 const Phase = enum { idle, down, dragging, held };
74
75 phase: Phase = .idle,
76 /// The cell the press was on, which is what a later motion is
77 /// compared against to tell a drag from a tremor.
78 at: Cell = .{ .row = 0, .col = 0 },
79 anchor: Hit = .{ .tile = 0, .row = 0, .col = 0 },
80 active: Hit = .{ .tile = 0, .row = 0, .col = 0 },
81
82 /// Whatever was held is dropped however the press turns out: a new
83 /// press is a new selection, and a press on nothing (a label bar) is
84 /// the user putting the old one away.
85 pub fn press(self: *Drag, cell: Cell, hit: ?Hit) void {
86 const h = hit orelse {
87 self.* = .{};
88 return;
89 };
90 self.* = .{ .phase = .down, .at = cell, .anchor = h, .active = h };
91 }
92
93 /// Cell, not pixel: `?1002h` reports a CELL change, and a hand trembling
94 /// inside one cell still points at one line. Once it IS a drag it stays one.
95 /// Confined to its starting tile, or it would ask the wrong session.
96 pub fn motion(self: *Drag, cell: Cell, hit: ?Hit) void {
97 switch (self.phase) {
98 .idle, .held => return,
99 .down => {
100 if (cell.row == self.at.row and cell.col == self.at.col) return;
101 self.phase = .dragging;
102 },
103 .dragging => {},
104 }
105 const h = hit orelse return;
106 if (h.tile != self.anchor.tile) return;
107 self.active = h;
108 }
109
110 /// The button came up. See `Release` for what the three answers mean.
111 pub fn release(self: *Drag) Release {
112 switch (self.phase) {
113 .idle, .held => return .nothing,
114 .down => {
115 const hit = self.anchor;
116 self.* = .{};
117 return .{ .click = hit };
118 },
119 .dragging => {
120 self.phase = .held;
121 return .{ .selection = self.rangeLocked() };
122 },
123 }
124 }
125
126 /// Coordinates stopped meaning what they meant.
127 pub fn clear(self: *Drag) void {
128 self.* = .{};
129 }
130
131 /// A press that has not moved yet counts; `range`, by contrast,
132 /// answers only about what is on screen.
133 pub fn on(self: *const Drag) ?usize {
134 return if (self.phase == .idle) null else self.anchor.tile;
135 }
136
137 /// The selection as an ordered pair, or null while there is none.
138 pub fn range(self: *const Drag) ?Range {
139 return switch (self.phase) {
140 .idle, .down => null,
141 .dragging, .held => self.rangeLocked(),
142 };
143 }
144
145 fn rangeLocked(self: *const Drag) Range {
146 const a = self.anchor;
147 const b = self.active;
148 const forward = b.row > a.row or (b.row == a.row and b.col >= a.col);
149 return if (forward) .{ .from = a, .to = b } else .{ .from = b, .to = a };
150 }
151
152 /// `Range.span` for the live selection, or null while there is none.
153 pub fn span(self: *const Drag, tile: usize, row: u32, cols: u16) ?Span {
154 const r = self.range() orelse return null;
155 return r.span(tile, row, cols);
156 }
157 };
158
159 test "select: a press alone is a click, and highlights nothing on its way" {
160 var d: Drag = .{};
161 const hit: Hit = .{ .tile = 1, .row = 40, .col = 7 };
162 d.press(.{ .row = 12, .col = 7 }, hit);
163 // Nothing is highlighted while the button is merely down: a press that
164 // turns out to be a click must never flicker an inversion on its way.
165 try std.testing.expect(d.span(1, 40, 80) == null);
166 try std.testing.expect(d.range() == null);
167 const r = d.release();
168 try std.testing.expectEqual(@as(usize, 1), r.click.tile);
169 try std.testing.expectEqual(@as(u32, 40), r.click.row);
170 try std.testing.expectEqual(@as(u16, 7), r.click.col);
171 // ...and the click leaves nothing behind to paint.
172 try std.testing.expect(d.range() == null);
173 }
174
175 test "select: a drag names its tile from the press, before it is a selection" {
176 var d: Drag = .{};
177 try std.testing.expect(d.on() == null);
178 d.press(.{ .row = 2, .col = 1 }, .{ .tile = 3, .row = 5, .col = 1 });
179 // Pressed, not yet dragged: nothing to paint, and still a tile whose
180 // coordinates a caller may have to drop.
181 try std.testing.expect(d.range() == null);
182 try std.testing.expectEqual(@as(usize, 3), d.on().?);
183 d.motion(.{ .row = 4, .col = 1 }, .{ .tile = 3, .row = 7, .col = 1 });
184 try std.testing.expectEqual(@as(usize, 3), d.on().?);
185 _ = d.release();
186 // Still held after the button came up, which is what a highlight is.
187 try std.testing.expectEqual(@as(usize, 3), d.on().?);
188 d.clear();
189 try std.testing.expect(d.on() == null);
190 }
191
192 test "select: a press on nothing selectable is nothing at all" {
193 var d: Drag = .{};
194 d.press(.{ .row = 0, .col = 3 }, null);
195 d.motion(.{ .row = 4, .col = 9 }, .{ .tile = 0, .row = 5, .col = 9 });
196 try std.testing.expect(d.range() == null);
197 try std.testing.expect(d.release() == .nothing);
198 }
199
200 test "select: either drag direction yields the same ordered pair" {
201 const top: Hit = .{ .tile = 0, .row = 100, .col = 4 };
202 const bot: Hit = .{ .tile = 0, .row = 103, .col = 12 };
203
204 var down: Drag = .{};
205 down.press(.{ .row = 2, .col = 4 }, top);
206 down.motion(.{ .row = 5, .col = 12 }, bot);
207 const a = down.release().selection;
208
209 var up: Drag = .{};
210 up.press(.{ .row = 5, .col = 12 }, bot);
211 up.motion(.{ .row = 2, .col = 4 }, top);
212 const b = up.release().selection;
213
214 try std.testing.expectEqual(a.from, b.from);
215 try std.testing.expectEqual(a.to, b.to);
216 try std.testing.expectEqual(@as(u32, 100), a.from.row);
217 try std.testing.expectEqual(@as(u16, 4), a.from.col);
218 try std.testing.expectEqual(@as(u32, 103), a.to.row);
219 try std.testing.expectEqual(@as(u16, 12), a.to.col);
220 }
221
222 test "select: a one-row drag reads left to right whichever way the hand moved" {
223 var d: Drag = .{};
224 d.press(.{ .row = 3, .col = 9 }, .{ .tile = 0, .row = 50, .col = 9 });
225 d.motion(.{ .row = 3, .col = 2 }, .{ .tile = 0, .row = 50, .col = 2 });
226 const s = d.span(0, 50, 80).?;
227 try std.testing.expectEqual(@as(u16, 2), s.from);
228 try std.testing.expectEqual(@as(u16, 9), s.to);
229 // One row, so neither neighbour is in it.
230 try std.testing.expect(d.span(0, 49, 80) == null);
231 try std.testing.expect(d.span(0, 51, 80) == null);
232 }
233
234 test "select: the ends are clipped at the anchors, and the middle is the full width" {
235 var d: Drag = .{};
236 d.press(.{ .row = 1, .col = 30 }, .{ .tile = 2, .row = 7, .col = 30 });
237 d.motion(.{ .row = 4, .col = 6 }, .{ .tile = 2, .row = 10, .col = 6 });
238
239 const first = d.span(2, 7, 80).?;
240 try std.testing.expectEqual(@as(u16, 30), first.from);
241 try std.testing.expectEqual(@as(u16, 79), first.to);
242 const middle = d.span(2, 8, 80).?;
243 try std.testing.expectEqual(@as(u16, 0), middle.from);
244 try std.testing.expectEqual(@as(u16, 79), middle.to);
245 const last = d.span(2, 10, 80).?;
246 try std.testing.expectEqual(@as(u16, 0), last.from);
247 try std.testing.expectEqual(@as(u16, 6), last.to);
248 // Outside the range on both sides.
249 try std.testing.expect(d.span(2, 6, 80) == null);
250 try std.testing.expect(d.span(2, 11, 80) == null);
251 }
252
253 test "select: the highlight is one tile's, and the drag cannot leave it" {
254 var d: Drag = .{};
255 d.press(.{ .row = 2, .col = 1 }, .{ .tile = 0, .row = 5, .col = 1 });
256 // A drag onto the neighbouring stripe: it IS a drag, and the active
257 // end stays on the last line of the tile it started in.
258 d.motion(.{ .row = 9, .col = 40 }, .{ .tile = 1, .row = 200, .col = 40 });
259 const s = d.span(0, 5, 80).?;
260 try std.testing.expectEqual(@as(u16, 1), s.from);
261 try std.testing.expectEqual(@as(u16, 1), s.to);
262 // Neither the row it strayed onto nor the tile it strayed into.
263 try std.testing.expect(d.span(1, 200, 80) == null);
264 try std.testing.expect(d.span(0, 200, 80) == null);
265 // The same rows, asked for as somebody else's tile, are not the answer.
266 try std.testing.expect(d.span(1, 5, 80) == null);
267 }
268
269 test "select: a pointer that never leaves the press cell has not dragged" {
270 var d: Drag = .{};
271 d.press(.{ .row = 6, .col = 20 }, .{ .tile = 0, .row = 6, .col = 20 });
272 // `?1002h` reports on a cell change, but a terminal repeating the cell
273 // must not turn a click into an empty selection.
274 d.motion(.{ .row = 6, .col = 20 }, .{ .tile = 0, .row = 6, .col = 20 });
275 try std.testing.expect(d.range() == null);
276 try std.testing.expect(d.release() == .click);
277 }
278
279 test "select: a drag that comes back to where it started is still a drag" {
280 var d: Drag = .{};
281 const home: Hit = .{ .tile = 0, .row = 6, .col = 20 };
282 d.press(.{ .row = 6, .col = 20 }, home);
283 d.motion(.{ .row = 6, .col = 25 }, .{ .tile = 0, .row = 6, .col = 25 });
284 d.motion(.{ .row = 6, .col = 20 }, home);
285 try std.testing.expect(d.release() == .selection);
286 // One cell selected, and it is still on screen after the button is up.
287 const s = d.span(0, 6, 80).?;
288 try std.testing.expectEqual(@as(u16, 20), s.from);
289 try std.testing.expectEqual(@as(u16, 20), s.to);
290 }
291
292 test "select: the highlight outlives the release, and dies on clear" {
293 var d: Drag = .{};
294 d.press(.{ .row = 0, .col = 0 }, .{ .tile = 0, .row = 3, .col = 0 });
295 d.motion(.{ .row = 1, .col = 5 }, .{ .tile = 0, .row = 4, .col = 5 });
296 _ = d.release();
297 try std.testing.expect(d.span(0, 3, 80) != null);
298 // A motion after the button is up is somebody else's pointer moving
299 // over a selection that is finished.
300 d.motion(.{ .row = 8, .col = 8 }, .{ .tile = 0, .row = 11, .col = 8 });
301 try std.testing.expectEqual(@as(u32, 4), d.range().?.to.row);
302 try std.testing.expect(d.release() == .nothing);
303 d.clear();
304 try std.testing.expect(d.range() == null);
305 try std.testing.expect(d.span(0, 3, 80) == null);
306 }
307
308 test "select: a new press drops the selection the last one left" {
309 var d: Drag = .{};
310 d.press(.{ .row = 0, .col = 0 }, .{ .tile = 0, .row = 3, .col = 0 });
311 d.motion(.{ .row = 1, .col = 5 }, .{ .tile = 0, .row = 4, .col = 5 });
312 _ = d.release();
313 d.press(.{ .row = 7, .col = 2 }, .{ .tile = 0, .row = 10, .col = 2 });
314 try std.testing.expect(d.span(0, 3, 80) == null);
315 try std.testing.expect(d.range() == null);
316 // ...and a press on nothing drops it just as thoroughly.
317 d.motion(.{ .row = 7, .col = 6 }, .{ .tile = 0, .row = 10, .col = 6 });
318 try std.testing.expect(d.range() != null);
319 d.press(.{ .row = 0, .col = 0 }, null);
320 try std.testing.expect(d.range() == null);
321 }
322
323 test "select: a motion with no button down selects nothing" {
324 var d: Drag = .{};
325 d.motion(.{ .row = 4, .col = 4 }, .{ .tile = 0, .row = 4, .col = 4 });
326 try std.testing.expect(d.range() == null);
327 try std.testing.expect(d.release() == .nothing);
328 }
329
330 test "select: a span past the grid's right edge is clipped, not painted at the edge" {
331 // The tty is wider than the grid — latest-wins leaves that shape
332 // routinely — so a pointer can reach columns with no cell behind them.
333 var d: Drag = .{};
334 d.press(.{ .row = 0, .col = 90 }, .{ .tile = 0, .row = 2, .col = 90 });
335 d.motion(.{ .row = 1, .col = 95 }, .{ .tile = 0, .row = 3, .col = 95 });
336 // The first row's anchor is off the grid entirely: nothing to invert,
337 // and NOT one cell at column 39.
338 try std.testing.expect(d.span(0, 2, 40) == null);
339 // The last row runs from the left edge to the grid's own right edge.
340 const last = d.span(0, 3, 40).?;
341 try std.testing.expectEqual(@as(u16, 0), last.from);
342 try std.testing.expectEqual(@as(u16, 39), last.to);
343 }
344
345 // Forces semantic analysis of every pub decl under `zig build test`, so an
346 // unreferenced decl must at least compile (the silent-module-loss hazard,
347 // decisions.md). Pub decls only: std.meta.declarations sees nothing private.
348 test {
349 std.testing.refAllDeclsRecursive(@This());
350 }
src/tui/interact.zig
Old New
@@ -23,13 +23,14 @@ const client_os = @import("client_os");
23 // `paint`, which a container-level `paint` would collide with. 23 // `paint`, which a container-level `paint` would collide with.
24 const paint_mod = @import("paint.zig"); 24 const paint_mod = @import("paint.zig");
25 const askpass = @import("client").askpass; 25 const askpass = @import("client").askpass;
26 const select = @import("select.zig"); 26 const select = @import("client").selection;
27 // The command prefix byte. Owned by keymap because the dialler in client.zig 27 // The command prefix byte. Owned by keymap because the dialler in client.zig
28 // watches for it with no session and no terminal in the picture. 28 // watches for it with no session and no terminal in the picture.
29 const detach_key = @import("client").keymap.detach_key; 29 const detach_key = @import("client").keymap.detach_key;
30 30
31 /// What `select` answers, in the shape `paint` asks for. The two are siblings 31 /// What `select` answers, in the shape `paint` asks for. Selection policy lives
32 /// that may not import one another, so somebody above both joins them. 32 /// in `client.selection`; this adapter converts its absolute rows to the wall's
33 /// grid rows before painting.
33 /// 34 ///
34 /// It CONVERTS as well as adapts: `select` speaks absolute rows and a painter 35 /// It CONVERTS as well as adapts: `select` speaks absolute rows and a painter
35 /// speaks grid rows, so the history count of the frame being painted turns one 36 /// speaks grid rows, so the history count of the frame being painted turns one
src/tui/paint.zig
Old New
@@ -18,8 +18,8 @@ pub const sync_end = "\x1b[?25h\x1b[?2026l";
18 /// Inclusive grid columns of one row, painted inverted. 18 /// Inclusive grid columns of one row, painted inverted.
19 pub const Span = struct { from: u16, to: u16 }; 19 pub const Span = struct { from: u16, to: u16 };
20 20
21 /// A callback, not a shape: `select.zig` is a sibling child file of the same 21 /// A callback, not a shape: `client.selection` owns selection state, while
22 /// module root, and neither file imports the other. 22 /// this file adapts its spans into painted output.
23 pub const Highlight = struct { 23 pub const Highlight = struct {
24 ctx: ?*anyopaque = null, 24 ctx: ?*anyopaque = null,
25 span: ?*const fn (?*anyopaque, row: u16, cols: u16) ?Span = null, 25 span: ?*const fn (?*anyopaque, row: u16, cols: u16) ?Span = null,
src/tui/select.zig
Old New
@@ -1,350 +0,0 @@
1 //! The drag behind text selection, as pure state: press, motion, release, and
2 //! the span of columns highlighted on one line of one session.
3 //!
4 //! No tty, no transport, no engine, no allocation beyond its own struct — a
5 //! driver resolves a mouse report into `Hit` and this owns what a SEQUENCE of
6 //! those means, which is what lets two drivers in different layers share one
7 //! meaning. Resolving a terminal row to a session line stays with the layout.
8 //!
9 //! Rows are ABSOLUTE, counted from the oldest row the daemon retains: a drag
10 //! held while the session scrolls must keep naming the lines it started over,
11 //! and a terminal row renames itself the moment output moves the window.
12 const std = @import("std");
13
14 /// One place in one session, as a driver has resolved a report.
15 pub const Hit = struct {
16 tile: usize,
17 /// Absolute row: counted from the oldest row the daemon still retains.
18 row: u32,
19 /// Grid column, zero-based. Terminal column is the same number —
20 /// every painter emits from column 1 with no x-offset.
21 col: u16,
22 };
23
24 /// A zero-based terminal cell, which is not a `Hit`: it is where the POINTER
25 /// is, not what is under it. The two part company when the session scrolls, and
26 /// telling a click from a drag is the pointer's question.
27 pub const Cell = struct { row: u16, col: u16 };
28
29 /// Inclusive columns, the same convention `engine.extractSelection` and
30 /// ghostty's `Selection` use at the other end of the wire.
31 pub const Span = struct { from: u16, to: u16 };
32
33 /// A completed selection, normalized: `from` is at or before `to` in
34 /// reading order, whichever way the hand moved.
35 pub const Range = struct {
36 from: Hit,
37 to: Hit,
38
39 /// On `Range` rather than `Drag` because two get compared: a drag that moved
40 /// repaints the rows whose span CHANGED. `cols` is the GRID's width, and the
41 /// clamp is not decoration — a grid narrower than its terminal has columns a
42 /// pointer can reach with no cell behind them.
43 pub fn span(self: Range, tile: usize, row: u32, cols: u16) ?Span {
44 if (self.from.tile != tile) return null;
45 if (row < self.from.row or row > self.to.row) return null;
46 const from = if (row == self.from.row) self.from.col else 0;
47 const to = @min(if (row == self.to.row) self.to.col else cols -| 1, cols -| 1);
48 // A selection whose whole width fell off the grid's right edge
49 // highlights nothing on this row, rather than one clamped cell at
50 // the edge that nobody pointed at.
51 if (from > to) return null;
52 return .{ .from = from, .to = to };
53 }
54 };
55
56 /// What a button coming up meant.
57 pub const Release = union(enum) {
58 /// Nothing was down, or the press landed on nothing selectable.
59 nothing,
60 /// Press and release in one cell. Not a selection — tmux's
61 /// `MouseDown1Pane`, which a driver answers by moving its own
62 /// selection to the thing clicked.
63 click: Hit,
64 /// A drag that ended. The highlight STANDS after this: it is what a
65 /// copy is taken from, and what the next press or `clear` drops.
66 selection: Range,
67 };
68
69 /// One button's worth of drag. Four states, because a press is not yet a
70 /// selection and a release is not the end of one: `.down` may still turn out to
71 /// be a click, and `.held` is a finished selection still on screen.
72 pub const Drag = struct {
73 const Phase = enum { idle, down, dragging, held };
74
75 phase: Phase = .idle,
76 /// The cell the press was on, which is what a later motion is
77 /// compared against to tell a drag from a tremor.
78 at: Cell = .{ .row = 0, .col = 0 },
79 anchor: Hit = .{ .tile = 0, .row = 0, .col = 0 },
80 active: Hit = .{ .tile = 0, .row = 0, .col = 0 },
81
82 /// Whatever was held is dropped however the press turns out: a new
83 /// press is a new selection, and a press on nothing (a label bar) is
84 /// the user putting the old one away.
85 pub fn press(self: *Drag, cell: Cell, hit: ?Hit) void {
86 const h = hit orelse {
87 self.* = .{};
88 return;
89 };
90 self.* = .{ .phase = .down, .at = cell, .anchor = h, .active = h };
91 }
92
93 /// Cell, not pixel: `?1002h` reports a CELL change, and a hand trembling
94 /// inside one cell still points at one line. Once it IS a drag it stays one.
95 /// Confined to its starting tile, or it would ask the wrong session.
96 pub fn motion(self: *Drag, cell: Cell, hit: ?Hit) void {
97 switch (self.phase) {
98 .idle, .held => return,
99 .down => {
100 if (cell.row == self.at.row and cell.col == self.at.col) return;
101 self.phase = .dragging;
102 },
103 .dragging => {},
104 }
105 const h = hit orelse return;
106 if (h.tile != self.anchor.tile) return;
107 self.active = h;
108 }
109
110 /// The button came up. See `Release` for what the three answers mean.
111 pub fn release(self: *Drag) Release {
112 switch (self.phase) {
113 .idle, .held => return .nothing,
114 .down => {
115 const hit = self.anchor;
116 self.* = .{};
117 return .{ .click = hit };
118 },
119 .dragging => {
120 self.phase = .held;
121 return .{ .selection = self.rangeLocked() };
122 },
123 }
124 }
125
126 /// Coordinates stopped meaning what they meant.
127 pub fn clear(self: *Drag) void {
128 self.* = .{};
129 }
130
131 /// A press that has not moved yet counts; `range`, by contrast,
132 /// answers only about what is on screen.
133 pub fn on(self: *const Drag) ?usize {
134 return if (self.phase == .idle) null else self.anchor.tile;
135 }
136
137 /// The selection as an ordered pair, or null while there is none.
138 pub fn range(self: *const Drag) ?Range {
139 return switch (self.phase) {
140 .idle, .down => null,
141 .dragging, .held => self.rangeLocked(),
142 };
143 }
144
145 fn rangeLocked(self: *const Drag) Range {
146 const a = self.anchor;
147 const b = self.active;
148 const forward = b.row > a.row or (b.row == a.row and b.col >= a.col);
149 return if (forward) .{ .from = a, .to = b } else .{ .from = b, .to = a };
150 }
151
152 /// `Range.span` for the live selection, or null while there is none.
153 pub fn span(self: *const Drag, tile: usize, row: u32, cols: u16) ?Span {
154 const r = self.range() orelse return null;
155 return r.span(tile, row, cols);
156 }
157 };
158
159 test "select: a press alone is a click, and highlights nothing on its way" {
160 var d: Drag = .{};
161 const hit: Hit = .{ .tile = 1, .row = 40, .col = 7 };
162 d.press(.{ .row = 12, .col = 7 }, hit);
163 // Nothing is highlighted while the button is merely down: a press that
164 // turns out to be a click must never flicker an inversion on its way.
165 try std.testing.expect(d.span(1, 40, 80) == null);
166 try std.testing.expect(d.range() == null);
167 const r = d.release();
168 try std.testing.expectEqual(@as(usize, 1), r.click.tile);
169 try std.testing.expectEqual(@as(u32, 40), r.click.row);
170 try std.testing.expectEqual(@as(u16, 7), r.click.col);
171 // ...and the click leaves nothing behind to paint.
172 try std.testing.expect(d.range() == null);
173 }
174
175 test "select: a drag names its tile from the press, before it is a selection" {
176 var d: Drag = .{};
177 try std.testing.expect(d.on() == null);
178 d.press(.{ .row = 2, .col = 1 }, .{ .tile = 3, .row = 5, .col = 1 });
179 // Pressed, not yet dragged: nothing to paint, and still a tile whose
180 // coordinates a caller may have to drop.
181 try std.testing.expect(d.range() == null);
182 try std.testing.expectEqual(@as(usize, 3), d.on().?);
183 d.motion(.{ .row = 4, .col = 1 }, .{ .tile = 3, .row = 7, .col = 1 });
184 try std.testing.expectEqual(@as(usize, 3), d.on().?);
185 _ = d.release();
186 // Still held after the button came up, which is what a highlight is.
187 try std.testing.expectEqual(@as(usize, 3), d.on().?);
188 d.clear();
189 try std.testing.expect(d.on() == null);
190 }
191
192 test "select: a press on nothing selectable is nothing at all" {
193 var d: Drag = .{};
194 d.press(.{ .row = 0, .col = 3 }, null);
195 d.motion(.{ .row = 4, .col = 9 }, .{ .tile = 0, .row = 5, .col = 9 });
196 try std.testing.expect(d.range() == null);
197 try std.testing.expect(d.release() == .nothing);
198 }
199
200 test "select: either drag direction yields the same ordered pair" {
201 const top: Hit = .{ .tile = 0, .row = 100, .col = 4 };
202 const bot: Hit = .{ .tile = 0, .row = 103, .col = 12 };
203
204 var down: Drag = .{};
205 down.press(.{ .row = 2, .col = 4 }, top);
206 down.motion(.{ .row = 5, .col = 12 }, bot);
207 const a = down.release().selection;
208
209 var up: Drag = .{};
210 up.press(.{ .row = 5, .col = 12 }, bot);
211 up.motion(.{ .row = 2, .col = 4 }, top);
212 const b = up.release().selection;
213
214 try std.testing.expectEqual(a.from, b.from);
215 try std.testing.expectEqual(a.to, b.to);
216 try std.testing.expectEqual(@as(u32, 100), a.from.row);
217 try std.testing.expectEqual(@as(u16, 4), a.from.col);
218 try std.testing.expectEqual(@as(u32, 103), a.to.row);
219 try std.testing.expectEqual(@as(u16, 12), a.to.col);
220 }
221
222 test "select: a one-row drag reads left to right whichever way the hand moved" {
223 var d: Drag = .{};
224 d.press(.{ .row = 3, .col = 9 }, .{ .tile = 0, .row = 50, .col = 9 });
225 d.motion(.{ .row = 3, .col = 2 }, .{ .tile = 0, .row = 50, .col = 2 });
226 const s = d.span(0, 50, 80).?;
227 try std.testing.expectEqual(@as(u16, 2), s.from);
228 try std.testing.expectEqual(@as(u16, 9), s.to);
229 // One row, so neither neighbour is in it.
230 try std.testing.expect(d.span(0, 49, 80) == null);
231 try std.testing.expect(d.span(0, 51, 80) == null);
232 }
233
234 test "select: the ends are clipped at the anchors, and the middle is the full width" {
235 var d: Drag = .{};
236 d.press(.{ .row = 1, .col = 30 }, .{ .tile = 2, .row = 7, .col = 30 });
237 d.motion(.{ .row = 4, .col = 6 }, .{ .tile = 2, .row = 10, .col = 6 });
238
239 const first = d.span(2, 7, 80).?;
240 try std.testing.expectEqual(@as(u16, 30), first.from);
241 try std.testing.expectEqual(@as(u16, 79), first.to);
242 const middle = d.span(2, 8, 80).?;
243 try std.testing.expectEqual(@as(u16, 0), middle.from);
244 try std.testing.expectEqual(@as(u16, 79), middle.to);
245 const last = d.span(2, 10, 80).?;
246 try std.testing.expectEqual(@as(u16, 0), last.from);
247 try std.testing.expectEqual(@as(u16, 6), last.to);
248 // Outside the range on both sides.
249 try std.testing.expect(d.span(2, 6, 80) == null);
250 try std.testing.expect(d.span(2, 11, 80) == null);
251 }
252
253 test "select: the highlight is one tile's, and the drag cannot leave it" {
254 var d: Drag = .{};
255 d.press(.{ .row = 2, .col = 1 }, .{ .tile = 0, .row = 5, .col = 1 });
256 // A drag onto the neighbouring stripe: it IS a drag, and the active
257 // end stays on the last line of the tile it started in.
258 d.motion(.{ .row = 9, .col = 40 }, .{ .tile = 1, .row = 200, .col = 40 });
259 const s = d.span(0, 5, 80).?;
260 try std.testing.expectEqual(@as(u16, 1), s.from);
261 try std.testing.expectEqual(@as(u16, 1), s.to);
262 // Neither the row it strayed onto nor the tile it strayed into.
263 try std.testing.expect(d.span(1, 200, 80) == null);
264 try std.testing.expect(d.span(0, 200, 80) == null);
265 // The same rows, asked for as somebody else's tile, are not the answer.
266 try std.testing.expect(d.span(1, 5, 80) == null);
267 }
268
269 test "select: a pointer that never leaves the press cell has not dragged" {
270 var d: Drag = .{};
271 d.press(.{ .row = 6, .col = 20 }, .{ .tile = 0, .row = 6, .col = 20 });
272 // `?1002h` reports on a cell change, but a terminal repeating the cell
273 // must not turn a click into an empty selection.
274 d.motion(.{ .row = 6, .col = 20 }, .{ .tile = 0, .row = 6, .col = 20 });
275 try std.testing.expect(d.range() == null);
276 try std.testing.expect(d.release() == .click);
277 }
278
279 test "select: a drag that comes back to where it started is still a drag" {
280 var d: Drag = .{};
281 const home: Hit = .{ .tile = 0, .row = 6, .col = 20 };
282 d.press(.{ .row = 6, .col = 20 }, home);
283 d.motion(.{ .row = 6, .col = 25 }, .{ .tile = 0, .row = 6, .col = 25 });
284 d.motion(.{ .row = 6, .col = 20 }, home);
285 try std.testing.expect(d.release() == .selection);
286 // One cell selected, and it is still on screen after the button is up.
287 const s = d.span(0, 6, 80).?;
288 try std.testing.expectEqual(@as(u16, 20), s.from);
289 try std.testing.expectEqual(@as(u16, 20), s.to);
290 }
291
292 test "select: the highlight outlives the release, and dies on clear" {
293 var d: Drag = .{};
294 d.press(.{ .row = 0, .col = 0 }, .{ .tile = 0, .row = 3, .col = 0 });
295 d.motion(.{ .row = 1, .col = 5 }, .{ .tile = 0, .row = 4, .col = 5 });
296 _ = d.release();
297 try std.testing.expect(d.span(0, 3, 80) != null);
298 // A motion after the button is up is somebody else's pointer moving
299 // over a selection that is finished.
300 d.motion(.{ .row = 8, .col = 8 }, .{ .tile = 0, .row = 11, .col = 8 });
301 try std.testing.expectEqual(@as(u32, 4), d.range().?.to.row);
302 try std.testing.expect(d.release() == .nothing);
303 d.clear();
304 try std.testing.expect(d.range() == null);
305 try std.testing.expect(d.span(0, 3, 80) == null);
306 }
307
308 test "select: a new press drops the selection the last one left" {
309 var d: Drag = .{};
310 d.press(.{ .row = 0, .col = 0 }, .{ .tile = 0, .row = 3, .col = 0 });
311 d.motion(.{ .row = 1, .col = 5 }, .{ .tile = 0, .row = 4, .col = 5 });
312 _ = d.release();
313 d.press(.{ .row = 7, .col = 2 }, .{ .tile = 0, .row = 10, .col = 2 });
314 try std.testing.expect(d.span(0, 3, 80) == null);
315 try std.testing.expect(d.range() == null);
316 // ...and a press on nothing drops it just as thoroughly.
317 d.motion(.{ .row = 7, .col = 6 }, .{ .tile = 0, .row = 10, .col = 6 });
318 try std.testing.expect(d.range() != null);
319 d.press(.{ .row = 0, .col = 0 }, null);
320 try std.testing.expect(d.range() == null);
321 }
322
323 test "select: a motion with no button down selects nothing" {
324 var d: Drag = .{};
325 d.motion(.{ .row = 4, .col = 4 }, .{ .tile = 0, .row = 4, .col = 4 });
326 try std.testing.expect(d.range() == null);
327 try std.testing.expect(d.release() == .nothing);
328 }
329
330 test "select: a span past the grid's right edge is clipped, not painted at the edge" {
331 // The tty is wider than the grid — latest-wins leaves that shape
332 // routinely — so a pointer can reach columns with no cell behind them.
333 var d: Drag = .{};
334 d.press(.{ .row = 0, .col = 90 }, .{ .tile = 0, .row = 2, .col = 90 });
335 d.motion(.{ .row = 1, .col = 95 }, .{ .tile = 0, .row = 3, .col = 95 });
336 // The first row's anchor is off the grid entirely: nothing to invert,
337 // and NOT one cell at column 39.
338 try std.testing.expect(d.span(0, 2, 40) == null);
339 // The last row runs from the left edge to the grid's own right edge.
340 const last = d.span(0, 3, 40).?;
341 try std.testing.expectEqual(@as(u16, 0), last.from);
342 try std.testing.expectEqual(@as(u16, 39), last.to);
343 }
344
345 // Forces semantic analysis of every pub decl under `zig build test`, so an
346 // unreferenced decl must at least compile (the silent-module-loss hazard,
347 // decisions.md). Pub decls only: std.meta.declarations sees nothing private.
348 test {
349 std.testing.refAllDeclsRecursive(@This());
350 }
src/tui/wallview.zig
Old New
@@ -12,7 +12,7 @@ const spawn = @import("spawn");
12 const proxy = @import("proxy"); 12 const proxy = @import("proxy");
13 const grid_mod = @import("term").grid; 13 const grid_mod = @import("term").grid;
14 const paint = @import("paint.zig"); 14 const paint = @import("paint.zig");
15 const select = @import("select.zig"); 15 const select = client.selection;
16 // Counters ride out through `Shared` because a detached pump never reaches 16 // Counters ride out through `Shared` because a detached pump never reaches
17 // a `Core.deinit`. 17 // a `Core.deinit`.
18 // The chord table and the prediction hooks a focused tile shares with the 18 // The chord table and the prediction hooks a focused tile shares with the
@@ -2643,7 +2643,6 @@ pub fn run(alloc: std.mem.Allocator, host_specs: []const HostSpec, entry: Entry)
2643 test { 2643 test {
2644 _ = @import("interact.zig"); 2644 _ = @import("interact.zig");
2645 _ = @import("paint.zig"); 2645 _ = @import("paint.zig");
2646 _ = @import("select.zig");
2647 _ = @import("predict.zig"); 2646 _ = @import("predict.zig");
2648 _ = @import("wall_test_harness.zig"); 2647 _ = @import("wall_test_harness.zig");
2649 _ = @import("wall_test_host.zig"); 2648 _ = @import("wall_test_host.zig");