a73x

cc6b38d8

feat: a drag is a state machine, and it knows nothing about terminals

a73x   2026-08-22 13:13

Commit message
feat: a drag is a state machine, and it knows nothing about terminals

The meaning of a press, a motion and a release, with no tty, no
transport, no engine and no allocation under it. Two drivers need the
same answer from different layers — a zoomed tile's Core at 2, the
wall's keyboard loop at 4 — so the meaning is a module and each driver
brings its own world to it.

It must not know about `wallview.Stripe`, which is layer 4 and would
invert the graph, so it takes an already-resolved hit: which tile,
which line, which column. That is also what makes a column layout
cheap later — the hit-test changes and nothing below it does.

Rows are absolute, counted from the oldest row the daemon retains, for
the same reason the hit-test resolves at press time: a terminal row
renames itself the moment output scrolls the window under it, and a
drag held across a scroll would end up selecting lines the hand never
crossed.

Four states, because a press is not yet a selection and a release is
not the end of one. `down` may still turn out to be a click, which the
wall answers by moving its selection rather than by highlighting
anything; `held` is a finished selection with the button up and the
inversion still on screen, which is what a copy will be taken from.

Layer 1 with no imports at all, rather than layer 0. Layer 0 is this
program's vocabulary — the wire, the engine, the pty — and a drag is
something the client does with them, not a thing they are made of.

Patch: 063cec67
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GiryvVYESzBqqtf5cH1uoA

