a73x

d49ecd82

feat: a drag across a stripe highlights the lines it crossed

a73x   2026-08-22 13:13

Commit message
feat: a drag across a stripe highlights the lines it crossed

The unzoomed half. The keyboard owns the mouse and the hit-test; the
PUMPS own the paint. So the drag lives in `Shared` under `paint_mu`,
written by the keyboard and drawn by each stripe paint that was going
to happen anyway — anything the keyboard drew itself would survive
exactly until the next delta from that session repainted the row.

The keyboard makes it prompt the way `setZoom` does: bump `repaint_gen`
and ring every doorbell. Only when the highlight actually moved, which
is what keeps a press from repainting the whole wall for a click, and
what makes a drag that has run off the edge of its tile cost nothing.

A drag is confined to the stripe it started in. Off that stripe the far
end simply stops moving — a wall is panes, and a selection that leaked
into the neighbour would ask the wrong daemon session for its text.

`Click` becomes the drag machine, and `Hit` becomes `select.Hit`: the
same triple, so there is one of it. The click rule is unchanged and
still `MouseDown1Pane`, it is now the `.click` answer a release gives
when the pointer never left the cell.

Three clears, three different reasons, and none of them is a fourth
copy of the same line. `WallInput.reset` drops it with the rest of what
a zoom transition invalidates — the drag is in `Shared` only because
the pumps must read it, and it is dropped where its siblings are.
`relayout` drops it because the stripes are about to be re-cut under
the anchor, which covers `x` as well since forgetting a tile ends
there. And a reconnecting pump drops the wall's if it is over ITS tile,
because the resync about to land renames the absolute row space
outright.

