a73x

c2a60319

feat: a click at the wall selects the stripe under it

a73x   2026-08-22 13:13

Commit message
feat: a click at the wall selects the stripe under it

tmux's MouseDown1Pane -> select-pane, which is what the user already has
in their hands. Press and release in one cell moves the selection; a
drag does not, and nothing consumes a drag yet. The selection follows
the release, so a press that turns into a drag never flickers the marker
on its way past. Left button only — middle is the terminal's own paste
and right its menu.

Resolving a click takes two steps the keyboard cannot take alone. A
click arrives on the keyboard thread and the keyboard thread has no
replica, because the Engine is pump-local: so each stripe paint leaves
behind the two numbers a terminal row needs to become a line of a
session's history — the daemon's retained-history count and the grid row
the window began at — written under `paint_mu` inside the paint that
used them. One lock over both, because a window from one frame read
against a history count from another names a row nobody ever saw.

The hit-test then turns a terminal row into a tile index and an ABSOLUTE
row, absolute because a terminal-row anchor silently renames itself the
moment output scrolls the window under it. A label bar and a row past
the last stripe resolve to nothing rather than to the nearest line — the
bar is exactly where a hand rests between two stripes, and a
nearest-line answer there would move a selection nobody asked to move.
`present` decides whether a tile is there, not its geometry: relayout
never compacts the array, so a forgotten tile keeps the stripe it had
when it left.

Keys and reports are drained INTERLEAVED, in the order the terminal
wrote them. `Event.at` is what re-joins the two flat lists `feed`
returns, and the read that proves it is one holding a Return and a
click: draining every event first zooms the tile the click had just
selected rather than the one the user chose.

The wall's input reset moves into `setZoom`. It sat at one of six call
sites and held only because the other five are reachable from an
already-zoomed wall; it belongs to the transition, which is what
`WallInput` now names. `sendKeys`' doc gave up more than it could keep —
a report cut by a read boundary with a zoom in the gap does lose its
head to the wall and hand its tail to a session, and that corner is
written down rather than claimed away.

The e2e legs are read off the DAEMON: Enter zooms whatever is selected,
so a marker typed afterwards names the session the click chose. Their
row numbers are chosen the way the filter leg chose its `2` — read as
keys, `\x1b[<0;3;19M` offers `0`, `3`, `1` and `9`, and the `1` jumps to
session a, so a selection that ends on b cannot have got there by a
digit. The label-bar leg starts from a selection already on b for the
same reason: "nothing moved" is only a claim when there was somewhere to
move from.

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