build.zig
Old New
@@ -209,6 +209,13 @@ const mod_table = [_]ModSpec{
209 // types in, and knows nothing about transports — which is what lets its 209 // types in, and knows nothing about transports — which is what lets its
210 // tests drive every painter through a pipe with no daemon anywhere. 210 // tests drive every painter through a pipe with no daemon anywhere.
211 .{ .name = "paint", .path = "src/paint.zig", .layer = 1, .imports = &.{ "engine", "protocol" } }, 211 .{ .name = "paint", .path = "src/paint.zig", .layer = 1, .imports = &.{ "engine", "protocol" } },
212 // What a press, a drag and a release MEAN, and nothing else: no tty, no
213 // transport, no engine, no allocation. It imports nothing at all, and is
214 // still layer 1 rather than 0 — layer 0 is this program's vocabulary
215 // (the wire, the engine, the pty), and a drag is a thing the client does
216 // with them. Its two drivers sit at layers 2 and 4, which is the whole
217 // reason it is a module: one meaning, two worlds acting on it.
218 .{ .name = "select", .path = "src/select.zig", .layer = 1 },
212 // Replays a captured client stdout stream and prints the final grid in 219 // Replays a captured client stdout stream and prints the final grid in
213 // `muxd dump`'s formats — the client half of the M11 render-vs-dump 220 // `muxd dump`'s formats — the client half of the M11 render-vs-dump
214 // convergence check. Imports the engine module so both sides of the 221 // convergence check. Imports the engine module so both sides of the
@@ -522,12 +529,12 @@ fn docGate(b: *std.Build, target: std.Build.ResolvedTarget, check_step: *std.Bui
522 /// escape pins. mux and exe are executable roots but carry the argument 529 /// escape pins. mux and exe are executable roots but carry the argument
523 /// parsers — a test that is never built is not a test (decisions.md). 530 /// parsers — a test that is never built is not a test (decisions.md).
524 const test_order = [_][]const u8{ 531 const test_order = [_][]const u8{
525 "script", "protocol", "client_core", "interact", "engine", "pty", "delta", 532 "script", "select", "protocol", "client_core", "interact", "engine", "pty",
526 "cmd", "wall", "shellint", "replica", "keymap", "webhub", "wallview", 533 "delta", "cmd", "wall", "shellint", "replica", "keymap", "webhub",
527 "sockpath", "muxa", "server", "client", "proxy", "mux", "quic", 534 "wallview", "sockpath", "muxa", "server", "client", "proxy", "mux",
528 "quic_server", "exe", "testtmp", "quic_client", "predict", "rawmode", "delaypipe", 535 "quic", "quic_server", "exe", "testtmp", "quic_client", "predict", "rawmode",
529 "xdg", "spawn", "handoff", "paint", "render", "ptyclient", "webhub_main", 536 "delaypipe", "xdg", "spawn", "handoff", "paint", "render", "ptyclient",
530 "wsclient", 537 "webhub_main", "wsclient",
531 }; 538 };
532 539
533 comptime { 540 comptime {
src/select.zig
Old New
@@ -0,0 +1,353 @@
1 //! The drag behind text selection, as pure state: press, motion, release,
2 //! and the span of columns highlighted on one line of one session.
3 //!
4 //! It is deliberately ignorant of everything around it. No tty, no
5 //! transport, no engine, no allocation beyond its own struct — a driver
6 //! resolves a mouse report into `Hit` (which tile, which line, which
7 //! column) and this owns what a sequence of those MEANS. That is what
8 //! lets two drivers in different layers share one meaning: a zoomed
9 //! `interact.Core` at layer 2 and the wall's keyboard loop at layer 4.
10 //!
11 //! In particular it must not know about `wallview.Stripe`, which is layer
12 //! 4 and would invert the graph. Resolving a terminal row to a session
13 //! line stays with whoever owns the layout; a column layout later changes
14 //! that hit-test and nothing here.
15 //!
16 //! Rows are ABSOLUTE — counted from the oldest row the daemon still
17 //! retains, the coordinate space `protocol.SelectionReq` speaks. A drag
18 //! held while the session scrolls must keep naming the lines it was
19 //! started over, and a terminal row silently renames itself the moment
20 //! output moves the window under it.
21 const std = @import("std");
22
23 /// One place in one session, as a driver has resolved a report.
24 pub const Hit = struct {
25 tile: usize,
26 /// Absolute row: counted from the oldest row the daemon still retains.
27 row: u32,
28 /// Grid column, zero-based. Terminal column is the same number —
29 /// every painter emits from column 1 with no x-offset.
30 col: u16,
31 };
32
33 /// A zero-based terminal cell, which is a different thing from a `Hit`:
34 /// it is where the POINTER is, not what is under it. The two part company
35 /// exactly when the session scrolls, and telling a click from a drag is
36 /// the pointer's question — a hand resting still over moving output has
37 /// not dragged anything.
38 pub const Cell = struct { row: u16, col: u16 };
39
40 /// Inclusive columns, the same convention `engine.extractSelection` and
41 /// ghostty's `Selection` use at the other end of the wire.
42 pub const Span = struct { from: u16, to: u16 };
43
44 /// A completed selection, normalized: `from` is at or before `to` in
45 /// reading order, whichever way the hand moved.
46 pub const Range = struct { from: Hit, to: Hit };
47
48 /// What a button coming up meant.
49 pub const Release = union(enum) {
50 /// Nothing was down, or the press landed on nothing selectable.
51 nothing,
52 /// Press and release in one cell. Not a selection — tmux's
53 /// `MouseDown1Pane`, which a driver answers by moving its own
54 /// selection to the thing clicked.
55 click: Hit,
56 /// A drag that ended. The highlight STANDS after this: it is what a
57 /// copy is taken from, and what the next press or `clear` drops.
58 selection: Range,
59 };
60
61 /// One button's worth of drag.
62 ///
63 /// The states are four because a press is not yet a selection and a
64 /// release is not the end of one: `.down` is a press that may still turn
65 /// out to be a click, and `.held` is a finished selection with the button
66 /// up and the highlight still on screen.
67 pub const Drag = struct {
68 const Phase = enum { idle, down, dragging, held };
69
70 phase: Phase = .idle,
71 /// The cell the press was on, which is what a later motion is
72 /// compared against to tell a drag from a tremor.
73 at: Cell = .{ .row = 0, .col = 0 },
74 anchor: Hit = .{ .tile = 0, .row = 0, .col = 0 },
75 active: Hit = .{ .tile = 0, .row = 0, .col = 0 },
76
77 /// A button went down. Whatever was held is dropped here, however the
78 /// press turns out: a new press is a new selection, and a press on
79 /// nothing (a label bar, a row past the last stripe) is the user
80 /// putting the old one away.
81 pub fn press(self: *Drag, cell: Cell, hit: ?Hit) void {
82 const h = hit orelse {
83 self.* = .{};
84 return;
85 };
86 self.* = .{ .phase = .down, .at = cell, .anchor = h, .active = h };
87 }
88
89 /// The pointer moved with the button down.
90 ///
91 /// Cell granularity, not pixel: `?1002h` reports motion when the
92 /// pointer changes CELL, and a hand that trembles inside one cell is
93 /// still pointing at one line. Once it IS a drag it stays one, even
94 /// coming back over the press cell — a selection dragged out and back
95 /// is a selection of one cell, not a click.
96 ///
97 /// A drag is confined to the tile it started in. Off that tile the
98 /// active end simply stops moving: a wall is panes, and a selection
99 /// that leaked into the neighbour would ask the wrong daemon session
100 /// for its text.
101 pub fn motion(self: *Drag, cell: Cell, hit: ?Hit) void {
102 switch (self.phase) {
103 .idle, .held => return,
104 .down => {
105 if (cell.row == self.at.row and cell.col == self.at.col) return;
106 self.phase = .dragging;
107 },
108 .dragging => {},
109 }
110 const h = hit orelse return;
111 if (h.tile != self.anchor.tile) return;
112 self.active = h;
113 }
114
115 /// The button came up. See `Release` for what the three answers mean.
116 pub fn release(self: *Drag) Release {
117 switch (self.phase) {
118 .idle, .held => return .nothing,
119 .down => {
120 const hit = self.anchor;
121 self.* = .{};
122 return .{ .click = hit };
123 },
124 .dragging => {
125 self.phase = .held;
126 return .{ .selection = self.rangeLocked() };
127 },
128 }
129 }
130
131 /// Drop the drag and the highlight both.
132 ///
133 /// Every caller is a moment when the coordinates stop meaning what
134 /// they meant: a relayout or a forget re-cuts the stripes under the
135 /// anchor, a zoom transition hands the screen to somebody else, and a
136 /// resync renames the absolute row space outright — a highlight kept
137 /// across one of those is a highlight over rows nobody selected.
138 pub fn clear(self: *Drag) void {
139 self.* = .{};
140 }
141
142 /// The selection as an ordered pair, or null while there is none.
143 pub fn range(self: *const Drag) ?Range {
144 return switch (self.phase) {
145 .idle, .down => null,
146 .dragging, .held => self.rangeLocked(),
147 };
148 }
149
150 fn rangeLocked(self: *const Drag) Range {
151 const a = self.anchor;
152 const b = self.active;
153 const forward = b.row > a.row or (b.row == a.row and b.col >= a.col);
154 return if (forward) .{ .from = a, .to = b } else .{ .from = b, .to = a };
155 }
156
157 /// Which columns of absolute row `row` in tile `tile` are highlighted,
158 /// on a grid `cols` wide, or null for a row this selection does not
159 /// cover.
160 ///
161 /// `cols` is the GRID's width and the clamp is not decoration: a grid
162 /// narrower than the terminal it is painted on has columns a pointer
163 /// can reach and no cell behind them, and an out-of-range column is
164 /// what makes the daemon answer `.invalid` at the other end.
165 pub fn span(self: *const Drag, tile: usize, row: u32, cols: u16) ?Span {
166 const r = self.range() orelse return null;
167 if (r.from.tile != tile) return null;
168 if (row < r.from.row or row > r.to.row) return null;
169 const from = if (row == r.from.row) r.from.col else 0;
170 const to = @min(if (row == r.to.row) r.to.col else cols -| 1, cols -| 1);
171 // A selection whose whole width fell off the grid's right edge
172 // highlights nothing on this row, rather than one clamped cell at
173 // the edge that nobody pointed at.
174 if (from > to) return null;
175 return .{ .from = from, .to = to };
176 }
177 };
178
179 test "select: a press alone is a click, and highlights nothing on its way" {
180 var d: Drag = .{};
181 const hit: Hit = .{ .tile = 1, .row = 40, .col = 7 };
182 d.press(.{ .row = 12, .col = 7 }, hit);
183 // Nothing is highlighted while the button is merely down: a press that
184 // turns out to be a click must never flicker an inversion on its way.
185 try std.testing.expect(d.span(1, 40, 80) == null);
186 try std.testing.expect(d.range() == null);
187 const r = d.release();
188 try std.testing.expectEqual(@as(usize, 1), r.click.tile);
189 try std.testing.expectEqual(@as(u32, 40), r.click.row);
190 try std.testing.expectEqual(@as(u16, 7), r.click.col);
191 // ...and the click leaves nothing behind to paint.
192 try std.testing.expect(d.range() == null);
193 }
194
195 test "select: a press on nothing selectable is nothing at all" {
196 var d: Drag = .{};
197 d.press(.{ .row = 0, .col = 3 }, null);
198 d.motion(.{ .row = 4, .col = 9 }, .{ .tile = 0, .row = 5, .col = 9 });
199 try std.testing.expect(d.range() == null);
200 try std.testing.expect(d.release() == .nothing);
201 }
202
203 test "select: either drag direction yields the same ordered pair" {
204 const top: Hit = .{ .tile = 0, .row = 100, .col = 4 };
205 const bot: Hit = .{ .tile = 0, .row = 103, .col = 12 };
206
207 var down: Drag = .{};
208 down.press(.{ .row = 2, .col = 4 }, top);
209 down.motion(.{ .row = 5, .col = 12 }, bot);
210 const a = down.release().selection;
211
212 var up: Drag = .{};
213 up.press(.{ .row = 5, .col = 12 }, bot);
214 up.motion(.{ .row = 2, .col = 4 }, top);
215 const b = up.release().selection;
216
217 try std.testing.expectEqual(a.from, b.from);
218 try std.testing.expectEqual(a.to, b.to);
219 try std.testing.expectEqual(@as(u32, 100), a.from.row);
220 try std.testing.expectEqual(@as(u16, 4), a.from.col);
221 try std.testing.expectEqual(@as(u32, 103), a.to.row);
222 try std.testing.expectEqual(@as(u16, 12), a.to.col);
223 }
224
225 test "select: a one-row drag reads left to right whichever way the hand moved" {
226 var d: Drag = .{};
227 d.press(.{ .row = 3, .col = 9 }, .{ .tile = 0, .row = 50, .col = 9 });
228 d.motion(.{ .row = 3, .col = 2 }, .{ .tile = 0, .row = 50, .col = 2 });
229 const s = d.span(0, 50, 80).?;
230 try std.testing.expectEqual(@as(u16, 2), s.from);
231 try std.testing.expectEqual(@as(u16, 9), s.to);
232 // One row, so neither neighbour is in it.
233 try std.testing.expect(d.span(0, 49, 80) == null);
234 try std.testing.expect(d.span(0, 51, 80) == null);
235 }
236
237 test "select: the ends are clipped at the anchors, and the middle is the full width" {
238 var d: Drag = .{};
239 d.press(.{ .row = 1, .col = 30 }, .{ .tile = 2, .row = 7, .col = 30 });
240 d.motion(.{ .row = 4, .col = 6 }, .{ .tile = 2, .row = 10, .col = 6 });
241
242 const first = d.span(2, 7, 80).?;
243 try std.testing.expectEqual(@as(u16, 30), first.from);
244 try std.testing.expectEqual(@as(u16, 79), first.to);
245 const middle = d.span(2, 8, 80).?;
246 try std.testing.expectEqual(@as(u16, 0), middle.from);
247 try std.testing.expectEqual(@as(u16, 79), middle.to);
248 const last = d.span(2, 10, 80).?;
249 try std.testing.expectEqual(@as(u16, 0), last.from);
250 try std.testing.expectEqual(@as(u16, 6), last.to);
251 // Outside the range on both sides.
252 try std.testing.expect(d.span(2, 6, 80) == null);
253 try std.testing.expect(d.span(2, 11, 80) == null);
254 }
255
256 test "select: the highlight is one tile's, and the drag cannot leave it" {
257 var d: Drag = .{};
258 d.press(.{ .row = 2, .col = 1 }, .{ .tile = 0, .row = 5, .col = 1 });
259 // A drag onto the neighbouring stripe: it IS a drag, and the active
260 // end stays on the last line of the tile it started in.
261 d.motion(.{ .row = 9, .col = 40 }, .{ .tile = 1, .row = 200, .col = 40 });
262 const s = d.span(0, 5, 80).?;
263 try std.testing.expectEqual(@as(u16, 1), s.from);
264 try std.testing.expectEqual(@as(u16, 1), s.to);
265 // Neither the row it strayed onto nor the tile it strayed into.
266 try std.testing.expect(d.span(1, 200, 80) == null);
267 try std.testing.expect(d.span(0, 200, 80) == null);
268 // The same rows, asked for as somebody else's tile, are not the answer.
269 try std.testing.expect(d.span(1, 5, 80) == null);
270 }
271
272 test "select: a pointer that never leaves the press cell has not dragged" {
273 var d: Drag = .{};
274 d.press(.{ .row = 6, .col = 20 }, .{ .tile = 0, .row = 6, .col = 20 });
275 // `?1002h` reports on a cell change, but a terminal repeating the cell
276 // must not turn a click into an empty selection.
277 d.motion(.{ .row = 6, .col = 20 }, .{ .tile = 0, .row = 6, .col = 20 });
278 try std.testing.expect(d.range() == null);
279 try std.testing.expect(d.release() == .click);
280 }
281
282 test "select: a drag that comes back to where it started is still a drag" {
283 var d: Drag = .{};
284 const home: Hit = .{ .tile = 0, .row = 6, .col = 20 };
285 d.press(.{ .row = 6, .col = 20 }, home);
286 d.motion(.{ .row = 6, .col = 25 }, .{ .tile = 0, .row = 6, .col = 25 });
287 d.motion(.{ .row = 6, .col = 20 }, home);
288 try std.testing.expect(d.release() == .selection);
289 // One cell selected, and it is still on screen after the button is up.
290 const s = d.span(0, 6, 80).?;
291 try std.testing.expectEqual(@as(u16, 20), s.from);
292 try std.testing.expectEqual(@as(u16, 20), s.to);
293 }
294
295 test "select: the highlight outlives the release, and dies on clear" {
296 var d: Drag = .{};
297 d.press(.{ .row = 0, .col = 0 }, .{ .tile = 0, .row = 3, .col = 0 });
298 d.motion(.{ .row = 1, .col = 5 }, .{ .tile = 0, .row = 4, .col = 5 });
299 _ = d.release();
300 try std.testing.expect(d.span(0, 3, 80) != null);
301 // A motion after the button is up is somebody else's pointer moving
302 // over a selection that is finished.
303 d.motion(.{ .row = 8, .col = 8 }, .{ .tile = 0, .row = 11, .col = 8 });
304 try std.testing.expectEqual(@as(u32, 4), d.range().?.to.row);
305 try std.testing.expect(d.release() == .nothing);
306 d.clear();
307 try std.testing.expect(d.range() == null);
308 try std.testing.expect(d.span(0, 3, 80) == null);
309 }
310
311 test "select: a new press drops the selection the last one left" {
312 var d: Drag = .{};
313 d.press(.{ .row = 0, .col = 0 }, .{ .tile = 0, .row = 3, .col = 0 });
314 d.motion(.{ .row = 1, .col = 5 }, .{ .tile = 0, .row = 4, .col = 5 });
315 _ = d.release();
316 d.press(.{ .row = 7, .col = 2 }, .{ .tile = 0, .row = 10, .col = 2 });
317 try std.testing.expect(d.span(0, 3, 80) == null);
318 try std.testing.expect(d.range() == null);
319 // ...and a press on nothing drops it just as thoroughly.
320 d.motion(.{ .row = 7, .col = 6 }, .{ .tile = 0, .row = 10, .col = 6 });
321 try std.testing.expect(d.range() != null);
322 d.press(.{ .row = 0, .col = 0 }, null);
323 try std.testing.expect(d.range() == null);
324 }
325
326 test "select: a motion with no button down selects nothing" {
327 var d: Drag = .{};
328 d.motion(.{ .row = 4, .col = 4 }, .{ .tile = 0, .row = 4, .col = 4 });
329 try std.testing.expect(d.range() == null);
330 try std.testing.expect(d.release() == .nothing);
331 }
332
333 test "select: a span past the grid's right edge is clipped, not painted at the edge" {
334 // The tty is wider than the grid — latest-wins leaves that shape
335 // routinely — so a pointer can reach columns with no cell behind them.
336 var d: Drag = .{};
337 d.press(.{ .row = 0, .col = 90 }, .{ .tile = 0, .row = 2, .col = 90 });
338 d.motion(.{ .row = 1, .col = 95 }, .{ .tile = 0, .row = 3, .col = 95 });
339 // The first row's anchor is off the grid entirely: nothing to invert,
340 // and NOT one cell at column 39.
341 try std.testing.expect(d.span(0, 2, 40) == null);
342 // The last row runs from the left edge to the grid's own right edge.
343 const last = d.span(0, 3, 40).?;
344 try std.testing.expectEqual(@as(u16, 0), last.from);
345 try std.testing.expectEqual(@as(u16, 39), last.to);
346 }
347
348 // Forces semantic analysis of every pub decl under `zig build test`, so an
349 // unreferenced decl must at least compile (the silent-module-loss hazard,
350 // decisions.md). Pub decls only: std.meta.declarations sees nothing private.
351 test {
352 std.testing.refAllDeclsRecursive(@This());
353 }