The spec learns what building this measured. The highlight bracket was
wrong in both halves — it opens with a reset before the inversion or the
head's own SGR survives inside it, and closes with a reset rather than
`27m`, which was clean only by accident of the plain emitter. The
formatter trims by default, so span pieces are addressed by CHA and
never by counting characters. Reusing the machinery does not conjure
cells: a drag past the end of a short line stops at the text, whatever
column the pointer reached. The keyboard-must-not-paint rule was written
universally and is a WALL rule — `Core.forward` already runs on the pump
thread that owns the paint, so the zoomed driver paints directly. And a
delta cannot paint a highlighted row, which was in neither the spec nor
the brief: rows come back from the daemon as sent, so a held selection
develops holes wherever the session is writing.

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
@@ -280,7 +280,7 @@ const mod_table = [_]ModSpec{
280 // typed-at session; the overlay machinery itself is interact's, shared — 280 // typed-at session; the overlay machinery itself is interact's, shared —
281 // and phase 3 promotes the tile into that core rather than growing a 281 // and phase 3 promotes the tile into that core rather than growing a
282 // second copy of it. 282 // second copy of it.
283 .{ .name = "wallview", .path = "src/wallview.zig", .layer = 4, .link_libc = true, .imports = &.{ "protocol", "client", "interact", "wall", "handoff", "xdg", "sockpath", "proxy", "engine", "paint" }, .test_imports = &.{"testtmp"}, .quic_tests = true }, 283 .{ .name = "wallview", .path = "src/wallview.zig", .layer = 4, .link_libc = true, .imports = &.{ "protocol", "client", "interact", "wall", "handoff", "xdg", "sockpath", "proxy", "engine", "paint", "select" }, .test_imports = &.{"testtmp"}, .quic_tests = true },
284 // ---- layer 5 ---- 284 // ---- layer 5 ----
285 // wall owns the spelling grammar and the state file, so argv is parsed 285 // wall owns the spelling grammar and the state file, so argv is parsed
286 // by the SAME rules the page's POST /tiles and the restored file are — 286 // by the SAME rules the page's POST /tiles and the restored file are —
docs/superpowers/specs/2026-08-21-cli-drag-selection-design.md
Old New
@@ -221,7 +221,14 @@ under `paint_mu` inside the stripe paint. **Anchors convert to absolute rows
221 at press time**: a terminal-row anchor silently re-selects a different line 221 at press time**: a terminal-row anchor silently re-selects a different line
222 when output scrolls the window mid-drag. 222 when output scrolls the window mid-drag.
223 223
224 **The keyboard must never paint the highlight.** Pumps repaint stripes from 224 **The keyboard must never paint the highlight — on the WALL.** M3 found this
225 rule is narrower than written: `Core.forward` runs on the tile's *pump*
226 thread, which already owns the paint and the sink, so the zoomed driver
227 paints its own highlight directly and needs no shared state at all. The
228 relay below is a wall requirement, not a universal one, and M4's zoomed path
229 can call `beginSelection` from where it already stands.
230
231 On the wall it holds exactly as written. Pumps repaint stripes from
225 the replica on every frame under `paint_mu`; anything the keyboard drew dies 232 the replica on every frame under `paint_mu`; anything the keyboard drew dies
226 one delta later. Highlight spans live in shared state under `paint_mu`, 233 one delta later. Highlight spans live in shared state under `paint_mu`,
227 written by the keyboard and *rendered by the pump* inside its own stripe 234 written by the keyboard and *rendered by the pump* inside its own stripe
@@ -250,6 +257,15 @@ doc). The pump samples `history_rows` when issuing and discards a reply whose
250 space is renamed; in-flight requests are saved by the watermark, but a 257 space is renamed; in-flight requests are saved by the watermark, but a
251 retained highlight over a resynced replica is stale coordinates. 258 retained highlight over a resynced replica is stale coordinates.
252 259
260 **A delta cannot paint a highlighted row**, which this spec missed entirely
261 and M3 found. `paintDeltaClipped` paints the rows the daemon sent AS SENT,
262 so a row under the inversion comes back plain and the selection grows holes
263 wherever the session is still writing. A held selection therefore takes the
264 full-repaint arm that resync and contradiction already share. The cost is
265 full repaints while a selection is held over a busy session — acceptable for
266 a transient user action, and worth remembering next to main's recent work on
267 not rendering rows nobody is watching.
268
253 ## Rendering the highlight 269 ## Rendering the highlight
254 270
255 There is no VT sequence that inverts a sub-row span of what is already on 271 There is no VT sequence that inverts a sub-row span of what is already on
@@ -266,10 +282,26 @@ pub fn dumpVtRow(self: *Engine, alloc: std.mem.Allocator, y: u16) ![]u8 {
266 282
267 `viewportRows(y0, y1)` (`engine.zig` `viewportRows`) pins a `vt.Selection` from column 0 283 `viewportRows(y0, y1)` (`engine.zig` `viewportRows`) pins a `vt.Selection` from column 0
268 to `cols-1`; `formatSelection` (`engine.zig` `formatSelection`) renders any selection to VT 284 to `cols-1`; `formatSelection` (`engine.zig` `formatSelection`) renders any selection to VT
269 bytes. A span dump pins arbitrary columns instead and wraps the range in 285 bytes. A span dump pins arbitrary columns instead. The ghostty-vt machinery
270 `\x1b[7m` … `\x1b[27m`, neutralizing the row's own SGR inside it. The 286 that already handles wide cells and styling does the work.
271 ghostty-vt machinery that already handles wide cells and styling does the 287
272 work. 288 **Corrected against the real bytes when M3 built it** — three things this
289 section had wrong:
290
291 - The bracket is `\x1b[0m\x1b[7m` … `\x1b[0m`, **not** `\x1b[7m` … `\x1b[27m`.
292 Without the leading reset the head piece's SGR is still active inside the
293 highlight, which is the exact "styled cell escapes the inversion" case the
294 span dump exists to prevent. And `\x1b[27m` alone leaves the terminal clean
295 only by accident of `.emit = .plain`, while the tail's formatter assumes it
296 starts from default — a reset makes that true instead of lucky.
297 - **The formatter's `trim` defaults to true**, so the three pieces of a span
298 dump cannot be joined by counting characters: a head ending in blanks
299 leaves the cursor somewhere its byte length does not predict. Every piece
300 is addressed by CHA (`CSI n G`).
301 - **Reusing the machinery does not conjure cells.** `trim = false` restores
302 *written* spaces, but cells past the end of a row do not exist and no
303 option invents them. A drag off the end of a short line highlights to the
304 text and no further, whatever column the pointer reached.
273 305
274 ## The clipboard cap, which fails silently today 306 ## The clipboard cap, which fails silently today
275 307
@@ -311,8 +343,17 @@ Each ships and is usable on its own.
311 selection. First user-visible behaviour, no selection state yet. 343 selection. First user-visible behaviour, no selection state yet.
312 - **M3 — drag paints.** `select.zig`, spans under `paint_mu`, pump-side 344 - **M3 — drag paints.** `select.zig`, spans under `paint_mu`, pump-side
313 render via the new `Engine` span dump. Highlight only; nothing copies yet. 345 render via the new `Engine` span dump. Highlight only; nothing copies yet.
314 - **M4 — release copies.** The ask-relay to the pump, `beginSelection`, the 346 - **M4 — release copies.** The ask-relay to the pump (wall only — the zoomed
315 watermark check, OSC 52 out, and the too-large refusal message. 347 driver is already on the pump thread), `beginSelection`, the watermark
348 check, OSC 52 out, and the too-large refusal message.
349
350 M4 inherits one inconsistency it should decide rather than discover: the
351 highlight shows where the hand went, but `extractSelection` sets
352 `.trim = true`, so a selection ending in *written* blanks copies less than
353 it highlighted. Accepted rather than fixed — both terminals measured on
354 2026-08-21 trim trailing whitespace on copy too, and `.trim` is the
355 contract muxweb already shares, so changing it would change the browser's
356 answer to match a CLI edge case nobody asked for.
316 357
317 ## Deliberate exclusions 358 ## Deliberate exclusions
318 359
src/select.zig
Old New
@@ -139,6 +139,16 @@ pub const Drag = struct {
139 self.* = .{}; 139 self.* = .{};
140 } 140 }
141 141
142 /// Which tile this drag belongs to, or null when there is no drag.
143 ///
144 /// A press that has not moved yet counts: its anchor is already on a
145 /// tile, and a caller dropping that tile's coordinates has to drop it
146 /// too. That is the difference from `range`, which answers only about
147 /// what is on screen.
148 pub fn on(self: *const Drag) ?usize {
149 return if (self.phase == .idle) null else self.anchor.tile;
150 }
151
142 /// The selection as an ordered pair, or null while there is none. 152 /// The selection as an ordered pair, or null while there is none.
143 pub fn range(self: *const Drag) ?Range { 153 pub fn range(self: *const Drag) ?Range {
144 return switch (self.phase) { 154 return switch (self.phase) {
@@ -192,6 +202,23 @@ test "select: a press alone is a click, and highlights nothing on its way" {
192 try std.testing.expect(d.range() == null); 202 try std.testing.expect(d.range() == null);
193 } 203 }
194 204
205 test "select: a drag names its tile from the press, before it is a selection" {
206 var d: Drag = .{};
207 try std.testing.expect(d.on() == null);
208 d.press(.{ .row = 2, .col = 1 }, .{ .tile = 3, .row = 5, .col = 1 });
209 // Pressed, not yet dragged: nothing to paint, and still a tile whose
210 // coordinates a caller may have to drop.
211 try std.testing.expect(d.range() == null);
212 try std.testing.expectEqual(@as(usize, 3), d.on().?);
213 d.motion(.{ .row = 4, .col = 1 }, .{ .tile = 3, .row = 7, .col = 1 });
214 try std.testing.expectEqual(@as(usize, 3), d.on().?);
215 _ = d.release();
216 // Still held after the button came up, which is what a highlight is.
217 try std.testing.expectEqual(@as(usize, 3), d.on().?);
218 d.clear();
219 try std.testing.expect(d.on() == null);
220 }
221
195 test "select: a press on nothing selectable is nothing at all" { 222 test "select: a press on nothing selectable is nothing at all" {
196 var d: Drag = .{}; 223 var d: Drag = .{};
197 d.press(.{ .row = 0, .col = 3 }, null); 224 d.press(.{ .row = 0, .col = 3 }, null);
src/wallview.zig
Old New
@@ -101,6 +101,7 @@ const sockpath = @import("sockpath");
101 const proxy = @import("proxy"); 101 const proxy = @import("proxy");
102 const Engine = @import("engine").Engine; 102 const Engine = @import("engine").Engine;
103 const paint = @import("paint"); 103 const paint = @import("paint");
104 const select = @import("select");
104 // Counters ride out through `Shared` because a detached pump never reaches 105 // Counters ride out through `Shared` because a detached pump never reaches
105 // a `Core.deinit`. 106 // a `Core.deinit`.
106 // The chord table and the prediction hooks a zoomed tile shares with the 107 // The chord table and the prediction hooks a zoomed tile shares with the
@@ -314,6 +315,19 @@ const Shared = struct {
314 /// it against what they last painted at, which is the only thing that 315 /// it against what they last painted at, which is the only thing that
315 /// repaints a stripe whose session sent no new frame meanwhile. 316 /// repaints a stripe whose session sent no new frame meanwhile.
316 repaint_gen: std.atomic.Value(u64) = std.atomic.Value(u64).init(0), 317 repaint_gen: std.atomic.Value(u64) = std.atomic.Value(u64).init(0),
318 /// The text selection being dragged over a stripe, and the highlight
319 /// standing after it.
320 ///
321 /// SHARED, and under `paint_mu`, because the two halves of a highlight
322 /// belong to different threads. The keyboard is its only writer — it
323 /// is the thread that reads the mouse — and every pump is a reader:
324 /// a stripe is repainted from its replica on every frame, so anything
325 /// the keyboard drew itself would die one delta later. The keyboard
326 /// makes it prompt the way `setZoom` does, by bumping `repaint_gen`
327 /// and ringing. The doorbell coalesces motion-rate updates for free.
328 ///
329 /// Precedent: `sel` is under this lock for exactly the same reason.
330 drag: select.Drag = .{},
317 }; 331 };
318 332
319 /// The stripe window a paint used, in the only terms a click can be 333 /// The stripe window a paint used, in the only terms a click can be
@@ -485,20 +499,15 @@ fn stripeContentRow(s: Stripe, row: u16) ?u16 {
485 return if (off < s.rows -| 1) off else null; 499 return if (off < s.rows -| 1) off else null;
486 } 500 }
487 501
488 /// One place in one session, as a click names it.
489 const Hit = struct {
490 tile: usize,
491 /// Absolute row, counted from the oldest row the daemon still retains
492 /// — the coordinate space `protocol.SelectionReq` speaks.
493 row: u32,
494 /// Terminal column, zero-based. Grid columns are the same number:
495 /// every painter emits from column 1 with no x-offset, and DECAWM-off
496 /// clips at the right edge.
497 col: u16,
498 };
499
500 /// Which session line a zero-based terminal (row, col) points at. 502 /// Which session line a zero-based terminal (row, col) points at.
501 /// 503 ///
504 /// `select.Hit` and not a spelling of its own: it is the same triple the
505 /// drag machine takes, and a second one would be a conversion nobody
506 /// needs. Absolute rows, counted from the oldest row the daemon still
507 /// retains — the coordinate space `protocol.SelectionReq` speaks — and a
508 /// terminal column, which is a grid column too: every painter emits from
509 /// column 1 with no x-offset, and DECAWM-off clips at the right edge.
510 ///
502 /// Under `paint_mu` because `Tile.stripe` and `Tile.win` are both written 511 /// Under `paint_mu` because `Tile.stripe` and `Tile.win` are both written
503 /// under it: a re-layout landing between reading one and the other would 512 /// under it: a re-layout landing between reading one and the other would
504 /// resolve the click against a geometry that never existed. 513 /// resolve the click against a geometry that never existed.
@@ -506,7 +515,7 @@ const Hit = struct {
506 /// `present` and not the geometry decides whether a tile is there at all — 515 /// `present` and not the geometry decides whether a tile is there at all —
507 /// `relayout` never compacts the tile array, so a forgotten tile keeps the 516 /// `relayout` never compacts the tile array, so a forgotten tile keeps the
508 /// stripe it had when it left. 517 /// stripe it had when it left.
509 fn hitTest(tiles: []Tile, present: []const bool, shared: *Shared, row: u16, col: u16) ?Hit { 518 fn hitTest(tiles: []Tile, present: []const bool, shared: *Shared, row: u16, col: u16) ?select.Hit {
510 shared.paint_mu.lock(); 519 shared.paint_mu.lock();
511 defer shared.paint_mu.unlock(); 520 defer shared.paint_mu.unlock();
512 for (tiles, present) |*t, p| { 521 for (tiles, present) |*t, p| {
@@ -559,26 +568,14 @@ const WallDrain = struct {
559 } 568 }
560 }; 569 };
561 570
562 /// What the wall is holding between a press and its release.
563 const Click = struct {
564 /// Where the press landed, or null when this wall knows of no button
565 /// down. Resolved at PRESS time and kept resolved: a terminal row
566 /// remembered instead would name a different line as soon as the
567 /// session scrolled the window under it.
568 down: ?Hit = null,
569 /// The terminal cell the press was on.
570 row: u16 = 0,
571 col: u16 = 0,
572 /// Whether the pointer left that cell before the button came up.
573 dragged: bool = false,
574 };
575
576 /// What a mouse report does at an UNZOOMED wall. 571 /// What a mouse report does at an UNZOOMED wall.
577 /// 572 ///
578 /// A plain click — press and release with the pointer never leaving the 573 /// A plain click — press and release with the pointer never leaving the
579 /// cell — moves the selection to the stripe under it. That is tmux's 574 /// cell — moves the selection to the stripe under it. That is tmux's
580 /// `MouseDown1Pane -> select-pane`, deliberately, because it is what the 575 /// `MouseDown1Pane -> select-pane`, deliberately, because it is what the
581 /// user already has in their hands. A drag does nothing yet. 576 /// user already has in their hands. A DRAG highlights the lines it
577 /// crossed, inside the stripe it started in, and leaves that highlight
578 /// standing when the button comes up.
582 /// 579 ///
583 /// The selection follows the RELEASE and not the press, so a press that 580 /// The selection follows the RELEASE and not the press, so a press that
584 /// turns out to be the start of a drag never flickers the marker on its 581 /// turns out to be the start of a drag never flickers the marker on its
@@ -588,36 +585,55 @@ const Click = struct {
588 /// stealing either would be a surprise the wall has no answer for. The 585 /// stealing either would be a surprise the wall has no answer for. The
589 /// button word arrives from the filter verbatim, modifier bits and the 586 /// button word arrives from the filter verbatim, modifier bits and the
590 /// motion bit included, so it is the low two bits that name the button. 587 /// motion bit included, so it is the low two bits that name the button.
588 ///
589 /// The keyboard never PAINTS the highlight. It writes the drag under
590 /// `paint_mu` and rings, and the pumps draw it inside the stripe paints
591 /// they were doing anyway (`paintStripe`) — anything drawn from here
592 /// would be overwritten by the next frame from the session.
591 fn wallMouse( 593 fn wallMouse(
592 tiles: []Tile, 594 tiles: []Tile,
593 present: []const bool, 595 present: []const bool,
594 shared: *Shared, 596 shared: *Shared,
595 click: *Click,
596 ev: interact.MouseFilter.Event, 597 ev: interact.MouseFilter.Event,
597 ) void { 598 ) void {
598 if (ev.button & 0b11 != 0) return; 599 if (ev.button & 0b11 != 0) return;
599 switch (ev.kind) { 600 // Resolved BEFORE the lock and outside it, because `hitTest` takes the
600 .press => click.* = .{ 601 // same mutex. Nothing can invalidate the answer in between: the only
601 .down = hitTest(tiles, present, shared, ev.row, ev.col), 602 // thread that re-cuts stripes is this one.
602 .row = ev.row, 603 const hit = hitTest(tiles, present, shared, ev.row, ev.col);
603 .col = ev.col, 604 const cell: select.Cell = .{ .row = ev.row, .col = ev.col };
604 }, 605
605 // Cell granularity, not pixel: `?1002h` reports motion when the 606 var ended: select.Release = .nothing;
606 // pointer changes CELL, and a hand that trembles inside one cell 607 var moved = false;
607 // is still pointing at one line. 608 {
608 .motion => if (ev.row != click.row or ev.col != click.col) { 609 shared.paint_mu.lock();
609 click.dragged = true; 610 defer shared.paint_mu.unlock();
610 }, 611 const was = shared.drag.range();
611 .release => { 612 switch (ev.kind) {
612 const was = click.*; 613 .press => shared.drag.press(cell, hit),
613 click.* = .{}; 614 .motion => shared.drag.motion(cell, hit),
614 if (was.dragged) return; 615 .release => ended = shared.drag.release(),
615 const hit = was.down orelse return; 616 }
616 // The press may have landed on a tile `x` has taken away 617 moved = !std.meta.eql(was, shared.drag.range());
617 // since — the same guard `Enter` keeps for the same reason. 618 }
618 if (hit.tile < present.len and present[hit.tile]) 619
619 moveSelection(tiles, shared, hit.tile); 620 // The highlight changed, so every stripe is now a frame out of date.
620 }, 621 // One bump and one round of doorbells, exactly as `setZoom` does it —
622 // and the doorbell is what makes a drag feel like a drag rather than
623 // like one poll timeout per cell.
624 if (moved) {
625 _ = shared.repaint_gen.fetchAdd(1, .release);
626 for (tiles, present) |*t, p| {
627 if (p) ring(t);
628 }
629 }
630
631 if (ended == .click) {
632 // The press may have landed on a tile `x` has taken away since —
633 // the same guard `Enter` keeps for the same reason.
634 const at = ended.click;
635 if (at.tile < present.len and present[at.tile])
636 moveSelection(tiles, shared, at.tile);
621 } 637 }
622 } 638 }
623 639
@@ -637,14 +653,20 @@ const WallInput = struct {
637 /// bytes it is handed zoomed, this one runs over the bytes that arrive 653 /// bytes it is handed zoomed, this one runs over the bytes that arrive
638 /// UNZOOMED, which the wall's own modes are what produce. 654 /// UNZOOMED, which the wall's own modes are what produce.
639 mouse: interact.MouseFilter = .{}, 655 mouse: interact.MouseFilter = .{},
640 click: Click = .{},
641 656
642 fn reset(self: *WallInput) void { 657 /// The drag itself lives in `Shared` — the pumps have to read it to
658 /// paint it — but it is dropped HERE, with the rest, because it is
659 /// dropped for the same reason: the screen it describes has stopped
660 /// existing, and its anchor names a stripe that is about to belong to
661 /// nobody.
662 fn reset(self: *WallInput, shared: *Shared) void {
643 self.prefix = .{}; 663 self.prefix = .{};
644 // Whatever the filter still holds is the head of a report from the 664 // Whatever the filter still holds is the head of a report from the
645 // read being dropped here. 665 // read being dropped here.
646 self.mouse.reset(); 666 self.mouse.reset();
647 self.click = .{}; 667 shared.paint_mu.lock();
668 defer shared.paint_mu.unlock();
669 shared.drag.clear();
648 } 670 }
649 }; 671 };
650 672
@@ -833,7 +855,18 @@ fn paintStripe(t: *Tile, alloc: std.mem.Allocator, eng: *Engine, history_rows: u
833 .history_rows = history_rows, 855 .history_rows = history_rows,
834 .win_start = paint.stripeWinStart(@intCast(eng.term.rows), eng.cursorPos().y, view.rows), 856 .win_start = paint.stripeWinStart(@intCast(eng.term.rows), eng.cursorPos().y, view.rows),
835 }; 857 };
836 paint.renderStripe(alloc, eng, t.stripe.top + 1, view, .{}, t.shared.out_fd) catch {}; 858 // The highlight is drawn HERE and never by the keyboard. A stripe is
859 // repainted from its replica on every frame, so an inversion the
860 // keyboard wrote onto the terminal would last exactly until the next
861 // delta; the keyboard writes the drag under this lock instead and
862 // rings, and this paint — the one that would have erased it — is the
863 // one that draws it.
864 const hl: interact.Highlight = .{
865 .drag = &t.shared.drag,
866 .tile = t.idx,
867 .history_rows = history_rows,
868 };
869 paint.renderStripe(alloc, eng, t.stripe.top + 1, view, hl.sink(), t.shared.out_fd) catch {};
837 return true; 870 return true;
838 } 871 }
839 872
@@ -1261,13 +1294,34 @@ fn redial(
1261 transport.* = dial(alloc, t, target) orelse return false; 1294 transport.* = dial(alloc, t, target) orelse return false;
1262 // Clears `state_since_attach` (so the next exit_status is read as a 1295 // Clears `state_since_attach` (so the next exit_status is read as a
1263 // refusal again) and drops speculation made against a connection that 1296 // refusal again) and drops speculation made against a connection that
1264 // no longer exists. 1297 // no longer exists — the zoomed Core's own highlight with it.
1265 core.reattached(); 1298 core.reattached();
1299 dropDragOver(t);
1266 const have = core.rep.attachArgs(); 1300 const have = core.rep.attachArgs();
1267 sendAttach(t, transport, have.have_seq, have.have_epoch) catch return false; 1301 sendAttach(t, transport, have.have_seq, have.have_epoch) catch return false;
1268 return true; 1302 return true;
1269 } 1303 }
1270 1304
1305 /// Drop the WALL's highlight, if it is over this tile.
1306 ///
1307 /// The pump's own clear, and the third of three: the keyboard's two are
1308 /// about the screen changing shape (`WallInput.reset`, `relayout`), and
1309 /// this one is about the coordinates changing meaning. Absolute rows count
1310 /// from the oldest row the daemon still retains, and the resync a
1311 /// reconnect brings renames that space outright — a highlight kept across
1312 /// it inverts rows nobody selected.
1313 ///
1314 /// Only this tile's. A neighbour reconnecting says nothing about a
1315 /// selection over a session that never went away.
1316 ///
1317 /// Written from the pump because the pump is where a reconnect is noticed,
1318 /// under the same lock the keyboard writes the drag with.
1319 fn dropDragOver(t: *Tile) void {
1320 t.shared.paint_mu.lock();
1321 defer t.shared.paint_mu.unlock();
1322 if (t.shared.drag.on() == t.idx) t.shared.drag.clear();
1323 }
1324
1271 /// One tile's life: dial → attach → replay frames into its Core → repaint. 1325 /// One tile's life: dial → attach → replay frames into its Core → repaint.
1272 /// Runs on its own thread (see module header). On transport death: 1326 /// Runs on its own thread (see module header). On transport death:
1273 /// reconnect on the CLI's backoff schedule, quoting have_seq/have_epoch, 1327 /// reconnect on the CLI's backoff schedule, quoting have_seq/have_epoch,
@@ -1992,7 +2046,7 @@ fn setZoom(tiles: []Tile, shared: *Shared, input: *WallInput, next: usize) void
1992 // are mid-way through belongs to a screen that is about to stop 2046 // are mid-way through belongs to a screen that is about to stop
1993 // existing. Here rather than at the call sites: this is the transition, 2047 // existing. Here rather than at the call sites: this is the transition,
1994 // and there are six of them. 2048 // and there are six of them.
1995 input.reset(); 2049 input.reset(shared);
1996 shared.paint_mu.lock(); 2050 shared.paint_mu.lock();
1997 defer shared.paint_mu.unlock(); 2051 defer shared.paint_mu.unlock();
1998 // See the note above: the outgoing session's modes come off HERE, on 2052 // See the note above: the outgoing session's modes come off HERE, on
@@ -2125,6 +2179,10 @@ fn relayout(
2125 shared.paint_mu.lock(); 2179 shared.paint_mu.lock();
2126 defer shared.paint_mu.unlock(); 2180 defer shared.paint_mu.unlock();
2127 shared.sel = sel; 2181 shared.sel = sel;
2182 // The stripes are about to be re-cut, so an anchor resolved against
2183 // the old ones names a line on a stripe that is moving out from under
2184 // it. Covers `x` as well, which ends here.
2185 shared.drag.clear();
2128 2186
2129 var live: usize = 0; 2187 var live: usize = 0;
2130 for (present) |p| { 2188 for (present) |p| {
@@ -3024,7 +3082,7 @@ pub fn run(alloc: std.mem.Allocator, resolved: []const Resolved, entry: Entry) !
3024 while (drain.next()) |step| { 3082 while (drain.next()) |step| {
3025 const key = switch (step) { 3083 const key = switch (step) {
3026 .mouse => |ev| { 3084 .mouse => |ev| {
3027 wallMouse(tiles[0..live], present[0..live], &shared, &input.click, ev); 3085 wallMouse(tiles[0..live], present[0..live], &shared, ev);
3028 continue; 3086 continue;
3029 }, 3087 },
3030 .key => |k| k, 3088 .key => |k| k,
@@ -3637,7 +3695,14 @@ test "the wall drains keys and reports in the order the terminal wrote them" {
3637 try std.testing.expect(d.next() == null); 3695 try std.testing.expect(d.next() == null);
3638 } 3696 }
3639 3697
3640 test "a plain click selects the stripe under it, and a drag selects nothing" { 3698 /// Everything sitting in the wall's output pipe right now. Non-blocking,
3699 /// so an empty pipe reads as empty rather than parking the suite.
3700 fn drainWallPipe(fd: std.posix.fd_t, buf: []u8) []const u8 {
3701 const n = std.posix.read(fd, buf) catch return "";
3702 return buf[0..n];
3703 }
3704
3705 test "a plain click selects the stripe under it, and a drag highlights instead" {
3641 const p = try std.posix.pipe2(.{ .NONBLOCK = true }); 3706 const p = try std.posix.pipe2(.{ .NONBLOCK = true });
3642 defer std.posix.close(p[0]); 3707 defer std.posix.close(p[0]);
3643 defer std.posix.close(p[1]); 3708 defer std.posix.close(p[1]);
@@ -3647,42 +3712,212 @@ test "a plain click selects the stripe under it, and a drag selects nothing" {
3647 .{ .r = .{ .target = .{ .sock = "/s" }, .label = "b", .session = "b" }, .stripe = .{ .top = 12, .rows = 12 }, .shared = &shared, .idx = 1 }, 3712 .{ .r = .{ .target = .{ .sock = "/s" }, .label = "b", .session = "b" }, .stripe = .{ .top = 12, .rows = 12 }, .shared = &shared, .idx = 1 },
3648 }; 3713 };
3649 var present = [_]bool{ true, true }; 3714 var present = [_]bool{ true, true };
3650 var click: Click = .{}; 3715 // Both stripes have painted once, so a terminal row resolves to a line.
3716 tiles[0].win = .{ .history_rows = 100, .win_start = 0 };
3717 tiles[1].win = .{ .history_rows = 200, .win_start = 3 };
3651 3718
3652 const press: interact.MouseFilter.Event = .{ .kind = .press, .button = 0, .col = 4, .row = 15, .at = 0 }; 3719 const press: interact.MouseFilter.Event = .{ .kind = .press, .button = 0, .col = 4, .row = 15, .at = 0 };
3653 const release: interact.MouseFilter.Event = .{ .kind = .release, .button = 0, .col = 4, .row = 15, .at = 0 }; 3720 const release: interact.MouseFilter.Event = .{ .kind = .release, .button = 0, .col = 4, .row = 15, .at = 0 };
3654 3721
3655 // Press and release in the same cell: tmux's MouseDown1Pane. 3722 // Press and release in the same cell: tmux's MouseDown1Pane.
3656 wallMouse(&tiles, &present, &shared, &click, press); 3723 wallMouse(&tiles, &present, &shared, press);
3657 // The press alone moves nothing — the selection follows the RELEASE, so 3724 // The press alone moves nothing — the selection follows the RELEASE, so
3658 // a press that turns into a drag never flickers the marker on its way. 3725 // a press that turns into a drag never flickers the marker on its way.
3659 try std.testing.expectEqual(@as(usize, 0), shared.sel); 3726 try std.testing.expectEqual(@as(usize, 0), shared.sel);
3660 wallMouse(&tiles, &present, &shared, &click, release); 3727 // ...and highlights nothing either, for the same reason.
3728 try std.testing.expect(shared.drag.range() == null);
3729 wallMouse(&tiles, &present, &shared, release);
3661 try std.testing.expectEqual(@as(usize, 1), shared.sel); 3730 try std.testing.expectEqual(@as(usize, 1), shared.sel);
3662 3731
3663 // A drag: the pointer leaves the cell before the button comes up, so 3732 // A drag: the pointer leaves the cell before the button comes up, so
3664 // the release is a drag end and not a click. Nothing consumes a drag 3733 // the release is a drag end and not a click. It must not quietly mean
3665 // yet, and it must not quietly mean "select" in the meantime. 3734 // "select" — it means "highlight", and the marker stays where it was.
3666 moveSelection(&tiles, &shared, 0); 3735 moveSelection(&tiles, &shared, 0);
3667 wallMouse(&tiles, &present, &shared, &click, press); 3736 wallMouse(&tiles, &present, &shared, press);
3668 wallMouse(&tiles, &present, &shared, &click, .{ .kind = .motion, .button = 32, .col = 9, .row = 17, .at = 0 }); 3737 wallMouse(&tiles, &present, &shared, .{ .kind = .motion, .button = 32, .col = 9, .row = 17, .at = 0 });
3669 wallMouse(&tiles, &present, &shared, &click, .{ .kind = .release, .button = 0, .col = 9, .row = 17, .at = 0 }); 3738 wallMouse(&tiles, &present, &shared, .{ .kind = .release, .button = 0, .col = 9, .row = 17, .at = 0 });
3670 try std.testing.expectEqual(@as(usize, 0), shared.sel); 3739 try std.testing.expectEqual(@as(usize, 0), shared.sel);
3671 3740 // Stripe b, whose window began at grid row 3 with 200 rows behind it:
3672 // A click on a label bar names no line, so it moves nothing. 3741 // terminal row 15 is the third content line of that stripe.
3673 wallMouse(&tiles, &present, &shared, &click, .{ .kind = .press, .button = 0, .col = 0, .row = 12, .at = 0 }); 3742 const held = shared.drag.range().?;
3674 wallMouse(&tiles, &present, &shared, &click, .{ .kind = .release, .button = 0, .col = 0, .row = 12, .at = 0 }); 3743 try std.testing.expectEqual(@as(usize, 1), held.from.tile);
3744 try std.testing.expectEqual(@as(u32, 205), held.from.row);
3745 try std.testing.expectEqual(@as(u16, 4), held.from.col);
3746 try std.testing.expectEqual(@as(u32, 207), held.to.row);
3747 try std.testing.expectEqual(@as(u16, 9), held.to.col);
3748
3749 // A click on a label bar names no line, so it moves nothing — and puts
3750 // the highlight away, which is what a press anywhere does.
3751 wallMouse(&tiles, &present, &shared, .{ .kind = .press, .button = 0, .col = 0, .row = 12, .at = 0 });
3752 wallMouse(&tiles, &present, &shared, .{ .kind = .release, .button = 0, .col = 0, .row = 12, .at = 0 });
3675 try std.testing.expectEqual(@as(usize, 0), shared.sel); 3753 try std.testing.expectEqual(@as(usize, 0), shared.sel);
3754 try std.testing.expect(shared.drag.range() == null);
3676 3755
3677 // Right and middle stay the terminal's own: its paste and its menu. 3756 // Right and middle stay the terminal's own: its paste and its menu.
3678 wallMouse(&tiles, &present, &shared, &click, .{ .kind = .press, .button = 2, .col = 4, .row = 15, .at = 0 }); 3757 wallMouse(&tiles, &present, &shared, .{ .kind = .press, .button = 2, .col = 4, .row = 15, .at = 0 });
3679 wallMouse(&tiles, &present, &shared, &click, .{ .kind = .release, .button = 2, .col = 4, .row = 15, .at = 0 }); 3758 wallMouse(&tiles, &present, &shared, .{ .kind = .motion, .button = 34, .col = 9, .row = 17, .at = 0 });
3759 wallMouse(&tiles, &present, &shared, .{ .kind = .release, .button = 2, .col = 9, .row = 17, .at = 0 });
3680 try std.testing.expectEqual(@as(usize, 0), shared.sel); 3760 try std.testing.expectEqual(@as(usize, 0), shared.sel);
3761 try std.testing.expect(shared.drag.range() == null);
3681 3762
3682 // A release with no press behind it — the other half arrived while a 3763 // A release with no press behind it — the other half arrived while a
3683 // zoom held the terminal, or before this wall started reading at all. 3764 // zoom held the terminal, or before this wall started reading at all.
3684 wallMouse(&tiles, &present, &shared, &click, release); 3765 wallMouse(&tiles, &present, &shared, release);
3685 try std.testing.expectEqual(@as(usize, 0), shared.sel); 3766 try std.testing.expectEqual(@as(usize, 0), shared.sel);
3767 try std.testing.expect(shared.drag.range() == null);
3768 }
3769
3770 test "a drag at the wall stays in the stripe it started in, and rings for a repaint" {
3771 const p = try std.posix.pipe2(.{ .NONBLOCK = true });
3772 defer std.posix.close(p[0]);
3773 defer std.posix.close(p[1]);
3774 var shared = Shared{ .out_fd = p[1], .size = .{ .cols = 80, .rows = 24 }, .is_tty = true };
3775 var tiles = [_]Tile{
3776 .{ .r = .{ .target = .{ .sock = "/s" }, .label = "a", .session = "a" }, .stripe = .{ .top = 0, .rows = 12 }, .shared = &shared, .idx = 0 },
3777 .{ .r = .{ .target = .{ .sock = "/s" }, .label = "b", .session = "b" }, .stripe = .{ .top = 12, .rows = 12 }, .shared = &shared, .idx = 1 },
3778 };
3779 var present = [_]bool{ true, true };
3780 for (&tiles) |*t| {
3781 const pipe = try std.posix.pipe2(.{ .NONBLOCK = true });
3782 t.wake_r = pipe[0];
3783 t.wake_w = pipe[1];
3784 }
3785 defer for (&tiles) |*t| {
3786 std.posix.close(t.wake_r);
3787 std.posix.close(t.wake_w);
3788 };
3789 tiles[0].win = .{ .history_rows = 100, .win_start = 0 };
3790 tiles[1].win = .{ .history_rows = 200, .win_start = 3 };
3791
3792 const gen0 = shared.repaint_gen.load(.acquire);
3793 wallMouse(&tiles, &present, &shared, .{ .kind = .press, .button = 0, .col = 4, .row = 3, .at = 0 });
3794 // A press changes no highlight, so it costs no repaint: a wall that
3795 // bumped the generation on every press would redraw every stripe for
3796 // a click.
3797 try std.testing.expectEqual(gen0, shared.repaint_gen.load(.acquire));
3798
3799 // Down into the NEXT stripe. It is a drag, and its far end stops at
3800 // the last line of the tile it started in — a selection that leaked
3801 // into the neighbour would ask the wrong session for its text.
3802 wallMouse(&tiles, &present, &shared, .{ .kind = .motion, .button = 32, .col = 7, .row = 9, .at = 0 });
3803 const grew = shared.repaint_gen.load(.acquire);
3804 // The move that put a highlight on screen rang for a repaint. The
3805 // PUMPS draw it, so without the bump and the doorbell a drag would
3806 // appear one poll timeout per cell late — or never, over a session
3807 // quiet enough to send no frames of its own.
3808 try std.testing.expectEqual(gen0 + 1, grew);
3809 var bell: [8]u8 = undefined;
3810 try std.testing.expect(try std.posix.read(tiles[0].wake_r, &bell) > 0);
3811 try std.testing.expect(try std.posix.read(tiles[1].wake_r, &bell) > 0);
3812
3813 wallMouse(&tiles, &present, &shared, .{ .kind = .motion, .button = 32, .col = 2, .row = 20, .at = 0 });
3814 const r = shared.drag.range().?;
3815 try std.testing.expectEqual(@as(usize, 0), r.from.tile);
3816 try std.testing.expectEqual(@as(u32, 102), r.from.row);
3817 try std.testing.expectEqual(@as(u32, 108), r.to.row);
3818 try std.testing.expectEqual(@as(u16, 7), r.to.col);
3819 // ...and the move that left the tile changed nothing on screen, so it
3820 // rang for nothing: the far end was already parked at that edge.
3821 try std.testing.expectEqual(grew, shared.repaint_gen.load(.acquire));
3822 try std.testing.expectError(error.WouldBlock, std.posix.read(tiles[0].wake_r, &bell));
3823
3824 // The highlight is one tile's: the stripe it did not start in shows
3825 // nothing, whatever row is asked about.
3826 try std.testing.expect(shared.drag.span(0, 105, 80) != null);
3827 try std.testing.expect(shared.drag.span(1, 105, 80) == null);
3828 }
3829
3830 test "a stripe paints its own highlight and nobody else's" {
3831 const alloc = std.testing.allocator;
3832 const p = try std.posix.pipe2(.{ .NONBLOCK = true });
3833 defer std.posix.close(p[0]);
3834 defer std.posix.close(p[1]);
3835 var shared = Shared{ .out_fd = p[1], .size = .{ .cols = 20, .rows = 8 }, .is_tty = true };
3836 var tiles = [_]Tile{
3837 .{ .r = .{ .target = .{ .sock = "/s" }, .label = "a", .session = "a" }, .stripe = .{ .top = 0, .rows = 4 }, .shared = &shared, .idx = 0 },
3838 .{ .r = .{ .target = .{ .sock = "/s" }, .label = "b", .session = "b" }, .stripe = .{ .top = 4, .rows = 4 }, .shared = &shared, .idx = 1 },
3839 };
3840 var present = [_]bool{ true, true };
3841 var eng = try Engine.init(alloc, .{ .cols = 20, .rows = 3 });
3842 defer eng.deinit();
3843 eng.feed("row-zero\r\nrow-one\r\nrow-two");
3844
3845 // Both stripes have painted, so a click resolves. Tile a's window sits
3846 // at grid row 0 with 100 rows of history behind it.
3847 tiles[0].win = .{ .history_rows = 100, .win_start = 0 };
3848 tiles[1].win = .{ .history_rows = 100, .win_start = 0 };
3849 // Drag across tile a's second content line, columns 2..5.
3850 wallMouse(&tiles, &present, &shared, .{ .kind = .press, .button = 0, .col = 2, .row = 2, .at = 0 });
3851 wallMouse(&tiles, &present, &shared, .{ .kind = .motion, .button = 32, .col = 5, .row = 2, .at = 0 });
3852
3853 var buf: [8192]u8 = undefined;
3854 try std.testing.expect(paintStripe(&tiles[0], alloc, eng, 100));
3855 const mine = drainWallPipe(p[0], &buf);
3856 // Grid row 1 of a's own replica, inverted from column 3 (1-based).
3857 try std.testing.expectEqual(@as(usize, 1), std.mem.count(u8, mine, "\x1b[7m"));
3858 try std.testing.expect(std.mem.indexOf(u8, mine, "\x1b[3G\x1b[0m\x1b[7m") != null);
3859 // At the stripe's own rows: content line two is terminal row 3.
3860 try std.testing.expect(std.mem.indexOf(u8, mine, "\x1b[3;1H\x1b[2K\x1b[0mro\x1b[3G") != null);
3861
3862 // The neighbour paints the same replica at its own offset, with NO
3863 // inversion: the highlight belongs to the tile the press landed in.
3864 try std.testing.expect(paintStripe(&tiles[1], alloc, eng, 100));
3865 const theirs = drainWallPipe(p[0], &buf);
3866 try std.testing.expect(std.mem.indexOf(u8, theirs, "row-one") != null);
3867 try std.testing.expect(std.mem.indexOf(u8, theirs, "\x1b[7m") == null);
3868 }
3869
3870 test "a reconnect drops the highlight over its own tile, and only its own" {
3871 const p = try std.posix.pipe2(.{ .NONBLOCK = true });
3872 defer std.posix.close(p[0]);
3873 defer std.posix.close(p[1]);
3874 var shared = Shared{ .out_fd = p[1], .size = .{ .cols = 80, .rows = 24 }, .is_tty = true };
3875 var tiles = [_]Tile{
3876 .{ .r = .{ .target = .{ .sock = "/s" }, .label = "a", .session = "a" }, .stripe = .{ .top = 0, .rows = 12 }, .shared = &shared, .idx = 0 },
3877 .{ .r = .{ .target = .{ .sock = "/s" }, .label = "b", .session = "b" }, .stripe = .{ .top = 12, .rows = 12 }, .shared = &shared, .idx = 1 },
3878 };
3879 var present = [_]bool{ true, true };
3880 tiles[0].win = .{ .history_rows = 100, .win_start = 0 };
3881
3882 wallMouse(&tiles, &present, &shared, .{ .kind = .press, .button = 0, .col = 4, .row = 3, .at = 0 });
3883 wallMouse(&tiles, &present, &shared, .{ .kind = .motion, .button = 32, .col = 7, .row = 5, .at = 0 });
3884 try std.testing.expect(shared.drag.range() != null);
3885
3886 // The NEIGHBOUR reconnects. That says nothing about a selection over a
3887 // session which never went away, and dropping it would make one tile's
3888 // flaky link erase another tile's selection.
3889 dropDragOver(&tiles[1]);
3890 try std.testing.expect(shared.drag.range() != null);
3891
3892 // The tile under the highlight reconnects: the absolute rows it was
3893 // anchored in are about to mean something else.
3894 dropDragOver(&tiles[0]);
3895 try std.testing.expect(shared.drag.range() == null);
3896 }
3897
3898 test "a relayout drops the highlight, because the stripes move under it" {
3899 const alloc = std.testing.allocator;
3900 const p = try std.posix.pipe2(.{ .NONBLOCK = true });
3901 defer std.posix.close(p[0]);
3902 defer std.posix.close(p[1]);
3903 var shared = Shared{ .out_fd = p[1], .size = .{ .cols = 80, .rows = 24 }, .is_tty = true };
3904 var tiles = [_]Tile{
3905 .{ .r = .{ .target = .{ .sock = "/s" }, .label = "a", .session = "a" }, .stripe = .{ .top = 0, .rows = 12 }, .shared = &shared, .idx = 0 },
3906 .{ .r = .{ .target = .{ .sock = "/s" }, .label = "b", .session = "b" }, .stripe = .{ .top = 12, .rows = 12 }, .shared = &shared, .idx = 1 },
3907 };
3908 var present = [_]bool{ true, true };
3909 tiles[0].win = .{ .history_rows = 100, .win_start = 0 };
3910
3911 wallMouse(&tiles, &present, &shared, .{ .kind = .press, .button = 0, .col = 4, .row = 3, .at = 0 });
3912 wallMouse(&tiles, &present, &shared, .{ .kind = .motion, .button = 32, .col = 7, .row = 5, .at = 0 });
3913 try std.testing.expect(shared.drag.range() != null);
3914
3915 // `x` ends here too, which is why one clear covers both: an anchor
3916 // resolved against the old stripes names a line on a stripe that is
3917 // about to be somewhere else, or gone.
3918 relayout(alloc, &tiles, &present, &shared, 0);
3919 try std.testing.expect(shared.drag.range() == null);
3920 try std.testing.expect(shared.drag.on() == null);
3686 } 3921 }
3687 3922
3688 test "a zoom move drops what the wall's input filters are holding" { 3923 test "a zoom move drops what the wall's input filters are holding" {
@@ -3711,14 +3946,20 @@ test "a zoom move drops what the wall's input filters are holding" {
3711 var mouse_out: [mailbox_max + interact.MouseFilter.max_held]u8 = undefined; 3946 var mouse_out: [mailbox_max + interact.MouseFilter.max_held]u8 = undefined;
3712 _ = input.mouse.feed("\x1b[<0;3", &mouse_out); 3947 _ = input.mouse.feed("\x1b[<0;3", &mouse_out);
3713 try std.testing.expect(input.mouse.len > 0); 3948 try std.testing.expect(input.mouse.len > 0);
3714 wallMouse(&tiles, &present, &shared, &input.click, .{ .kind = .press, .button = 0, .col = 4, .row = 15, .at = 0 }); 3949 tiles[1].win = .{ .history_rows = 200, .win_start = 3 };
3715 try std.testing.expect(input.click.down != null); 3950 wallMouse(&tiles, &present, &shared, .{ .kind = .press, .button = 0, .col = 4, .row = 15, .at = 0 });
3951 wallMouse(&tiles, &present, &shared, .{ .kind = .motion, .button = 32, .col = 9, .row = 17, .at = 0 });
3952 try std.testing.expect(shared.drag.range() != null);
3716 3953
3717 setZoom(&tiles, &shared, &input, 0); 3954 setZoom(&tiles, &shared, &input, 0);
3718 3955
3719 try std.testing.expect(!input.prefix.pending); 3956 try std.testing.expect(!input.prefix.pending);
3720 try std.testing.expectEqual(@as(usize, 0), input.mouse.len); 3957 try std.testing.expectEqual(@as(usize, 0), input.mouse.len);
3721 try std.testing.expect(input.click.down == null); 3958 // The drag lives in `Shared` so the pumps can paint it, and is dropped
3959 // by the same reset: its anchor names a stripe that has just stopped
3960 // being on the screen.
3961 try std.testing.expect(shared.drag.range() == null);
3962 try std.testing.expect(shared.drag.on() == null);
3722 } 3963 }
3723 3964
3724 test "stripeContentRow: a label bar and the rows outside a stripe name no line" { 3965 test "stripeContentRow: a label bar and the rows outside a stripe name no line" {