README.md
Old New
@@ -69,6 +69,7 @@ Zoomed out — the wall reads the keys and nothing reaches a session:
69 | Key | Effect | 69 | Key | Effect |
70 |---|---| 70 |---|---|
71 | `j` / `k` (or `n` / `p`), `1`-`9` | select a tile | 71 | `j` / `k` (or `n` / `p`), `1`-`9` | select a tile |
72 | click a stripe | select that tile |
72 | `Enter` | zoom the selected tile | 73 | `Enter` | zoom the selected tile |
73 | `x` | forget the selected tile (off the wall file; the session keeps running) | 74 | `x` | forget the selected tile (off the wall file; the session keeps running) |
74 | `q` (or `Ctrl-\`) | leave mux | 75 | `q` (or `Ctrl-\`) | leave mux |
@@ -311,7 +312,8 @@ tile mid-paint — so a wall you never zoom keeps its old widths.
311 The keys are the ones listed under "Quick start, local" — `mux wall` and 312 The keys are the ones listed under "Quick start, local" — `mux wall` and
312 `mux` are the same program, entered through different doors, so there is 313 `mux` are the same program, entered through different doors, so there is
313 one key table and not two. Zoomed out, `j`/`k` (or `n`/`p`) and `1`-`9` 314 one key table and not two. Zoomed out, `j`/`k` (or `n`/`p`) and `1`-`9`
314 select, `Enter` zooms, `x` forgets, `q` leaves. Zoomed in, the session has 315 select — as does clicking a stripe — `Enter` zooms, `x` forgets, `q`
316 leaves. Zoomed in, the session has
315 the terminal and `Ctrl-\` is the only thing held back: `w` zooms out, `d` 317 the terminal and `Ctrl-\` is the only thing held back: `w` zooms out, `d`
316 detaches and leaves, `n`/`p`/`c` walk the daemon's session ring, `l` skips 318 detaches and leaves, `n`/`p`/`c` walk the daemon's session ring, `l` skips
317 back to the last tile you zoomed. Any other command key after the prefix is 319 back to the last tile you zoomed. Any other command key after the prefix is
src/wallview.zig
Old New
@@ -7,8 +7,9 @@
7 //! the terminal's size, which is all of `sendAttach`. On the wall, `q` or 7 //! the terminal's size, which is all of `sendAttach`. On the wall, `q` or
8 //! Ctrl-\ leaves. 8 //! Ctrl-\ leaves.
9 //! 9 //!
10 //! One stripe is SELECTED (`j`/`k` or `n`/`p` to move, `1`-`9` to jump); 10 //! One stripe is SELECTED (`j`/`k` or `n`/`p` to move, `1`-`9` to jump, or
11 //! its label bar carries a `> ` marker. `Enter` ZOOMS it, in place. 11 //! click one); its label bar carries a `> ` marker. `Enter` ZOOMS it, in
12 //! place.
12 //! 13 //!
13 //! `x` FORGETS the selected tile: its line leaves the wall file, its pump 14 //! `x` FORGETS the selected tile: its line leaves the wall file, its pump
14 //! ends (closing the transport, freeing the daemon slot), and the stripes 15 //! ends (closing the transport, freeing the daemon slot), and the stripes
@@ -315,6 +316,19 @@ const Shared = struct {
315 repaint_gen: std.atomic.Value(u64) = std.atomic.Value(u64).init(0), 316 repaint_gen: std.atomic.Value(u64) = std.atomic.Value(u64).init(0),
316 }; 317 };
317 318
319 /// The stripe window a paint used, in the only terms a click can be
320 /// resolved in.
321 ///
322 /// Absolute rows are counted from the oldest row the DAEMON still retains
323 /// (`protocol.SelectionReply`), so a grid row alone names nothing: it has
324 /// to be read against the history count the same frame carried.
325 const Window = struct {
326 history_rows: u32 = 0,
327 /// The grid row the window's first content line came from
328 /// (`paint.stripeWinStart`).
329 win_start: u16 = 0,
330 };
331
318 const Tile = struct { 332 const Tile = struct {
319 r: Resolved, 333 r: Resolved,
320 stripe: Stripe, 334 stripe: Stripe,
@@ -439,11 +453,201 @@ const Tile = struct {
439 /// keystrokes never vanish without the wall admitting to it. 453 /// keystrokes never vanish without the wall admitting to it.
440 in_dropped: std.atomic.Value(bool) = std.atomic.Value(bool).init(false), 454 in_dropped: std.atomic.Value(bool) = std.atomic.Value(bool).init(false),
441 455
456 /// What this tile's last stripe paint showed.
457 ///
458 /// Written by the PUMP under `paint_mu` inside the paint that used it,
459 /// read by the KEYBOARD under the same lock when a click has to be
460 /// turned into a line. One lock over both fields is the point: a
461 /// window from one frame read against a history count from another
462 /// names a row nobody ever saw.
463 ///
464 /// Anchors are converted at PRESS time for the same reason — a
465 /// terminal row remembered instead would quietly become a different
466 /// line as soon as the session scrolled the window under it.
467 win: Window = .{},
468
442 fn viewRows(t: *const Tile) u16 { 469 fn viewRows(t: *const Tile) u16 {
443 return t.stripe.rows - 1; 470 return t.stripe.rows - 1;
444 } 471 }
445 }; 472 };
446 473
474 /// How far down a stripe's CONTENT a zero-based terminal row lands, or null
475 /// when that row names no session line at all.
476 ///
477 /// Two rows are null on purpose: the label bar (`Stripe.top` itself, since
478 /// `Stripe` counts from zero and the bar is its first row) and anything
479 /// outside the stripe. A click there points at nothing, and answering with
480 /// the nearest line would move a selection the user did not ask to move —
481 /// the bar is exactly where a hand rests between two stripes.
482 fn stripeContentRow(s: Stripe, row: u16) ?u16 {
483 if (row <= s.top) return null;
484 const off = row - s.top - 1;
485 return if (off < s.rows -| 1) off else null;
486 }
487
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.
501 ///
502 /// 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
504 /// resolve the click against a geometry that never existed.
505 ///
506 /// `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
508 /// stripe it had when it left.
509 fn hitTest(tiles: []Tile, present: []const bool, shared: *Shared, row: u16, col: u16) ?Hit {
510 shared.paint_mu.lock();
511 defer shared.paint_mu.unlock();
512 for (tiles, present) |*t, p| {
513 if (!p) continue;
514 const off = stripeContentRow(t.stripe, row) orelse continue;
515 return .{
516 .tile = t.idx,
517 .row = t.win.history_rows + t.win.win_start + off,
518 .col = col,
519 };
520 }
521 return null;
522 }
523
524 /// One read's keys and mouse reports, handed back in the order the terminal
525 /// wrote them.
526 ///
527 /// `MouseFilter.feed` returns two flat lists, and `Event.at` — how many
528 /// forwarded bytes preceded a report — is the only record left of how they
529 /// interleaved. Draining all of one list and then the other is wrong in
530 /// both directions, and the read that proves it is one holding a Return and
531 /// a click: Enter zooms the SELECTED tile, a press moves the selection, so
532 /// events-first zooms the tile the click had just selected rather than the
533 /// one the user chose.
534 const WallStep = union(enum) { key: u8, mouse: interact.MouseFilter.Event };
535
536 const WallDrain = struct {
537 forward: []const u8,
538 events: []const interact.MouseFilter.Event,
539 ki: usize = 0,
540 ei: usize = 0,
541
542 fn init(out: interact.MouseFilter.Out) WallDrain {
543 return .{ .forward = out.forward, .events = out.events };
544 }
545
546 fn next(self: *WallDrain) ?WallStep {
547 // `at` counts the bytes emitted BEFORE the report finished, so a
548 // report tied with a key arrived ahead of it. The same test drains
549 // the tail: a report that ended the read carries `at == forward.len`.
550 if (self.ei < self.events.len and self.events[self.ei].at <= self.ki) {
551 defer self.ei += 1;
552 return .{ .mouse = self.events[self.ei] };
553 }
554 if (self.ki < self.forward.len) {
555 defer self.ki += 1;
556 return .{ .key = self.forward[self.ki] };
557 }
558 return null;
559 }
560 };
561
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.
577 ///
578 /// 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
580 /// `MouseDown1Pane -> select-pane`, deliberately, because it is what the
581 /// user already has in their hands. A drag does nothing yet.
582 ///
583 /// 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
585 /// way past.
586 ///
587 /// Left button only. Middle is the terminal's own paste and right its menu;
588 /// 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
590 /// motion bit included, so it is the low two bits that name the button.
591 fn wallMouse(
592 tiles: []Tile,
593 present: []const bool,
594 shared: *Shared,
595 click: *Click,
596 ev: interact.MouseFilter.Event,
597 ) void {
598 if (ev.button & 0b11 != 0) return;
599 switch (ev.kind) {
600 .press => click.* = .{
601 .down = hitTest(tiles, present, shared, ev.row, ev.col),
602 .row = ev.row,
603 .col = ev.col,
604 },
605 // Cell granularity, not pixel: `?1002h` reports motion when the
606 // pointer changes CELL, and a hand that trembles inside one cell
607 // is still pointing at one line.
608 .motion => if (ev.row != click.row or ev.col != click.col) {
609 click.dragged = true;
610 },
611 .release => {
612 const was = click.*;
613 click.* = .{};
614 if (was.dragged) return;
615 const hit = was.down orelse return;
616 // The press may have landed on a tile `x` has taken away
617 // since — the same guard `Enter` keeps for the same reason.
618 if (hit.tile < present.len and present[hit.tile])
619 moveSelection(tiles, shared, hit.tile);
620 },
621 }
622 }
623
624 /// Everything the wall's keyboard loop is holding mid-stream.
625 ///
626 /// One struct because they are dropped at one instant: `setZoom` hands the
627 /// terminal to somebody else, and a chord half typed, a report half read or
628 /// a button still down at the wall is all state about a screen that has
629 /// stopped existing. The reset is `setZoom`'s own (`WallInput.reset`)
630 /// rather than a line beside one of its six call sites — it belongs to the
631 /// transition, and the version that sat at a single call site held only
632 /// because the other five are reachable from an already-zoomed wall.
633 const WallInput = struct {
634 prefix: interact.PrefixFilter = .{},
635 /// The wall's own mouse filter — the second in the process, and not an
636 /// alternative to the first: a promoted tile's `Core` runs one over the
637 /// bytes it is handed zoomed, this one runs over the bytes that arrive
638 /// UNZOOMED, which the wall's own modes are what produce.
639 mouse: interact.MouseFilter = .{},
640 click: Click = .{},
641
642 fn reset(self: *WallInput) void {
643 self.prefix = .{};
644 // Whatever the filter still holds is the head of a report from the
645 // read being dropped here.
646 self.mouse.reset();
647 self.click = .{};
648 }
649 };
650
447 /// The next tile in `present` after `sel`, wrapping, or null when none is 651 /// The next tile in `present` after `sel`, wrapping, or null when none is
448 /// left. `sel` itself is the answer when it is the only one present, which 652 /// left. `sel` itself is the answer when it is the only one present, which
449 /// is what makes a one-tile wall's `j` a no-op rather than a null. 653 /// is what makes a one-tile wall's `j` a no-op rather than a null.
@@ -617,14 +821,19 @@ fn moveSelection(tiles: []Tile, shared: *Shared, next: usize) void {
617 /// of it, and while it is DEMOTED the Core paints nothing at all and this 821 /// of it, and while it is DEMOTED the Core paints nothing at all and this
618 /// draws the wall's own crop of the same replica. One replica per tile, one 822 /// draws the wall's own crop of the same replica. One replica per tile, one
619 /// applier for it, two ways of looking at it. 823 /// applier for it, two ways of looking at it.
620 fn paintStripe(t: *Tile, alloc: std.mem.Allocator, eng: *Engine) bool { 824 fn paintStripe(t: *Tile, alloc: std.mem.Allocator, eng: *Engine, history_rows: u32) bool {
621 t.shared.paint_mu.lock(); 825 t.shared.paint_mu.lock();
622 defer t.shared.paint_mu.unlock(); 826 defer t.shared.paint_mu.unlock();
623 if (paintModeLocked(t) != .stripe) return false; 827 if (paintModeLocked(t) != .stripe) return false;
624 paint.renderStripe(alloc, eng, t.stripe.top + 1, .{ 828 const view: proto.Size = .{ .cols = t.shared.size.cols, .rows = t.viewRows() };
625 .cols = t.shared.size.cols, 829 // Published from inside the paint, under the lock the paint already
626 .rows = t.viewRows(), 830 // holds, because the keyboard's hit-test has to describe the rows that
627 }, t.shared.out_fd) catch {}; 831 // are ON the terminal — not the ones the next frame will put there.
832 t.win = .{
833 .history_rows = history_rows,
834 .win_start = paint.stripeWinStart(@intCast(eng.term.rows), eng.cursorPos().y, view.rows),
835 };
836 paint.renderStripe(alloc, eng, t.stripe.top + 1, view, t.shared.out_fd) catch {};
628 return true; 837 return true;
629 } 838 }
630 839
@@ -799,10 +1008,20 @@ fn endWith(t: *Tile, reason: EndReason, code: u8) void {
799 /// only on the zoomed branch of that loop, so a report that gets here 1008 /// only on the zoomed branch of that loop, so a report that gets here
800 /// belongs to a session by construction. 1009 /// belongs to a session by construction.
801 /// 1010 ///
802 /// A report in flight across the boundary lands on whichever side the zoom 1011 /// A WHOLE report in flight across the boundary lands on whichever side the
803 /// was on when the read returned: the wall's filter eats it, or the 1012 /// zoom was on when the read returned: the wall's filter eats it, or the
804 /// session's does. Neither side is ever handed the other's, which is what 1013 /// session's does. That is what keeps a wall drag from becoming tile input.
805 /// keeps a wall drag from becoming tile input. 1014 ///
1015 /// A report CUT by a read boundary is the one corner that is not covered,
1016 /// and it is the same split class `interact.MouseFilter` admits to at its
1017 /// own edge. The wall holds the head; `setZoom` drops it (`WallInput.reset`
1018 /// — the bytes describe a screen that has changed hands); the next read
1019 /// arrives zoomed and its tail comes here as keystrokes, so a shell can be
1020 /// handed `0;3;2M`. It needs a terminal that splits one report across two
1021 /// writes AND a zoom landing in the gap, which is why it is named rather
1022 /// than defended against: the alternative is holding a partial report
1023 /// across the transition, which is state about a screen nobody is looking
1024 /// at any more.
806 /// 1025 ///
807 /// A chunk that does not fit is dropped WHOLE. The obvious alternative — 1026 /// A chunk that does not fit is dropped WHOLE. The obvious alternative —
808 /// copy what fits — splices: the head of one read lands in the mailbox, the 1027 /// copy what fits — splices: the head of one read lands in the mailbox, the
@@ -1376,7 +1595,7 @@ fn pumpTile(t: *Tile) void {
1376 // The Core has already painted the terminal if this 1595 // The Core has already painted the terminal if this
1377 // tile owns it; this is the wall's own crop of the 1596 // tile owns it; this is the wall's own crop of the
1378 // same replica, and it draws only when it doesn't. 1597 // same replica, and it draws only when it doesn't.
1379 _ = paintStripe(t, alloc, core.grid()); 1598 _ = paintStripe(t, alloc, core.grid(), core.rep.history_rows);
1380 }, 1599 },
1381 // The replica is suspect, not the transport: re-attach 1600 // The replica is suspect, not the transport: re-attach
1382 // quoting (0,0) explicitly — a quoted seq would invite 1601 // quoting (0,0) explicitly — a quoted seq would invite
@@ -1641,7 +1860,7 @@ fn pumpTile(t: *Tile) void {
1641 // stripe, and records only if the stripe landed — a tile hidden 1860 // stripe, and records only if the stripe landed — a tile hidden
1642 // behind another tile's zoom has not satisfied anything and 1861 // behind another tile's zoom has not satisfied anything and
1643 // must repaint when the terminal comes back to the wall. 1862 // must repaint when the terminal comes back to the wall.
1644 if (promoted or paintStripe(t, alloc, core.grid())) painted_gen = gen; 1863 if (promoted or paintStripe(t, alloc, core.grid(), core.rep.history_rows)) painted_gen = gen;
1645 } 1864 }
1646 } 1865 }
1647 } 1866 }
@@ -1768,7 +1987,12 @@ pub fn zoomChord(
1768 /// `n`/`p`/`l`. Both symptoms are the same hole: a dead tile's stripe never 1987 /// `n`/`p`/`l`. Both symptoms are the same hole: a dead tile's stripe never
1769 /// comes back to the wall, and zooming a dead tile paints an entirely blank 1988 /// comes back to the wall, and zooming a dead tile paints an entirely blank
1770 /// terminal that admits to nothing. See `Tile.alive`. 1989 /// terminal that admits to nothing. See `Tile.alive`.
1771 fn setZoom(tiles: []Tile, shared: *Shared, next: usize) void { 1990 fn setZoom(tiles: []Tile, shared: *Shared, input: *WallInput, next: usize) void {
1991 // The terminal is changing hands, so whatever the wall's own filters
1992 // 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,
1994 // and there are six of them.
1995 input.reset();
1772 shared.paint_mu.lock(); 1996 shared.paint_mu.lock();
1773 defer shared.paint_mu.unlock(); 1997 defer shared.paint_mu.unlock();
1774 // See the note above: the outgoing session's modes come off HERE, on 1998 // See the note above: the outgoing session's modes come off HERE, on
@@ -2606,12 +2830,7 @@ pub fn run(alloc: std.mem.Allocator, resolved: []const Resolved, entry: Entry) !
2606 // reaches a session — that is what makes an unzoomed tile claim 2830 // reaches a session — that is what makes an unzoomed tile claim
2607 // nothing); zoomed IN the terminal belongs to the session and only the 2831 // nothing); zoomed IN the terminal belongs to the session and only the
2608 // `Ctrl-\` chord layer is held back. 2832 // `Ctrl-\` chord layer is held back.
2609 var prefix: interact.PrefixFilter = .{}; 2833 var input: WallInput = .{};
2610 // The wall's own mouse filter — the second in the process, and not an
2611 // alternative to the first: a promoted tile's `Core` runs one over the
2612 // bytes it is handed zoomed, this one runs over the bytes that arrive
2613 // UNZOOMED, which the wall's own modes are what produce.
2614 var mouse: interact.MouseFilter = .{};
2615 // `feed` hands a candidate held across the previous read back ahead of 2834 // `feed` hands a candidate held across the previous read back ahead of
2616 // this chunk, so its room is a whole chunk plus that hold. 2835 // this chunk, so its room is a whole chunk plus that hold.
2617 var mouse_out: [mailbox_max + interact.MouseFilter.max_held]u8 = undefined; 2836 var mouse_out: [mailbox_max + interact.MouseFilter.max_held]u8 = undefined;
@@ -2668,7 +2887,7 @@ pub fn run(alloc: std.mem.Allocator, resolved: []const Resolved, entry: Entry) !
2668 )) { 2887 )) {
2669 .moved => |to| { 2888 .moved => |to| {
2670 last_zoom = z; 2889 last_zoom = z;
2671 setZoom(tiles[0..live], &shared, to); 2890 setZoom(tiles[0..live], &shared, &input, to);
2672 }, 2891 },
2673 .full => setNotice(&shared, "[no room on the wall for another tile]"), 2892 .full => setNotice(&shared, "[no room on the wall for another tile]"),
2674 .stay => {}, 2893 .stay => {},
@@ -2679,7 +2898,7 @@ pub fn run(alloc: std.mem.Allocator, resolved: []const Resolved, entry: Entry) !
2679 .ignore => {}, 2898 .ignore => {},
2680 .unzoom => { 2899 .unzoom => {
2681 last_zoom = ended; 2900 last_zoom = ended;
2682 setZoom(tiles[0..live], &shared, no_zoom); 2901 setZoom(tiles[0..live], &shared, &input, no_zoom);
2683 }, 2902 },
2684 .fall_back => |back| { 2903 .fall_back => |back| {
2685 // A `Ctrl-\ c` the daemon refused: the tile it was 2904 // A `Ctrl-\ c` the daemon refused: the tile it was
@@ -2694,7 +2913,7 @@ pub fn run(alloc: std.mem.Allocator, resolved: []const Resolved, entry: Entry) !
2694 tiles[ended].gone.store(true, .release); 2913 tiles[ended].gone.store(true, .release);
2695 std.debug.print("mux: cannot create a new session (daemon full?)\n", .{}); 2914 std.debug.print("mux: cannot create a new session (daemon full?)\n", .{});
2696 setNotice(&shared, "[cannot create a new session (daemon full?)]"); 2915 setNotice(&shared, "[cannot create a new session (daemon full?)]");
2697 setZoom(tiles[0..live], &shared, back); 2916 setZoom(tiles[0..live], &shared, &input, back);
2698 }, 2917 },
2699 .finish => |how| { 2918 .finish => |how| {
2700 exit_code = how.code; 2919 exit_code = how.code;
@@ -2717,7 +2936,7 @@ pub fn run(alloc: std.mem.Allocator, resolved: []const Resolved, entry: Entry) !
2717 2936
2718 const z = shared.zoom.load(.acquire); 2937 const z = shared.zoom.load(.acquire);
2719 if (z != no_zoom) { 2938 if (z != no_zoom) {
2720 const cmd = prefix.feed(b[0..n]); 2939 const cmd = input.prefix.feed(b[0..n]);
2721 // A BARE Ctrl-\ while there is no session to command is the 2940 // A BARE Ctrl-\ while there is no session to command is the
2722 // user giving up on the wait. Judged after the filter rather 2941 // user giving up on the wait. Judged after the filter rather
2723 // than by scanning the raw bytes, because the two readings of 2942 // than by scanning the raw bytes, because the two readings of
@@ -2726,7 +2945,7 @@ pub fn run(alloc: std.mem.Allocator, resolved: []const Resolved, entry: Entry) !
2726 // asking for the wall — and on a wall there is one to give 2945 // asking for the wall — and on a wall there is one to give
2727 // them, which is what the plain client this rule came from 2946 // them, which is what the plain client this rule came from
2728 // never had. 2947 // never had.
2729 if (cmd.action == .none and prefix.pending) { 2948 if (cmd.action == .none and input.prefix.pending) {
2730 if (awaitingSession(&tiles[z])) |waiting| { 2949 if (awaitingSession(&tiles[z])) |waiting| {
2731 exit_code = 0; 2950 exit_code = 0;
2732 exit_msg = switch (waiting) { 2951 exit_msg = switch (waiting) {
@@ -2763,7 +2982,7 @@ pub fn run(alloc: std.mem.Allocator, resolved: []const Resolved, entry: Entry) !
2763 _ = hydrate(alloc, tiles, present, &live, &shared, entry.key, entry.idle_ms); 2982 _ = hydrate(alloc, tiles, present, &live, &shared, entry.key, entry.idle_ms);
2764 } 2983 }
2765 last_zoom = z; 2984 last_zoom = z;
2766 setZoom(tiles[0..live], &shared, no_zoom); 2985 setZoom(tiles[0..live], &shared, &input, no_zoom);
2767 // The stripes were cut for the tiles that existed when 2986 // The stripes were cut for the tiles that existed when
2768 // the wall started; an unzoom re-cuts them over the 2987 // the wall started; an unzoom re-cuts them over the
2769 // tiles that are there NOW — the hydration's, and every 2988 // tiles that are there NOW — the hydration's, and every
@@ -2776,29 +2995,40 @@ pub fn run(alloc: std.mem.Allocator, resolved: []const Resolved, entry: Entry) !
2776 }, 2995 },
2777 .to => |next| { 2996 .to => |next| {
2778 last_zoom = z; 2997 last_zoom = z;
2779 setZoom(tiles[0..live], &shared, next); 2998 setZoom(tiles[0..live], &shared, &input, next);
2780 }, 2999 },
2781 } 3000 }
2782 continue; 3001 continue;
2783 } 3002 }
2784 3003
2785 // Unzoomed, this terminal reports the mouse on the WALL's own claim, 3004 // Unzoomed, this terminal reports the mouse on the WALL's own claim,
2786 // so a read here can carry SGR reports as well as keys. They come 3005 // so a read here can carry SGR reports as well as keys. The filter
2787 // out AHEAD of the byte loop because a report is made of characters 3006 // takes the reports OUT of the byte stream, because a report is
2788 // the wall acts on: its parameters are decimal digits, and a digit 3007 // made of characters the wall acts on: its parameters are decimal
2789 // is a jump key in `selectKey`. One wheel spin over a stripe would 3008 // digits, and a digit is a jump key in `selectKey`. One wheel spin
2790 // otherwise walk the selection at random. 3009 // over a stripe would otherwise walk the selection at random.
2791 const report = mouse.feed(b[0..n], &mouse_out); 3010 const report = input.mouse.feed(b[0..n], &mouse_out);
2792 // Both halves are dropped, by decision and not by omission. There 3011 // The wheel is dropped, by decision and not by omission: there is
2793 // is no wall-level scrollback for a notch to move, and a press, a 3012 // no wall-level scrollback for a notch to move, and a user who sees
2794 // drag or a release has nothing behind it yet — a user who sees the 3013 // the wall answer the mouse at all will spin it over a stripe.
2795 // wall answer the mouse at all will spin the wheel over a stripe, 3014 // Until something is listening, nothing happening is the honest
2796 // and until something is listening, nothing happening is the honest 3015 // answer.
2797 // answer to that.
2798 _ = report.wheel; 3016 _ = report.wheel;
2799 _ = report.events;
2800 3017
2801 for (report.forward) |key| { 3018 // What the filter took out goes back in HERE, interleaved with the
3019 // keys in the order the terminal wrote them (`WallDrain`). Draining
3020 // every event first would let a click that arrived after a Return
3021 // zoom the tile it had just selected rather than the one the user
3022 // chose.
3023 var drain = WallDrain.init(report);
3024 while (drain.next()) |step| {
3025 const key = switch (step) {
3026 .mouse => |ev| {
3027 wallMouse(tiles[0..live], present[0..live], &shared, &input.click, ev);
3028 continue;
3029 },
3030 .key => |k| k,
3031 };
2802 if (key == 'q' or key == 0x1c) break :keys; 3032 if (key == 'q' or key == 0x1c) break :keys;
2803 // Both spellings of Enter: ICRNL is off, so a Return arrives 3033 // Both spellings of Enter: ICRNL is off, so a Return arrives
2804 // as CR, but a script or a paste can just as easily send LF. 3034 // as CR, but a script or a paste can just as easily send LF.
@@ -2811,14 +3041,11 @@ pub fn run(alloc: std.mem.Allocator, resolved: []const Resolved, entry: Entry) !
2811 // An empty wall has nothing to hand the terminal to, and 3041 // An empty wall has nothing to hand the terminal to, and
2812 // `sel` still names the tile `x` just took away. 3042 // `sel` still names the tile `x` just took away.
2813 if (shared.sel >= live or !present[shared.sel]) continue :keys; 3043 if (shared.sel >= live or !present[shared.sel]) continue :keys;
2814 setZoom(tiles[0..live], &shared, shared.sel);
2815 // The rest of this read was typed at the WALL, before the 3044 // The rest of this read was typed at the WALL, before the
2816 // terminal changed hands — it is not the session's input. 3045 // terminal changed hands — it is not the session's input,
2817 // The prefix layer starts clean for the same reason, and so 3046 // and neither are the reports still to be drained from it.
2818 // does the mouse filter: anything it is still holding is 3047 // `setZoom` clears what the filters are holding.
2819 // the head of a report from the read being dropped here. 3048 setZoom(tiles[0..live], &shared, &input, shared.sel);
2820 prefix = .{};
2821 mouse.reset();
2822 continue :keys; 3049 continue :keys;
2823 } 3050 }
2824 // `x` forgets the selected tile: off the wall file, off this 3051 // `x` forgets the selected tile: off the wall file, off this
@@ -3325,12 +3552,13 @@ test "setZoom writes the outgoing zoom's release, and writes it before the store
3325 }; 3552 };
3326 3553
3327 var buf: [512]u8 = undefined; 3554 var buf: [512]u8 = undefined;
3555 var input: WallInput = .{};
3328 3556
3329 // Zoomed OUT: there is no outgoing session, so there is nothing of a 3557 // Zoomed OUT: there is no outgoing session, so there is nothing of a
3330 // session's to take off. The wall's own screen bytes are all this may 3558 // session's to take off. The wall's own screen bytes are all this may
3331 // write — a release here would unset modes the user's own terminal may 3559 // write — a release here would unset modes the user's own terminal may
3332 // have arrived with. 3560 // have arrived with.
3333 setZoom(&tiles, &shared, 0); 3561 setZoom(&tiles, &shared, &input, 0);
3334 var out = readAvail(p[0], &buf); 3562 var out = readAvail(p[0], &buf);
3335 try std.testing.expect(std.mem.indexOf(u8, out, interact.session_release) == null); 3563 try std.testing.expect(std.mem.indexOf(u8, out, interact.session_release) == null);
3336 3564
@@ -3339,7 +3567,7 @@ test "setZoom writes the outgoing zoom's release, and writes it before the store
3339 // `paint_mu`, ahead of the store, so the bytes are on the wire before 3567 // `paint_mu`, ahead of the store, so the bytes are on the wire before
3340 // any pump can read the new zoom and claim. Here that shows up as the 3568 // any pump can read the new zoom and claim. Here that shows up as the
3341 // release preceding the screen clear the same call writes. 3569 // release preceding the screen clear the same call writes.
3342 setZoom(&tiles, &shared, 1); 3570 setZoom(&tiles, &shared, &input, 1);
3343 out = readAvail(p[0], &buf); 3571 out = readAvail(p[0], &buf);
3344 const rel = std.mem.indexOf(u8, out, interact.session_release) orelse 3572 const rel = std.mem.indexOf(u8, out, interact.session_release) orelse
3345 return error.NoReleaseOnZoomMove; 3573 return error.NoReleaseOnZoomMove;
@@ -3349,7 +3577,7 @@ test "setZoom writes the outgoing zoom's release, and writes it before the store
3349 3577
3350 // Zoom B -> the wall: same release, and this is the path the e2e 3578 // Zoom B -> the wall: same release, and this is the path the e2e
3351 // no-leaked-mode leg counts. 3579 // no-leaked-mode leg counts.
3352 setZoom(&tiles, &shared, no_zoom); 3580 setZoom(&tiles, &shared, &input, no_zoom);
3353 out = readAvail(p[0], &buf); 3581 out = readAvail(p[0], &buf);
3354 const rel_out = std.mem.indexOf(u8, out, interact.session_release) orelse 3582 const rel_out = std.mem.indexOf(u8, out, interact.session_release) orelse
3355 return error.NoReleaseOnUnzoom; 3583 return error.NoReleaseOnUnzoom;
@@ -3366,6 +3594,233 @@ test "setZoom writes the outgoing zoom's release, and writes it before the store
3366 try std.testing.expect(rel_out < arm); 3594 try std.testing.expect(rel_out < arm);
3367 } 3595 }
3368 3596
3597 test "the wall drains keys and reports in the order the terminal wrote them" {
3598 // `feed` returns two flat lists and `Event.at` is what re-joins them.
3599 // The read that costs is one holding a Return AND a click: Enter zooms
3600 // the SELECTED tile and a press moves the selection, so a driver that
3601 // drained every event first would zoom the tile the click had just
3602 // selected rather than the one the user chose. `at` being right and the
3603 // driver using it right are two different properties; this is the
3604 // second one.
3605 var f: interact.MouseFilter = .{};
3606 var out: [mailbox_max + interact.MouseFilter.max_held]u8 = undefined;
3607
3608 // Enter first, then the press.
3609 var d = WallDrain.init(f.feed("\r\x1b[<0;3;2M", &out));
3610 try std.testing.expectEqual(@as(u8, '\r'), (d.next() orelse return error.NoStep).key);
3611 try std.testing.expectEqual(
3612 interact.MouseFilter.Event.Kind.press,
3613 (d.next() orelse return error.NoStep).mouse.kind,
3614 );
3615 try std.testing.expect(d.next() == null);
3616
3617 // The same two bytes the other way round, and the opposite answer.
3618 f.reset();
3619 d = WallDrain.init(f.feed("\x1b[<0;3;2M\r", &out));
3620 try std.testing.expectEqual(
3621 interact.MouseFilter.Event.Kind.press,
3622 (d.next() orelse return error.NoStep).mouse.kind,
3623 );
3624 try std.testing.expectEqual(@as(u8, '\r'), (d.next() orelse return error.NoStep).key);
3625 try std.testing.expect(d.next() == null);
3626
3627 // A report in the MIDDLE of the keys stays there: `1` and `j` are both
3628 // wall keys and the press belongs between them.
3629 f.reset();
3630 d = WallDrain.init(f.feed("1\x1b[<0;3;1Mj", &out));
3631 try std.testing.expectEqual(@as(u8, '1'), (d.next() orelse return error.NoStep).key);
3632 try std.testing.expectEqual(
3633 interact.MouseFilter.Event.Kind.press,
3634 (d.next() orelse return error.NoStep).mouse.kind,
3635 );
3636 try std.testing.expectEqual(@as(u8, 'j'), (d.next() orelse return error.NoStep).key);
3637 try std.testing.expect(d.next() == null);
3638 }
3639
3640 test "a plain click selects the stripe under it, and a drag selects nothing" {
3641 const p = try std.posix.pipe2(.{ .NONBLOCK = true });
3642 defer std.posix.close(p[0]);
3643 defer std.posix.close(p[1]);
3644 var shared = Shared{ .out_fd = p[1], .size = .{ .cols = 80, .rows = 24 }, .is_tty = true };
3645 var tiles = [_]Tile{
3646 .{ .r = .{ .target = .{ .sock = "/s" }, .label = "a", .session = "a" }, .stripe = .{ .top = 0, .rows = 12 }, .shared = &shared, .idx = 0 },
3647 .{ .r = .{ .target = .{ .sock = "/s" }, .label = "b", .session = "b" }, .stripe = .{ .top = 12, .rows = 12 }, .shared = &shared, .idx = 1 },
3648 };
3649 var present = [_]bool{ true, true };
3650 var click: Click = .{};
3651
3652 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 };
3654
3655 // Press and release in the same cell: tmux's MouseDown1Pane.
3656 wallMouse(&tiles, &present, &shared, &click, press);
3657 // 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.
3659 try std.testing.expectEqual(@as(usize, 0), shared.sel);
3660 wallMouse(&tiles, &present, &shared, &click, release);
3661 try std.testing.expectEqual(@as(usize, 1), shared.sel);
3662
3663 // 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
3665 // yet, and it must not quietly mean "select" in the meantime.
3666 moveSelection(&tiles, &shared, 0);
3667 wallMouse(&tiles, &present, &shared, &click, press);
3668 wallMouse(&tiles, &present, &shared, &click, .{ .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 });
3670 try std.testing.expectEqual(@as(usize, 0), shared.sel);
3671
3672 // A click on a label bar names no line, so it moves nothing.
3673 wallMouse(&tiles, &present, &shared, &click, .{ .kind = .press, .button = 0, .col = 0, .row = 12, .at = 0 });
3674 wallMouse(&tiles, &present, &shared, &click, .{ .kind = .release, .button = 0, .col = 0, .row = 12, .at = 0 });
3675 try std.testing.expectEqual(@as(usize, 0), shared.sel);
3676
3677 // 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 });
3679 wallMouse(&tiles, &present, &shared, &click, .{ .kind = .release, .button = 2, .col = 4, .row = 15, .at = 0 });
3680 try std.testing.expectEqual(@as(usize, 0), shared.sel);
3681
3682 // 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.
3684 wallMouse(&tiles, &present, &shared, &click, release);
3685 try std.testing.expectEqual(@as(usize, 0), shared.sel);
3686 }
3687
3688 test "a zoom move drops what the wall's input filters are holding" {
3689 // The reset used to sit at ONE of six `setZoom` call sites and held
3690 // only because the other five are reachable from an already-zoomed
3691 // wall. It belongs to the TRANSITION: every one of them hands the
3692 // terminal to somebody else, and a chord half typed, a report half
3693 // read or a button still down at the wall is state about a screen that
3694 // has stopped existing.
3695 const p = try std.posix.pipe2(.{ .NONBLOCK = true });
3696 defer std.posix.close(p[0]);
3697 defer std.posix.close(p[1]);
3698 var shared = Shared{ .out_fd = p[1], .size = .{ .cols = 80, .rows = 24 }, .is_tty = true };
3699 var tiles = [_]Tile{
3700 .{ .r = .{ .target = .{ .sock = "/s" }, .label = "a", .session = "a" }, .stripe = .{ .top = 0, .rows = 12 }, .shared = &shared, .idx = 0 },
3701 .{ .r = .{ .target = .{ .sock = "/s" }, .label = "b", .session = "b" }, .stripe = .{ .top = 12, .rows = 12 }, .shared = &shared, .idx = 1 },
3702 };
3703 var present = [_]bool{ true, true };
3704 var input: WallInput = .{};
3705
3706 // A chord waiting for its command key, a report split across the read
3707 // boundary, and a button held down over a stripe.
3708 var chord = [_]u8{0x1c};
3709 _ = input.prefix.feed(&chord);
3710 try std.testing.expect(input.prefix.pending);
3711 var mouse_out: [mailbox_max + interact.MouseFilter.max_held]u8 = undefined;
3712 _ = input.mouse.feed("\x1b[<0;3", &mouse_out);
3713 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 });
3715 try std.testing.expect(input.click.down != null);
3716
3717 setZoom(&tiles, &shared, &input, 0);
3718
3719 try std.testing.expect(!input.prefix.pending);
3720 try std.testing.expectEqual(@as(usize, 0), input.mouse.len);
3721 try std.testing.expect(input.click.down == null);
3722 }
3723
3724 test "stripeContentRow: a label bar and the rows outside a stripe name no line" {
3725 const alloc = std.testing.allocator;
3726 const st = try layoutStripes(alloc, 3, 25);
3727 defer alloc.free(st);
3728 // 25 rows over 3 stripes: 9, 8, 8, remainder at the top.
3729 try std.testing.expectEqual(@as(u16, 0), st[0].top);
3730 try std.testing.expectEqual(@as(u16, 9), st[0].rows);
3731
3732 // The bar itself: a click on the label points at no session line, and
3733 // answering with the row under it would move a selection nobody asked
3734 // to move.
3735 try std.testing.expectEqual(@as(?u16, null), stripeContentRow(st[0], 0));
3736 try std.testing.expectEqual(@as(?u16, 0), stripeContentRow(st[0], 1));
3737 try std.testing.expectEqual(@as(?u16, 7), stripeContentRow(st[0], 8));
3738 // Row 9 is the NEXT stripe's bar, not this stripe's ninth content row.
3739 try std.testing.expectEqual(@as(?u16, null), stripeContentRow(st[0], 9));
3740 // ...and nothing above a stripe belongs to it either.
3741 try std.testing.expectEqual(@as(?u16, null), stripeContentRow(st[1], 8));
3742 try std.testing.expectEqual(@as(?u16, 0), stripeContentRow(st[1], 10));
3743 }
3744
3745 test "hitTest names the absolute row under a click, and nothing at all off a stripe" {
3746 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false };
3747 var tiles = [_]Tile{
3748 .{ .r = .{ .target = .{ .sock = "/s" }, .label = "a", .session = "a" }, .stripe = .{ .top = 0, .rows = 12 }, .shared = &shared, .idx = 0 },
3749 .{ .r = .{ .target = .{ .sock = "/s" }, .label = "b", .session = "b" }, .stripe = .{ .top = 12, .rows = 12 }, .shared = &shared, .idx = 1 },
3750 };
3751 // What each pump last painted. The daemon holds 100 rows above tile
3752 // b's viewport and the stripe was showing that grid from row 5.
3753 tiles[0].win = .{ .history_rows = 0, .win_start = 0 };
3754 tiles[1].win = .{ .history_rows = 100, .win_start = 5 };
3755 var present = [_]bool{ true, true };
3756
3757 // Tile b's first content row: terminal row 13, grid row 5, and 100
3758 // retained rows above the viewport.
3759 const first = hitTest(&tiles, &present, &shared, 13, 4) orelse return error.NoHit;
3760 try std.testing.expectEqual(@as(usize, 1), first.tile);
3761 try std.testing.expectEqual(@as(u32, 105), first.row);
3762 // Columns map 1:1 — every painter emits from column 1 with no offset.
3763 try std.testing.expectEqual(@as(u16, 4), first.col);
3764
3765 // ...and its last.
3766 const last = hitTest(&tiles, &present, &shared, 23, 0) orelse return error.NoHit;
3767 try std.testing.expectEqual(@as(u32, 115), last.row);
3768
3769 // Tile a, whose window sits at the grid top with no history behind it.
3770 const top = hitTest(&tiles, &present, &shared, 1, 0) orelse return error.NoHit;
3771 try std.testing.expectEqual(@as(usize, 0), top.tile);
3772 try std.testing.expectEqual(@as(u32, 0), top.row);
3773
3774 // Both label bars: a defined no-op, not the nearest line.
3775 try std.testing.expect(hitTest(&tiles, &present, &shared, 0, 0) == null);
3776 try std.testing.expect(hitTest(&tiles, &present, &shared, 12, 0) == null);
3777 // Past the last stripe — a terminal taller than the wall was cut for.
3778 try std.testing.expect(hitTest(&tiles, &present, &shared, 24, 0) == null);
3779
3780 // A forgotten tile keeps its stale stripe (`relayout` never compacts
3781 // the array), so `present` and not the geometry is what answers here.
3782 present[1] = false;
3783 try std.testing.expect(hitTest(&tiles, &present, &shared, 13, 0) == null);
3784 }
3785
3786 test "paintStripe publishes the window it painted" {
3787 // A click is resolved on the KEYBOARD thread, which has no replica in
3788 // reach — the `Engine` is the pump's. So the pump has to leave behind
3789 // the two numbers that name a line, and this asserts they describe the
3790 // window that actually went to the terminal rather than some other one.
3791 const alloc = std.testing.allocator;
3792 const p = try std.posix.pipe2(.{ .NONBLOCK = true });
3793 defer std.posix.close(p[0]);
3794 defer std.posix.close(p[1]);
3795
3796 var shared = Shared{ .out_fd = p[1], .size = .{ .cols = 20, .rows = 24 }, .is_tty = true };
3797 var t = Tile{
3798 .r = .{ .target = .{ .sock = "/s" }, .label = "a", .session = "a" },
3799 // A label bar and three content rows.
3800 .stripe = .{ .top = 0, .rows = 4 },
3801 .shared = &shared,
3802 .idx = 0,
3803 };
3804
3805 const eng = try Engine.init(alloc, .{ .cols = 20, .rows = 5 });
3806 defer eng.deinit();
3807 eng.feed("row0\r\nrow1\r\nrow2\r\nrow3\r\nrow4");
3808
3809 try std.testing.expect(paintStripe(&t, alloc, eng, 7));
3810 // Three content rows over a five-row grid with the cursor on the last:
3811 // the window is grid rows 2..4.
3812 try std.testing.expectEqual(@as(u16, 2), t.win.win_start);
3813 // Carried through untouched — it is the daemon's count, not the
3814 // replica's, because absolute rows are named from the oldest row the
3815 // DAEMON still retains.
3816 try std.testing.expectEqual(@as(u32, 7), t.win.history_rows);
3817
3818 var buf: [4096]u8 = undefined;
3819 const out = readAvail(p[0], &buf);
3820 try std.testing.expect(std.mem.indexOf(u8, out, "row2") != null);
3821 try std.testing.expect(std.mem.indexOf(u8, out, "row1") == null);
3822 }
3823
3369 test "agent channels: slots fill in order, a full table refuses, and a close is announced" { 3824 test "agent channels: slots fill in order, a full table refuses, and a close is announced" {
3370 // The pump's table at the size its refusal path needs. The e2e proves 3825 // The pump's table at the size its refusal path needs. The e2e proves
3371 // one channel end to end and will never fill eight of them. 3826 // one channel end to end and will never fill eight of them.
test/e2e.sh
Old New
@@ -1552,6 +1552,8 @@ cleanup() {
1552 rm -f "$SOCK50" "$OUT.wmse.d" "$OUT.wmsa" "$OUT.wmsa.err" "$OUT.wmsb" \ 1552 rm -f "$SOCK50" "$OUT.wmse.d" "$OUT.wmsa" "$OUT.wmsa.err" "$OUT.wmsb" \
1553 "$OUT.wmsb.err" "$OUT.wmcap" "$OUT.wmcap.err" "$OUT.wmpc" \ 1553 "$OUT.wmsb.err" "$OUT.wmcap" "$OUT.wmcap.err" "$OUT.wmpc" \
1554 "$OUT.wmcap2" "$OUT.wmcap2.err" "$OUT.wmpc2" "$OUT.wmfa" "$OUT.wmfb" \ 1554 "$OUT.wmcap2" "$OUT.wmcap2.err" "$OUT.wmpc2" "$OUT.wmfa" "$OUT.wmfb" \
1555 "$OUT.wmcap3" "$OUT.wmcap3.err" "$OUT.wmpc3" \
1556 "$OUT.wmcap4" "$OUT.wmcap4.err" "$OUT.wmpc4" \
1555 "$OUT.wmstop" 1557 "$OUT.wmstop"
1556 # The convergence files a FAILING assert_converged leaves behind 1558 # The convergence files a FAILING assert_converged leaves behind
1557 # (.render/.dump/.rvt/.dvt/.diff for that capture) are deliberately not 1559 # (.render/.dump/.rvt/.dvt/.diff for that capture) are deliberately not
@@ -7569,14 +7571,17 @@ wait_grid "$SOCK50" "wma-pin" "wall mouse: session a's marker" a
7569 wait_grid "$SOCK50" "wmb-pin" "wall mouse: session b's marker" b 7571 wait_grid "$SOCK50" "wmb-pin" "wall mouse: session b's marker" b
7570 7572
7571 # A press, a drag and a release at column 3, then a wheel notch — every 7573 # A press, a drag and a release at column 3, then a wheel notch — every
7572 # shape the filter has to swallow. The wall opens with tile 1 (session a) 7574 # shape the filter has to take out of the key stream. The wall opens with
7573 # selected, and each report ENDS on a `2`: that is not decoration. The key 7575 # tile 1 (session a) selected and row 2 is inside tile a's own stripe, so
7576 # what the click means is "stay", which is what makes this the leg about
7577 # the KEYS: each report ENDS on a `2`, and that is not decoration. The key
7574 # loop acts on every digit as it passes, so a report ending on `1` would 7578 # loop acts on every digit as it passes, so a report ending on `1` would
7575 # jump to b and back to a and leave the selection where it started — 7579 # jump to b and back to a and leave the selection where it started —
7576 # passing this leg with the filter deleted. Measured, not reasoned about: 7580 # passing this leg with the filter deleted. Measured, not reasoned about:
7577 # with `\x1b[<0;2;1M` this leg passed against a loop that read the reports 7581 # with `\x1b[<0;2;1M` this leg passed against a loop that read the reports
7578 # as keys. Row 2 is the only parameter free to carry that `2`, since 7582 # as keys. Row 2 is the only parameter free to carry that `2`, since
7579 # column 3 and the button words have to spell something else. 7583 # column 3 and the button words have to spell something else. The legs
7584 # below are the other half: a click that MOVES the selection.
7580 set +e 7585 set +e
7581 timeout 60 "$PTYCLIENT" --cols 100 --rows 30 --out "$OUT.wmcap" --err "$OUT.wmcap.err" -- \ 7586 timeout 60 "$PTYCLIENT" --cols 100 --rows 30 --out "$OUT.wmcap" --err "$OUT.wmcap.err" -- \
7582 "$MUX" wall "--sock $SOCK50#a" "--sock $SOCK50#b" > "$OUT.wmpc" 2>&1 <<'EOF' 7587 "$MUX" wall "--sock $SOCK50#a" "--sock $SOCK50#b" > "$OUT.wmpc" 2>&1 <<'EOF'
@@ -7632,9 +7637,85 @@ timeout 20 "$MUXA" capture --sock "$SOCK50" --session b > "$OUT.wmfb" 2>&1
7632 grep -q "wm-two" "$OUT.wmfb" || { 7637 grep -q "wm-two" "$OUT.wmfb" || {
7633 echo "e2e FAIL: wall mouse: a bare 2 did not move the selection, so the leg above proves nothing:" 7638 echo "e2e FAIL: wall mouse: a bare 2 did not move the selection, so the leg above proves nothing:"
7634 cat "$OUT.wmfb"; exit 1; } 7639 cat "$OUT.wmfb"; exit 1; }
7640 ok "a mouse report at the unzoomed wall is swallowed; the same digit typed alone still jumps"
7641
7642 # ...and now the same report SELECTS. Two tiles over 30 rows is two
7643 # fifteen-row stripes, each opening with a label bar, so terminal row 19 is
7644 # inside session b's stripe and terminal row 1 is session a's BAR.
7645 #
7646 # The row numbers are chosen the way the leg above chose its `2`. Read as
7647 # keys, `\x1b[<0;3;19M` offers `0`, `3`, `1` and `9`: `1` jumps to session a
7648 # and the rest name no tile, so a selection that ends on b cannot have got
7649 # there by a digit. Which session it ended on is read off the DAEMON again,
7650 # never off the paint.
7651 set +e
7652 timeout 60 "$PTYCLIENT" --cols 100 --rows 30 --out "$OUT.wmcap3" --err "$OUT.wmcap3.err" -- \
7653 "$MUX" wall "--sock $SOCK50#a" "--sock $SOCK50#b" > "$OUT.wmpc3" 2>&1 <<'EOF'
7654 expect wmb-pin 20000
7655 settle 700 20000
7656 send \x1b[<0;3;19M\x1b[<0;3;19m
7657 settle 700 20000
7658 send \r
7659 settle 700 20000
7660 send printf 'wm-%s\\n' three\n
7661 expect wm-three 15000
7662 settle 400 15000
7663 send \x1cd
7664 waitexit 10000
7665 EOF
7666 RC=$?
7667 set -e
7668 [ "$RC" -eq 0 ] || {
7669 echo "e2e FAIL: wall click: ptyclient leg exited $RC (did the click zoom anything?):"
7670 cat "$OUT.wmpc3"; exit 1; }
7671 timeout 20 "$MUXA" capture --sock "$SOCK50" --session a > "$OUT.wmfa" 2>&1
7672 timeout 20 "$MUXA" capture --sock "$SOCK50" --session b > "$OUT.wmfb" 2>&1
7673 grep -q "wm-three" "$OUT.wmfb" || {
7674 echo "e2e FAIL: wall click: a click in session b's stripe did not select it:"
7675 cat "$OUT.wmfb"; exit 1; }
7676 grep -q "wm-three" "$OUT.wmfa" && {
7677 echo "e2e FAIL: wall click: the selection stayed on session a:"
7678 cat "$OUT.wmfa"; exit 1; }
7679
7680 # A click on a LABEL BAR names no session line, so it moves nothing — the
7681 # bar is exactly where a hand rests between two stripes. Asserted against a
7682 # selection that is already somewhere else, because "nothing moved" is only
7683 # a claim when there was somewhere to move from: `2` puts the selection on
7684 # b, and the click that follows is on session a's bar. Its own bytes would
7685 # not leave it there — a `1` read as a key jumps straight to a.
7686 set +e
7687 timeout 60 "$PTYCLIENT" --cols 100 --rows 30 --out "$OUT.wmcap4" --err "$OUT.wmcap4.err" -- \
7688 "$MUX" wall "--sock $SOCK50#a" "--sock $SOCK50#b" > "$OUT.wmpc4" 2>&1 <<'EOF'
7689 expect wmb-pin 20000
7690 settle 700 20000
7691 send 2
7692 settle 700 20000
7693 send \x1b[<0;3;1M\x1b[<0;3;1m
7694 settle 700 20000
7695 send \r
7696 settle 700 20000
7697 send printf 'wm-%s\\n' four\n
7698 expect wm-four 15000
7699 settle 400 15000
7700 send \x1cd
7701 waitexit 10000
7702 EOF
7703 RC=$?
7704 set -e
7705 [ "$RC" -eq 0 ] || {
7706 echo "e2e FAIL: wall click: label-bar leg exited $RC:"
7707 cat "$OUT.wmpc4"; exit 1; }
7708 timeout 20 "$MUXA" capture --sock "$SOCK50" --session a > "$OUT.wmfa" 2>&1
7709 timeout 20 "$MUXA" capture --sock "$SOCK50" --session b > "$OUT.wmfb" 2>&1
7710 grep -q "wm-four" "$OUT.wmfb" || {
7711 echo "e2e FAIL: wall click: a click on a label bar moved the selection:"
7712 cat "$OUT.wmfa"; exit 1; }
7713 grep -q "wm-four" "$OUT.wmfa" && {
7714 echo "e2e FAIL: wall click: a label-bar click selected the tile it belongs to:"
7715 cat "$OUT.wmfa"; exit 1; }
7635 assert_stopped "$SOCK50" "$D48PID" "wall mouse" "$OUT.wmstop" 7716 assert_stopped "$SOCK50" "$D48PID" "wall mouse" "$OUT.wmstop"
7636 D48PID="" 7717 D48PID=""
7637 ok "a mouse report at the unzoomed wall is swallowed; the same digit typed alone still jumps" 7718 ok "a click selects the stripe under it, and a click on a label bar selects nothing"
7638 7719
7639 7720
7640 # The long-lived daemon has served every scenario that wanted it; stop it 7721 # The long-lived daemon has served every scenario that wanted it; stop it
@@ -7744,16 +7825,17 @@ DPID=""
7744 # since both replicate the same session and hold both answers alike. The 7825 # since both replicate the same session and hold both answers alike. The
7745 # 59th is the `-A` preflight, and no convergence point because it never 7826 # 59th is the `-A` preflight, and no convergence point because it never
7746 # attaches: what it asserts on is an exit code and a message, before any 7827 # attaches: what it asserts on is an exit code and a message, before any
7747 # transport exists to converge. The 61st is the mouse report at the 7828 # transport exists to converge. The 61st and 62nd are the wall's mouse —
7748 # unzoomed wall, and no convergence point because its subject is WHICH 7829 # a report not read as keys, then a click that selects — and neither is a
7749 # SESSION a keystroke reached — read off two daemon captures, one of which 7830 # convergence point, because their subject is WHICH SESSION a keystroke
7750 # must not contain it — and a client grid compared against the daemon's 7831 # reached (read off two daemon captures, one of which must not contain it)
7751 # says nothing about where the wall decided to send input. It is last in 7832 # and a client grid compared against the daemon's says nothing about where
7752 # the file rather than beside the other wall legs on purpose: the ordinals 7833 # the wall decided to send input. They are last in the file rather than
7753 # in this paragraph are positions, so a scenario inserted in the middle 7834 # beside the other wall legs on purpose: the ordinals in this paragraph are
7754 # renumbers every sentence after it. 7835 # positions, so a scenario inserted in the middle renumbers every sentence
7755 [ "$OK_COUNT" = "61" ] || { 7836 # after it.
7756 echo "e2e FAIL: $OK_COUNT scenario checkpoints ran, the pin says 61 —" 7837 [ "$OK_COUNT" = "62" ] || {
7838 echo "e2e FAIL: $OK_COUNT scenario checkpoints ran, the pin says 62 —"
7757 echo " a scenario was added (update the pin) or silently lost" 7839 echo " a scenario was added (update the pin) or silently lost"
7758 exit 1 7840 exit 1
7759 } 7841 }
@@ -7761,4 +7843,4 @@ DPID=""
7761 echo "e2e FAIL: $CONV_COUNT convergence points ran, the pin says 35" 7843 echo "e2e FAIL: $CONV_COUNT convergence points ran, the pin says 35"
7762 exit 1 7844 exit 1
7763 } 7845 }
7764 echo "e2e OK (61 scenarios, 35 convergence points)" 7846 echo "e2e OK (62 scenarios, 35 convergence points)"