a73x

31771807

feat: every tile claims its rect; focus replaces zoom

a73x   2026-08-24 11:32

Commit message
feat: every tile claims its rect; focus replaces zoom

A wall tile used to attach at 0x0 and show a crop of whatever grid the
daemon held; zoom promoted one tile to the terminal. Now every tile
attaches at its stripe and paints through its own Core at a row offset,
and focus — which tile the keyboard and mouse modes belong to — is
client-local. Nothing bare is intercepted any more: wall motion lives
under the prefix, and `mux TARGET` is a one-tile wall with no label bar.

The keyboard thread owns SIGWINCH and relayout; pumps follow doorbells
(claim, release, resize) so each transport keeps one writer.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

CLAUDE.md
Old New
@@ -59,11 +59,12 @@ real pty), `wsclient` (browser stand-in), `rawmode`, `delaypipe`, `render`.
59 - **One replay core.** CLI, wasm, and test fixtures all go through `replica.zig`. 59 - **One replay core.** CLI, wasm, and test fixtures all go through `replica.zig`.
60 Do not hand-roll a second applier. 60 Do not hand-roll a second applier.
61 - **Latest wins.** The grid follows the most recently active client. 61 - **Latest wins.** The grid follows the most recently active client.
62 - **An unzoomed tile claims nothing; zoom promotes/demotes the tile's existing 62 - **Every tile claims its rect.** Attach sends the rect, relayout resends
63 connection.** Promote resizes the attach from 0×0 to the terminal and starts 63 it. Focus is client-local and sends nothing on the wire: the keyboard
64 forwarding; demote is client-local and sends nothing. `mux [TARGET]` is a 64 writes the outgoing tile's `session_release` under `paint_mu`, then
65 wall of one tile entered zoomed — there is ONE interaction loop, and it is a 65 doorbells the old pump (release) and the new (claim). `mux [TARGET]` is a
66 tile pump. 66 wall of one tile whose rect is the whole terminal — ONE interaction loop,
67 a tile pump.
67 - **`muxa` attaches at 0×0** so an agent never resizes a human's session. 68 - **`muxa` attaches at 0×0** so an agent never resizes a human's session.
68 - **OSC 133 marks are opt-in** (`MUX_SHELL_INTEGRATION=1`); without them `muxa` 69 - **OSC 133 marks are opt-in** (`MUX_SHELL_INTEGRATION=1`); without them `muxa`
69 falls back to `pgid`/`settle` and there is no real exit code. Every `muxa` 70 falls back to `pgid`/`settle` and there is no real exit code. Every `muxa`
src/client.zig
Old New
@@ -1591,7 +1591,7 @@ test "client: a picked name off the wire is validated before it is copied" {
1591 // Nothing to go to is the same answer, and the one the ring already 1591 // Nothing to go to is the same answer, and the one the ring already
1592 // gave: stay put. 1592 // gave: stay put.
1593 try std.testing.expectEqual(@as(?SessionName, null), validPick(null)); 1593 try std.testing.expectEqual(@as(?SessionName, null), validPick(null));
1594 // A name the daemon could have meant still moves the zoom. 1594 // A name the daemon could have meant still moves the focus.
1595 const ok = validPick("work").?; 1595 const ok = validPick("work").?;
1596 try std.testing.expectEqualStrings("work", ok.slice()); 1596 try std.testing.expectEqualStrings("work", ok.slice());
1597 const at_cap = validPick("a" ** proto.session_name_max).?; 1597 const at_cap = validPick("a" ** proto.session_name_max).?;
src/interact.zig
Old New
@@ -35,7 +35,7 @@ const select = @import("select");
35 /// The two are siblings at layer 1 and may not import one another, so 35 /// The two are siblings at layer 1 and may not import one another, so
36 /// somebody above both has to join them; this is the smaller half of that 36 /// somebody above both has to join them; this is the smaller half of that
37 /// join, and the wall reuses it rather than keeping a second copy 37 /// join, and the wall reuses it rather than keeping a second copy
38 /// (`wallview.paintStripe`). 38 /// (`wallview`'s Core sink).
39 /// 39 ///
40 /// It converts as well as adapts. `select` speaks ABSOLUTE rows — 40 /// It converts as well as adapts. `select` speaks ABSOLUTE rows —
41 /// counted from the oldest row the daemon retains — and a painter speaks 41 /// counted from the oldest row the daemon retains — and a painter speaks
@@ -45,7 +45,7 @@ const select = @import("select");
45 /// history and painted onto another's is a highlight on the wrong lines. 45 /// history and painted onto another's is a highlight on the wrong lines.
46 pub const Highlight = struct { 46 pub const Highlight = struct {
47 drag: *const select.Drag, 47 drag: *const select.Drag,
48 /// Which tile the drag belongs to. Zero for a zoomed `Core`, which is 48 /// Which tile the drag belongs to. Zero for a focused `Core`, which is
49 /// the only session on its screen; the wall passes the real index. 49 /// the only session on its screen; the wall passes the real index.
50 tile: usize, 50 tile: usize,
51 history_rows: u32, 51 history_rows: u32,
@@ -72,7 +72,7 @@ pub const detach_key: u8 = 0x1c;
72 /// The attached client's keybinding layer: Ctrl-\ selects a command rather 72 /// The attached client's keybinding layer: Ctrl-\ selects a command rather
73 /// than acting on its own. `d` or a second Ctrl-\ detach, `c` creates a new 73 /// than acting on its own. `d` or a second Ctrl-\ detach, `c` creates a new
74 /// session, `n` and `p` step to the next and previous one, `l` skips to the 74 /// session, `n` and `p` step to the next and previous one, `l` skips to the
75 /// last one visited, `w` unzooms to the wall of tiles; any other 75 /// last one visited, `w` shows the full wall; any other
76 /// key is dropped along with the prefix. Dropping is not a loss — a literal 76 /// key is dropped along with the prefix. Dropping is not a loss — a literal
77 /// 0x1c never reached the pty before this layer existed either. 77 /// 0x1c never reached the pty before this layer existed either.
78 /// 78 ///
@@ -82,12 +82,11 @@ pub const detach_key: u8 = 0x1c;
82 /// they were typed. 82 /// they were typed.
83 /// 83 ///
84 /// Public because the CLI wall's ZOOMED tile needs the same layer over the 84 /// Public because the CLI wall's ZOOMED tile needs the same layer over the
85 /// same keys (wallview.zig): a zoomed tile is a session on this terminal, 85 /// same keys (wallview.zig): a focused tile is a session on this terminal,
86 /// and a second copy of this table would be a twin that drifts. What each 86 /// and a second copy of this table would be a twin that drifts. What each
87 /// action MEANS is the caller's — in a zoomed tile `.detach` detaches and 87 /// action MEANS is the caller's — in the wall `.detach` detaches and ends
88 /// ends the run while `.wall` unzooms, and `.next_session` moves the zoom 88 /// the run while `.next_session` moves the focus to the next session
89 /// to the next session (`wallview.zoomChord`) — but which byte spells it 89 /// (`wallview`'s run loop) — but which byte spells it is one table.
90 /// is one table.
91 pub const PrefixFilter = struct { 90 pub const PrefixFilter = struct {
92 /// Callers switch on it, so a new variant is additive. 91 /// Callers switch on it, so a new variant is additive.
93 pub const Action = union(enum) { 92 pub const Action = union(enum) {
@@ -188,7 +187,7 @@ const wheel_rows: u32 = 3;
188 /// (and reset, so a report split across that transition cannot be 187 /// (and reset, so a report split across that transition cannot be
189 /// half-eaten). The CLI wall owns one more, in front of its UNZOOMED key 188 /// half-eaten). The CLI wall owns one more, in front of its UNZOOMED key
190 /// loop, where there is no session to bypass it for — the modes are the 189 /// loop, where there is no session to bypass it for — the modes are the
191 /// wall's own (`wall_mouse_claim`) and every report is the wall's. 190 /// wall's own and every report is the wall's.
192 /// 191 ///
193 /// Only the SGR form (`ESC [ < b ; x ; y M|m`) is recognised, because it is 192 /// Only the SGR form (`ESC [ < b ; x ; y M|m`) is recognised, because it is
194 /// the only form the client ever asks its terminal for (`client_mouse_setup`). 193 /// the only form the client ever asks its terminal for (`client_mouse_setup`).
@@ -214,7 +213,7 @@ pub const MouseFilter = struct {
214 /// 213 ///
215 /// `button` is the SGR button word verbatim, modifier bits and all — 214 /// `button` is the SGR button word verbatim, modifier bits and all —
216 /// decoding it is the DRIVER's business, because what a click means 215 /// decoding it is the DRIVER's business, because what a click means
217 /// differs between a wall and a zoomed tile and this filter must not 216 /// differs between the wall's keyboard and a focused tile and this filter must not
218 /// have to know which it is feeding. 217 /// have to know which it is feeding.
219 pub const Event = struct { 218 pub const Event = struct {
220 pub const Kind = enum { press, motion, release }; 219 pub const Kind = enum { press, motion, release };
@@ -234,8 +233,7 @@ pub const MouseFilter = struct {
234 /// A read carries keys and reports interleaved, and `feed` returns 233 /// A read carries keys and reports interleaved, and `feed` returns
235 /// them as two flat lists; without this the interleaving is lost. 234 /// them as two flat lists; without this the interleaving is lost.
236 /// The case that costs is one read holding `\r` and a click: at the 235 /// The case that costs is one read holding `\r` and a click: at the
237 /// wall, Enter zooms whatever tile is selected and a press moves 236 /// wall, a press moves the focus, so draining all the events first focuses the tile
238 /// the selection, so draining all the events first zooms the tile
239 /// the click had just selected rather than the one the user chose. 237 /// the click had just selected rather than the one the user chose.
240 /// Two orderings, one flattening, opposite right answers. 238 /// Two orderings, one flattening, opposite right answers.
241 /// 239 ///
@@ -397,10 +395,10 @@ fn onWinch(_: c_int) callconv(.c) void {
397 winch_flag.store(true, .release); 395 winch_flag.store(true, .release);
398 } 396 }
399 397
400 /// Arm SIGWINCH, so `Core.winch` has something to answer. 398 /// Arm SIGWINCH, so `winchRaised` has something to answer.
401 /// 399 ///
402 /// The wall has no Core, so it arms the signal and the promoted pump 400 /// The keyboard thread polls the flag and relayouts; there is no single
403 /// answers it — one handler per process, which is what the flag is. 401 /// promoted pump that owns the screen, so no pump consumes the signal.
404 pub fn watchWinch() void { 402 pub fn watchWinch() void {
405 var sa: std.posix.Sigaction = .{ 403 var sa: std.posix.Sigaction = .{
406 .handler = .{ .handler = onWinch }, 404 .handler = .{ .handler = onWinch },
@@ -410,6 +408,11 @@ pub fn watchWinch() void {
410 std.posix.sigaction(std.posix.SIG.WINCH, &sa, null); 408 std.posix.sigaction(std.posix.SIG.WINCH, &sa, null);
411 } 409 }
412 410
411 /// Whether SIGWINCH fired since the last check. Swaps to false.
412 pub fn winchRaised() bool {
413 return winch_flag.swap(false, .acq_rel);
414 }
415
413 /// This terminal's size, or null when there is no terminal to measure. 416 /// This terminal's size, or null when there is no terminal to measure.
414 /// 417 ///
415 /// Public because a driver that lays a screen out BEFORE it has a Core to 418 /// Public because a driver that lays a screen out BEFORE it has a Core to
@@ -486,7 +489,7 @@ const session_claim = client_mouse_setup;
486 /// What that costs is a report per motion event, read and, until something 489 /// What that costs is a report per motion event, read and, until something
487 /// wants one, dropped. That cost is why this set stopped at 1000, and it 490 /// wants one, dropped. That cost is why this set stopped at 1000, and it
488 /// stopped being decisive when the wall itself became a mouse claimant 491 /// stopped being decisive when the wall itself became a mouse claimant
489 /// (`wall_mouse_claim`): the reports now have a consumer. The price is also 492 /// (`client_mouse_setup`): the reports now have a consumer. The price is also
490 /// one every editor already pays — nvim with `set mouse=a` sets exactly 493 /// one every editor already pays — nvim with `set mouse=a` sets exactly
491 /// `?1002h` and `?1006h` and holds them for the whole session (measured 494 /// `?1002h` and `?1006h` and holds them for the whole session (measured
492 /// 2026-08-21). 495 /// 2026-08-21).
@@ -569,7 +572,7 @@ const terminal_teardown = session_release ++ "\x1b[?7h\x1b[?25h\x1b[23;0t\x1b[?1
569 /// transition and a tile's pump are different threads, and the release has 572 /// transition and a tile's pump are different threads, and the release has
570 /// to be ordered against the NEXT tile's claim rather than merely happen — 573 /// to be ordered against the NEXT tile's claim rather than merely happen —
571 /// so the thread that moves the zoom writes it, before the store that lets 574 /// so the thread that moves the zoom writes it, before the store that lets
572 /// the next pump see the move (wallview's `setZoom`). Exported rather than 575 /// the next pump see the move (wallview's `setFocus`). Exported rather than
573 /// duplicated, for `wall_setup`/`wall_teardown`'s reason: a mouse mode 576 /// duplicated, for `wall_setup`/`wall_teardown`'s reason: a mouse mode
574 /// added to the claim must not need finding in four places. 577 /// added to the claim must not need finding in four places.
575 pub const session_release = "\x1b[?2004l" ++ mouse_teardown; 578 pub const session_release = "\x1b[?2004l" ++ mouse_teardown;
@@ -608,31 +611,15 @@ pub const session_release = "\x1b[?2004l" ++ mouse_teardown;
608 /// deeper is not the part they will notice. Same exposure as every other 611 /// deeper is not the part they will notice. Same exposure as every other
609 /// line of the teardown, not a new one. 612 /// line of the teardown, not a new one.
610 /// 613 ///
611 /// The MOUSE modes ride in here as well, which the screen half's name does 614 /// The wall no longer claims the mouse itself: the focused tile's
612 /// not suggest: an UNZOOMED wall reads mouse reports on its own account, so 615 /// `claimTerminal` arms `session_claim`, so a click reaches the session
613 /// it arms `wall_mouse_claim` once, for its life, exactly as it does the 616 /// that asked for it and a click in another tile's rect moves the focus
614 /// screen. That is also the one part of this string a demote can take away 617 /// there. `session_release` (written by the keyboard on a focus move)
615 /// — `session_release` clears the whole wire table without knowing whose 618 /// clears the whole wire table, and the newly focused tile's claim puts
616 /// mode is whose — so `wallview`'s `setZoom` puts it back on the way out of 619 /// the modes back — ordered by the `paint_mu` lock, not by a re-arm here.
617 /// a zoom. 620 pub const wall_setup = terminal_frame_setup ++ "\x1b[H\x1b[2J";
618 pub const wall_setup = terminal_frame_setup ++ wall_mouse_claim ++ "\x1b[H\x1b[2J";
619 pub const wall_teardown = terminal_teardown; 621 pub const wall_teardown = terminal_teardown;
620 622
621 /// The mouse modes the WALL asks for on its own behalf, armed once for the
622 /// wall's whole life rather than per zoom.
623 ///
624 /// The same bytes as `session_claim`'s, and that is structure rather than
625 /// coincidence: both are `client_mouse_capture`, and while no tile is
626 /// promoted the wall IS the client. A wall that claimed nothing would be
627 /// handed no reports at all — which is what it was until the unzoomed wall
628 /// acquired something to do with them.
629 ///
630 /// Named apart from `session_claim` because the two come off at different
631 /// moments. A demote writes `session_release`, which clears the whole wire
632 /// table and cannot tell a wall's mode from a session's, so the wall has to
633 /// put its own back afterwards (`wallview`'s `setZoom`).
634 pub const wall_mouse_claim = client_mouse_setup;
635
636 /// Built from the wire table, so a mode added there cannot be missed here 623 /// Built from the wire table, so a mode added there cannot be missed here
637 /// and leave the user's shell reporting clicks after mux exits. 624 /// and leave the user's shell reporting clicks after mux exits.
638 const mouse_teardown = blk: { 625 const mouse_teardown = blk: {
@@ -1252,7 +1239,7 @@ pub const Core = struct {
1252 /// 1239 ///
1253 /// Taken under the SINK for ORDER, not painting: the wall writes the 1240 /// Taken under the SINK for ORDER, not painting: the wall writes the
1254 /// previous holder's release under the same lock, before the store that 1241 /// previous holder's release under the same lock, before the store that
1255 /// makes the handover visible (wallview's `setZoom`). A claim taken 1242 /// makes the handover visible (wallview's `setFocus`). A claim taken
1256 /// outside it can land AFTER that release, leaving modes nothing 1243 /// outside it can land AFTER that release, leaving modes nothing
1257 /// undoes. `false` is a promote the zoom moved out from under. 1244 /// undoes. `false` is a promote the zoom moved out from under.
1258 /// 1245 ///
@@ -1780,7 +1767,7 @@ pub const Core = struct {
1780 // This Core owns its own screen and its own thread — a tile's pump 1767 // This Core owns its own screen and its own thread — a tile's pump
1781 // is what runs `forward` — so the highlight is painted here rather 1768 // is what runs `forward` — so the highlight is painted here rather
1782 // than left in shared state for somebody else to draw. The wall's 1769 // than left in shared state for somebody else to draw. The wall's
1783 // unzoomed loop cannot do that and does not (`wallview.paintStripe`). 1770 // keyboard cannot do that and does not (the Core sink does).
1784 try self.paintDragChange(was); 1771 try self.paintDragChange(was);
1785 // Drag copies on release, tmux's `copy-pipe-and-cancel` rule and 1772 // Drag copies on release, tmux's `copy-pipe-and-cancel` rule and
1786 // the reason there is no copy chord: `Ctrl+Shift+C` cannot reach a 1773 // the reason there is no copy chord: `Ctrl+Shift+C` cannot reach a
@@ -1913,7 +1900,7 @@ pub const Core = struct {
1913 /// the tile's pump — the thread that owns this transport. The WALL's 1900 /// the tile's pump — the thread that owns this transport. The WALL's
1914 /// keyboard may not: a `Transport` has exactly one owning thread, so it 1901 /// keyboard may not: a `Transport` has exactly one owning thread, so it
1915 /// posts the range and its pump calls this instead 1902 /// posts the range and its pump calls this instead
1916 /// (`wallview.wallMouse`). 1903 /// (`wallview`'s `copySelection`).
1917 pub fn requestSelection(self: *Core, transport: anytype, r: select.Range) !void { 1904 pub fn requestSelection(self: *Core, transport: anytype, r: select.Range) !void {
1918 self.sel_id +%= 1; 1905 self.sel_id +%= 1;
1919 // Sampled HERE, from the replica the coordinates were resolved 1906 // Sampled HERE, from the replica the coordinates were resolved
@@ -1977,14 +1964,19 @@ pub const Core = struct {
1977 // rather than half-answered — the alternative is a selection whose 1964 // rather than half-answered — the alternative is a selection whose
1978 // coordinates belong to the live view the user is not looking at. 1965 // coordinates belong to the live view the user is not looking at.
1979 if (self.scroll_rows > 0) return null; 1966 if (self.scroll_rows > 0) return null;
1967 // A wall tile paints at `row_off`, so a terminal row is a grid row
1968 // only after the offset comes off. The plain client and a one-tile
1969 // wall leave `row_off` 0 and this is the identity it always was.
1970 if (ev.row < self.row_off) return null;
1971 const grow = ev.row - self.row_off;
1980 const grid_rows: u16 = @intCast(self.rep.eng.term.rows); 1972 const grid_rows: u16 = @intCast(self.rep.eng.term.rows);
1981 const cols: u16 = @intCast(self.rep.eng.term.cols); 1973 const cols: u16 = @intCast(self.rep.eng.term.cols);
1982 // Rows past the grid (a terminal taller than the daemon's grid) 1974 // Rows past the grid (a terminal taller than the daemon's grid)
1983 // hold no session line: `renderClipped` never painted them. 1975 // hold no session line: `renderClipped` never painted them.
1984 if (ev.row >= @min(grid_rows, self.size.rows)) return null; 1976 if (grow >= @min(grid_rows, self.size.rows)) return null;
1985 return .{ 1977 return .{
1986 .tile = zoomed_tile, 1978 .tile = zoomed_tile,
1987 .row = self.rep.history_rows + ev.row, 1979 .row = self.rep.history_rows + grow,
1988 // Columns clamp instead of refusing, because the right edge is 1980 // Columns clamp instead of refusing, because the right edge is
1989 // where a hand naturally overshoots. An out-of-range column is 1981 // where a hand naturally overshoots. An out-of-range column is
1990 // what makes the daemon answer `.invalid`, and a round trip 1982 // what makes the daemon answer `.invalid`, and a round trip
@@ -2061,8 +2053,8 @@ test "interact: an unknown command key is swallowed with its prefix" {
2061 try std.testing.expectEqualStrings("d", next.forward); 2053 try std.testing.expectEqualStrings("d", next.forward);
2062 } 2054 }
2063 2055
2064 // The TABLE names `l` and `wallview.zoomChord` gives it meaning; what this 2056 // The TABLE names `l` and the wall's run loop gives it meaning; what this
2065 // pins is the chord layer, not the zoom move. 2057 // pins is the chord layer, not the focus move.
2066 // Split across reads for the same reason every other chord is: 2058 // Split across reads for the same reason every other chord is:
2067 // a read boundary is not a chord boundary. 2059 // a read boundary is not a chord boundary.
2068 test "interact: Ctrl-\\ l is a chord in the table, whoever acts on it" { 2060 test "interact: Ctrl-\\ l is a chord in the table, whoever acts on it" {
@@ -2958,7 +2950,6 @@ test "interact: the exit teardown unsets every mode mux turned on, and pops the
2958 // deep forever or one pop too many. 2950 // deep forever or one pop too many.
2959 try std.testing.expectEqualStrings( 2951 try std.testing.expectEqualStrings(
2960 "\x1b[22;0t\x1b[?1049h\x1b[?25l\x1b[?7l" ++ 2952 "\x1b[22;0t\x1b[?1049h\x1b[?25l\x1b[?7l" ++
2961 "\x1b[?1000h\x1b[?1002h\x1b[?1006h" ++
2962 "\x1b[H\x1b[2J", 2953 "\x1b[H\x1b[2J",
2963 wall_setup, 2954 wall_setup,
2964 ); 2955 );
@@ -2987,22 +2978,20 @@ test "interact: a borrowed terminal's claim is a session's, and every teardown u
2987 try std.testing.expect(std.mem.startsWith(u8, terminal_teardown, session_release)); 2978 try std.testing.expect(std.mem.startsWith(u8, terminal_teardown, session_release));
2988 try std.testing.expect(std.mem.indexOf(u8, wall_teardown, session_release) != null); 2979 try std.testing.expect(std.mem.indexOf(u8, wall_teardown, session_release) != null);
2989 // And it is exported, because the DEMOTE is written by a thread that 2980 // And it is exported, because the DEMOTE is written by a thread that
2990 // holds no Core: wallview's `setZoom`. A release that only a Core could 2981 // holds no Core: wallview's `setFocus`. A release that only a Core could
2991 // write is a release that cannot be ordered against the next Core's 2982 // write is a release that cannot be ordered against the next Core's
2992 // claim. 2983 // claim.
2993 try std.testing.expectEqualStrings( 2984 try std.testing.expectEqualStrings(
2994 "\x1b[?2004l\x1b[?9l\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1005l\x1b[?1006l\x1b[?1015l\x1b[?1016l", 2985 "\x1b[?2004l\x1b[?9l\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1005l\x1b[?1006l\x1b[?1015l\x1b[?1016l",
2995 session_release, 2986 session_release,
2996 ); 2987 );
2997 // The wall now enters holding the same mouse modes a tile's claim 2988 // The wall no longer claims the mouse itself: the focused tile's
2998 // brings, because an UNZOOMED wall is itself a mouse consumer: with no 2989 // `claimTerminal` does. So `wall_setup` is the screen half only — no
2999 // mode of its own it would receive nothing to consume. The bytes are 2990 // mouse modes — and the teardown still clears them all because it is
3000 // the same bytes for the same reason — this set is the CLIENT's, and 2991 // built from the whole wire table.
3001 // the wall is the client when no tile is promoted. 2992 try std.testing.expect(std.mem.indexOf(u8, wall_setup, client_mouse_setup) == null);
3002 try std.testing.expect(std.mem.indexOf(u8, wall_setup, wall_mouse_claim) != null);
3003 try std.testing.expectEqualStrings(client_mouse_setup, wall_mouse_claim);
3004 try std.testing.expect(std.mem.startsWith(u8, wall_setup, "\x1b[22;0t\x1b[?1049h")); 2993 try std.testing.expect(std.mem.startsWith(u8, wall_setup, "\x1b[22;0t\x1b[?1049h"));
3005 // ...and the wall's own claim comes off on the way out. `wall_teardown` 2994 // ...and every mouse mode comes off on the way out. `wall_teardown`
3006 // is `terminal_teardown`, which is built from the whole wire table, so 2995 // is `terminal_teardown`, which is built from the whole wire table, so
3007 // this holds for a mode added to the capture set tomorrow as well. 2996 // this holds for a mode added to the capture set tomorrow as well.
3008 inline for (client_mouse_capture) |dec| { 2997 inline for (client_mouse_capture) |dec| {
src/mux_main.zig
Old New
@@ -4,7 +4,7 @@
4 //! `mux HOST` runs the ssh→QUIC handoff — ssh fetches the daemon's QUIC 4 //! `mux HOST` runs the ssh→QUIC handoff — ssh fetches the daemon's QUIC
5 //! coordinates and carries the session only if the QUIC dial does not. 5 //! coordinates and carries the session only if the QUIC dial does not.
6 //! 6 //!
7 //! Every one of those is a WALL of one tile, entered zoomed 7 //! Every one of those is a WALL of one tile, entered focused
8 //! (`wallview.runAttach`); `mux wall` is the same program entered on the 8 //! (`wallview.runAttach`); `mux wall` is the same program entered on the
9 //! wall itself. What lives up here is argv, the refusals that must happen 9 //! wall itself. What lives up here is argv, the refusals that must happen
10 //! before a dial (self-attach, an unbindable socket path), the auto-start, 10 //! before a dial (self-attach, an unbindable socket path), the auto-start,
@@ -38,8 +38,8 @@ const usage =
38 \\ --version prints the version 38 \\ --version prints the version
39 \\ 39 \\
40 \\ mux wall [SPELLING...] shows several sessions at once, one stripe 40 \\ mux wall [SPELLING...] shows several sessions at once, one stripe
41 \\ each; `Enter` zooms the selected one and types into it, `q` or 41 \\ each; `Ctrl-\ 1-9` focuses a tile and types into it, `Ctrl-\ d`
42 \\ Ctrl-\ leaves. SPELLING is the wall grammar 42 \\ leaves. SPELLING is the wall grammar
43 \\ (HOST[#SESSION] | quic://HOST[:PORT][#SESSION] | --sock PATH[#SESSION], 43 \\ (HOST[#SESSION] | quic://HOST[:PORT][#SESSION] | --sock PATH[#SESSION],
44 \\ one argument per tile, but `--sock PATH` may also be two arguments 44 \\ one argument per tile, but `--sock PATH` may also be two arguments
45 \\ as in muxweb); with none, the saved wall is shown. 45 \\ as in muxweb); with none, the saved wall is shown.
@@ -399,7 +399,7 @@ pub fn main() !u8 {
399 // and a bare `mux` typed in a session shell is exactly the 399 // and a bare `mux` typed in a session shell is exactly the
400 // mistake this catches. It sits on the USER's attach only — 400 // mistake this catches. It sits on the USER's attach only —
401 // the Ctrl-\ chords grow their tiles from inside the wall and 401 // the Ctrl-\ chords grow their tiles from inside the wall and
402 // never come back through this switch, so zooming from session 402 // never come back through this switch, so focusing from session
403 // 0 to session 1 keeps working. 403 // 0 to session 1 keeps working.
404 if (insideThisSession( 404 if (insideThisSession(
405 std.posix.getenv(proto.sock_env), 405 std.posix.getenv(proto.sock_env),
@@ -551,7 +551,7 @@ fn wallMain(alloc: std.mem.Allocator, args: []const [:0]const u8) !u8 {
551 } 551 }
552 } 552 }
553 // The other door into the same program: `mux wall` opens on the wall, 553 // The other door into the same program: `mux wall` opens on the wall,
554 // `mux TARGET` opens zoomed into the tile it just attached to. It does 554 // `mux TARGET` opens focused on the tile it just attached to. It does
555 // not hydrate — it has already read the file, or was handed the 555 // not hydrate — it has already read the file, or was handed the
556 // spellings it must show and no others — and it does need a terminal, 556 // spellings it must show and no others — and it does need a terminal,
557 // because there is nothing to cut stripes from without one. 557 // because there is nothing to cut stripes from without one.
src/paint.zig
Old New
@@ -56,14 +56,21 @@ pub fn renderClipped(
56 ) !void { 56 ) !void {
57 var paint: std.ArrayList(u8) = .empty; 57 var paint: std.ArrayList(u8) = .empty;
58 defer paint.deinit(alloc); 58 defer paint.deinit(alloc);
59 try paint.appendSlice(alloc, sync_begin ++ "\x1b[H\x1b[2J"); 59 // A paint at row 0 owns the whole screen — clear it once, as the plain
60 // client always did. A paint at an offset owns only its sub-rect: a
61 // whole-screen clear there would wipe every other tile on the wall, so
62 // each row clears itself instead. The wall's relayout already cleared
63 // the screen once for the layout; this is the per-tile touch-up.
64 const owns_screen = row_off == 0;
65 try paint.appendSlice(alloc, if (owns_screen) sync_begin ++ "\x1b[H\x1b[2J" else sync_begin);
60 66
61 const grid_rows: u16 = @intCast(replica.term.rows); 67 const grid_rows: u16 = @intCast(replica.term.rows);
62 const limit = @min(grid_rows, tty.rows); 68 const limit = @min(grid_rows, tty.rows);
63 var y: u16 = 0; 69 var y: u16 = 0;
64 while (y < limit) : (y += 1) { 70 while (y < limit) : (y += 1) {
65 var cup: [16]u8 = undefined; 71 var cup: [20]u8 = undefined;
66 try paint.appendSlice(alloc, try std.fmt.bufPrint(&cup, "\x1b[{d};1H", .{y + row_off + 1})); 72 const clear: []const u8 = if (owns_screen) "" else "\x1b[2K";
73 try paint.appendSlice(alloc, try std.fmt.bufPrint(&cup, "\x1b[{d};1H{s}", .{ y + row_off + 1, clear }));
67 const row = try dumpRow(alloc, replica, y, hl); 74 const row = try dumpRow(alloc, replica, y, hl);
68 defer alloc.free(row); 75 defer alloc.free(row);
69 try paint.appendSlice(alloc, row); 76 try paint.appendSlice(alloc, row);
src/wallview.zig
Old New
@@ -1,13 +1,12 @@
1 //! The CLI wall: N sessions in one terminal, the multiattach the browser hub 1 //! The CLI wall: N sessions in one terminal, the multiattach the browser hub
2 //! gives without a browser, over the same wall file and grammar (wall.zig). 2 //! gives without a browser, over the same wall file and grammar (wall.zig).
3 //! 3 //!
4 //! ZOOM IS A LENS, NOT A MODE (CLAUDE.md): zooming PROMOTES a tile's 4 //! Every tile claims its rectangle: attach sends the rect, relayout resends
5 //! existing connection and unzooming DEMOTES it, at no round trip. `mux 5 //! it, and focus is client-local and sends nothing on the wire.
6 //! [TARGET]` is a wall of one tile entered zoomed — the only loop here.
7 //! 6 //!
8 //! Tiles are never compacted: pump threads hold pointers into the tile array, 7 //! Tiles are never compacted: pump threads hold pointers into the tile array,
9 //! so a forgotten tile is a hole motion steps over (`selectKey`) and nothing 8 //! so a forgotten tile is a hole focus steps over (`stepPresent`). A tile
10 //! may paint (`paintModeLocked`). 9 //! whose pump has ended keeps its rect and narrates on its label bar.
11 //! 10 //!
12 //! One thread per tile, owning its transport BOTH directions. A `Transport` 11 //! One thread per tile, owning its transport BOTH directions. A `Transport`
13 //! is single-threaded — `service()` and `writeFrame` share QUIC state, and a 12 //! is single-threaded — `service()` and `writeFrame` share QUIC state, and a
@@ -30,7 +29,7 @@ const paint = @import("paint");
30 const select = @import("select"); 29 const select = @import("select");
31 // Counters ride out through `Shared` because a detached pump never reaches 30 // Counters ride out through `Shared` because a detached pump never reaches
32 // a `Core.deinit`. 31 // a `Core.deinit`.
33 // The chord table and the prediction hooks a zoomed tile shares with the 32 // The chord table and the prediction hooks a focused tile shares with the
34 // client: one interaction core, not a second copy (interact.zig). 33 // client: one interaction core, not a second copy (interact.zig).
35 const interact = @import("interact"); 34 const interact = @import("interact");
36 const TmpDir = @import("testtmp").TmpDir; 35 const TmpDir = @import("testtmp").TmpDir;
@@ -141,11 +140,6 @@ const State = enum {
141 } 140 }
142 }; 141 };
143 142
144 /// `Shared.zoom` when the wall is zoomed OUT — no tile owns the terminal.
145 /// A sentinel rather than an optional so the field can be atomic: every
146 /// pump reads it on every paint decision.
147 const no_zoom: usize = std.math.maxInt(usize);
148
149 /// The mailbox: how many typed bytes can wait for a pump to put them on 143 /// The mailbox: how many typed bytes can wait for a pump to put them on
150 /// the wire. One stdin read's worth, which is all a keyboard can produce 144 /// the wire. One stdin read's worth, which is all a keyboard can produce
151 /// between two turns of a pump's poll loop. 145 /// between two turns of a pump's poll loop.
@@ -172,13 +166,13 @@ const Shared = struct {
172 running: std.atomic.Value(bool) = std.atomic.Value(bool).init(true), 166 running: std.atomic.Value(bool) = std.atomic.Value(bool).init(true),
173 /// Serializes every write to the terminal. Held (and never released) 167 /// Serializes every write to the terminal. Held (and never released)
174 /// at teardown, so no stripe paints across the restore, and held 168 /// at teardown, so no stripe paints across the restore, and held
175 /// across a zoom move, so no stripe paints into the gap between the 169 /// across a focus move, so no stripe paints into the gap between the
176 /// screen being cleared and the terminal changing hands. 170 /// screen being cleared and the terminal changing hands.
177 paint_mu: std.Thread.Mutex = .{}, 171 paint_mu: std.Thread.Mutex = .{},
178 out_fd: std.posix.fd_t, 172 out_fd: std.posix.fd_t,
179 /// Whether there is a terminal here at all. 173 /// Whether there is a terminal here at all.
180 /// 174 ///
181 /// `mux TARGET` is a wall of one zoomed tile now, and a scripted `mux` 175 /// `mux TARGET` is a wall of one tile now, and a scripted `mux`
182 /// with a pipe on both ends is the shape half the suite runs. On a pipe 176 /// with a pipe on both ends is the shape half the suite runs. On a pipe
183 /// it must write exactly what the plain client wrote — the grid, and no 177 /// it must write exactly what the plain client wrote — the grid, and no
184 /// terminal state whatever: no alternate screen, no raw mode, no 178 /// terminal state whatever: no alternate screen, no raw mode, no
@@ -190,14 +184,14 @@ const Shared = struct {
190 /// 184 ///
191 /// A pump rings it when something only the keyboard can act on has 185 /// A pump rings it when something only the keyboard can act on has
192 /// happened: a tile went quiet, a `sessions_reply` came back with a 186 /// happened: a tile went quiet, a `sessions_reply` came back with a
193 /// name to zoom, a detach was acknowledged. Without it the keyboard 187 /// name to focus, a detach was acknowledged. Without it the keyboard
194 /// sits in read(2) on a stdin that may never say anything again, and a 188 /// sits in read(2) on a stdin that may never say anything again, and a
195 /// shell that exits under the zoom would go unnoticed until the next 189 /// shell that exits under the focus would go unnoticed until the next
196 /// keystroke. 190 /// keystroke.
197 kb_r: std.posix.fd_t = -1, 191 kb_r: std.posix.fd_t = -1,
198 kb_w: std.posix.fd_t = -1, 192 kb_w: std.posix.fd_t = -1,
199 /// The zoomed tile's prediction counters, republished on every pass a 193 /// The focused tile's prediction counters, republished on every pass a
200 /// tile is promoted. 194 /// tile paints while it holds the terminal.
201 /// 195 ///
202 /// `MUX_PREDICT_STATS` used to be printed by `Core.deinit`, and a wall 196 /// `MUX_PREDICT_STATS` used to be printed by `Core.deinit`, and a wall
203 /// tile's Core never reaches one: its pump is a detached thread the 197 /// tile's Core never reaches one: its pump is a detached thread the
@@ -205,53 +199,41 @@ const Shared = struct {
205 /// driver that does own the exit, which prints them once, after the 199 /// driver that does own the exit, which prints them once, after the
206 /// terminal is back — the same line, on the same screen, in the same 200 /// terminal is back — the same line, on the same screen, in the same
207 /// order the plain client put it there. A struct copy under a lock the 201 /// order the plain client put it there. A struct copy under a lock the
208 /// promoted pump is already taking on every paint. 202 /// focused pump is already taking on every paint.
209 stats: interact.PredictCounters = .{}, 203 stats: interact.PredictCounters = .{},
210 /// One sentence for the next promoted tile to put in its corner. 204 /// One sentence for the next focused tile to put in its corner.
211 /// 205 ///
212 /// Written by the keyboard under `paint_mu`, taken by the pump that 206 /// Written by the keyboard under `paint_mu`, taken by the pump that
213 /// owns the terminal. The keyboard has no painter of its own for a 207 /// owns the terminal. The keyboard has no painter of its own for a
214 /// LIVE tile — `Core.banner` belongs to a Core, and the Core that 208 /// LIVE tile — `Core.banner` belongs to a Core, and the Core that
215 /// matters is the one being zoomed TO — so the message is left where 209 /// matters is the one being focused ON — so the message is left where
216 /// that pump will find it on its promote. 210 /// that pump will find it when it claims.
217 /// Sized for a corner banner, not for arbitrary text: `setNotice` 211 /// Sized for a corner banner, not for arbitrary text: `setNotice`
218 /// truncates rather than allocate for a message nobody can read at 212 /// truncates rather than allocate for a message nobody can read at
219 /// that width anyway. 213 /// that width anyway.
220 notice: [96]u8 = undefined, 214 notice: [96]u8 = undefined,
221 notice_len: usize = 0, 215 notice_len: usize = 0,
222 /// The terminal's shape: measured at startup, and re-written by the one 216 /// The terminal's shape: measured at startup, and re-measured by the
223 /// PROMOTED pump that answers a SIGWINCH (`setWallSize`) — the flag is 217 /// KEYBOARD thread on a SIGWINCH (the flag is process-wide, and only
224 /// process-wide, so only the tile owning the screen may consume it. 218 /// the thread that lays the wall out may act on it). Stripes are cut
225 /// Stripes are cut from it and a promoted tile claims exactly it. 219 /// from it and a one-tile wall claims exactly it.
226 size: proto.Size, 220 size: proto.Size,
227 /// The selected tile's index. Under `paint_mu` rather than atomic 221 /// The selected tile's index — the FOCUSED tile, the one input goes to
228 /// because it is read while a label bar is being drawn: a pump 222 /// and the one whose pump has claimed the terminal. Under `paint_mu`
229 /// repainting on a frame must draw the selection the keyboard has, 223 /// rather than atomic because it is read while a label bar is being
230 /// not the one it had when the frame arrived. 224 /// drawn: a pump repainting on a frame must draw the focus the keyboard
225 /// has, not the one it had when the frame arrived.
231 sel: usize = 0, 226 sel: usize = 0,
232 /// Which tile the terminal currently belongs to, or `no_zoom` for the 227 /// One label bar per tile when the wall holds more than one session; a
233 /// wall. Written only by the keyboard loop, and only under `paint_mu` 228 /// one-tile wall owns every row and draws no bar. Set by `relayout`, so
234 /// — a pump deciding what it may draw must not see the zoom move 229 /// `viewRows` and `core.row_off` agree with what is on the screen.
235 /// between that decision and the bytes going out. 230 label_rows: u16 = 1,
236 zoom: std.atomic.Value(usize) = std.atomic.Value(usize).init(no_zoom),
237 /// Bumped when the terminal's contents are no longer anybody's paint — 231 /// Bumped when the terminal's contents are no longer anybody's paint —
238 /// a zoom owned the screen, or has just given it back. Pumps compare 232 /// a relayout re-cut the stripes, or the focus moved and the newly
239 /// it against what they last painted at, which is the only thing that 233 /// focused tile owes a full repaint. Pumps compare it against what
240 /// repaints a stripe whose session sent no new frame meanwhile. 234 /// they last painted at, which is the only thing that repaints a tile
235 /// whose session sent no new frame meanwhile.
241 repaint_gen: std.atomic.Value(u64) = std.atomic.Value(u64).init(0), 236 repaint_gen: std.atomic.Value(u64) = std.atomic.Value(u64).init(0),
242 /// The text selection being dragged over a stripe, and the highlight
243 /// standing after it.
244 ///
245 /// SHARED, and under `paint_mu`, because the two halves of a highlight
246 /// belong to different threads. The keyboard is its only writer — it
247 /// is the thread that reads the mouse — and every pump is a reader:
248 /// a stripe is repainted from its replica on every frame, so anything
249 /// the keyboard drew itself would die one delta later. The keyboard
250 /// makes it prompt the way `setZoom` does, by bumping `repaint_gen`
251 /// and ringing. The doorbell coalesces motion-rate updates for free.
252 ///
253 /// Precedent: `sel` is under this lock for exactly the same reason.
254 drag: select.Drag = .{},
255 }; 237 };
256 238
257 /// The stripe window a paint used, in the only terms a click can be 239 /// The stripe window a paint used, in the only terms a click can be
@@ -260,51 +242,32 @@ const Shared = struct {
260 /// Absolute rows are counted from the oldest row the DAEMON still retains 242 /// Absolute rows are counted from the oldest row the DAEMON still retains
261 /// (`protocol.SelectionReply`), so a grid row alone names nothing: it has 243 /// (`protocol.SelectionReply`), so a grid row alone names nothing: it has
262 /// to be read against the history count the same frame carried. 244 /// to be read against the history count the same frame carried.
263 const Window = struct {
264 history_rows: u32 = 0,
265 /// The grid row the window's first content line came from
266 /// (`paint.stripeWinStart`).
267 win_start: u16 = 0,
268 /// The replica's grid at that paint, which is what bounds a click.
269 /// A stripe attaches at 0x0 and inherits whatever grid the daemon
270 /// holds, so a stripe is routinely TALLER and WIDER than the session
271 /// inside it — `renderStripe` clears the rows below the grid rather
272 /// than painting them, and DECAWM-off clips the columns past it.
273 /// Zero by default, so a tile that has not painted yet resolves no
274 /// click at all: there is nothing on the terminal to have clicked.
275 grid: proto.Size = .{ .cols = 0, .rows = 0 },
276 };
277
278 const Tile = struct { 245 const Tile = struct {
279 r: Resolved, 246 r: Resolved,
280 stripe: Stripe, 247 stripe: Stripe,
281 shared: *Shared, 248 shared: *Shared,
282 /// This tile's place in the wall: what the selection and the `1`-`9` 249 /// This tile's place in the wall: what the focus and the `1`-`9`
283 /// jump are indices into. 250 /// jump are indices into.
284 idx: usize, 251 idx: usize,
285 /// The state its label last narrated. Owned by the pump but stored 252 /// The state its label last narrated. Owned by the pump but stored
286 /// here (under `paint_mu`) because the KEYBOARD repaints this bar too, 253 /// here (under `paint_mu`) because the KEYBOARD repaints this bar too,
287 /// when the selection moves, and it has no other way to know it. 254 /// when the focus moves, and it has no other way to know it.
288 state: State = .connecting, 255 state: State = .connecting,
289 /// Whether this tile's pump thread is still running. 256 /// Whether this tile's pump thread is still running.
290 /// 257 ///
291 /// Only pumps answer `repaint_gen`, so a tile whose pump has ENDED — a 258 /// Only pumps answer `repaint_gen`, so a tile whose pump has ENDED — a
292 /// refused attach, a session that exited — has nobody to redraw it 259 /// refused attach, a session that exited — has nobody to redraw its
293 /// after a screen clear. That was invisible while a clear happened once 260 /// rect after a relayout clear. The keyboard reads this and paints the
294 /// per zoom; the wall clears on every `n`/`p`/`l` too, so a dead stripe 261 /// dead tile's label bar, which is the only paint it ever does on a
295 /// would vanish for the wall's whole remaining life, and zooming one 262 /// tile's behalf.
296 /// would paint an entirely blank terminal with no cursor and no way
297 /// out that the screen admits to. The keyboard reads this and paints
298 /// for the dead, which is the only paint it ever does on a tile's
299 /// behalf.
300 /// 263 ///
301 /// Deliberately ONLY for dead pumps. A tile that is merely 264 /// Deliberately ONLY for dead pumps. A tile that is merely
302 /// `[reconnecting]` still has a thread that will repaint its hot 265 /// `[reconnecting]` still has a thread that will repaint its hot
303 /// replica within a poll timeout, and letting the keyboard draw over 266 /// replica within a poll timeout, and letting the keyboard draw over
304 /// that would replace something true with something stale. 267 /// that would replace something true with something stale.
305 alive: std.atomic.Value(bool) = std.atomic.Value(bool).init(true), 268 alive: std.atomic.Value(bool) = std.atomic.Value(bool).init(true),
306 /// Forgotten by `x`: off the wall, and off the wire as soon as the 269 /// Forgotten by `Ctrl-\ x`: off the wall, and off the wire as soon as
307 /// pump notices. The pump's answer is to RETURN — which closes its 270 /// the pump notices. The pump's answer is to RETURN — which closes its
308 /// transport and frees the daemon slot — and nothing more: "remove is 271 /// transport and frees the daemon slot — and nothing more: "remove is
309 /// detach", so the session itself is untouched and still there for the 272 /// detach", so the session itself is untouched and still there for the
310 /// next `mux` that asks for it. 273 /// next `mux` that asks for it.
@@ -319,12 +282,12 @@ const Tile = struct {
319 /// ringing free to ring again. 282 /// ringing free to ring again.
320 wake_r: std.posix.fd_t = -1, 283 wake_r: std.posix.fd_t = -1,
321 wake_w: std.posix.fd_t = -1, 284 wake_w: std.posix.fd_t = -1,
322 /// Bytes typed at this tile while it was zoomed, waiting for its pump. 285 /// Bytes typed at this tile while it was focused, waiting for its pump.
323 /// The keyboard is the only writer and it writes ONLY while this tile 286 /// The keyboard is the only writer and it writes ONLY to the focused
324 /// is the zoom — which is the entire enforcement of "an unzoomed tile 287 /// tile — which is the whole enforcement of "input goes to the focus".
325 /// claims nothing". The pump therefore sends whatever it finds here 288 /// The pump therefore sends whatever it finds here without re-checking
326 /// without re-checking the zoom: bytes typed at a session belong to 289 /// the focus: bytes typed at a session belong to that session even if
327 /// that session even if the zoom has moved on since. 290 /// the focus has moved on since.
328 in_mu: std.Thread.Mutex = .{}, 291 in_mu: std.Thread.Mutex = .{},
329 in: [mailbox_max]u8 = undefined, 292 in: [mailbox_max]u8 = undefined,
330 in_len: usize = 0, 293 in_len: usize = 0,
@@ -339,7 +302,7 @@ const Tile = struct {
339 /// on the first state and only then). 302 /// on the first state and only then).
340 /// 303 ///
341 /// True for the tile a human's `mux` is attaching to and for every tile 304 /// True for the tile a human's `mux` is attaching to and for every tile
342 /// a zoom chord adds: those claim the grid, which is the whole of the 305 /// a focus chord adds: those claim the grid, which is the whole of the
343 /// mechanical attach-adds rule. False for tiles restored from the wall 306 /// mechanical attach-adds rule. False for tiles restored from the wall
344 /// file — already the record — and for spellings named on `mux wall`'s 307 /// file — already the record — and for spellings named on `mux wall`'s
345 /// command line, which are a view and were never an attach of the 308 /// command line, which are a view and were never an attach of the
@@ -350,9 +313,9 @@ const Tile = struct {
350 /// unit that has an attach to record. 313 /// unit that has an attach to record.
351 wall_warned: bool = false, 314 wall_warned: bool = false,
352 /// Where a chord-created tile came from, so a REFUSED one has somewhere 315 /// Where a chord-created tile came from, so a REFUSED one has somewhere
353 /// to put the zoom back — the plain client's `.refused` recovery, which 316 /// to put the focus back — the plain client's `.refused` recovery, which
354 /// was the one attach failure it did not exit on. Null for tiles that 317 /// was the one attach failure it did not exit on. Null for tiles that
355 /// no zoom created. 318 /// no tile created.
356 born_from: ?usize = null, 319 born_from: ?usize = null,
357 /// Whether a link that dies before this tile ever saw state is worth 320 /// Whether a link that dies before this tile ever saw state is worth
358 /// retrying. 321 /// retrying.
@@ -372,14 +335,14 @@ const Tile = struct {
372 /// Whether the keyboard has already acted on this tile's end. Keyboard 335 /// Whether the keyboard has already acted on this tile's end. Keyboard
373 /// state, so no lock: nothing else reads or writes it. 336 /// state, so no lock: nothing else reads or writes it.
374 end_seen: bool = false, 337 end_seen: bool = false,
375 /// A zoom chord's question, posted by the keyboard for this tile's pump 338 /// A focus chord's question, posted by the keyboard for this tile's pump
376 /// to put on the wire as a `sessions_req`. `client.SwitchIntent` as a 339 /// to put on the wire as a `sessions_req`. `client.SwitchIntent` as a
377 /// u8, zero being `.none`; the pump takes it with a swap, so one 340 /// u8, zero being `.none`; the pump takes it with a swap, so one
378 /// keystroke asks exactly one question. 341 /// keystroke asks exactly one question.
379 /// 342 ///
380 /// It is the PUMP that asks because a Transport has one owning thread, 343 /// It is the PUMP that asks because a Transport has one owning thread,
381 /// and the pump that answers because the reply arrives on its link — 344 /// and the pump that answers because the reply arrives on its link —
382 /// the keyboard only learns the name to zoom to. 345 /// the keyboard only learns the name to focus to.
383 ask: std.atomic.Value(u8) = std.atomic.Value(u8).init(0), 346 ask: std.atomic.Value(u8) = std.atomic.Value(u8).init(0),
384 /// The pump's answer: the name the ring (or `nextFreeName`) landed on. 347 /// The pump's answer: the name the ring (or `nextFreeName`) landed on.
385 /// An EMPTY name is the honest "nowhere to go" — a one-session daemon, 348 /// An EMPTY name is the honest "nowhere to go" — a one-session daemon,
@@ -399,234 +362,44 @@ const Tile = struct {
399 /// keystrokes never vanish without the wall admitting to it. 362 /// keystrokes never vanish without the wall admitting to it.
400 in_dropped: std.atomic.Value(bool) = std.atomic.Value(bool).init(false), 363 in_dropped: std.atomic.Value(bool) = std.atomic.Value(bool).init(false),
401 364
402 /// What this tile's last stripe paint showed. 365 /// The keyboard re-cut the stripes and this tile's rect changed: the
403 /// 366 /// pump owes the daemon a `.resize` at its new content size. The pump
404 /// Written by the PUMP under `paint_mu` inside the paint that used it, 367 /// is the transport's only writer, so relayout doorbells here and the
405 /// read by the KEYBOARD under the same lock when a click has to be 368 /// pump sends — the same single-writer rule the claim used to keep.
406 /// turned into a line. One lock over both fields is the point: a 369 resize_pending: std.atomic.Value(bool) = std.atomic.Value(bool).init(false),
407 /// window from one frame read against a history count from another 370 /// Focus moved onto this tile: its pump owes a `claimTerminal` (the
408 /// names a row nobody ever saw. 371 /// session's mouse modes and side channels). Doorbell-driven so the
409 /// 372 /// claim runs on the pump thread, the one that owns the transport and
410 /// Anchors are converted at PRESS time for the same reason — a 373 /// the Core.
411 /// terminal row remembered instead would quietly become a different 374 claim_pending: std.atomic.Value(bool) = std.atomic.Value(bool).init(false),
412 /// line as soon as the session scrolled the window under it. 375 /// Focus moved off this tile: its pump owes a `releaseTerminal` to drop
413 win: Window = .{}, 376 /// the claim it took when it was focused. `.already_written`, because
414 377 /// the keyboard wrote the session's release under `paint_mu` before
415 /// A finished wall drag waiting for this tile's PUMP to ask the daemon 378 /// doorbelling — the handover is ordered, not eventual.
416 /// what is under it. 379 release_pending: std.atomic.Value(bool) = std.atomic.Value(bool).init(false),
417 ///
418 /// The keyboard is the thread that reads the mouse and it may not touch
419 /// a `Transport` — one owning thread per transport, and a reconnect
420 /// swaps the struct out from under the pump. So it posts the range and
421 /// rings, and the pump sends, receives on its own link and acts. The
422 /// same shape as `ask`, which the zoom chords use for the same reason.
423 ///
424 /// Under `paint_mu`, the lock the drag it came from is already under —
425 /// a second lock here would order nothing that one does not.
426 sel_req: ?select.Range = null,
427 380
428 fn viewRows(t: *const Tile) u16 { 381 fn viewRows(t: *const Tile) u16 {
429 return t.stripe.rows - 1; 382 return t.stripe.rows -| t.shared.label_rows;
430 } 383 }
431 }; 384 };
432 385
433 /// Null for the label bar as well as outside the stripe — the bar is where a 386 /// Which tile's rectangle a zero-based terminal row falls in, or null
434 /// hand rests between stripes, and the nearest line is not what was clicked. 387 /// when none does. The keyboard routes a click to the tile it lands in so
435 fn stripeContentRow(s: Stripe, row: u16) ?u16 { 388 /// focus can follow it; the drag itself is the focused tile's Core's.
436 if (row <= s.top) return null; 389 /// Under `paint_mu` because `Tile.stripe` is written under it.
437 const off = row - s.top - 1; 390 fn rectHit(tiles: []Tile, present: []const bool, shared: *Shared, row: u16) ?usize {
438 return if (off < s.rows -| 1) off else null;
439 }
440
441 /// A swap and not a read: one posted question is one question asked, the
442 /// discipline `ask` already keeps.
443 fn takeSelectionReq(t: *Tile) ?select.Range {
444 t.shared.paint_mu.lock();
445 defer t.shared.paint_mu.unlock();
446 defer t.sel_req = null;
447 return t.sel_req;
448 }
449
450 /// Which session line a zero-based terminal (row, col) points at.
451 ///
452 /// `select.Hit` and not a spelling of its own: it is the same triple the
453 /// drag machine takes, and a second one would be a conversion nobody
454 /// needs. Absolute rows, counted from the oldest row the daemon still
455 /// retains — the coordinate space `protocol.SelectionReq` speaks — and a
456 /// terminal column, which is a grid column too: every painter emits from
457 /// column 1 with no x-offset, and DECAWM-off clips at the right edge.
458 ///
459 /// Under `paint_mu` because `Tile.stripe` and `Tile.win` are both written
460 /// under it: a re-layout landing between reading one and the other would
461 /// resolve the click against a geometry that never existed.
462 ///
463 /// `present` and not the geometry decides whether a tile is there at all —
464 /// `relayout` never compacts the tile array, so a forgotten tile keeps the
465 /// stripe it had when it left.
466 fn hitTest(tiles: []Tile, present: []const bool, shared: *Shared, row: u16, col: u16) ?select.Hit {
467 shared.paint_mu.lock(); 391 shared.paint_mu.lock();
468 defer shared.paint_mu.unlock(); 392 defer shared.paint_mu.unlock();
469 for (tiles, present) |*t, p| { 393 for (tiles, present) |*t, p| {
470 if (!p) continue; 394 if (!p) continue;
471 const off = stripeContentRow(t.stripe, row) orelse continue; 395 if (row >= t.stripe.top and row < t.stripe.top + t.stripe.rows) return t.idx;
472 // Past the grid's last row this stripe shows a CLEARED row, not a
473 // session line — `renderStripe`'s second loop wrote it. Refused
474 // rather than answered, and refused here rather than left for the
475 // daemon: an out-of-range row makes `extractSelection` reply
476 // `.invalid`, which reaches the user as a drag that copied nothing
477 // and said nothing about why. Same rule and same reasoning as
478 // `interact.hitTest`, which the zoomed driver uses; the two answer
479 // one question and must not answer it differently.
480 const grid_row: u32 = @as(u32, t.win.win_start) + off;
481 if (grid_row >= t.win.grid.rows) return null;
482 return .{
483 .tile = t.idx,
484 .row = t.win.history_rows + grid_row,
485 // Clamped, not refused: the right edge is where a hand
486 // overshoots, and a copy is better than a lesson.
487 .col = @min(col, t.win.grid.cols -| 1),
488 };
489 } 396 }
490 return null; 397 return null;
491 } 398 }
492 399
493 /// One read's keys and mouse reports, handed back in the order the terminal 400 /// The keyboard loop's state: just the prefix filter.
494 /// wrote them.
495 ///
496 /// `MouseFilter.feed` returns two flat lists, and `Event.at` — how many
497 /// forwarded bytes preceded a report — is the only record left of how they
498 /// interleaved. Draining all of one list and then the other is wrong in
499 /// both directions, and the read that proves it is one holding a Return and
500 /// a click: Enter zooms the SELECTED tile, a press moves the selection, so
501 /// events-first zooms the tile the click had just selected rather than the
502 /// one the user chose.
503 const WallStep = union(enum) { key: u8, mouse: interact.MouseFilter.Event };
504
505 const WallDrain = struct {
506 forward: []const u8,
507 events: []const interact.MouseFilter.Event,
508 ki: usize = 0,
509 ei: usize = 0,
510
511 fn init(out: interact.MouseFilter.Out) WallDrain {
512 return .{ .forward = out.forward, .events = out.events };
513 }
514
515 fn next(self: *WallDrain) ?WallStep {
516 // `at` counts the bytes emitted BEFORE the report finished, so a
517 // report tied with a key arrived ahead of it. The same test drains
518 // the tail: a report that ended the read carries `at == forward.len`.
519 if (self.ei < self.events.len and self.events[self.ei].at <= self.ki) {
520 defer self.ei += 1;
521 return .{ .mouse = self.events[self.ei] };
522 }
523 if (self.ki < self.forward.len) {
524 defer self.ki += 1;
525 return .{ .key = self.forward[self.ki] };
526 }
527 return null;
528 }
529 };
530
531 /// Selection follows the RELEASE, so a press that becomes a drag does not
532 /// flicker the marker past.
533 fn wallMouse(
534 tiles: []Tile,
535 present: []const bool,
536 shared: *Shared,
537 ev: interact.MouseFilter.Event,
538 ) void {
539 if (ev.button & 0b11 != 0) return;
540 // Resolved BEFORE the lock and outside it, because `hitTest` takes the
541 // same mutex. Nothing can invalidate the answer in between: the only
542 // thread that re-cuts stripes is this one.
543 const hit = hitTest(tiles, present, shared, ev.row, ev.col);
544 const cell: select.Cell = .{ .row = ev.row, .col = ev.col };
545
546 var ended: select.Release = .nothing;
547 var moved = false;
548 {
549 shared.paint_mu.lock();
550 defer shared.paint_mu.unlock();
551 const was = shared.drag.range();
552 switch (ev.kind) {
553 .press => shared.drag.press(cell, hit),
554 .motion => shared.drag.motion(cell, hit),
555 .release => ended = shared.drag.release(),
556 }
557 moved = !std.meta.eql(was, shared.drag.range());
558 // The drag copies, so the button coming up is a question for the
559 // daemon — and the keyboard may not ask it. Posted here, under the
560 // lock the drag itself is under, for the pump whose link it goes
561 // out on. The tile is the drag's own and not the SELECTED one: a
562 // drag never moved the selection.
563 if (ended == .selection) {
564 const r = ended.selection;
565 // `x` may have taken the stripe away while the button was down
566 // — the same guard the click keeps, for the same reason.
567 if (r.from.tile < present.len and present[r.from.tile])
568 tiles[r.from.tile].sel_req = r;
569 }
570 }
571
572 // The highlight changed, so every stripe is now a frame out of date.
573 // One bump and one round of doorbells, exactly as `setZoom` does it —
574 // and the doorbell is what makes a drag feel like a drag rather than
575 // like one poll timeout per cell.
576 if (moved) {
577 _ = shared.repaint_gen.fetchAdd(1, .release);
578 for (tiles, present) |*t, p| {
579 if (p) ring(t);
580 }
581 }
582
583 if (ended == .click) {
584 // The press may have landed on a tile `x` has taken away since —
585 // the same guard `Enter` keeps for the same reason.
586 const at = ended.click;
587 if (at.tile < present.len and present[at.tile])
588 moveSelection(tiles, shared, at.tile);
589 }
590 // The question posted above needs a doorbell of its own: a release
591 // moves no highlight, so the bump and the round of rings above did not
592 // happen, and a pump over a quiet session would sit in poll(2) holding
593 // an unasked question until something else woke it.
594 if (ended == .selection) {
595 const at = ended.selection.from.tile;
596 if (at < present.len and present[at]) ring(&tiles[at]);
597 }
598 }
599
600 /// Everything the wall's keyboard loop is holding mid-stream.
601 ///
602 /// One struct because they are dropped at one instant: `setZoom` hands the
603 /// terminal to somebody else, and a chord half typed, a report half read or
604 /// a button still down at the wall is all state about a screen that has
605 /// stopped existing. The reset is `setZoom`'s own (`WallInput.reset`)
606 /// rather than a line beside one of its six call sites — it belongs to the
607 /// transition, and the version that sat at a single call site held only
608 /// because the other five are reachable from an already-zoomed wall.
609 const WallInput = struct { 401 const WallInput = struct {
610 prefix: interact.PrefixFilter = .{}, 402 prefix: interact.PrefixFilter = .{},
611 /// The wall's own mouse filter — the second ROLE, not an alternative
612 /// to a Core's: a promoted tile's `Core` runs its own over the bytes
613 /// it is handed zoomed, this one runs over the bytes that arrive
614 /// UNZOOMED, which the wall's own modes are what produce.
615 mouse: interact.MouseFilter = .{},
616
617 /// The drag lives in `Shared` — the pumps read it to paint it — but is
618 /// dropped HERE for the same reason as the rest: the screen it describes
619 /// has stopped existing, and its anchor names a stripe about to belong to
620 /// nobody.
621 fn reset(self: *WallInput, shared: *Shared) void {
622 self.prefix = .{};
623 // Whatever the filter still holds is the head of a report from the
624 // read being dropped here.
625 self.mouse.reset();
626 shared.paint_mu.lock();
627 defer shared.paint_mu.unlock();
628 shared.drag.clear();
629 }
630 }; 403 };
631 404
632 /// The next tile in `present` after `sel`, wrapping, or null when none is 405 /// The next tile in `present` after `sel`, wrapping, or null when none is
@@ -644,71 +417,13 @@ pub fn stepPresent(present: []const bool, sel: usize, forward: bool) ?usize {
644 return null; 417 return null;
645 } 418 }
646 419
647 /// Where a key takes the selection, or null for "not a selection key" or 420 /// The state is remembered on the tile: the keyboard repaints this bar
648 /// "nowhere to go". 421 /// with no frame, and only when the wall shows more than one tile.
649 ///
650 /// `present[i]` is whether tile i is still ON the wall. `x` forgets a tile
651 /// IN PLACE — the pump threads hold pointers into the tile array, so it is
652 /// never compacted — and a forgotten tile has to be invisible to every
653 /// motion: stepped over by `j`/`k`, and not counted by the digits, which
654 /// are read off the bars the eye can actually see.
655 ///
656 /// A digit past the last present tile is swallowed rather than clamped: a
657 /// jump to a tile that is not there should do nothing, not move somewhere
658 /// the user did not ask for.
659 pub fn selectKey(present: []const bool, sel: usize, key: u8) ?usize {
660 return switch (key) {
661 // `j`/`k` for the vi hands, `n`/`p` for the session ring's
662 // spelling (client.zig's Ctrl-\ n / Ctrl-\ p) — same motion.
663 'j', 'n' => stepPresent(present, sel, true),
664 'k', 'p' => stepPresent(present, sel, false),
665 // 1-based, counted over what is SHOWN: after `x` the bars renumber
666 // themselves, and `2` means the second bar on the screen.
667 '1'...'9' => blk: {
668 var want: usize = key - '1';
669 for (present, 0..) |p, i| {
670 if (!p) continue;
671 if (want == 0) break :blk i;
672 want -= 1;
673 }
674 break :blk null;
675 },
676 else => null,
677 };
678 }
679
680 /// What this tile may put on the terminal right now. Caller holds
681 /// `paint_mu`, which is what makes the answer stable long enough to act on
682 /// — the zoom moves under that same lock.
683 const PaintMode = enum {
684 /// The wall: this tile's stripe, at its own rows.
685 stripe,
686 /// This tile IS the zoom: the whole terminal, from its replica.
687 full,
688 /// Some other tile is the zoom. Painting here would be the wall
689 /// talking over the session the user is typing at.
690 none,
691 };
692
693 fn paintModeLocked(t: *const Tile) PaintMode {
694 // A forgotten tile paints NOTHING, ever again. Checked here rather
695 // than only at the keyboard because the pump may already be past its
696 // own `gone` check when `x` lands; this test is under `paint_mu`, the
697 // same lock the re-layout holds, so there is no window in which a
698 // forgotten stripe can land on rows that now belong to a tile below.
699 if (t.gone.load(.acquire)) return .none;
700 const z = t.shared.zoom.load(.acquire);
701 if (z == no_zoom) return .stripe;
702 return if (z == t.idx) .full else .none;
703 }
704
705 /// The state is remembered on the tile: the keyboard repaints this bar with
706 /// no frame in hand, and a zoom leaves no bars at all to hold it.
707 fn paintLabel(t: *Tile, state: State) void { 422 fn paintLabel(t: *Tile, state: State) void {
708 t.shared.paint_mu.lock(); 423 t.shared.paint_mu.lock();
709 defer t.shared.paint_mu.unlock(); 424 defer t.shared.paint_mu.unlock();
710 t.state = state; 425 t.state = state;
711 if (paintModeLocked(t) == .stripe) paintLabelLocked(t); 426 if (t.shared.label_rows != 0) paintLabelLocked(t);
712 } 427 }
713 428
714 /// Bounded by `cols` AND by `buf`: `cols` alone overran past 250 columns. 429 /// Bounded by `cols` AND by `buf`: `cols` alone overran past 250 columns.
@@ -735,11 +450,12 @@ pub fn labelText(
735 /// The label bar: inverse, full width, `> LABEL [state]`, truncated at the 450 /// The label bar: inverse, full width, `> LABEL [state]`, truncated at the
736 /// terminal edge. Caller holds `paint_mu` — the marker and the state are 451 /// terminal edge. Caller holds `paint_mu` — the marker and the state are
737 /// shared state, and this is also what writes the bytes out. Only called 452 /// shared state, and this is also what writes the bytes out. Only called
738 /// in `.stripe` mode: a zoomed tile is a session, not a stripe with a bar. 453 /// when `label_rows` is nonzero: a one-tile wall owns every row and has no
454 /// bar to draw.
739 fn paintLabelLocked(t: *Tile) void { 455 fn paintLabelLocked(t: *Tile) void {
740 // ASCII, not an arrow glyph: this bar is byte-truncated, greppable in 456 // ASCII, not an arrow glyph: this bar is byte-truncated, greppable in
741 // a capture, and must not depend on a font. The unselected marker is 457 // a capture, and must not depend on a font. The unfocused marker is
742 // the same width, so labels do not shift as the selection moves. 458 // the same width, so labels do not shift as the focus moves.
743 const marker: []const u8 = if (t.shared.sel == t.idx) "> " else " "; 459 const marker: []const u8 = if (t.shared.sel == t.idx) "> " else " ";
744 // Keystrokes the mailbox had no room for are said where the eye already 460 // Keystrokes the mailbox had no room for are said where the eye already
745 // is, beside the state that explains them: input is only ever dropped 461 // is, beside the state that explains them: input is only ever dropped
@@ -765,67 +481,14 @@ fn paintLabelLocked(t: *Tile) void {
765 proto.writeAllFd(t.shared.out_fd, fbs.getWritten()) catch {}; 481 proto.writeAllFd(t.shared.out_fd, fbs.getWritten()) catch {};
766 } 482 }
767 483
768 /// Move the selection and repaint the two bars it touched, both under one 484 /// `Core.banner` parks a status line in the focused tile's own corner; this
769 /// hold of `paint_mu` — a wall that showed two markers, even for a frame, 485 /// is the fallback for a sentence the keyboard owes a tile whose Core has
770 /// would be lying about which session `Enter` is about to hand the 486 /// not claimed. `row_off` is the focused tile's content origin.
771 /// terminal to. 487 fn wallBanner(shared: *Shared, text: []const u8, row_off: u16) void {
772 fn moveSelection(tiles: []Tile, shared: *Shared, next: usize) void {
773 shared.paint_mu.lock();
774 defer shared.paint_mu.unlock();
775 const prev = shared.sel;
776 shared.sel = next;
777 if (prev != next and prev < tiles.len) paintLabelLocked(&tiles[prev]);
778 paintLabelLocked(&tiles[next]);
779 }
780
781 /// Draw this tile's session as a STRIPE on the wall.
782 ///
783 /// Returns whether anything actually reached the terminal, because the
784 /// repaint generation must only be recorded by a pump that satisfied it: a
785 /// tile hidden behind another's zoom has not, and its stripe would stay
786 /// blank when the zoom ends.
787 ///
788 /// The full-screen half of this is the tile's Core now
789 /// (`interact.Core.repaint`, and every paint a frame drives through it):
790 /// while a tile is PROMOTED its Core owns the terminal and paints the whole
791 /// of it, and while it is DEMOTED the Core paints nothing at all and this
792 /// draws the wall's own crop of the same replica. One replica per tile, one
793 /// applier for it, two ways of looking at it.
794 fn paintStripe(t: *Tile, alloc: std.mem.Allocator, eng: *Engine, history_rows: u32) bool {
795 t.shared.paint_mu.lock();
796 defer t.shared.paint_mu.unlock();
797 if (paintModeLocked(t) != .stripe) return false;
798 const view: proto.Size = .{ .cols = t.shared.size.cols, .rows = t.viewRows() };
799 // Published from inside the paint, under the lock the paint already
800 // holds, because the keyboard's hit-test has to describe the rows that
801 // are ON the terminal — not the ones the next frame will put there.
802 t.win = .{
803 .history_rows = history_rows,
804 .win_start = paint.stripeWinStart(@intCast(eng.term.rows), eng.cursorPos().y, view.rows),
805 .grid = .{ .cols = @intCast(eng.term.cols), .rows = @intCast(eng.term.rows) },
806 };
807 // The highlight is drawn HERE and never by the keyboard. A stripe is
808 // repainted from its replica on every frame, so an inversion the
809 // keyboard wrote onto the terminal would last exactly until the next
810 // delta; the keyboard writes the drag under this lock instead and
811 // rings, and this paint — the one that would have erased it — is the
812 // one that draws it.
813 const hl: interact.Highlight = .{
814 .drag = &t.shared.drag,
815 .tile = t.idx,
816 .history_rows = history_rows,
817 };
818 paint.renderStripe(alloc, eng, t.stripe.top + 1, view, hl.sink(), t.shared.out_fd) catch {};
819 return true;
820 }
821
822 /// `Core.banner` refuses when demoted, and this has to be said either way.
823 /// The next bar repaint erases it — the right lifetime for a line read once.
824 fn wallBanner(shared: *Shared, text: []const u8) void {
825 if (!shared.is_tty) return; 488 if (!shared.is_tty) return;
826 shared.paint_mu.lock(); 489 shared.paint_mu.lock();
827 defer shared.paint_mu.unlock(); 490 defer shared.paint_mu.unlock();
828 paint.paintBanner(shared.out_fd, shared.size, text, 0); 491 paint.paintBanner(shared.out_fd, shared.size, text, row_off);
829 } 492 }
830 493
831 /// Under `paint_mu`: a clear spliced into a 64 KiB OSC 52 write eats the 494 /// Under `paint_mu`: a clear spliced into a 64 KiB OSC 52 write eats the
@@ -840,14 +503,14 @@ fn copySelection(
840 { 503 {
841 t.shared.paint_mu.lock(); 504 t.shared.paint_mu.lock();
842 defer t.shared.paint_mu.unlock(); 505 defer t.shared.paint_mu.unlock();
843 const held = if (t.shared.zoom.load(.acquire) == t.idx) 506 // The drag is this tile's own Core's now: a drag is per tile, and
844 core.drag.range() 507 // the pump that owns the link is the one whose Core holds it.
845 else 508 const held = core.drag.range();
846 t.shared.drag.range();
847 answer = core.selectionCopy(payload, held); 509 answer = core.selectionCopy(payload, held);
848 switch (answer) { 510 switch (answer) {
849 // `is_tty` and not the tile's claim: a demoted stripe is the 511 // `is_tty` and not the tile's claim: a tile that has released
850 // usual answerer here and the copy is still the user's. A piped 512 // the terminal can still be the one whose finished drag the
513 // answer reaches, and the copy is still the user's. A piped
851 // `mux` asks its terminal for no mouse modes, so it can have no 514 // `mux` asks its terminal for no mouse modes, so it can have no
852 // drag to copy in the first place. 515 // drag to copy in the first place.
853 .text => |text| if (t.shared.is_tty) 516 .text => |text| if (t.shared.is_tty)
@@ -856,19 +519,17 @@ fn copySelection(
856 } 519 }
857 } 520 }
858 // Outside the hold: `wallBanner` takes the same lock, which is not 521 // Outside the hold: `wallBanner` takes the same lock, which is not
859 // reentrant. 522 // reentrant. The banner lands in this tile's own rect.
860 if (answer == .too_large) wallBanner(t.shared, "[selection too large to copy]"); 523 if (answer == .too_large) wallBanner(t.shared, "[selection too large to copy]", core.row_off);
861 } 524 }
862 525
863 /// Only while this tile IS the zoom, `paint_mu` held for the whole paint 526 /// Every live tile paints its own rect, so the sink admits a paint whenever
864 /// (`interact.Sink`): a zoom that moved between decision and bytes puts one 527 /// this tile has not been forgotten. `paint_mu` is held for the whole paint.
865 /// session's rows on another's screen.
866 fn tilePaintBegin(ctx: ?*anyopaque) bool { 528 fn tilePaintBegin(ctx: ?*anyopaque) bool {
867 const t: *Tile = @ptrCast(@alignCast(ctx.?)); 529 const t: *Tile = @ptrCast(@alignCast(ctx.?));
530 if (t.gone.load(.acquire)) return false;
868 t.shared.paint_mu.lock(); 531 t.shared.paint_mu.lock();
869 if (paintModeLocked(t) == .full) return true; 532 return true;
870 t.shared.paint_mu.unlock();
871 return false;
872 } 533 }
873 534
874 fn tilePaintEnd(ctx: ?*anyopaque) void { 535 fn tilePaintEnd(ctx: ?*anyopaque) void {
@@ -876,15 +537,12 @@ fn tilePaintEnd(ctx: ?*anyopaque) void {
876 t.shared.paint_mu.unlock(); 537 t.shared.paint_mu.unlock();
877 } 538 }
878 539
879 /// The one place a wall tile puts an attach on the wire. 0x0 while 540 /// The one place a wall tile puts an attach on the wire. Always this tile's
880 /// unzoomed — a stripe never claims the grid and never creates a session — 541 /// content rect — a tile claims its rectangle on attach and on every
881 /// and this terminal's size while zoomed, because a reconnect under a zoom 542 /// reconnect, so a redial comes back claiming what the tile claimed, not
882 /// must come back claiming what the zoom claimed, not hand the grid away. 543 /// handing the grid away.
883 fn sendAttach(t: *Tile, tr: *client.Transport, have_seq: u64, have_epoch: u64) !void { 544 fn sendAttach(t: *Tile, tr: *client.Transport, have_seq: u64, have_epoch: u64) !void {
884 const size: proto.Size = if (t.shared.zoom.load(.acquire) == t.idx) 545 const size: proto.Size = .{ .cols = t.shared.size.cols, .rows = t.viewRows() };
885 t.shared.size
886 else
887 .{ .cols = 0, .rows = 0 };
888 var buf: [proto.attach_max_len]u8 = undefined; 546 var buf: [proto.attach_max_len]u8 = undefined;
889 try tr.writeFrame(.attach, proto.encodeAttachNamed( 547 try tr.writeFrame(.attach, proto.encodeAttachNamed(
890 &buf, 548 &buf,
@@ -919,16 +577,8 @@ fn drainBell(fd: std.posix.fd_t) void {
919 } else |_| {} 577 } else |_| {}
920 } 578 }
921 579
922 /// Written by the one promoted pump that answered the SIGWINCH, so the next 580 /// FOCUSED pumps only: a tile that never held the terminal has zero
923 /// re-cut and promote use the size that is true now. 581 /// counters, and publishing them would clobber the tile the user typed at.
924 fn setWallSize(shared: *Shared, size: proto.Size) void {
925 shared.paint_mu.lock();
926 defer shared.paint_mu.unlock();
927 shared.size = size;
928 }
929
930 /// PROMOTED pumps only: a stripe's counters are all zero, and publishing
931 /// them would clobber the tile the user typed at.
932 fn publishStats(shared: *Shared, c: interact.PredictCounters) void { 582 fn publishStats(shared: *Shared, c: interact.PredictCounters) void {
933 shared.paint_mu.lock(); 583 shared.paint_mu.lock();
934 defer shared.paint_mu.unlock(); 584 defer shared.paint_mu.unlock();
@@ -936,8 +586,8 @@ fn publishStats(shared: *Shared, c: interact.PredictCounters) void {
936 } 586 }
937 587
938 /// Leave a sentence for whichever tile owns the terminal next. Keyboard 588 /// Leave a sentence for whichever tile owns the terminal next. Keyboard
939 /// thread only, and only for a zoom it is about to move — the pump that 589 /// thread only, and only for a focus it is about to move — the pump that
940 /// lands there paints it as a banner on its promote. 590 /// lands there paints it as a banner on its claim.
941 fn setNotice(shared: *Shared, text: []const u8) void { 591 fn setNotice(shared: *Shared, text: []const u8) void {
942 shared.paint_mu.lock(); 592 shared.paint_mu.lock();
943 defer shared.paint_mu.unlock(); 593 defer shared.paint_mu.unlock();
@@ -979,11 +629,11 @@ fn endWith(t: *Tile, reason: EndReason, code: u8) void {
979 t.end.store(@intFromEnum(reason), .release); 629 t.end.store(@intFromEnum(reason), .release);
980 } 630 }
981 631
982 /// Called ONLY from the keyboard loop and ONLY while `t` is the zoomed tile: 632 /// Called ONLY from the keyboard loop and ONLY while `t` is the focused tile:
983 /// that restriction IS the enforcement of "an unzoomed tile claims nothing". 633 /// that restriction IS the enforcement of "an unfocused tile claims no terminal modes".
984 /// 634 ///
985 /// Mouse reports travel it unsplit — the split needs the pump's state — and 635 /// Mouse reports travel it unsplit — the split needs the pump's state — and
986 /// an unzoomed wall's never arrive, since this is the zoomed branch only. 636 /// an unfocused tile's never arrive, since this is the focused tile only.
987 /// 637 ///
988 /// A chunk that does not fit is dropped WHOLE: copying what fits splices two 638 /// A chunk that does not fit is dropped WHOLE: copying what fits splices two
989 /// reads into a command nobody typed; blocking wedges the wall. 639 /// reads into a command nobody typed; blocking wedges the wall.
@@ -1179,9 +829,8 @@ fn redial(
1179 transport.* = dial(alloc, t, target) orelse return false; 829 transport.* = dial(alloc, t, target) orelse return false;
1180 // Clears `state_since_attach` (so the next exit_status is read as a 830 // Clears `state_since_attach` (so the next exit_status is read as a
1181 // refusal again) and drops speculation made against a connection that 831 // refusal again) and drops speculation made against a connection that
1182 // no longer exists — the zoomed Core's own highlight with it. 832 // no longer exists — the Core's own highlight with it.
1183 core.reattached(); 833 core.reattached();
1184 dropDragOver(t);
1185 const have = core.rep.attachArgs(); 834 const have = core.rep.attachArgs();
1186 sendAttach(t, transport, have.have_seq, have.have_epoch) catch return false; 835 sendAttach(t, transport, have.have_seq, have.have_epoch) catch return false;
1187 return true; 836 return true;
@@ -1189,21 +838,15 @@ fn redial(
1189 838
1190 /// Absolute rows count from the oldest row the daemon keeps, and a resync 839 /// Absolute rows count from the oldest row the daemon keeps, and a resync
1191 /// renames that space: a kept highlight inverts rows nobody selected. 840 /// renames that space: a kept highlight inverts rows nobody selected.
1192 fn dropDragOver(t: *Tile) void { 841 /// One tile's life: dial → attach → replay frames into its Core → repaint
1193 t.shared.paint_mu.lock(); 842 /// at its rect. Runs on its own thread (see module header). On transport
1194 defer t.shared.paint_mu.unlock(); 843 /// death: reconnect on the CLI's backoff schedule, quoting
1195 if (t.shared.drag.on() == t.idx) t.shared.drag.clear(); 844 /// have_seq/have_epoch, and the snapshot-vs-delta resolution does the rest.
1196 } 845 /// Ends when `running` clears, the session exits, or the attach is refused.
1197
1198 /// One tile's life: dial → attach → replay frames into its Core → repaint.
1199 /// Runs on its own thread (see module header). On transport death:
1200 /// reconnect on the CLI's backoff schedule, quoting have_seq/have_epoch,
1201 /// and the snapshot-vs-delta resolution does the rest. Ends when `running`
1202 /// clears, the session exits, or the attach is refused.
1203 /// 846 ///
1204 /// This thread is also the tile's WRITER: the promote resize and every 847 /// This thread is also the tile's WRITER: every frame the keyboard doorbells
1205 /// keystroke the keyboard queued go out from here, because a Transport has 848 /// for — a resize, a detach, a focus claim or release — goes out from here,
1206 /// exactly one owning thread (module header). 849 /// because a Transport has exactly one owning thread (module header).
1207 fn pumpTile(t: *Tile) void { 850 fn pumpTile(t: *Tile) void {
1208 // FIRST defer, so it runs LAST: every `return` below — a refused 851 // FIRST defer, so it runs LAST: every `return` below — a refused
1209 // attach, an exited session, a dial the quit interrupted, a Core that 852 // attach, an exited session, a dial the quit interrupted, a Core that
@@ -1230,17 +873,11 @@ fn pumpTile(t: *Tile) void {
1230 // stderr would corrupt the alternate screen. Same as the hub's pump. 873 // stderr would corrupt the alternate screen. Same as the hub's pump.
1231 if (target == .hand) target.hand.report_fallback = false; 874 if (target == .hand) target.hand.report_fallback = false;
1232 875
1233 // ONE Core per tile, from birth — never built at promote time. It owns 876 // ONE Core per tile, from birth. It owns this tile's replica, its
1234 // this tile's replica and its prediction overlay for the tile's whole 877 // prediction overlay and its drag for the tile's whole life: the tile
1235 // life, because both are things a STRIPE needs: the stripe is painted 878 // paints from that replica at its own offset, and what decides whether
1236 // from that replica, and what decides whether a keystroke may be 879 // a keystroke may be speculated at all is the pty's line discipline,
1237 // speculated at all is the pty's line discipline, which arrives in 880 // which arrives in `.pty_mode` frames long before the tile is focused.
1238 // `.pty_mode` frames long before anyone zooms. A Core built at promote
1239 // would be a second replica for one tile, which is the invariant.
1240 //
1241 // Sized from the wall's ONE reading of the terminal rather than a
1242 // second ioctl: the stripes were cut from that number, and a promoted
1243 // tile claims exactly it.
1244 // 881 //
1245 // `in_fd` is the wall's stdin and this Core never reads it — the 882 // `in_fd` is the wall's stdin and this Core never reads it — the
1246 // keyboard thread does, on the far side of the mailbox. It is passed 883 // keyboard thread does, on the far side of the mailbox. It is passed
@@ -1254,16 +891,20 @@ fn pumpTile(t: *Tile) void {
1254 ) catch return; 891 ) catch return;
1255 defer core.deinit(); 892 defer core.deinit();
1256 // Whether this tile has EVER held the terminal, which is the only 893 // Whether this tile has EVER held the terminal, which is the only
1257 // question the publish below is gated on: a tile that was never the 894 // question the publish below is gated on: a tile that was never focused
1258 // zoom has nothing to say about prediction and must not overwrite what 895 // has nothing to say about prediction and must not overwrite what the
1259 // the tile that was does. 896 // tile that was does.
1260 var ever_promoted = false; 897 var ever_focused = false;
898 // Focus, as this pump knows it. Not `core.claim`: a claim needs a
899 // terminal, and `mux` on a pipe has none, yet its one tile is focused
900 // and its predictions still expire and still get counted.
901 var focused = false;
1261 // The last word on this tile's prediction, whichever way the pump ends 902 // The last word on this tile's prediction, whichever way the pump ends
1262 // — an exit_status arrives and RETURNS, so the per-pass publish inside 903 // — an exit_status arrives and RETURNS, so the per-pass publish inside
1263 // the loop is always one pass stale by then. 904 // the loop is always one pass stale by then.
1264 defer if (ever_promoted) publishStats(t.shared, core.overlay.counters); 905 defer if (ever_focused) publishStats(t.shared, core.overlay.counters);
1265 // Where this Core's paints are allowed to land: nowhere, until this 906 // Where this Core's paints land: this tile's rect. The sink admits a
1266 // tile is the zoom. See `tilePaintBegin`. 907 // paint whenever the tile has not been forgotten; see `tilePaintBegin`.
1267 core.sink = .{ .ctx = t, .begin = tilePaintBegin, .end = tilePaintEnd }; 908 core.sink = .{ .ctx = t, .begin = tilePaintBegin, .end = tilePaintEnd };
1268 // This thread does not own the exit and cannot print the stats line — 909 // This thread does not own the exit and cannot print the stats line —
1269 // see `Shared.stats`. 910 // see `Shared.stats`.
@@ -1280,14 +921,11 @@ fn pumpTile(t: *Tile) void {
1280 break :blk tr; 921 break :blk tr;
1281 } else dial(alloc, t, target) orelse return; 922 } else dial(alloc, t, target) orelse return;
1282 defer transport.close(); 923 defer transport.close();
1283 // Whether the attach frame this pump is about to send already claims 924 // The attach frame carries this tile's rect, so the session is sized to
1284 // the terminal's size, which it does whenever this tile is the zoom 925 // the rectangle the tile claims — the entry tile the whole terminal, a
1285 // when the pump starts — the entry tile always, a chord-added one 926 // wall tile its stripe. No second resize follows the claim: re-asserting
1286 // usually. The promote below then owes only the terminal claim, not a 927 // a size the daemon just heard costs one more snapshot on every `mux`,
1287 // second resize: re-asserting a size the daemon just heard costs one 928 // which is exactly the round trip the convergence must not add.
1288 // more snapshot on every `mux`, which is exactly the round trip the
1289 // convergence must not add.
1290 var size_claimed = t.shared.zoom.load(.acquire) == t.idx;
1291 sendAttach(t, &transport, 0, 0) catch { 929 sendAttach(t, &transport, 0, 0) catch {
1292 endWith(t, .lost, 1); 930 endWith(t, .lost, 1);
1293 return; 931 return;
@@ -1314,12 +952,7 @@ fn pumpTile(t: *Tile) void {
1314 defer dropLocals(&agent_locals); 952 defer dropLocals(&agent_locals);
1315 953
1316 var state: State = .connecting; 954 var state: State = .connecting;
1317 // Whether this tile has already claimed the grid for the zoom it is 955 // What this tile's paint is worth: while it matches the wall's
1318 // in. Compared against the zoom on every turn: the EDGE is what sends
1319 // the resize, so a zoom that comes back to this tile re-asserts the
1320 // claim and one that stays put costs nothing.
1321 var promoted = false;
1322 // What this stripe's paint is worth: while it matches the wall's
1323 // generation the terminal still holds what this thread drew. 956 // generation the terminal still holds what this thread drew.
1324 var painted_gen = t.shared.repaint_gen.load(.acquire); 957 var painted_gen = t.shared.repaint_gen.load(.acquire);
1325 // `gone` ends this thread exactly as `running` does — the defers close 958 // `gone` ends this thread exactly as `running` does — the defers close
@@ -1346,22 +979,91 @@ fn pumpTile(t: *Tile) void {
1346 transport.service(); 979 transport.service();
1347 if (fdbuf[1].revents != 0) drainWake(t); 980 if (fdbuf[1].revents != 0) drainWake(t);
1348 981
1349 const zoomed = t.shared.zoom.load(.acquire) == t.idx; 982 // The paint offset for this pass: a relayout may have re-cut this
983 // tile's stripe, and every paint the pass drives through the Core
984 // has to land in the rect the tile currently owns.
985 core.row_off = t.stripe.top + t.shared.label_rows;
986
987 // FOCUS CLAIM. The keyboard moved the focus onto this tile; the
988 // session's mouse modes and side channels go on here, on the thread
989 // that owns the transport and the Core. The resize the claim used
990 // to send is gone — the attach already carried the rect, and a
991 // relayout doorbells `resize_pending` for any later change.
992 if (t.claim_pending.swap(false, .acq_rel)) {
993 ever_focused = true;
994 focused = true;
995 // A tile born before somebody resized the terminal clips its
996 // paints to a screen that is gone; adopt the tile's current
997 // RECT — never the whole terminal, which on a wall of two
998 // would let this tile paint over its neighbour — so the first
999 // paint is at the right grid.
1000 const rect: proto.Size = .{ .cols = t.shared.size.cols, .rows = t.viewRows() };
1001 if (core.size.cols != rect.cols or core.size.rows != rect.rows)
1002 core.adoptSize(rect);
1003 _ = core.claimTerminal();
1004 // A sentence the keyboard left for whoever owns the terminal
1005 // next — a refused `Ctrl-\ c`, so far. Painted here because a
1006 // banner belongs to a Core and this is the Core that has just
1007 // taken the screen; painted AFTER the repaint below would be
1008 // wrong, so it is taken now and shown once the grid is up.
1009 var notice_buf: [96]u8 = undefined;
1010 const notice = takeNotice(t.shared, &notice_buf);
1011 // The replica has been hot the whole time, so a claim paints
1012 // from it NOW rather than waiting for the daemon's answering
1013 // snapshot. That is the headline: moving the focus costs a
1014 // local repaint, never a wire frame.
1015 // ...but only when there IS one. A tile focused before its
1016 // first snapshot — the entry tile, on every `mux` — would
1017 // otherwise paint a blank grid over the terminal before the
1018 // session has said anything, which is a screen the plain client
1019 // never drew and bytes a capture never held.
1020 if (core.rep.session_epoch != 0) core.repaint() catch {};
1021 if (notice.len > 0) core.banner(notice);
1022 }
1023 // FOCUS RELEASE. The keyboard moved the focus off this tile and
1024 // wrote the session's release itself, under `paint_mu`, before
1025 // doorbelling — so the handover is ordered and this pump owes only
1026 // its own state. `.already_written` is that discipline.
1027 if (t.release_pending.swap(false, .acq_rel)) {
1028 focused = false;
1029 core.releaseTerminal(.already_written);
1030 // The speculation described a screen this terminal no longer
1031 // shows.
1032 core.overlay.flush();
1033 }
1034
1035 // RELAYOUT DOORBELL: this tile's rect changed. The pump is the
1036 // transport's only writer, so relayout sets the flag and the pump
1037 // sends the `.resize` from here.
1038 if (t.resize_pending.swap(false, .acq_rel)) {
1039 core.overlay.setResizePending(true);
1040 // The Core clips every paint to its size; a resize the daemon
1041 // hears but the Core does not leaves the bottom of the new
1042 // grid cut off on screen forever.
1043 core.adoptSize(.{ .cols = t.shared.size.cols, .rows = t.viewRows() });
1044 transport.writeFrame(
1045 .resize,
1046 &proto.encodeSize(t.shared.size.cols, t.viewRows()),
1047 ) catch {
1048 if (!redial(t, alloc, &core, &transport, target, &state, &agent_locals)) return;
1049 continue :outer;
1050 };
1051 }
1350 1052
1351 // `Ctrl-\ d` under this zoom: hand the daemon its slot back before 1053 // `Ctrl-\ d`: hand the daemon its slot back before the process
1352 // the process dies, rather than leaving it for the socket's death 1054 // dies, rather than leaving it for the socket's death to be
1353 // to be noticed. Only this thread may write the frame, so the 1055 // noticed. Only this thread may write the frame, so the keyboard
1354 // keyboard asked and is waiting for the ack. 1056 // asked and is waiting for the ack.
1355 if (t.detach_req.swap(false, .acq_rel)) { 1057 if (t.detach_req.swap(false, .acq_rel)) {
1356 transport.writeFrame(.detach, "") catch {}; 1058 transport.writeFrame(.detach, "") catch {};
1357 t.detach_ack.store(true, .release); 1059 t.detach_ack.store(true, .release);
1358 ringKeyboard(t.shared); 1060 ringKeyboard(t.shared);
1359 } 1061 }
1360 1062
1361 // A zoom chord's question, put on the wire from the thread that 1063 // A focus chord's question, put on the wire from the thread that
1362 // owns the link. The ANSWER is read out of the `.sessions_reply` 1064 // owns the link. The ANSWER is read out of the `.sessions_reply`
1363 // arm below and handed to the keyboard, which is the only thread 1065 // arm below and handed to the keyboard, which is the only thread
1364 // that may move a zoom or grow the wall. 1066 // that may move the focus or grow the wall.
1365 const asked: client.SwitchIntent = @enumFromInt(t.ask.swap(0, .acq_rel)); 1067 const asked: client.SwitchIntent = @enumFromInt(t.ask.swap(0, .acq_rel));
1366 if (asked != .none) { 1068 if (asked != .none) {
1367 // The deadline starts HERE, when the question goes on the wire, 1069 // The deadline starts HERE, when the question goes on the wire,
@@ -1375,15 +1077,6 @@ fn pumpTile(t: *Tile) void {
1375 continue :outer; 1077 continue :outer;
1376 }; 1078 };
1377 } 1079 }
1378 // A wall drag that ended over this tile's stripe. Same reason the
1379 // chord above is asked from here: the keyboard reads the mouse but
1380 // this thread owns the link, and the answer arrives on it.
1381 if (takeSelectionReq(t)) |r| {
1382 core.requestSelection(&transport, r) catch {
1383 if (!redial(t, alloc, &core, &transport, target, &state, &agent_locals)) return;
1384 continue :outer;
1385 };
1386 }
1387 1080
1388 // A chord that was never answered. Said with the banner rather than 1081 // A chord that was never answered. Said with the banner rather than
1389 // stderr for the client's reason: the terminal is in raw mode on 1082 // stderr for the client's reason: the terminal is in raw mode on
@@ -1392,77 +1085,6 @@ fn pumpTile(t: *Tile) void {
1392 if (pending.expired(std.time.milliTimestamp())) 1085 if (pending.expired(std.time.milliTimestamp()))
1393 core.banner("[no session list: upgrade muxd]"); 1086 core.banner("[no session list: upgrade muxd]");
1394 1087
1395 // PROMOTE, and BEFORE the mailbox is drained below: the claim goes
1396 // out before the first keystroke can, which is what keeps the
1397 // passivity rule exception-free — the client that types this
1398 // session is a full-size one, claiming under latest-wins like any
1399 // other. No attach, no dial: the same connection, resized.
1400 if (zoomed and !promoted) {
1401 promoted = true;
1402 ever_promoted = true;
1403 // The TERMINAL side of the claim, which the daemon never hears
1404 // about. The wall owns the screen and keeps it; what a session
1405 // needs on top is the mouse modes its wheel is read out of —
1406 // without them this terminal answers the wheel by synthesising
1407 // arrow keys into the session (DEC 1007), which is exactly what
1408 // a zoomed tile did before it had a Core. Setting it is also
1409 // what ARMS the demote's teardown; see `Core.claimTerminal`.
1410 //
1411 // Its answer is discarded on purpose. `false` is the zoom
1412 // having moved away between the load above and the lock the
1413 // claim takes — the claim is then correctly not made, and the
1414 // next pass demotes a Core holding nothing. What still goes out
1415 // either way is the RESIZE, because it is about the daemon's
1416 // grid rather than about this terminal, and re-asserting a size
1417 // the session already has costs one snapshot and claims nothing
1418 // under latest-wins.
1419 _ = core.claimTerminal();
1420 // A tile born before somebody resized the terminal clips its
1421 // paints to a screen that is gone. Only the promoted pump can
1422 // answer a SIGWINCH (the flag is process-wide), so the wall's
1423 // current size is what a promote adopts.
1424 if (core.size.cols != t.shared.size.cols or core.size.rows != t.shared.size.rows) {
1425 core.adoptSize(t.shared.size);
1426 size_claimed = false;
1427 }
1428 // ...unless the ATTACH frame already carried that size, which is
1429 // the entry tile's case on every `mux`. Then the claim is
1430 // already made and a resize would buy a second snapshot for
1431 // nothing. One-shot: a zoom that comes back to this tile later
1432 // does re-assert, because by then some other client may have
1433 // claimed the grid.
1434 if (size_claimed) {
1435 size_claimed = false;
1436 } else {
1437 core.overlay.setResizePending(true);
1438 transport.writeFrame(
1439 .resize,
1440 &proto.encodeSize(t.shared.size.cols, t.shared.size.rows),
1441 ) catch {
1442 if (!redial(t, alloc, &core, &transport, target, &state, &agent_locals)) return;
1443 continue :outer;
1444 };
1445 }
1446 // A sentence the keyboard left for whoever owns the terminal
1447 // next — a refused `Ctrl-\ c`, so far. Painted here because a
1448 // banner belongs to a Core and this is the Core that has just
1449 // taken the screen; painted AFTER the repaint below would be
1450 // wrong, so it is taken now and shown once the grid is up.
1451 var notice_buf: [96]u8 = undefined;
1452 const notice = takeNotice(t.shared, &notice_buf);
1453 // The replica has been hot the whole time this was a stripe, so
1454 // the zoom paints from it NOW rather than waiting for the
1455 // daemon's answering snapshot. That is the headline: moving the
1456 // zoom costs a local repaint.
1457 // ...but only when there IS one. A tile promoted before its
1458 // first snapshot — the entry tile, on every `mux` — would
1459 // otherwise paint a blank grid over the terminal before the
1460 // session has said anything, which is a screen the plain client
1461 // never drew and bytes a capture never held.
1462 if (core.rep.session_epoch != 0) core.repaint() catch {};
1463 if (notice.len > 0) core.banner(notice);
1464 }
1465
1466 // FRAMES BEFORE KEYS, and that order is load-bearing rather than 1088 // FRAMES BEFORE KEYS, and that order is load-bearing rather than
1467 // arbitrary. What the session has already said must be known before 1089 // arbitrary. What the session has already said must be known before
1468 // what the user is saying is interpreted, because the interpreting 1090 // what the user is saying is interpreted, because the interpreting
@@ -1492,31 +1114,29 @@ fn pumpTile(t: *Tile) void {
1492 }; 1114 };
1493 defer frame.deinit(alloc); 1115 defer frame.deinit(alloc);
1494 // Everything the frame means to the replica and the screen, 1116 // Everything the frame means to the replica and the screen,
1495 // in the one place that switch lives. What the Core paints 1117 // in the one place that switch lives. The Core paints at
1496 // it paints only if this tile owns the terminal — and what 1118 // this tile's offset through the sink, and writes side
1497 // it WRITES it writes only then either, which is why a 1119 // channels only while it holds the claim — so a tile that
1498 // stripe's session can set a title or arm the mouse without 1120 // is not focused still keeps its replica hot and its grid
1499 // the wall's terminal hearing about it. 1121 // on its rect, but its title and mouse modes reach no
1122 // terminal until the focus comes to it.
1500 // 1123 //
1501 // The one exception is deliberate and is `.pty_mode`: the 1124 // The one exception is deliberate and is `.pty_mode`: the
1502 // Core feeds the overlay's mode whatever the claim, because 1125 // Core feeds the overlay's mode whatever the claim, because
1503 // the pty's line discipline is the entire gate on 1126 // the pty's line discipline is the entire gate on
1504 // speculation — a password prompt must never be predicted — 1127 // speculation — a password prompt must never be predicted —
1505 // and a promote has to start from the truth rather than 1128 // and a claim has to start from the truth rather than
1506 // from `.never` and a round trip. 1129 // from `.never` and a round trip.
1507 const routed = core.frame(frame.type, frame.payload) catch break :frames; 1130 const routed = core.frame(frame.type, frame.payload) catch break :frames;
1508 switch (routed) { 1131 switch (routed) {
1509 .skip, .handled => {}, 1132 .skip, .handled => {},
1510 .state => { 1133 .state => {
1511 // The attach LANDED, which is when it becomes 1134 // The attach LANDED, which is when it becomes
1512 // history. Only for a tile that claims the grid — 1135 // history. Every tile claims its rect on attach, so
1513 // the entry tile and every tile a zoom chord added; 1136 // every recording tile earns its line; the entry
1514 // a stripe attaches at 0x0 and records nothing, 1137 // tile and every tile a focus chord adds. Keyed off
1515 // which is the mechanical half of the attach-adds 1138 // `state_since_attach` exactly as the client keyed
1516 // rule and what keeps muxa's 0x0 invariant intact. 1139 // it, so a refusal cannot record on any path.
1517 // Keyed off `state_since_attach` exactly as the
1518 // client keyed it, so a refusal cannot record on
1519 // any path.
1520 if (t.record) client.recordOnState( 1140 if (t.record) client.recordOnState(
1521 &recorded, 1141 &recorded,
1522 &core.rep, 1142 &core.rep,
@@ -1529,10 +1149,6 @@ fn pumpTile(t: *Tile) void {
1529 state = .up; 1149 state = .up;
1530 paintLabel(t, state); 1150 paintLabel(t, state);
1531 } 1151 }
1532 // The Core has already painted the terminal if this
1533 // tile owns it; this is the wall's own crop of the
1534 // same replica, and it draws only when it doesn't.
1535 _ = paintStripe(t, alloc, core.grid(), core.rep.history_rows);
1536 }, 1152 },
1537 // The replica is suspect, not the transport: re-attach 1153 // The replica is suspect, not the transport: re-attach
1538 // quoting (0,0) explicitly — a quoted seq would invite 1154 // quoting (0,0) explicitly — a quoted seq would invite
@@ -1540,24 +1156,18 @@ fn pumpTile(t: *Tile) void {
1540 .resync => { 1156 .resync => {
1541 core.rep.state_since_attach = false; 1157 core.rep.state_since_attach = false;
1542 // A resync renames the absolute row space, so a 1158 // A resync renames the absolute row space, so a
1543 // held highlight now names rows nobody selected — 1159 // held drag now names rows nobody selected — and
1544 // and on an EPOCH change `sel_range` still matches 1160 // on an EPOCH change `sel_range` still matches it,
1545 // it, so an in-flight reply would copy the new 1161 // so an in-flight reply would copy the new session's
1546 // session's text. `redial` drops both sides for 1162 // text. `redial` drops it for this reason; this path
1547 // this reason; this path re-attaches without going 1163 // re-attaches without going through it.
1548 // through it. Not `core.reattached()`: that also
1549 // flushes the overlay and forces a repaint, which
1550 // this arm has never done and is a separate
1551 // question from the drag.
1552 core.drag.clear(); 1164 core.drag.clear();
1553 dropDragOver(t);
1554 sendAttach(t, &transport, 0, 0) catch return; 1165 sendAttach(t, &transport, 0, 0) catch return;
1555 }, 1166 },
1556 .not_mine => switch (frame.type) { 1167 .not_mine => switch (frame.type) {
1557 .exit_status => { 1168 .exit_status => {
1558 // Before any replay frame this is the refusal 1169 // Before any replay frame this is the refusal
1559 // path (a 0x0 attach joins but never creates); 1170 // path; after, the session really ended.
1560 // after, the session really ended.
1561 const landed = core.rep.state_since_attach; 1171 const landed = core.rep.state_since_attach;
1562 state = if (landed) .exited else .refused; 1172 state = if (landed) .exited else .refused;
1563 paintLabel(t, state); 1173 paintLabel(t, state);
@@ -1582,7 +1192,7 @@ fn pumpTile(t: *Tile) void {
1582 .sessions_reply => { 1192 .sessions_reply => {
1583 // Gated on the intent this tile's own chord 1193 // Gated on the intent this tile's own chord
1584 // armed: only a question we asked may move the 1194 // armed: only a question we asked may move the
1585 // zoom, so an unasked-for reply is ignored. The 1195 // focus, so an unasked-for reply is ignored. The
1586 // intent is SPENT here whichever name it picks 1196 // intent is SPENT here whichever name it picks
1587 // — one question, one answer. 1197 // — one question, one answer.
1588 var name_buf: [proto.session_name_max]u8 = undefined; 1198 var name_buf: [proto.session_name_max]u8 = undefined;
@@ -1727,15 +1337,8 @@ fn pumpTile(t: *Tile) void {
1727 // Whatever the keyboard left, through everything a plain client's 1337 // Whatever the keyboard left, through everything a plain client's
1728 // keystrokes go through: the mouse split, the wheel, alternate 1338 // keystrokes go through: the mouse split, the wheel, alternate
1729 // scroll, the scrollback view, the prediction and the input frame. 1339 // scroll, the scrollback view, the prediction and the input frame.
1730 // The first version of the wall hand-rolled a subset of that, and 1340 // Only the focused tile's mailbox is ever written, but every pump
1731 // a zoomed tile had no wheel at all. 1341 // drains its own — an empty mailbox is the common, cheap answer.
1732 //
1733 // Sent whatever the zoom is doing NOW: these bytes were typed AT
1734 // this session while this tile was the zoom, and the keyboard is
1735 // the only writer of that mailbox. The demote is deliberately
1736 // handled AFTER them, for the same reason read the other way — a
1737 // report typed at the zoom is the zoom's to split, not raw bytes
1738 // for somebody's shell to echo.
1739 var keys_buf: [mailbox_max]u8 = undefined; 1342 var keys_buf: [mailbox_max]u8 = undefined;
1740 const keys = takeKeys(t, &keys_buf); 1343 const keys = takeKeys(t, &keys_buf);
1741 if (keys.len > 0) { 1344 if (keys.len > 0) {
@@ -1749,268 +1352,94 @@ fn pumpTile(t: *Tile) void {
1749 // Input is moving again, so "input dropped" has stopped being 1352 // Input is moving again, so "input dropped" has stopped being
1750 // news. Repainted rather than merely cleared, because the bar 1353 // news. Repainted rather than merely cleared, because the bar
1751 // is still carrying the old sentence until something draws 1354 // is still carrying the old sentence until something draws
1752 // over it — and only in `.stripe` mode, which paintLabel 1355 // over it — and only when there is a bar, which `paintLabel`
1753 // already decides. 1356 // already decides.
1754 if (t.in_dropped.swap(false, .acq_rel)) paintLabel(t, state); 1357 if (t.in_dropped.swap(false, .acq_rel)) paintLabel(t, state);
1755 } 1358 }
1756 1359
1757 // DEMOTE. Nothing goes on the WIRE, deliberately — that is the
1758 // whole of "an unzoomed tile claims nothing", and the e2e attach
1759 // counter is its witness. What DOES come off is the terminal
1760 // claim: the mouse modes this session asked for and the bracketed
1761 // paste it armed, because the wall is about to show stripes again
1762 // and a wall still reporting clicks into the user's shell is what
1763 // that pairing exists to prevent.
1764 if (!zoomed and promoted) {
1765 promoted = false;
1766 // `.already_written`: the bytes went out on the KEYBOARD's
1767 // thread, under `paint_mu`, before the store this pass read —
1768 // see `setZoom`. Writing them again from here is what made the
1769 // handover a race, so what is left is the claim flag and the
1770 // scroll view.
1771 core.releaseTerminal(.already_written);
1772 // The speculation described a screen this terminal no longer
1773 // shows.
1774 core.overlay.flush();
1775 }
1776
1777 // A prediction the daemon never answered must not sit on the 1360 // A prediction the daemon never answered must not sit on the
1778 // screen forever, and only the clock can say so — no frame will. 1361 // screen forever, and only the clock can say so — no frame will.
1779 // Only while promoted: a stripe has no prediction on it to retire. 1362 // Only while focused: an unfocused tile's rect shows no prediction
1780 if (promoted) { 1363 // to retire.
1781 // The tty changed shape under the zoom. A zoomed tile IS a 1364 if (focused) {
1782 // full-screen client and has to follow it — `mux` resizing with
1783 // its terminal is the plain client's behaviour and one of the
1784 // things "unchanged in feel" is measured by. Only the promoted
1785 // pump asks: `winch` reads a process-wide flag, so a stripe
1786 // consuming one would steal the answer from the tile that owns
1787 // the screen.
1788 const was = core.size;
1789 if (core.winch(&transport) == .lost) {
1790 if (!redial(t, alloc, &core, &transport, target, &state, &agent_locals)) return;
1791 continue :outer;
1792 }
1793 if (core.size.cols != was.cols or core.size.rows != was.rows)
1794 setWallSize(t.shared, core.size);
1795 core.idle() catch {}; 1365 core.idle() catch {};
1796 publishStats(t.shared, core.overlay.counters); 1366 publishStats(t.shared, core.overlay.counters);
1797 } 1367 }
1798 1368
1799 // A zoom wrote over every stripe, and a quiet session sends 1369 // A relayout re-cut the stripes (or cleared the screen for an empty
1800 // nothing to trigger a repaint. The replica is current — the 1370 // wall), and a quiet session sends nothing to trigger a repaint.
1801 // SCREEN is not — so the generation is what puts it back. Checked 1371 // The replica is current — the SCREEN is not — so the generation is
1802 // on the poll timeout, so a stripe comes back within ~100ms of the 1372 // what puts it back. Checked on the poll timeout, so a tile comes
1803 // zoom leaving whether or not its session ever speaks again. 1373 // back within ~100ms of a relayout whether or not its session ever
1374 // speaks again. The drag clears here too: a relayout moved the rect
1375 // a held drag's anchor was resolved against.
1804 const gen = t.shared.repaint_gen.load(.acquire); 1376 const gen = t.shared.repaint_gen.load(.acquire);
1805 if (gen != painted_gen) { 1377 if (gen != painted_gen) {
1378 core.drag.clear();
1806 paintLabel(t, state); 1379 paintLabel(t, state);
1807 // A PROMOTED tile satisfies the generation by owning the 1380 core.repaint() catch {};
1808 // screen: its Core painted the whole terminal at the promote 1381 painted_gen = gen;
1809 // and repaints it on every frame after. A demoted one owes a
1810 // stripe, and records only if the stripe landed — a tile hidden
1811 // behind another tile's zoom has not satisfied anything and
1812 // must repaint when the terminal comes back to the wall.
1813 if (promoted or paintStripe(t, alloc, core.grid(), core.rep.history_rows)) painted_gen = gen;
1814 } 1382 }
1815 } 1383 }
1816 } 1384 }
1817 1385
1818 /// Where a chord takes the zoom. 1386 /// Move the focus to tile `next`. Client-local: decides which pump owns the
1819 pub const ZoomMove = union(enum) { 1387 /// terminal's modes, not which session the daemon hears. The outgoing tile's
1820 /// Swallowed: the zoom stays where it is. 1388 /// `session_release` is written HERE, under `paint_mu`, so the handover is
1821 stay, 1389 /// ordered — two pumps are two threads, and A's release and B's claim race
1822 /// Back to the wall. 1390 /// (measured on a real pty: A's release landed after B's claim in five of
1823 out, 1391 /// six). Written before the doorbell; the released pump writes nothing
1824 /// Detach and leave mux entirely — `Ctrl-\ d`, the muscle memory a 1392 /// (`.already_written`).
1825 /// plain client's detach left behind, kept meaning exactly what it
1826 /// always meant now that `mux` IS the wall.
1827 leave,
1828 /// Ask this tile's DAEMON for its session list, and move the zoom to
1829 /// what the answer names. The answer may grow the wall: a sibling with
1830 /// no tile gets one, because visiting it is an attach and attach adds.
1831 ask: client.SwitchIntent,
1832 /// Onto this tile — possibly the one already zoomed, which re-asserts
1833 /// the claim and costs one repaint.
1834 to: usize,
1835 };
1836
1837 /// Pure, so what a chord MEANS is assertable without a terminal or a daemon.
1838 pub fn zoomChord(
1839 action: interact.PrefixFilter.Action,
1840 cur: usize,
1841 present: []const bool,
1842 last: ?usize,
1843 ) ZoomMove {
1844 return switch (action) {
1845 .detach => .leave,
1846 .wall => .out,
1847 .new_session => .{ .ask = .new },
1848 .next_session => .{ .ask = .next },
1849 .prev_session => .{ .ask = .prev },
1850 .last_session => blk: {
1851 const back = last orelse break :blk .out;
1852 if (back >= present.len or !present[back] or back == cur) break :blk .out;
1853 break :blk .{ .to = back };
1854 },
1855 // The wall does not act on these from here yet.
1856 .focus, .forget => .stay,
1857 .none => .stay,
1858 };
1859 }
1860
1861 /// Move the zoom: to a tile, or to `no_zoom` for the wall.
1862 ///
1863 /// The whole transition happens under one hold of `paint_mu`, which is what
1864 /// makes it atomic as far as the pumps are concerned: between the screen
1865 /// being cleared and the terminal changing hands there is no window in
1866 /// which a stripe can paint. The pumps do the rest themselves — the tile
1867 /// that is now the zoom sends its resize and paints full-screen from its
1868 /// already-hot replica, and the tiles that are not stay quiet until the
1869 /// terminal comes back to the wall.
1870 ///
1871 /// Nothing here goes on the wire, in either direction. A promote's resize
1872 /// is the promoted PUMP's to send (one thread owns a transport); a demote
1873 /// has nothing to send at all, which is the point.
1874 ///
1875 /// What DOES go on the terminal here is the outgoing zoom's release, and it
1876 /// is written on this thread rather than left to that tile's pump because
1877 /// the handover has to be ORDERED and not merely eventual. Two pumps are
1878 /// two threads: A's release and B's claim race, and measured on a real pty
1879 /// A's release landed AFTER B's claim in five runs out of six — benign only
1880 /// because B's level-set happened to follow and repair it. A pump stalled
1881 /// in a dial backoff makes that window seconds wide, and the zoomed tile
1882 /// spends them with every mode off, which is the pre-3b bug exactly.
1883 ///
1884 /// Written BEFORE the store, so it is ordered against every claim that can
1885 /// follow: a pump reads the zoom with `.acquire` and takes its claim under
1886 /// this same lock (`Core.claimTerminal` goes through the sink), so it
1887 /// either claimed entirely before this or finds the zoom already moved.
1888 ///
1889 /// It also closes a hole that is worse than a stray escape, and that hole
1890 /// is what shapes the re-arm below. Legacy mouse reporting (1000 without
1891 /// 1006) spells a report `ESC [ M` plus three bytes that are ordinary
1892 /// characters — column 81 is `q`, which QUITS the wall, and column 88 is
1893 /// `x`, which forgets the selected tile and rewrites the wall file. A
1894 /// terminal 88 columns wide or more can therefore destroy wall state with
1895 /// one wheel spin. A session's application can have asked for exactly that
1896 /// spelling, so its modes come off here, ahead of the zoom moving.
1897 ///
1898 /// What goes back on is the WALL's own set (`interact.wall_mouse_claim`),
1899 /// and it is SGR — the one form the unzoomed key loop's `MouseFilter`
1900 /// recognises. So the wall ends up neither deaf nor exposed: it receives
1901 /// reports again, in a shape whose every byte the filter holds and no byte
1902 /// of which can be read as a wall key. What is still not covered is a
1903 /// legacy report already sitting in the kernel buffer when the release was
1904 /// written — untouched by any of this, and unreachable from here.
1905 /// 1393 ///
1906 /// The one thing it DOES paint is for the dead. Only pumps answer 1394 /// Legacy mouse reporting (1000 without 1006) spells a report `ESC [ M`
1907 /// `repaint_gen`, so a tile whose pump has ended has nobody to redraw it 1395 /// plus ordinary characters — column 81 is `q` (quits), column 88 is `x`
1908 /// after the clear above — and the wall clears the screen on every zoom 1396 /// (forgets the focused tile). So the outgoing session's modes come off
1909 /// move, not once per zoom, so what used to be a corner case is now every 1397 /// here, ahead of the focus moving. The claim and release are doorbells;
1910 /// `n`/`p`/`l`. Both symptoms are the same hole: a dead tile's stripe never 1398 /// no screen clear — every tile paints its own rect.
1911 /// comes back to the wall, and zooming a dead tile paints an entirely blank 1399 fn setFocus(tiles: []Tile, shared: *Shared, next: usize) void {
1912 /// terminal that admits to nothing. See `Tile.alive`. 1400 const prev = shared.sel;
1913 fn setZoom(tiles: []Tile, shared: *Shared, input: *WallInput, next: usize) void {
1914 // The terminal is changing hands, so whatever the wall's own filters
1915 // are mid-way through belongs to a screen that is about to stop
1916 // existing. Here rather than at the call sites: this is the transition,
1917 // and there are six of them.
1918 input.reset(shared);
1919 shared.paint_mu.lock(); 1401 shared.paint_mu.lock();
1920 defer shared.paint_mu.unlock(); 1402 defer shared.paint_mu.unlock();
1921 // See the note above: the outgoing session's modes come off HERE, on 1403 // The outgoing session's modes come off HERE, on the thread moving the
1922 // the thread that is moving the zoom, ahead of the store that lets the 1404 // focus, ahead of the doorbell that lets the next pump claim.
1923 // next tile's pump claim. The demoted pump writes nothing
1924 // (`Core.Undo.already_written`) — this is that write.
1925 // ...and only when there IS a terminal. A scripted `mux` on a pipe is a 1405 // ...and only when there IS a terminal. A scripted `mux` on a pipe is a
1926 // wall of one zoomed tile whose chords still work; what it must not do 1406 // wall of one tile whose chords still work; what it must not do is
1927 // is write a mode change, a clear or a cursor into a capture the plain 1407 // write a mode change into a capture the plain client left clean.
1928 // client left clean. Every write below is that gate. 1408 if (shared.is_tty and prev != next and prev < tiles.len and tiles[prev].alive.load(.acquire)) {
1929 if (shared.is_tty and shared.zoom.load(.acquire) != no_zoom) {
1930 proto.writeAllFd(shared.out_fd, interact.session_release) catch {}; 1409 proto.writeAllFd(shared.out_fd, interact.session_release) catch {};
1410 tiles[prev].release_pending.store(true, .release);
1411 ring(&tiles[prev]);
1931 } 1412 }
1932 shared.zoom.store(next, .release); 1413 shared.sel = next;
1933 if (!shared.is_tty) { 1414 if (next < tiles.len) {
1934 // The generation bump is not a terminal write and still owes the 1415 tiles[next].claim_pending.store(true, .release);
1935 // pumps an answer: a demoted pump must be told to stop painting 1416 ring(&tiles[next]);
1936 // full-screen, whatever it is painting onto.
1937 _ = shared.repaint_gen.fetchAdd(1, .release);
1938 for (tiles) |*t| ring(t);
1939 if (next != no_zoom) shared.sel = next;
1940 return;
1941 }
1942 if (next != no_zoom) {
1943 // The selection follows the zoom, so leaving one puts the marker
1944 // where the user just was and `Enter` goes straight back in.
1945 shared.sel = next;
1946 // The cursor belongs to the session now; `renderClipped` ends with
1947 // the sync bracket that shows it.
1948 proto.writeAllFd(shared.out_fd, "\x1b[H\x1b[2J") catch {};
1949 // Zooming a tile whose pump has ended: nobody will paint this
1950 // screen, ever. One line, at the top, saying whose session it was,
1951 // what became of it, and the way out — the alternative measured in
1952 // review was a blank terminal with no cursor and no hint.
1953 if (!tiles[next].alive.load(.acquire)) paintDeadZoomLocked(&tiles[next]);
1954 } else {
1955 // ...and the wall keeps it hidden again.
1956 proto.writeAllFd(shared.out_fd, "\x1b[?25l\x1b[H\x1b[2J") catch {};
1957 // The wall's own mouse modes, back on, AFTER the release above and
1958 // never before it. That release is the whole wire table — it has no
1959 // way to tell a mode the wall armed for itself from one a session
1960 // asked for through a promoted tile — so it takes the wall's with
1961 // it. An unzoom that skipped this would leave a wall that reads
1962 // mouse reports on its own account receiving none, for the rest of
1963 // the run.
1964 proto.writeAllFd(shared.out_fd, interact.wall_mouse_claim) catch {};
1965 } 1417 }
1966 // Every stripe is now blank screen over a current replica. One bump 1418 // Repaint the label bars: the focus marker moved. One bump tells every
1967 // tells the pumps so; without it a quiet session's stripe would stay 1419 // pump to redraw its bar; the doorbells above make the two involved
1968 // empty until it next said something. The doorbell makes that 1420 // tiles immediate, and the rest follow on their next poll timeout.
1969 // immediate rather than one poll timeout away — the skip between two
1970 // tiles must feel like a repaint, because that is all it is.
1971 _ = shared.repaint_gen.fetchAdd(1, .release); 1421 _ = shared.repaint_gen.fetchAdd(1, .release);
1972 for (tiles) |*t| ring(t); 1422 for (tiles) |*t| ring(t);
1973 // ...except for the tiles that have no pump left to hear the bell.
1974 // Their bars are the keyboard's to redraw, and only in `.stripe` mode:
1975 // while a zoom holds the terminal there are no bars at all.
1976 if (next == no_zoom) {
1977 for (tiles) |*t| {
1978 if (!t.alive.load(.acquire)) paintLabelLocked(t);
1979 }
1980 }
1981 } 1423 }
1982 1424
1983 /// The whole screen a dead tile's zoom gets: its label, its last state, and 1425 /// Re-cut before the move: `setFocus` releases the outgoing session
1984 /// the chord back. Caller holds `paint_mu`. 1426 /// and needs the true previous focus.
1985 /// 1427 fn focusAnswer(
1986 /// Row 1 rather than anywhere prettier because there is nothing else on the 1428 alloc: std.mem.Allocator,
1987 /// screen to lay it out against — the replica this tile would have painted 1429 tiles: []Tile,
1988 /// from is exactly what does not exist. ASCII and plain text, `labelText`'s 1430 present: []const bool,
1989 /// reasons: greppable in a capture, no font dependency, safe to truncate by 1431 shared: *Shared,
1990 /// byte. 1432 grew: bool,
1991 fn paintDeadZoomLocked(t: *Tile) void { 1433 to: usize,
1992 var status_buf: [64]u8 = undefined; 1434 ) void {
1993 const status = std.fmt.bufPrint( 1435 if (grew) relayout(alloc, tiles, present, shared, shared.sel);
1994 &status_buf, 1436 setFocus(tiles, shared, to);
1995 "{s} - Ctrl-\\ w for the wall",
1996 .{t.state.word()},
1997 ) catch t.state.word();
1998 var text_buf: [256]u8 = undefined;
1999 const shown = labelText(&text_buf, t.shared.size.cols, "", t.r.label, status);
2000 var out: [512]u8 = undefined;
2001 var fbs = std.io.fixedBufferStream(&out);
2002 const w = fbs.writer();
2003 // Cursor shown and parked at the end of the line: a terminal with no
2004 // cursor at all reads as hung, which is the impression this exists to
2005 // prevent.
2006 w.print("\x1b[1;1H{s}\x1b[?25h", .{shown}) catch return;
2007 proto.writeAllFd(t.shared.out_fd, fbs.getWritten()) catch {};
2008 } 1437 }
2009 1438
2010 /// The whole screen an empty wall gets. The first wall's dead-zoom line, 1439 /// The whole screen an empty wall gets: one line, at the top, saying the
2011 /// for the same reason: a blank terminal with no cursor reads as hung, and the 1440 /// wall is empty and the way out. A blank terminal with no cursor reads as
2012 /// last `x` is precisely when the user needs to be told that what they see 1441 /// hung, and the last `x` is precisely when the user needs to be told that
2013 /// is the answer and not a crash. 1442 /// what they see is the answer and not a crash.
2014 fn paintEmptyWallLocked(shared: *Shared) void { 1443 fn paintEmptyWallLocked(shared: *Shared) void {
2015 var text_buf: [256]u8 = undefined; 1444 var text_buf: [256]u8 = undefined;
2016 const shown = labelText( 1445 const shown = labelText(
@@ -2018,7 +1447,7 @@ fn paintEmptyWallLocked(shared: *Shared) void {
2018 shared.size.cols, 1447 shared.size.cols,
2019 "", 1448 "",
2020 "the wall is empty", 1449 "the wall is empty",
2021 "nothing left to show - q to leave", 1450 "nothing left to show - Ctrl-\\ d to leave",
2022 ); 1451 );
2023 var out: [512]u8 = undefined; 1452 var out: [512]u8 = undefined;
2024 var fbs = std.io.fixedBufferStream(&out); 1453 var fbs = std.io.fixedBufferStream(&out);
@@ -2038,15 +1467,16 @@ fn relayout(
2038 shared.paint_mu.lock(); 1467 shared.paint_mu.lock();
2039 defer shared.paint_mu.unlock(); 1468 defer shared.paint_mu.unlock();
2040 shared.sel = sel; 1469 shared.sel = sel;
2041 // The stripes are about to be re-cut, so an anchor resolved against
2042 // the old ones names a line on a stripe that is moving out from under
2043 // it. Covers `x` as well, which ends here.
2044 shared.drag.clear();
2045 1470
2046 var live: usize = 0; 1471 var live: usize = 0;
2047 for (present) |p| { 1472 for (present) |p| {
2048 if (p) live += 1; 1473 if (p) live += 1;
2049 } 1474 }
1475 // A one-tile wall owns every row and draws no label bar; two or more
1476 // tiles each lose their top row to one. Set before the stripes are cut
1477 // so `viewRows` is right from the first attach, and before the doorbells
1478 // so the pumps read it on the pass they answer.
1479 shared.label_rows = if (live > 1) 1 else 0;
2050 proto.writeAllFd(shared.out_fd, "\x1b[?25l\x1b[H\x1b[2J") catch {}; 1480 proto.writeAllFd(shared.out_fd, "\x1b[?25l\x1b[H\x1b[2J") catch {};
2051 if (live == 0) { 1481 if (live == 0) {
2052 paintEmptyWallLocked(shared); 1482 paintEmptyWallLocked(shared);
@@ -2058,20 +1488,26 @@ fn relayout(
2058 for (tiles, present) |*t, p| { 1488 for (tiles, present) |*t, p| {
2059 if (!p) continue; 1489 if (!p) continue;
2060 t.stripe = stripes[k]; 1490 t.stripe = stripes[k];
1491 // A stripe that moved or reshaped owes the daemon its new size:
1492 // the pump is the transport's only writer, so the doorbell is
1493 // set here and the pump sends the `.resize` from its own pass.
1494 // `viewRows` reads `label_rows` and the stripe together, both
1495 // set above and here — so the frame carries the rect this tile
1496 // now claims.
1497 t.resize_pending.store(true, .release);
2061 k += 1; 1498 k += 1;
2062 } 1499 }
2063 } else |_| {} 1500 } else |_| {}
2064 1501
2065 // The generation bump is what puts the stripes back, exactly as after 1502 // The generation bump is what puts the stripes back: every surviving
2066 // a zoom: every surviving pump repaints from its hot replica at its 1503 // pump repaints from its hot replica at its NEW rows, and the doorbell
2067 // NEW rows, and the doorbell makes that immediate rather than one poll 1504 // makes that immediate rather than one poll timeout away.
2068 // timeout away.
2069 _ = shared.repaint_gen.fetchAdd(1, .release); 1505 _ = shared.repaint_gen.fetchAdd(1, .release);
2070 for (tiles, present) |*t, p| { 1506 for (tiles, present) |*t, p| {
2071 if (p) ring(t); 1507 if (p) ring(t);
2072 } 1508 }
2073 // ...except the tiles with no pump left to hear it: their bars are the 1509 // ...except the tiles with no pump left to hear it: their bars are the
2074 // keyboard's, exactly as in `setZoom`. 1510 // keyboard's, exactly as in `setFocus`.
2075 for (tiles, present) |*t, p| { 1511 for (tiles, present) |*t, p| {
2076 if (p and !t.alive.load(.acquire)) paintLabelLocked(t); 1512 if (p and !t.alive.load(.acquire)) paintLabelLocked(t);
2077 } 1513 }
@@ -2158,7 +1594,7 @@ fn presentCount(present: []const bool) usize {
2158 } 1594 }
2159 1595
2160 /// One tile, with the doorbell its pump polls. Split out of `run` because 1596 /// One tile, with the doorbell its pump polls. Split out of `run` because
2161 /// tiles are born in two places now — at startup, and whenever a zoom chord 1597 /// tiles are born in two places now — at startup, and whenever a focus chord
2162 /// or the saved wall adds one. 1598 /// or the saved wall adds one.
2163 fn initTile(t: *Tile, r: Resolved, s: Stripe, shared: *Shared, idx: usize) !void { 1599 fn initTile(t: *Tile, r: Resolved, s: Stripe, shared: *Shared, idx: usize) !void {
2164 // The doorbell, before the pump that polls it exists. Non-blocking at 1600 // The doorbell, before the pump that polls it exists. Non-blocking at
@@ -2180,7 +1616,7 @@ fn spawnPump(t: *Tile) void {
2180 // A tile with no thread is a tile nothing will ever paint — the 1616 // A tile with no thread is a tile nothing will ever paint — the
2181 // same hole `pumpTile`'s exit closes, reached without the pump 1617 // same hole `pumpTile`'s exit closes, reached without the pump
2182 // having run at all. Marked here so the keyboard paints its bar and 1618 // having run at all. Marked here so the keyboard paints its bar and
2183 // narrates a zoom into it. `.connecting` would be a lie: nobody is 1619 // narrates a focus into it. `.connecting` would be a lie: nobody is
2184 // going to. 1620 // going to.
2185 // 1621 //
2186 // The entry tile's link is already OPEN, and nobody is left to 1622 // The entry tile's link is already OPEN, and nobody is left to
@@ -2202,7 +1638,7 @@ fn spawnPump(t: *Tile) void {
2202 th.detach(); 1638 th.detach();
2203 } 1639 }
2204 1640
2205 /// Where a zoom chord's answer takes the zoom. 1641 /// Where a focus chord's answer takes the focus.
2206 const ZoomTo = union(enum) { 1642 const ZoomTo = union(enum) {
2207 /// Onto this tile: one that was already on the wall (a hot replica and 1643 /// Onto this tile: one that was already on the wall (a hot replica and
2208 /// a local repaint — zero round trips) or one just added for it. 1644 /// a local repaint — zero round trips) or one just added for it.
@@ -2211,13 +1647,13 @@ const ZoomTo = union(enum) {
2211 /// because the alternative — a roaming connection that claims full size 1647 /// because the alternative — a roaming connection that claims full size
2212 /// without earning a tile — is a rule this spec states elsewhere. 1648 /// without earning a tile — is a rule this spec states elsewhere.
2213 full, 1649 full,
2214 /// Nowhere to go; the zoom stays put. 1650 /// Nowhere to go; the focus stays put.
2215 stay, 1651 stay,
2216 }; 1652 };
2217 1653
2218 /// A sibling is zoomed if it has a tile and GETS one if not — otherwise a 1654 /// A sibling is focused if it has a tile and GETS one if not — otherwise a
2219 /// tile labelled S would paint T. 1655 /// tile labelled S would paint T.
2220 fn zoomToSession( 1656 fn addSessionTile(
2221 alloc: std.mem.Allocator, 1657 alloc: std.mem.Allocator,
2222 tiles: []Tile, 1658 tiles: []Tile,
2223 present: []bool, 1659 present: []bool,
@@ -2250,8 +1686,8 @@ fn zoomToSession(
2250 // the flag on, so not inheriting would silently end the forwarding 1686 // the flag on, so not inheriting would silently end the forwarding
2251 // at the first session switch. 1687 // at the first session switch.
2252 .{ .target = target, .label = label, .session = session, .agent = tiles[from].r.agent }, 1688 .{ .target = target, .label = label, .session = session, .agent = tiles[from].r.agent },
2253 // Placeholder geometry: a tile added under a zoom paints nothing as 1689 // Placeholder geometry: a tile added under a focus paints nothing as
2254 // a stripe until the next unzoom, and that unzoom re-cuts every 1690 // a stripe until the next relayout, and that relayout re-cuts every
2255 // stripe on the wall. 1691 // stripe on the wall.
2256 .{ .top = 0, .rows = 2 }, 1692 .{ .top = 0, .rows = 2 },
2257 shared, 1693 shared,
@@ -2300,15 +1736,9 @@ fn tileLabel(alloc: std.mem.Allocator, target: client.Target, name: []const u8)
2300 /// can end the program: a stripe that goes quiet narrates itself and the 1736 /// can end the program: a stripe that goes quiet narrates itself and the
2301 /// wall goes on, which is what the dead-tile paint is for. 1737 /// wall goes on, which is what the dead-tile paint is for.
2302 const EndAction = union(enum) { 1738 const EndAction = union(enum) {
2303 /// A stripe went quiet. Its label already says so. 1739 /// The wall has other tiles: the focus moves to the next present one.
2304 ignore, 1740 /// Its label already says what became of the ended tile.
2305 /// Drop to the wall: the session under the zoom ended, and there are 1741 refocus: usize,
2306 /// other tiles to fall back to.
2307 unzoom,
2308 /// A tile a chord created and the daemon refused. It never existed, so
2309 /// it leaves the wall without a trace and the zoom goes back to where
2310 /// the chord was typed.
2311 fall_back: usize,
2312 /// Nothing left to look at. mux ends here. 1742 /// Nothing left to look at. mux ends here.
2313 finish: struct { code: u8, msg: ?[]const u8 }, 1743 finish: struct { code: u8, msg: ?[]const u8 },
2314 }; 1744 };
@@ -2325,10 +1755,11 @@ fn endAction(
2325 const reason: EndReason = @enumFromInt(t.end.load(.acquire)); 1755 const reason: EndReason = @enumFromInt(t.end.load(.acquire));
2326 if (reason == .refused) { 1756 if (reason == .refused) {
2327 if (t.born_from) |back| { 1757 if (t.born_from) |back| {
2328 if (back < live and present[back]) return .{ .fall_back = back }; 1758 if (back < live and present[back]) return .{ .refocus = back };
2329 } 1759 }
2330 } 1760 }
2331 if (stdin_open and presentCount(present[0..live]) > 1) return .unzoom; 1761 if (stdin_open and presentCount(present[0..live]) > 1)
1762 return .{ .refocus = stepPresent(present[0..live], ended, true) orelse ended };
2332 return .{ 1763 return .{
2333 .finish = switch (reason) { 1764 .finish = switch (reason) {
2334 // The shell's own code, which is the whole point of the single-tile 1765 // The shell's own code, which is the whole point of the single-tile
@@ -2354,11 +1785,11 @@ fn endAction(
2354 }; 1785 };
2355 } 1786 }
2356 1787
2357 /// Newly dead tiles, marked as seen. Returns the ZOOMED one if it is among 1788 /// Newly dead tiles, marked as seen. Returns the FOCUSED one if it is among
2358 /// them, because that is the only end the keyboard has to act on — the rest 1789 /// them, because that is the only end the keyboard has to act on — the rest
2359 /// have already narrated themselves on their own bars. 1790 /// have already narrated themselves on their own bars.
2360 fn endedTile(tiles: []Tile, present: []const bool, shared: *Shared) ?usize { 1791 fn endedTile(tiles: []Tile, present: []const bool, shared: *Shared) ?usize {
2361 const z = shared.zoom.load(.acquire); 1792 const z = shared.sel;
2362 var hit: ?usize = null; 1793 var hit: ?usize = null;
2363 for (tiles, present, 0..) |*t, p, i| { 1794 for (tiles, present, 0..) |*t, p, i| {
2364 if (!p or t.end_seen or t.alive.load(.acquire)) continue; 1795 if (!p or t.end_seen or t.alive.load(.acquire)) continue;
@@ -2421,7 +1852,7 @@ fn hydrate(
2421 } 1852 }
2422 // Said rather than silently done: a wall that quietly shows a 1853 // Said rather than silently done: a wall that quietly shows a
2423 // PREFIX of what the user recorded is a wall lying about what it 1854 // PREFIX of what the user recorded is a wall lying about what it
2424 // is. It lands on the next zoom rather than here, which is the only 1855 // is. It lands on the next focus rather than here, which is the only
2425 // channel a wall with no status line of its own has. 1856 // channel a wall with no status line of its own has.
2426 if (live.* >= max_tiles or 1857 if (live.* >= max_tiles or
2427 !wallFits(shared.size.rows, presentCount(present[0..live.*]) + 1)) 1858 !wallFits(shared.size.rows, presentCount(present[0..live.*]) + 1))
@@ -2473,7 +1904,7 @@ pub fn runAttach(
2473 .agent = agent, 1904 .agent = agent,
2474 }}; 1905 }};
2475 return run(alloc, &resolved, .{ 1906 return run(alloc, &resolved, .{
2476 .zoom0 = true, 1907 .focus0 = true,
2477 .pre = transport, 1908 .pre = transport,
2478 .carry = carry.items, 1909 .carry = carry.items,
2479 .record0 = true, 1910 .record0 = true,
@@ -2481,7 +1912,8 @@ pub fn runAttach(
2481 .key = key, 1912 .key = key,
2482 .idle_ms = idle_ms, 1913 .idle_ms = idle_ms,
2483 // A scripted `mux` has pipes on both ends and is still a wall of 1914 // A scripted `mux` has pipes on both ends and is still a wall of
2484 // one zoomed tile. Only `mux wall` needs a terminal. 1915 // one tile whose rect is the whole terminal. Only `mux wall` needs
1916 // a terminal.
2485 .needs_tty = false, 1917 .needs_tty = false,
2486 }) catch |err| { 1918 }) catch |err| {
2487 transport.close(); 1919 transport.close();
@@ -2490,11 +1922,12 @@ pub fn runAttach(
2490 } 1922 }
2491 1923
2492 /// How a wall is ENTERED. One program, two doors: `mux wall` opens on the 1924 /// How a wall is ENTERED. One program, two doors: `mux wall` opens on the
2493 /// wall itself, `mux [TARGET]` opens zoomed into the tile it just attached 1925 /// wall itself, `mux [TARGET]` opens focused on the tile it just attached
2494 /// to. Everything after the first paint is the same machinery. 1926 /// to. Everything after the first paint is the same machinery.
2495 pub const Entry = struct { 1927 pub const Entry = struct {
2496 /// Start zoomed into tile 0 rather than on the wall. 1928 /// Start focused on tile 0 rather than on the wall. `mux [TARGET]`
2497 zoom0: bool = false, 1929 /// opens here; `mux wall` does not (it already has every tile).
1930 focus0: bool = false,
2498 /// Tile 0's link, already open. See `Tile.pre`. 1931 /// Tile 0's link, already open. See `Tile.pre`.
2499 pre: ?client.Transport = null, 1932 pre: ?client.Transport = null,
2500 /// Keystrokes typed while that first handshake was in flight, owed to 1933 /// Keystrokes typed while that first handshake was in flight, owed to
@@ -2505,27 +1938,28 @@ pub const Entry = struct {
2505 /// Whether tile 0's attach earns a wall line. See `Tile.record`. 1938 /// Whether tile 0's attach earns a wall line. See `Tile.record`.
2506 record0: bool = false, 1939 record0: bool = false,
2507 /// Whether the SAVED wall's other tiles join this one on the first 1940 /// Whether the SAVED wall's other tiles join this one on the first
2508 /// unzoom. `mux TARGET` hydrates: `Ctrl-\ w` shows THE wall, which is 1941 /// `Ctrl-\ w`. `mux TARGET` hydrates: `Ctrl-\ w` shows THE wall, which
2509 /// the file's tiles plus the one you are standing on. `mux wall` does 1942 /// is the file's tiles plus the one you are standing on. `mux wall`
2510 /// not, because it already read the file (or was handed spellings on 1943 /// does not, because it already read the file (or was handed spellings
2511 /// its command line, and a wall of what was asked for must not grow 1944 /// on its command line, and a wall of what was asked for must not grow
2512 /// tiles nobody named). 1945 /// tiles nobody named).
2513 /// 1946 ///
2514 /// Deferred to the first unzoom rather than done at startup, and that 1947 /// Deferred to the first `Ctrl-\ w` rather than done at startup, and
2515 /// is a decision rather than laziness: a bare `mux` that dialled every 1948 /// that is a decision rather than laziness: a bare `mux` that dialled
2516 /// tile in a long attach history before showing the session would have 1949 /// every tile in a long attach history before showing the session would
2517 /// converged the code and broken the feel. Nothing is dialled until 1950 /// have converged the code and broken the feel. Nothing is dialled
2518 /// somebody asks to see the wall. 1951 /// until somebody asks to see the wall.
2519 hydrate: bool = false, 1952 hydrate: bool = false,
2520 /// The key and idle timeout a hydrated `quic://` tile is resolved 1953 /// The key and idle timeout a hydrated `quic://` tile is resolved
2521 /// with — the ones this invocation was given, so a wall restored on an 1954 /// with — the ones this invocation was given, so a wall restored on a
2522 /// unzoom dials the same way the command line would have. 1955 /// `Ctrl-\ w` dials the same way the command line would have.
2523 key: ?[]const u8 = null, 1956 key: ?[]const u8 = null,
2524 idle_ms: u32 = client.quic_idle_ms_default, 1957 idle_ms: u32 = client.quic_idle_ms_default,
2525 /// Whether this wall insists on a terminal. `mux wall` does — there is 1958 /// Whether this wall insists on a terminal. `mux wall` does — there is
2526 /// nothing to cut stripes from without one, and it says so. A scripted 1959 /// nothing to cut stripes from without one, and it says so. A scripted
2527 /// `mux TARGET` on a pipe does not: it is a wall of one zoomed tile, 1960 /// `mux TARGET` on a pipe does not: it is a wall of one tile whose rect
2528 /// and it writes exactly what the plain client wrote. 1961 /// is the whole terminal, and it writes exactly what the plain client
1962 /// wrote.
2529 needs_tty: bool = true, 1963 needs_tty: bool = true,
2530 }; 1964 };
2531 1965
@@ -2555,8 +1989,8 @@ pub fn run(alloc: std.mem.Allocator, resolved: []const Resolved, entry: Entry) !
2555 std.debug.print("mux: too many tiles ({d}, max {d})\n", .{ resolved.len, max_tiles }); 1989 std.debug.print("mux: too many tiles ({d}, max {d})\n", .{ resolved.len, max_tiles });
2556 return 2; 1990 return 2;
2557 } 1991 }
2558 // Stripes are cut for the tiles that exist NOW. A wall entered zoomed 1992 // Stripes are cut for the tiles that exist NOW. A wall of one tile
2559 // has one, and the geometry is re-cut when the first unzoom hydrates 1993 // has one, and the geometry is re-cut when the first Ctrl-\ w hydrates
2560 // the rest — `relayout` already does exactly that for `x`. 1994 // the rest — `relayout` already does exactly that for `x`.
2561 const stripes = layoutStripes(alloc, resolved.len, size.rows) catch |err| switch (err) { 1995 const stripes = layoutStripes(alloc, resolved.len, size.rows) catch |err| switch (err) {
2562 error.TooSmall => { 1996 error.TooSmall => {
@@ -2589,20 +2023,18 @@ pub fn run(alloc: std.mem.Allocator, resolved: []const Resolved, entry: Entry) !
2589 raw.iflag.IXON = false; 2023 raw.iflag.IXON = false;
2590 raw.iflag.ICRNL = false; 2024 raw.iflag.ICRNL = false;
2591 try std.posix.tcsetattr(stdin_fd, .FLUSH, raw); 2025 try std.posix.tcsetattr(stdin_fd, .FLUSH, raw);
2592 // Alternate screen, cursor hidden while the WALL owns the screen, 2026 // Alternate screen, cursor hidden while no tile has painted,
2593 // autowrap off (stripe clipping is the terminal's right edge), 2027 // autowrap off (stripe clipping is the terminal's right edge),
2594 // title pushed. Written from interact's own constant, because the 2028 // title pushed. Written from interact's own constant, because the
2595 // exit has to undo what a promoted TILE can have added on top of it 2029 // exit has to undo what a focused TILE can have added on top of it
2596 // and only that module knows the whole list. 2030 // and only that module knows the whole list. The wall no longer
2597 // 2031 // claims the mouse itself — the focused tile's `claimTerminal`
2598 // A wall entered ZOOMED hides no cursor: the terminal belongs to a 2032 // does, so a click reaches the session that asked for it and a
2599 // session from the first paint, and a session's cursor is its own. 2033 // click in another tile's rect moves the focus there.
2600 proto.writeAllFd(stdout_fd, interact.wall_setup) catch {}; 2034 proto.writeAllFd(stdout_fd, interact.wall_setup) catch {};
2601 if (!entry.zoom0) proto.writeAllFd(stdout_fd, "\x1b[?25l") catch {};
2602 // Armed by the wall and not by any Core: the wall owns this 2035 // Armed by the wall and not by any Core: the wall owns this
2603 // terminal's raw mode, and a tile only ever borrows a session's 2036 // terminal's raw mode. The KEYBOARD thread reads the process-wide
2604 // claim on it. The tile that is ZOOMED answers the signal, because 2037 // winch flag and relayouts; no pump consumes it.
2605 // it is a full-screen client and has to follow the tty.
2606 interact.watchWinch(); 2038 interact.watchWinch();
2607 } 2039 }
2608 // From here to the key loop every step can fail — a tile array, a 2040 // From here to the key loop every step can fail — a tile array, a
@@ -2627,10 +2059,10 @@ pub fn run(alloc: std.mem.Allocator, resolved: []const Resolved, entry: Entry) !
2627 const kb = try std.posix.pipe2(.{ .NONBLOCK = true, .CLOEXEC = true }); 2059 const kb = try std.posix.pipe2(.{ .NONBLOCK = true, .CLOEXEC = true });
2628 shared.kb_r = kb[0]; 2060 shared.kb_r = kb[0];
2629 shared.kb_w = kb[1]; 2061 shared.kb_w = kb[1];
2630 // Entered zoomed means tile 0 owns the terminal from the first byte: 2062 // Tile 0 is focused from the first byte: its `claim_pending` is set
2631 // set BEFORE its pump starts, so the attach frame it sends already 2063 // BEFORE its pump starts, so the claim goes out on the first pass and
2632 // claims the grid and no resize follows it. 2064 // the attach frame already carries the terminal's size. `shared.sel`
2633 if (entry.zoom0) shared.zoom.store(0, .release); 2065 // defaults to 0, which is the focused tile.
2634 2066
2635 // Allocated at CAPACITY, not at length: the pump threads are detached 2067 // Allocated at CAPACITY, not at length: the pump threads are detached
2636 // and hold `*Tile` for the wall's whole life, so the array may never 2068 // and hold `*Tile` for the wall's whole life, so the array may never
@@ -2644,7 +2076,15 @@ pub fn run(alloc: std.mem.Allocator, resolved: []const Resolved, entry: Entry) !
2644 present[live] = true; 2076 present[live] = true;
2645 live += 1; 2077 live += 1;
2646 } 2078 }
2647 if (entry.zoom0 and live > 0) { 2079 // A one-tile wall owns every row and draws no label bar; two or more
2080 // tiles each lose their top row to one. Set before the pumps start so
2081 // `viewRows` is right on the first attach.
2082 shared.label_rows = if (live > 1) 1 else 0;
2083 // Tile 0 is the focused tile: its pump claims the terminal on its
2084 // first pass. Set before `spawnPump` so the claim is the first thing
2085 // the pump does after the dial.
2086 if (live > 0) tiles[0].claim_pending.store(true, .release);
2087 if (entry.focus0 and live > 0) {
2648 tiles[0].pre = entry.pre; 2088 tiles[0].pre = entry.pre;
2649 tiles[0].record = entry.record0; 2089 tiles[0].record = entry.record0;
2650 // The entry tile keeps the plain client's cold-loss rule; see 2090 // The entry tile keeps the plain client's cold-loss rule; see
@@ -2659,18 +2099,20 @@ pub fn run(alloc: std.mem.Allocator, resolved: []const Resolved, entry: Entry) !
2659 } 2099 }
2660 for (tiles[0..live]) |*t| spawnPump(t); 2100 for (tiles[0..live]) |*t| spawnPump(t);
2661 2101
2662 // The wall's input surface has two halves, and which one a byte lands 2102 // The wall is always "in": every non-chord byte goes to the focused
2663 // in is the whole model: zoomed OUT the wall reads the keys (nothing 2103 // tile, whose own Core splits mouse reports and owns the drag. The
2664 // reaches a session — that is what makes an unzoomed tile claim 2104 // keyboard holds back only the `Ctrl-\` prefix, and a local MouseFilter
2665 // nothing); zoomed IN the terminal belongs to the session and only the 2105 // that parses the terminal's SGR reports to hit-test a press for
2666 // `Ctrl-\` chord layer is held back. 2106 // focus — the report bytes themselves go through to the focused tile
2107 // unchanged, so the Core that owns the drag sees them.
2667 var input: WallInput = .{}; 2108 var input: WallInput = .{};
2668 // `feed` hands a candidate held across the previous read back ahead of 2109 // `feed` hands a candidate held across the previous read back ahead of
2669 // this chunk, so its room is a whole chunk plus that hold. 2110 // this chunk, so its room is a whole chunk plus that hold.
2670 var mouse_out: [mailbox_max + interact.MouseFilter.max_held]u8 = undefined; 2111 var mouse_out: [mailbox_max + interact.MouseFilter.max_held]u8 = undefined;
2112 var mouse_filter: interact.MouseFilter = .{};
2671 // Where `Ctrl-\ l` goes back to. Keyboard-thread state: no pump reads 2113 // Where `Ctrl-\ l` goes back to. Keyboard-thread state: no pump reads
2672 // it, and no lock guards it, because nothing else writes it. 2114 // it, and no lock guards it, because nothing else writes it.
2673 var last_zoom: ?usize = null; 2115 var last_focus: ?usize = null;
2674 // The first wall-file error `x` hit, said after the terminal is 2116 // The first wall-file error `x` hit, said after the terminal is
2675 // restored: a line printed onto the alternate screen would corrupt the 2117 // restored: a line printed onto the alternate screen would corrupt the
2676 // paint it lands in. 2118 // paint it lands in.
@@ -2680,13 +2122,13 @@ pub fn run(alloc: std.mem.Allocator, resolved: []const Resolved, entry: Entry) !
2680 var exit_code: u8 = 0; 2122 var exit_code: u8 = 0;
2681 var exit_msg: ?[]const u8 = null; 2123 var exit_msg: ?[]const u8 = null;
2682 // Whether the saved wall has already been folded in. One-shot: the 2124 // Whether the saved wall has already been folded in. One-shot: the
2683 // first unzoom shows THE wall, and every unzoom after it shows the same 2125 // first `Ctrl-\ w` shows THE wall, and every one after it shows the
2684 // one rather than re-reading a file other clients are also writing. 2126 // same one rather than re-reading a file other clients are also
2127 // writing.
2685 var hydrated = !entry.hydrate; 2128 var hydrated = !entry.hydrate;
2686 // Stdin gone is not the wall gone. A piped `mux` whose script has run 2129 // Stdin gone is not the wall gone. A piped `mux` whose script has run
2687 // out still has a session on the far end, exactly as the plain client 2130 // out still has a session on the far end, exactly as the plain client
2688 // did — it ends when that session does. Zoomed OUT there is nobody left 2131 // did — it ends when that session does.
2689 // to press a key, so the wall closes.
2690 var stdin_open = true; 2132 var stdin_open = true;
2691 var b: [mailbox_max]u8 = undefined; 2133 var b: [mailbox_max]u8 = undefined;
2692 keys: while (true) { 2134 keys: while (true) {
@@ -2694,23 +2136,40 @@ pub fn run(alloc: std.mem.Allocator, resolved: []const Resolved, entry: Entry) !
2694 .{ .fd = if (stdin_open) stdin_fd else -1, .events = std.posix.POLL.IN, .revents = 0 }, 2136 .{ .fd = if (stdin_open) stdin_fd else -1, .events = std.posix.POLL.IN, .revents = 0 },
2695 .{ .fd = shared.kb_r, .events = std.posix.POLL.IN, .revents = 0 }, 2137 .{ .fd = shared.kb_r, .events = std.posix.POLL.IN, .revents = 0 },
2696 }; 2138 };
2697 _ = std.posix.poll(&fds, -1) catch break; 2139 // A 100ms cap so the keyboard can check the process-wide winch
2140 // flag without a pump having to — `winch` is one flag and the tile
2141 // that would have answered it is no longer special.
2142 const winch = interact.winchRaised();
2143 // A finite timeout, not -1: the flag is a flag, and poll retries
2144 // on EINTR — a SIGWINCH landing mid-poll would otherwise wait for
2145 // the next keystroke to be noticed. 100ms is the pumps' cadence.
2146 _ = std.posix.poll(&fds, if (winch) 0 else 100) catch break;
2147 if (winch) {
2148 const measured2 = interact.ttySize(stdout_fd) orelse continue;
2149 if (measured2.cols != shared.size.cols or measured2.rows != shared.size.rows) {
2150 shared.paint_mu.lock();
2151 shared.size = measured2;
2152 shared.paint_mu.unlock();
2153 relayout(alloc, tiles[0..live], present[0..live], &shared, shared.sel);
2154 }
2155 }
2698 2156
2699 if (fds[1].revents != 0) { 2157 if (fds[1].revents != 0) {
2700 drainBell(shared.kb_r); 2158 drainBell(shared.kb_r);
2701 // Answers first, ends second: a chord that has just been 2159 // Answers first, ends second: a chord that has just been
2702 // answered may zoom onto a tile whose end we are about to read, 2160 // answered may move the focus onto a tile whose end we are
2703 // and acting on the end of a tile nobody is looking at any more 2161 // about to read, and acting on the end of a tile nobody is
2704 // is the wrong order to notice things in. 2162 // looking at any more is the wrong order to notice things in.
2705 const z = shared.zoom.load(.acquire); 2163 const z = shared.sel;
2706 if (z != no_zoom and tiles[z].ans_ready.swap(false, .acq_rel)) { 2164 if (z < live and tiles[z].ans_ready.swap(false, .acq_rel)) {
2707 var name: client.SessionName = undefined; 2165 var name: client.SessionName = undefined;
2708 { 2166 {
2709 tiles[z].ans_mu.lock(); 2167 tiles[z].ans_mu.lock();
2710 defer tiles[z].ans_mu.unlock(); 2168 defer tiles[z].ans_mu.unlock();
2711 name = tiles[z].ans; 2169 name = tiles[z].ans;
2712 } 2170 }
2713 switch (zoomToSession( 2171 const before = live;
2172 switch (addSessionTile(
2714 alloc, 2173 alloc,
2715 tiles, 2174 tiles,
2716 present, 2175 present,
@@ -2720,8 +2179,8 @@ pub fn run(alloc: std.mem.Allocator, resolved: []const Resolved, entry: Entry) !
2720 name.slice(), 2179 name.slice(),
2721 )) { 2180 )) {
2722 .moved => |to| { 2181 .moved => |to| {
2723 last_zoom = z; 2182 last_focus = z;
2724 setZoom(tiles[0..live], &shared, &input, to); 2183 focusAnswer(alloc, tiles[0..live], present[0..live], &shared, live > before, to);
2725 }, 2184 },
2726 .full => setNotice(&shared, "[no room on the wall for another tile]"), 2185 .full => setNotice(&shared, "[no room on the wall for another tile]"),
2727 .stay => {}, 2186 .stay => {},
@@ -2729,25 +2188,9 @@ pub fn run(alloc: std.mem.Allocator, resolved: []const Resolved, entry: Entry) !
2729 } 2188 }
2730 if (endedTile(tiles[0..live], present[0..live], &shared)) |ended| { 2189 if (endedTile(tiles[0..live], present[0..live], &shared)) |ended| {
2731 switch (endAction(tiles, present, live, ended, stdin_open)) { 2190 switch (endAction(tiles, present, live, ended, stdin_open)) {
2732 .ignore => {}, 2191 .refocus => |to| {
2733 .unzoom => { 2192 last_focus = ended;
2734 last_zoom = ended; 2193 setFocus(tiles[0..live], &shared, to);
2735 setZoom(tiles[0..live], &shared, &input, no_zoom);
2736 },
2737 .fall_back => |back| {
2738 // A `Ctrl-\ c` the daemon refused: the tile it was
2739 // reaching for never existed, so it leaves the wall
2740 // with no trace (it recorded nothing — a refusal is
2741 // precisely "no state since attach") and the zoom
2742 // goes back where the chord was typed. Said on
2743 // stderr AND in the corner: the sentence is the
2744 // record a script reads, the banner is what the
2745 // user actually sees.
2746 present[ended] = false;
2747 tiles[ended].gone.store(true, .release);
2748 std.debug.print("mux: cannot create a new session (daemon full?)\n", .{});
2749 setNotice(&shared, "[cannot create a new session (daemon full?)]");
2750 setZoom(tiles[0..live], &shared, &input, back);
2751 }, 2194 },
2752 .finish => |how| { 2195 .finish => |how| {
2753 exit_code = how.code; 2196 exit_code = how.code;
@@ -2762,148 +2205,133 @@ pub fn run(alloc: std.mem.Allocator, resolved: []const Resolved, entry: Entry) !
2762 const n = std.posix.read(stdin_fd, &b) catch break; 2205 const n = std.posix.read(stdin_fd, &b) catch break;
2763 if (n == 0) { 2206 if (n == 0) {
2764 stdin_open = false; 2207 stdin_open = false;
2765 // Zoomed, the session outlives the script that was typing at 2208 // The session outlives the script that was typing at it;
2766 // it; `endAction` is what ends the run when that session does. 2209 // `endAction` is what ends the run when that session does.
2767 if (shared.zoom.load(.acquire) == no_zoom) break;
2768 continue; 2210 continue;
2769 } 2211 }
2770 2212
2771 const z = shared.zoom.load(.acquire); 2213 const z = shared.sel;
2772 if (z != no_zoom) { 2214 const cmd = input.prefix.feed(b[0..n]);
2773 const cmd = input.prefix.feed(b[0..n]); 2215 // A BARE Ctrl-\ while there is no session to command is the
2774 // A BARE Ctrl-\ while there is no session to command is the 2216 // user giving up on the wait. Judged after the filter rather
2775 // user giving up on the wait. Judged after the filter rather 2217 // than by scanning the raw bytes, because the two readings of
2776 // than by scanning the raw bytes, because the two readings of 2218 // the same byte have to stay apart: a `\x1c` still holding out
2777 // the same byte have to stay apart: a `\x1c` still holding out 2219 // for its command key is the abort, while `\x1c w` is somebody
2778 // for its command key is the abort, while `\x1c w` is somebody 2220 // asking for the wall.
2779 // asking for the wall — and on a wall there is one to give 2221 if (cmd.action == .none and input.prefix.pending) {
2780 // them, which is what the plain client this rule came from 2222 if (awaitingSession(&tiles[z])) |waiting| {
2781 // never had. 2223 exit_code = 0;
2782 if (cmd.action == .none and input.prefix.pending) { 2224 exit_msg = switch (waiting) {
2783 if (awaitingSession(&tiles[z])) |waiting| { 2225 // Nothing has been detached from a session that
2784 exit_code = 0; 2226 // never came up.
2785 exit_msg = switch (waiting) { 2227 .connecting => "mux: aborted before attaching",
2786 // Nothing has been detached from a session that 2228 else => "mux: detached while reconnecting (session still running; run mux to reattach)",
2787 // never came up. 2229 };
2788 .connecting => "mux: aborted before attaching", 2230 break :keys;
2789 else => "mux: detached while reconnecting (session still running; run mux to reattach)",
2790 };
2791 break :keys;
2792 }
2793 } 2231 }
2794 // What preceded the chord was typed AT the zoomed session, and 2232 }
2795 // it is queued BEFORE the zoom moves — so `Ctrl-\ n` cannot 2233 // What preceded the chord was typed AT the focused session, and
2796 // deliver the tail of a word to the tile it is jumping to. 2234 // it is queued BEFORE the focus moves — so `Ctrl-\ n` cannot
2797 if (cmd.forward.len > 0) sendKeys(&tiles[z], cmd.forward); 2235 // deliver the tail of a word to the tile it is jumping to.
2798 switch (zoomChord(cmd.action, z, present[0..live], last_zoom)) { 2236 if (cmd.forward.len > 0) {
2799 .stay => {}, 2237 // A press in another tile's rect moves the focus there.
2800 .leave => { 2238 // The MouseFilter parses the terminal's SGR reports to find
2801 // The slot goes back to the daemon before this process 2239 // the press; the report bytes themselves go through to the
2802 // does — the pump writes the frame, because a Transport 2240 // focused tile unchanged, so the Core that owns the drag
2803 // has one owning thread, and this waits briefly for the 2241 // sees them. A click that lands in the focused tile is not
2804 // ack so a `mux` typed straight afterwards does not find 2242 // intercepted at all — it reaches the session that asked for
2805 // the session full of its own corpse. 2243 // the mouse, exactly as a focused tile's did.
2806 tiles[z].detach_req.store(true, .release); 2244 const report = mouse_filter.feed(cmd.forward, &mouse_out);
2807 ring(&tiles[z]); 2245 for (report.events) |ev| {
2808 awaitDetach(&tiles[z], &shared); 2246 if (ev.kind != .press) continue;
2809 exit_code = 0; 2247 if (rectHit(tiles[0..live], present[0..live], &shared, ev.row)) |hit| {
2810 exit_msg = "mux: detached (session still running; run mux to reattach)"; 2248 if (hit != z) {
2811 break :keys; 2249 last_focus = z;
2812 }, 2250 setFocus(tiles[0..live], &shared, hit);
2813 .out => {
2814 if (!hydrated) {
2815 hydrated = true;
2816 _ = hydrate(alloc, tiles, present, &live, &shared, entry.key, entry.idle_ms);
2817 } 2251 }
2818 last_zoom = z; 2252 }
2819 setZoom(tiles[0..live], &shared, &input, no_zoom);
2820 // The stripes were cut for the tiles that existed when
2821 // the wall started; an unzoom re-cuts them over the
2822 // tiles that are there NOW — the hydration's, and every
2823 // one a zoom chord added meanwhile.
2824 relayout(alloc, tiles[0..live], present[0..live], &shared, shared.sel);
2825 },
2826 .ask => |intent| {
2827 tiles[z].ask.store(@intFromEnum(intent), .release);
2828 ring(&tiles[z]);
2829 },
2830 .to => |next| {
2831 last_zoom = z;
2832 setZoom(tiles[0..live], &shared, &input, next);
2833 },
2834 } 2253 }
2835 continue; 2254 sendKeys(&tiles[shared.sel], cmd.forward);
2836 } 2255 }
2837 2256 switch (cmd.action) {
2838 // Unzoomed, this terminal reports the mouse on the WALL's own claim, 2257 .none => {},
2839 // so a read here can carry SGR reports as well as keys. The filter 2258 .detach => {
2840 // takes the reports OUT of the byte stream, because a report is 2259 // The slot goes back to the daemon before this process
2841 // made of characters the wall acts on: its parameters are decimal 2260 // does — the pump writes the frame, because a Transport
2842 // digits, and a digit is a jump key in `selectKey`. One wheel spin 2261 // has one owning thread, and this waits briefly for the
2843 // over a stripe would otherwise walk the selection at random. 2262 // ack so a `mux` typed straight afterwards does not find
2844 const report = input.mouse.feed(b[0..n], &mouse_out); 2263 // the session full of its own corpse.
2845 // The wheel is dropped, by decision and not by omission: there is 2264 tiles[z].detach_req.store(true, .release);
2846 // no wall-level scrollback for a notch to move, and a user who sees 2265 ring(&tiles[z]);
2847 // the wall answer the mouse at all will spin it over a stripe. 2266 awaitDetach(&tiles[z], &shared);
2848 // Until something is listening, nothing happening is the honest 2267 exit_code = 0;
2849 // answer. 2268 exit_msg = "mux: detached (session still running; run mux to reattach)";
2850 _ = report.wheel; 2269 break :keys;
2851 2270 },
2852 // What the filter took out goes back in HERE, interleaved with the 2271 .wall => {
2853 // keys in the order the terminal wrote them (`WallDrain`). Draining 2272 // `Ctrl-\ w` shows the wall: hydrate the saved tiles
2854 // every event first would let a click that arrived after a Return 2273 // (one-shot) and re-cut the stripes over everything that
2855 // zoom the tile it had just selected rather than the one the user 2274 // is present NOW. On a `mux wall` this is a no-op
2856 // chose. 2275 // (already hydrated); on a `mux TARGET` it grows the one
2857 var drain = WallDrain.init(report); 2276 // tile into the full wall.
2858 while (drain.next()) |step| { 2277 if (!hydrated) {
2859 const key = switch (step) { 2278 hydrated = true;
2860 .mouse => |ev| { 2279 _ = hydrate(alloc, tiles, present, &live, &shared, entry.key, entry.idle_ms);
2861 wallMouse(tiles[0..live], present[0..live], &shared, ev); 2280 }
2862 continue; 2281 relayout(alloc, tiles[0..live], present[0..live], &shared, shared.sel);
2863 }, 2282 },
2864 .key => |k| k, 2283 .new_session => {
2865 }; 2284 // Ask the focused tile's daemon for a new session. The
2866 if (key == 'q' or key == 0x1c) break :keys; 2285 // answer arrives on the pump's transport and is posted
2867 // Both spellings of Enter: ICRNL is off, so a Return arrives 2286 // back to the keyboard (`postAnswer`), which moves the
2868 // as CR, but a script or a paste can just as easily send LF. 2287 // focus or grows the wall.
2869 // `sel` is read here without the mutex on purpose: this loop 2288 tiles[z].ask.store(@intFromEnum(client.SwitchIntent.new), .release);
2870 // is its only writer (moveSelection and setZoom take the lock 2289 ring(&tiles[z]);
2871 // to write it, so the pumps never read it half-written), and a 2290 },
2872 // value only this thread can change is not one it can read 2291 .next_session => {
2873 // stale. 2292 tiles[z].ask.store(@intFromEnum(client.SwitchIntent.next), .release);
2874 if (key == '\r' or key == '\n') { 2293 ring(&tiles[z]);
2875 // An empty wall has nothing to hand the terminal to, and 2294 },
2876 // `sel` still names the tile `x` just took away. 2295 .prev_session => {
2877 if (shared.sel >= live or !present[shared.sel]) continue :keys; 2296 tiles[z].ask.store(@intFromEnum(client.SwitchIntent.prev), .release);
2878 // The rest of this read was typed at the WALL, before the 2297 ring(&tiles[z]);
2879 // terminal changed hands — it is not the session's input, 2298 },
2880 // and neither are the reports still to be drained from it. 2299 .last_session => {
2881 // `setZoom` clears what the filters are holding. 2300 // Back to where the focus was before the last move.
2882 setZoom(tiles[0..live], &shared, &input, shared.sel); 2301 if (last_focus) |back| {
2883 continue :keys; 2302 if (back < live and present[back] and back != z) {
2884 } 2303 const prev = z;
2885 // `x` forgets the selected tile: off the wall file, off this 2304 last_focus = prev;
2886 // screen, and NEVER off the daemon. 2305 setFocus(tiles[0..live], &shared, back);
2887 if (key == 'x') { 2306 }
2888 if (shared.sel < live and present[shared.sel]) 2307 }
2889 forgetTile(alloc, tiles[0..live], present[0..live], &shared, shared.sel, &forget_err); 2308 },
2890 continue; 2309 .focus => |idx| {
2891 } 2310 // `Ctrl-\ 1-9` focuses tile N (one-based).
2892 if (selectKey(present[0..live], shared.sel, key)) |next| 2311 if (idx > 0 and idx <= live and present[idx - 1] and idx - 1 != z) {
2893 moveSelection(tiles[0..live], &shared, next); 2312 last_focus = z;
2313 setFocus(tiles[0..live], &shared, idx - 1);
2314 }
2315 },
2316 .forget => {
2317 // `Ctrl-\ x` forgets the focused tile: off the wall
2318 // file, off this screen, and NEVER off the daemon.
2319 if (z < live and present[z])
2320 forgetTile(alloc, tiles[0..live], present[0..live], &shared, z, &forget_err);
2321 },
2894 } 2322 }
2895 } 2323 }
2896 2324
2897 shared.running.store(false, .release); 2325 shared.running.store(false, .release);
2898 // Taken and never released: no stripe paints across the restore. The 2326 // Taken and never released: no tile paints across the restore. The
2899 // pump threads are detached and die with the process; joining them 2327 // pump threads are detached and die with the process; joining them
2900 // could wait on a blocked readFrame forever. 2328 // could wait on a blocked readFrame forever.
2901 shared.paint_mu.lock(); 2329 shared.paint_mu.lock();
2902 if (orig) |o| { 2330 if (orig) |o| {
2903 // Every mode this terminal can be carrying, off — the wall's own 2331 // Every mode this terminal can be carrying, off — the wall's own
2904 // and any a PROMOTED tile's session set through it. A wall left 2332 // and any focused tile's session set through it. A wall left
2905 // through a zoom (a detach chord, a session that ended) must not 2333 // through a detach chord or a session that ended must not leave a
2906 // leave a terminal reporting clicks into the user's shell. 2334 // terminal reporting clicks into the user's shell.
2907 proto.writeAllFd(stdout_fd, interact.wall_teardown) catch {}; 2335 proto.writeAllFd(stdout_fd, interact.wall_teardown) catch {};
2908 std.posix.tcsetattr(stdin_fd, .FLUSH, o) catch {}; 2336 std.posix.tcsetattr(stdin_fd, .FLUSH, o) catch {};
2909 } 2337 }
@@ -2916,7 +2344,7 @@ pub fn run(alloc: std.mem.Allocator, resolved: []const Resolved, entry: Entry) !
2916 // sentence — exactly where and when `Core.deinit` used to put them for 2344 // sentence — exactly where and when `Core.deinit` used to put them for
2917 // a plain client. Only for an ATTACH: `mux wall` never printed a stats 2345 // a plain client. Only for an ATTACH: `mux wall` never printed a stats
2918 // line and gains no reason to start. 2346 // line and gains no reason to start.
2919 if (entry.zoom0) interact.dumpPredictStats(shared.stats); 2347 if (entry.focus0) interact.dumpPredictStats(shared.stats);
2920 if (exit_msg) |m| std.debug.print("{s}\n", .{m}); 2348 if (exit_msg) |m| std.debug.print("{s}\n", .{m});
2921 // exit(), not return: returning would run the caller's frees and leak 2349 // exit(), not return: returning would run the caller's frees and leak
2922 // checks while detached pump threads still hold pointers into `tiles` 2350 // checks while detached pump threads still hold pointers into `tiles`
@@ -2981,154 +2409,6 @@ fn allPresent(comptime n: usize) [n]bool {
2981 return [_]bool{true} ** n; 2409 return [_]bool{true} ** n;
2982 } 2410 }
2983 2411
2984 test "a mouse report at the unzoomed wall is filtered out, never typed as a wall key" {
2985 // The hazard first, so the filter is measured against something real
2986 // rather than asserted into existence. An SGR report's parameters are
2987 // decimal digits and its final byte is a letter; on the wall a digit is
2988 // a jump key and `x` forgets a tile. Fed raw, this one report — button
2989 // 0 pressed at column 3, row 1 — moves the selection twice.
2990 const report = "\x1b[<0;3;1M";
2991 const present = [_]bool{ true, true, true };
2992 var jumped: usize = 0;
2993 for (report) |k| {
2994 if (selectKey(&present, 0, k)) |_| jumped += 1;
2995 }
2996 try std.testing.expect(jumped > 0);
2997
2998 // ...and through the filter the wall now runs its unzoomed reads
2999 // through, the same bytes reach the byte loop as nothing at all.
3000 var mouse: interact.MouseFilter = .{};
3001 var out: [64]u8 = undefined;
3002 const got = mouse.feed(report, &out);
3003 try std.testing.expectEqual(@as(usize, 0), got.forward.len);
3004 // Seen, not merely swallowed. A filter that dropped the bytes and
3005 // reported nothing would pass the line above and leave the slice that
3006 // turns a click into a stripe selection with nothing to build on.
3007 try std.testing.expectEqual(@as(usize, 1), got.events.len);
3008 try std.testing.expectEqual(@as(u16, 2), got.events[0].col);
3009 try std.testing.expectEqual(@as(u16, 0), got.events[0].row);
3010
3011 // Ordinary keys are untouched by the same call — the filter has to be
3012 // safe to put in front of EVERY unzoomed read, not just the ones
3013 // carrying a report.
3014 const mixed = mouse.feed("1\x1b[<0;3;1Mj", &out);
3015 try std.testing.expectEqualStrings("1j", mixed.forward);
3016 }
3017
3018 test "selectKey: j/k and n/p wrap at both ends" {
3019 const p3 = allPresent(3);
3020 try std.testing.expectEqual(@as(?usize, 1), selectKey(&p3, 0, 'j'));
3021 try std.testing.expectEqual(@as(?usize, 0), selectKey(&p3, 2, 'j')); // wraps forward
3022 try std.testing.expectEqual(@as(?usize, 2), selectKey(&p3, 0, 'k')); // wraps back
3023 try std.testing.expectEqual(@as(?usize, 1), selectKey(&p3, 2, 'k'));
3024 // The ring's spelling moves the same way as the vi hands.
3025 try std.testing.expectEqual(selectKey(&p3, 2, 'j'), selectKey(&p3, 2, 'n'));
3026 try std.testing.expectEqual(selectKey(&p3, 0, 'k'), selectKey(&p3, 0, 'p'));
3027 // A one-tile wall has nowhere to go, and says so by not moving.
3028 const p1 = allPresent(1);
3029 try std.testing.expectEqual(@as(?usize, 0), selectKey(&p1, 0, 'j'));
3030 }
3031
3032 test "selectKey: digits jump 1-based, and a digit past the wall is ignored" {
3033 const p3 = allPresent(3);
3034 try std.testing.expectEqual(@as(?usize, 0), selectKey(&p3, 2, '1'));
3035 try std.testing.expectEqual(@as(?usize, 2), selectKey(&p3, 0, '3'));
3036 // Out of range moves nothing rather than clamping to the last tile.
3037 try std.testing.expectEqual(@as(?usize, null), selectKey(&p3, 0, '4'));
3038 try std.testing.expectEqual(@as(?usize, null), selectKey(&p3, 0, '9'));
3039 // '0' is not a tile number, and neither is a key that means nothing.
3040 try std.testing.expectEqual(@as(?usize, null), selectKey(&p3, 1, '0'));
3041 try std.testing.expectEqual(@as(?usize, null), selectKey(&p3, 1, 'q'));
3042 const p0 = allPresent(0);
3043 try std.testing.expectEqual(@as(?usize, null), selectKey(&p0, 0, 'j'));
3044 }
3045
3046 test "selectKey: a forgotten tile is stepped over and never counted" {
3047 // Middle tile forgotten by `x`. Tiles are never compacted — the pumps
3048 // hold pointers into the array — so index 1 stays a hole forever.
3049 const p = [_]bool{ true, false, true };
3050 try std.testing.expectEqual(@as(?usize, 2), selectKey(&p, 0, 'j'));
3051 try std.testing.expectEqual(@as(?usize, 0), selectKey(&p, 2, 'j'));
3052 try std.testing.expectEqual(@as(?usize, 2), selectKey(&p, 0, 'k'));
3053 // The digits renumber with the BARS: `2` is the second one showing,
3054 // which is tile 2, and `3` is now past the end of the wall.
3055 try std.testing.expectEqual(@as(?usize, 0), selectKey(&p, 2, '1'));
3056 try std.testing.expectEqual(@as(?usize, 2), selectKey(&p, 0, '2'));
3057 try std.testing.expectEqual(@as(?usize, null), selectKey(&p, 0, '3'));
3058
3059 // Standing ON the hole (the moment after `x`, before the selection has
3060 // moved) still steps to a real tile.
3061 try std.testing.expectEqual(@as(?usize, 2), selectKey(&p, 1, 'j'));
3062 try std.testing.expectEqual(@as(?usize, 0), selectKey(&p, 1, 'k'));
3063
3064 // The last tile forgotten: nothing is a selection any more, and every
3065 // motion says so rather than picking a tile that is gone.
3066 const none = [_]bool{ false, false };
3067 try std.testing.expectEqual(@as(?usize, null), selectKey(&none, 0, 'j'));
3068 try std.testing.expectEqual(@as(?usize, null), selectKey(&none, 0, 'k'));
3069 try std.testing.expectEqual(@as(?usize, null), selectKey(&none, 0, '1'));
3070
3071 // One survivor is its own answer, exactly as a one-tile wall is.
3072 const one = [_]bool{ false, true, false };
3073 try std.testing.expectEqual(@as(?usize, 1), selectKey(&one, 1, 'j'));
3074 try std.testing.expectEqual(@as(?usize, 1), selectKey(&one, 0, 'k'));
3075 }
3076
3077 test "zoomChord: w unzooms, d leaves mux, n/p/c ask the daemon" {
3078 const p3 = allPresent(3);
3079 // The two keys that used to mean the same thing and now do not. `w` is
3080 // the only way out of a zoom that stays inside mux; `d` is detach, the
3081 // meaning every user's fingers already have for it.
3082 try std.testing.expectEqual(ZoomMove.out, zoomChord(.wall, 1, &p3, null));
3083 try std.testing.expectEqual(ZoomMove.leave, zoomChord(.detach, 1, &p3, null));
3084 // `n`/`p`/`c` are the DAEMON's session ring, not tile motion: which
3085 // sessions exist is the daemon's to say, so all three become one
3086 // question and the answer decides where the zoom lands.
3087 try std.testing.expectEqual(
3088 ZoomMove{ .ask = .next },
3089 zoomChord(.next_session, 1, &p3, null),
3090 );
3091 try std.testing.expectEqual(
3092 ZoomMove{ .ask = .prev },
3093 zoomChord(.prev_session, 2, &p3, null),
3094 );
3095 try std.testing.expectEqual(
3096 ZoomMove{ .ask = .new },
3097 zoomChord(.new_session, 1, &p3, null),
3098 );
3099 // The question is about sessions, so a one-tile wall asks it too —
3100 // there may be siblings with no tile yet, and visiting one is what
3101 // grows the wall.
3102 const p1 = allPresent(1);
3103 try std.testing.expectEqual(
3104 ZoomMove{ .ask = .next },
3105 zoomChord(.next_session, 0, &p1, null),
3106 );
3107 try std.testing.expectEqual(ZoomMove.stay, zoomChord(.none, 1, &p3, null));
3108 }
3109
3110 test "zoomChord: `l` goes back, and unzooms when there is nowhere to go" {
3111 const p3 = allPresent(3);
3112 const p2 = allPresent(2);
3113 const p1 = allPresent(1);
3114 try std.testing.expectEqual(ZoomMove{ .to = 0 }, zoomChord(.last_session, 2, &p3, 0));
3115 // The spec's chosen fallback: nowhere remembered means the wall, not a
3116 // guess. An index past the wall's end takes the same route.
3117 try std.testing.expectEqual(ZoomMove.out, zoomChord(.last_session, 2, &p3, null));
3118 try std.testing.expectEqual(ZoomMove.out, zoomChord(.last_session, 1, &p2, 7));
3119 // ...and so does a tile that has since been FORGOTTEN by `x`, which is
3120 // the case the spec named and the reason `present` is consulted at all
3121 // rather than just the length.
3122 const forgotten = [_]bool{ false, true, true };
3123 try std.testing.expectEqual(ZoomMove.out, zoomChord(.last_session, 2, &forgotten, 0));
3124 // "Go where I was" pointed at where you ARE is not "stay here": tmux's
3125 // prefix-l answers it the same way, and re-zooming in place would
3126 // clear the screen and repaint it to no visible effect.
3127 try std.testing.expectEqual(ZoomMove.out, zoomChord(.last_session, 1, &p3, 1));
3128 // A one-tile wall is that case always, so `l` there is simply "out".
3129 try std.testing.expectEqual(ZoomMove.out, zoomChord(.last_session, 0, &p1, 0));
3130 }
3131
3132 test "showsSelf: hydration drops the tile the walling shell is standing in" { 2412 test "showsSelf: hydration drops the tile the walling shell is standing in" {
3133 const sock = "/run/user/1000/muxd.sock"; 2413 const sock = "/run/user/1000/muxd.sock";
3134 const target: client.Target = .{ .sock = sock }; 2414 const target: client.Target = .{ .sock = sock };
@@ -3184,6 +2464,61 @@ fn endBench(
3184 tiles[ended].born_from = born_from; 2464 tiles[ended].born_from = born_from;
3185 } 2465 }
3186 2466
2467 test "sendKeys: a chunk that does not fit is dropped whole, and says so" {
2468 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false };
2469 var t = Tile{
2470 .r = .{ .target = .{ .sock = "/tmp/x" }, .label = "x", .session = "" },
2471 .stripe = .{ .top = 0, .rows = 4 },
2472 .shared = &shared,
2473 .idx = 0,
2474 // -1 both ends: `ring` writes to the doorbell and ignores the
2475 // failure, which is exactly what it promises to do.
2476 .wake_r = -1,
2477 .wake_w = -1,
2478 };
2479
2480 const head = "abc";
2481 sendKeys(&t, head);
2482 try std.testing.expectEqual(@as(usize, 3), t.in_len);
2483 try std.testing.expect(!t.in_dropped.load(.acquire));
2484
2485 // One byte more than the room left. The old code copied what fit.
2486 const too_big = [_]u8{'z'} ** (mailbox_max - 2);
2487 sendKeys(&t, &too_big);
2488 try std.testing.expectEqual(@as(usize, 3), t.in_len);
2489 try std.testing.expect(t.in_dropped.load(.acquire));
2490
2491 // ...and the mailbox still holds exactly what was typed before it, so
2492 // the next chunk cannot splice onto a half-delivered one.
2493 sendKeys(&t, "def");
2494 var out: [mailbox_max]u8 = undefined;
2495 try std.testing.expectEqualStrings("abcdef", takeKeys(&t, &out));
2496
2497 // Exactly filling it is not overflow — the bound is `>`, not `>=`.
2498 const exact = [_]u8{'q'} ** mailbox_max;
2499 t.in_dropped.store(false, .release);
2500 sendKeys(&t, &exact);
2501 try std.testing.expectEqual(@as(usize, mailbox_max), t.in_len);
2502 try std.testing.expect(!t.in_dropped.load(.acquire));
2503 }
2504
2505 test "labelText: a dropped chunk is narrated beside the state that caused it" {
2506 // The bar's own format, asserted through the same truncation path the
2507 // states go through, because "reconnecting, input dropped" is now the
2508 // longest thing a bar can say and `buf`'s bound is what it tests.
2509 var buf: [256]u8 = undefined;
2510 const long = "x" ** 400;
2511 const said = labelText(&buf, 300, "> ", long, "reconnecting, input dropped");
2512 try std.testing.expect(std.mem.endsWith(u8, said, " [reconnecting, input dropped]"));
2513 try std.testing.expect(said.len <= buf.len);
2514 }
2515
2516 test "resolveSpelling refuses a sun_path-overflowing sock path" {
2517 const alloc = std.testing.allocator;
2518 const long = "--sock /" ++ "x" ** 200;
2519 try std.testing.expectError(error.SockPathTooLong, resolveSpelling(alloc, long, null, 30_000));
2520 }
2521
3187 test "endAction: the only tile's ending is mux's, and so is any ending nobody can leave" { 2522 test "endAction: the only tile's ending is mux's, and so is any ending nobody can leave" {
3188 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false }; 2523 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false };
3189 var tiles: [3]Tile = undefined; 2524 var tiles: [3]Tile = undefined;
@@ -3199,15 +2534,18 @@ test "endAction: the only tile's ending is mux's, and so is any ending nobody ca
3199 ); 2534 );
3200 2535
3201 // Two tiles: there is somewhere to go and a bar that says what became 2536 // Two tiles: there is somewhere to go and a bar that says what became
3202 // of this one. 2537 // of this one. Focus moves to the next present tile.
3203 endBench(&tiles, &shared, 0, .exited, 7, null); 2538 endBench(&tiles, &shared, 0, .exited, 7, null);
3204 try std.testing.expectEqual(EndAction.unzoom, endAction(&tiles, &two, 3, 0, true)); 2539 try std.testing.expectEqual(
2540 EndAction{ .refocus = 1 },
2541 endAction(&tiles, &two, 3, 0, true),
2542 );
3205 2543
3206 // ...but not once stdin has closed. A piped `mux` dropped to a wall it 2544 // ...but not once stdin has closed. A piped `mux` with no keyboard
3207 // has no keyboard for waits in poll forever with the code in hand — 2545 // waits in poll forever with the code in hand — reproduced at RC=124
3208 // reproduced at RC=124 before this argument existed, which is the whole 2546 // before this argument existed, which is the whole reason the rule is
3209 // reason the rule is stated in one pure function and not at the read 2547 // stated in one pure function and not at the read that happens to
3210 // that happens to notice EOF. 2548 // notice EOF.
3211 endBench(&tiles, &shared, 0, .exited, 7, null); 2549 endBench(&tiles, &shared, 0, .exited, 7, null);
3212 try std.testing.expectEqual( 2550 try std.testing.expectEqual(
3213 EndAction{ .finish = .{ .code = 7, .msg = null } }, 2551 EndAction{ .finish = .{ .code = 7, .msg = null } },
@@ -3222,14 +2560,6 @@ test "endAction: the only tile's ending is mux's, and so is any ending nobody ca
3222 EndAction{ .finish = .{ .code = 3, .msg = null } }, 2560 EndAction{ .finish = .{ .code = 3, .msg = null } },
3223 endAction(&tiles, &one_left, 3, 0, true), 2561 endAction(&tiles, &one_left, 3, 0, true),
3224 ); 2562 );
3225
3226 // A DEAD sibling still counts, deliberately: its bar narrates what
3227 // became of it, which is something to come back to and the reason the
3228 // dead-tile paint exists at all.
3229 endBench(&tiles, &shared, 1, .exited, 0, null);
3230 tiles[0].alive.store(false, .release);
3231 tiles[0].end.store(@intFromEnum(EndReason.exited), .release);
3232 try std.testing.expectEqual(EndAction.unzoom, endAction(&tiles, &two, 3, 1, true));
3233 } 2563 }
3234 2564
3235 test "endAction: a refusal a chord earned goes back where the chord was typed" { 2565 test "endAction: a refusal a chord earned goes back where the chord was typed" {
@@ -3241,15 +2571,15 @@ test "endAction: a refusal a chord earned goes back where the chord was typed" {
3241 // the session, and the one the chord was typed in is still there. 2571 // the session, and the one the chord was typed in is still there.
3242 endBench(&tiles, &shared, 1, .refused, 1, 0); 2572 endBench(&tiles, &shared, 1, .refused, 1, 0);
3243 try std.testing.expectEqual( 2573 try std.testing.expectEqual(
3244 EndAction{ .fall_back = 0 }, 2574 EndAction{ .refocus = 0 },
3245 endAction(&tiles, &two, 3, 1, true), 2575 endAction(&tiles, &two, 3, 1, true),
3246 ); 2576 );
3247 2577
3248 // It outranks the closed stdin, because falling back lands on a live 2578 // It outranks the closed stdin, because refocusing lands on a live
3249 // SESSION rather than on a wall: whatever ends that one ends the run. 2579 // SESSION rather than on nothing: whatever ends that one ends the run.
3250 endBench(&tiles, &shared, 1, .refused, 1, 0); 2580 endBench(&tiles, &shared, 1, .refused, 1, 0);
3251 try std.testing.expectEqual( 2581 try std.testing.expectEqual(
3252 EndAction{ .fall_back = 0 }, 2582 EndAction{ .refocus = 0 },
3253 endAction(&tiles, &two, 3, 1, false), 2583 endAction(&tiles, &two, 3, 1, false),
3254 ); 2584 );
3255 2585
@@ -3273,862 +2603,168 @@ test "endAction: a refusal a chord earned goes back where the chord was typed" {
3273 ); 2603 );
3274 } 2604 }
3275 2605
3276 test "zoomChord: the chords come out of the client's own table" { 2606 test "every tile's attach carries its rect, not 0x0" {
3277 // Not a twin table: the bytes are filtered by interact.PrefixFilter and
3278 // only their MEANING is decided here. Fed as a real client would feed
3279 // it — split across reads, because a read boundary is not a chord
3280 // boundary — so a drift in either half fails here.
3281 const pt = allPresent(2);
3282 var f: interact.PrefixFilter = .{};
3283 var first = "vi\x1c".*;
3284 const a = f.feed(&first);
3285 try std.testing.expectEqualStrings("vi", a.forward);
3286 try std.testing.expectEqual(ZoomMove.stay, zoomChord(a.action, 0, &pt, null));
3287 var second = "n".*;
3288 const b = f.feed(&second);
3289 try std.testing.expectEqualStrings("", b.forward);
3290 try std.testing.expectEqual(ZoomMove{ .ask = .next }, zoomChord(b.action, 0, &pt, null));
3291
3292 // A doubled prefix is the client's second spelling of detach, and it
3293 // means here exactly what it meant there: leave, with the session still
3294 // running.
3295 var dbl = "\x1c\x1c".*;
3296 try std.testing.expectEqual(ZoomMove.leave, zoomChord(f.feed(&dbl).action, 1, &pt, null));
3297 // An unknown command key is swallowed with its prefix, and swallowing
3298 // it must not move the zoom.
3299 var unk = "a\x1czb".*;
3300 const u = f.feed(&unk);
3301 try std.testing.expectEqualStrings("ab", u.forward);
3302 try std.testing.expectEqual(ZoomMove.stay, zoomChord(u.action, 1, &pt, 0));
3303 }
3304
3305 // The splice this refuses is the one that matters: keep the head of a chunk
3306 // that does not fit, drop the middle of the stream, and the NEXT chunk
3307 // appends to the head — handing a shell a command nobody typed. All-or-
3308 // nothing means the mailbox only ever holds whole reads, in order.
3309 test "sendKeys: a chunk that does not fit is dropped whole, and says so" {
3310 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false }; 2607 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false };
3311 var t = Tile{ 2608 // A one-tile wall: no label bar, the tile claims every row.
2609 shared.label_rows = 0;
2610 var t0 = Tile{
3312 .r = .{ .target = .{ .sock = "/tmp/x" }, .label = "x", .session = "" }, 2611 .r = .{ .target = .{ .sock = "/tmp/x" }, .label = "x", .session = "" },
3313 .stripe = .{ .top = 0, .rows = 4 }, 2612 .stripe = .{ .top = 0, .rows = 24 },
3314 .shared = &shared, 2613 .shared = &shared,
3315 .idx = 0, 2614 .idx = 0,
3316 // -1 both ends: `ring` writes to the doorbell and ignores the
3317 // failure, which is exactly what it promises to do.
3318 .wake_r = -1, 2615 .wake_r = -1,
3319 .wake_w = -1, 2616 .wake_w = -1,
3320 }; 2617 };
2618 try std.testing.expectEqual(@as(u16, 24), t0.viewRows());
3321 2619
3322 const head = "abc"; 2620 // A two-tile wall: each tile loses one row to the label bar.
3323 sendKeys(&t, head); 2621 shared.label_rows = 1;
3324 try std.testing.expectEqual(@as(usize, 3), t.in_len); 2622 var t1 = Tile{
3325 try std.testing.expect(!t.in_dropped.load(.acquire)); 2623 .r = .{ .target = .{ .sock = "/tmp/x" }, .label = "x", .session = "" },
3326 2624 .stripe = .{ .top = 0, .rows = 12 },
3327 // One byte more than the room left. The old code copied what fit. 2625 .shared = &shared,
3328 const too_big = [_]u8{'z'} ** (mailbox_max - 2); 2626 .idx = 0,
3329 sendKeys(&t, &too_big); 2627 .wake_r = -1,
3330 try std.testing.expectEqual(@as(usize, 3), t.in_len); 2628 .wake_w = -1,
3331 try std.testing.expect(t.in_dropped.load(.acquire));
3332
3333 // ...and the mailbox still holds exactly what was typed before it, so
3334 // the next chunk cannot splice onto a half-delivered one.
3335 sendKeys(&t, "def");
3336 var out: [mailbox_max]u8 = undefined;
3337 try std.testing.expectEqualStrings("abcdef", takeKeys(&t, &out));
3338
3339 // Exactly filling it is not overflow — the bound is `>`, not `>=`.
3340 const exact = [_]u8{'q'} ** mailbox_max;
3341 t.in_dropped.store(false, .release);
3342 sendKeys(&t, &exact);
3343 try std.testing.expectEqual(@as(usize, mailbox_max), t.in_len);
3344 try std.testing.expect(!t.in_dropped.load(.acquire));
3345 }
3346
3347 test "labelText: a dropped chunk is narrated beside the state that caused it" {
3348 // The bar's own format, asserted through the same truncation path the
3349 // states go through, because "reconnecting, input dropped" is now the
3350 // longest thing a bar can say and `buf`'s bound is what it tests.
3351 var buf: [256]u8 = undefined;
3352 const long = "x" ** 400;
3353 const said = labelText(&buf, 300, "> ", long, "reconnecting, input dropped");
3354 try std.testing.expect(std.mem.endsWith(u8, said, " [reconnecting, input dropped]"));
3355 try std.testing.expect(said.len <= buf.len);
3356 }
3357
3358 test "resolveSpelling refuses a sun_path-overflowing sock path" {
3359 const alloc = std.testing.allocator;
3360 const long = "--sock /" ++ "x" ** 200;
3361 try std.testing.expectError(error.SockPathTooLong, resolveSpelling(alloc, long, null, 30_000));
3362 }
3363
3364 test "setZoom writes the outgoing zoom's release, and writes it before the store" {
3365 // The keyboard thread's half of the terminal handover. A demoted pump
3366 // deliberately writes nothing (`Core.Undo.already_written`), so if this
3367 // stops emitting, nothing does — and the wall goes on reporting mouse
3368 // clicks into whatever shell the user lands in.
3369 //
3370 // A pipe stands in for the terminal. `ring` writes to a `wake_w` of -1
3371 // and swallows the EBADF, which is what makes a Tile testable without
3372 // threads: the doorbell is a wake, never a fact.
3373 const p = try std.posix.pipe2(.{ .NONBLOCK = true });
3374 defer std.posix.close(p[0]);
3375 defer std.posix.close(p[1]);
3376
3377 // `is_tty` true: this leg is about what a zoom move WRITES, and the
3378 // gate that suppresses those writes on a pipe is exactly what it must
3379 // not be testing.
3380 var shared = Shared{ .out_fd = p[1], .size = .{ .cols = 80, .rows = 24 }, .is_tty = true };
3381 var tiles = [_]Tile{
3382 .{ .r = .{ .target = .{ .sock = "/s" }, .label = "a", .session = "a" }, .stripe = .{ .top = 0, .rows = 12 }, .shared = &shared, .idx = 0 },
3383 .{ .r = .{ .target = .{ .sock = "/s" }, .label = "b", .session = "b" }, .stripe = .{ .top = 12, .rows = 12 }, .shared = &shared, .idx = 1 },
3384 };
3385
3386 var buf: [512]u8 = undefined;
3387 var input: WallInput = .{};
3388
3389 // Zoomed OUT: there is no outgoing session, so there is nothing of a
3390 // session's to take off. The wall's own screen bytes are all this may
3391 // write — a release here would unset modes the user's own terminal may
3392 // have arrived with.
3393 setZoom(&tiles, &shared, &input, 0);
3394 var out = readAvail(p[0], &buf);
3395 try std.testing.expect(std.mem.indexOf(u8, out, interact.session_release) == null);
3396
3397 // Zoom A -> zoom B: A's session comes off. The ORDER is what matters
3398 // and it is structural rather than timed — the release is written under
3399 // `paint_mu`, ahead of the store, so the bytes are on the wire before
3400 // any pump can read the new zoom and claim. Here that shows up as the
3401 // release preceding the screen clear the same call writes.
3402 setZoom(&tiles, &shared, &input, 1);
3403 out = readAvail(p[0], &buf);
3404 const rel = std.mem.indexOf(u8, out, interact.session_release) orelse
3405 return error.NoReleaseOnZoomMove;
3406 const clear = std.mem.indexOf(u8, out, "\x1b[H\x1b[2J") orelse
3407 return error.NoClearOnZoomMove;
3408 try std.testing.expect(rel < clear);
3409
3410 // Zoom B -> the wall: same release, and this is the path the e2e
3411 // no-leaked-mode leg counts.
3412 setZoom(&tiles, &shared, &input, no_zoom);
3413 out = readAvail(p[0], &buf);
3414 const rel_out = std.mem.indexOf(u8, out, interact.session_release) orelse
3415 return error.NoReleaseOnUnzoom;
3416
3417 // ...and the wall takes its OWN mouse modes back, after that release
3418 // and not before it. `session_release` clears every mode in the wire
3419 // table because it cannot tell a session's from a client's, so an
3420 // unzoom that only released would leave the wall — which reads mouse
3421 // reports itself — deaf for the rest of the run. Ordering is the whole
3422 // assertion: re-arming first and releasing second unsets what was just
3423 // set, which is the same bug spelled backwards.
3424 const arm = std.mem.indexOf(u8, out, interact.wall_mouse_claim) orelse
3425 return error.NoWallMouseRearm;
3426 try std.testing.expect(rel_out < arm);
3427 }
3428
3429 test "the wall drains keys and reports in the order the terminal wrote them" {
3430 // `feed` returns two flat lists and `Event.at` is what re-joins them.
3431 // The read that costs is one holding a Return AND a click: Enter zooms
3432 // the SELECTED tile and a press moves the selection, so a driver that
3433 // drained every event first would zoom the tile the click had just
3434 // selected rather than the one the user chose. `at` being right and the
3435 // driver using it right are two different properties; this is the
3436 // second one.
3437 var f: interact.MouseFilter = .{};
3438 var out: [mailbox_max + interact.MouseFilter.max_held]u8 = undefined;
3439
3440 // Enter first, then the press.
3441 var d = WallDrain.init(f.feed("\r\x1b[<0;3;2M", &out));
3442 try std.testing.expectEqual(@as(u8, '\r'), (d.next() orelse return error.NoStep).key);
3443 try std.testing.expectEqual(
3444 interact.MouseFilter.Event.Kind.press,
3445 (d.next() orelse return error.NoStep).mouse.kind,
3446 );
3447 try std.testing.expect(d.next() == null);
3448
3449 // The same two bytes the other way round, and the opposite answer.
3450 f.reset();
3451 d = WallDrain.init(f.feed("\x1b[<0;3;2M\r", &out));
3452 try std.testing.expectEqual(
3453 interact.MouseFilter.Event.Kind.press,
3454 (d.next() orelse return error.NoStep).mouse.kind,
3455 );
3456 try std.testing.expectEqual(@as(u8, '\r'), (d.next() orelse return error.NoStep).key);
3457 try std.testing.expect(d.next() == null);
3458
3459 // A report in the MIDDLE of the keys stays there: `1` and `j` are both
3460 // wall keys and the press belongs between them.
3461 f.reset();
3462 d = WallDrain.init(f.feed("1\x1b[<0;3;1Mj", &out));
3463 try std.testing.expectEqual(@as(u8, '1'), (d.next() orelse return error.NoStep).key);
3464 try std.testing.expectEqual(
3465 interact.MouseFilter.Event.Kind.press,
3466 (d.next() orelse return error.NoStep).mouse.kind,
3467 );
3468 try std.testing.expectEqual(@as(u8, 'j'), (d.next() orelse return error.NoStep).key);
3469 try std.testing.expect(d.next() == null);
3470 }
3471
3472 /// Non-blocking, so an empty pipe reads as empty rather than parking the
3473 /// suite.
3474 fn drainWallPipe(fd: std.posix.fd_t, buf: []u8) []const u8 {
3475 const n = std.posix.read(fd, buf) catch return "";
3476 return buf[0..n];
3477 }
3478
3479 test "a wall click is bounded by the session's grid, not by the stripe" {
3480 const p = try std.posix.pipe2(.{ .NONBLOCK = true });
3481 defer std.posix.close(p[0]);
3482 defer std.posix.close(p[1]);
3483 var shared = Shared{ .out_fd = p[1], .size = .{ .cols = 80, .rows = 24 }, .is_tty = true };
3484 var tiles = [_]Tile{
3485 .{ .r = .{ .target = .{ .sock = "/s" }, .label = "a", .session = "a" }, .stripe = .{ .top = 0, .rows = 12 }, .shared = &shared, .idx = 0 },
3486 };
3487 var present = [_]bool{true};
3488 // A stripe eleven content rows tall over a grid of four: routine, not
3489 // contrived — a stripe attaches at 0x0 and inherits whatever grid the
3490 // daemon holds, so the stripe is nearly always the bigger of the two.
3491 tiles[0].win = .{ .history_rows = 100, .win_start = 0, .grid = .{ .cols = 10, .rows = 4 } };
3492
3493 // Inside the grid: the oldest retained row plus the offset.
3494 const in = hitTest(&tiles, &present, &shared, 1, 3).?;
3495 try std.testing.expectEqual(@as(u32, 100), in.row);
3496 try std.testing.expectEqual(@as(u16, 3), in.col);
3497
3498 // Right of the grid, clamped to its last column rather than refused —
3499 // the daemon would answer `.invalid` and the user would get silence.
3500 const wide = hitTest(&tiles, &present, &shared, 1, 40).?;
3501 try std.testing.expectEqual(@as(u16, 9), wide.col);
3502
3503 // Below the grid: `renderStripe` CLEARED that row, so it shows no
3504 // session line and there is nothing there to select.
3505 try std.testing.expect(hitTest(&tiles, &present, &shared, 5, 3) == null);
3506
3507 // A tile that has not painted yet has a zero grid, and so resolves
3508 // nothing at all.
3509 tiles[0].win = .{};
3510 try std.testing.expect(hitTest(&tiles, &present, &shared, 1, 3) == null);
3511 }
3512
3513 test "a plain click selects the stripe under it, and a drag highlights instead" {
3514 const p = try std.posix.pipe2(.{ .NONBLOCK = true });
3515 defer std.posix.close(p[0]);
3516 defer std.posix.close(p[1]);
3517 var shared = Shared{ .out_fd = p[1], .size = .{ .cols = 80, .rows = 24 }, .is_tty = true };
3518 var tiles = [_]Tile{
3519 .{ .r = .{ .target = .{ .sock = "/s" }, .label = "a", .session = "a" }, .stripe = .{ .top = 0, .rows = 12 }, .shared = &shared, .idx = 0 },
3520 .{ .r = .{ .target = .{ .sock = "/s" }, .label = "b", .session = "b" }, .stripe = .{ .top = 12, .rows = 12 }, .shared = &shared, .idx = 1 },
3521 };
3522 var present = [_]bool{ true, true };
3523 // Both stripes have painted once, so a terminal row resolves to a line.
3524 tiles[0].win = .{ .history_rows = 100, .win_start = 0, .grid = .{ .cols = 80, .rows = 24 } };
3525 tiles[1].win = .{ .history_rows = 200, .win_start = 3, .grid = .{ .cols = 80, .rows = 24 } };
3526
3527 const press: interact.MouseFilter.Event = .{ .kind = .press, .button = 0, .col = 4, .row = 15, .at = 0 };
3528 const release: interact.MouseFilter.Event = .{ .kind = .release, .button = 0, .col = 4, .row = 15, .at = 0 };
3529
3530 // Press and release in the same cell: tmux's MouseDown1Pane.
3531 wallMouse(&tiles, &present, &shared, press);
3532 // The press alone moves nothing — the selection follows the RELEASE, so
3533 // a press that turns into a drag never flickers the marker on its way.
3534 try std.testing.expectEqual(@as(usize, 0), shared.sel);
3535 // ...and highlights nothing either, for the same reason.
3536 try std.testing.expect(shared.drag.range() == null);
3537 wallMouse(&tiles, &present, &shared, release);
3538 try std.testing.expectEqual(@as(usize, 1), shared.sel);
3539
3540 // A drag: the pointer leaves the cell before the button comes up, so
3541 // the release is a drag end and not a click. It must not quietly mean
3542 // "select" — it means "highlight", and the marker stays where it was.
3543 moveSelection(&tiles, &shared, 0);
3544 wallMouse(&tiles, &present, &shared, press);
3545 wallMouse(&tiles, &present, &shared, .{ .kind = .motion, .button = 32, .col = 9, .row = 17, .at = 0 });
3546 wallMouse(&tiles, &present, &shared, .{ .kind = .release, .button = 0, .col = 9, .row = 17, .at = 0 });
3547 try std.testing.expectEqual(@as(usize, 0), shared.sel);
3548 // Stripe b, whose window began at grid row 3 with 200 rows behind it:
3549 // terminal row 15 is the third content line of that stripe.
3550 const held = shared.drag.range().?;
3551 try std.testing.expectEqual(@as(usize, 1), held.from.tile);
3552 try std.testing.expectEqual(@as(u32, 205), held.from.row);
3553 try std.testing.expectEqual(@as(u16, 4), held.from.col);
3554 try std.testing.expectEqual(@as(u32, 207), held.to.row);
3555 try std.testing.expectEqual(@as(u16, 9), held.to.col);
3556
3557 // A click on a label bar names no line, so it moves nothing — and puts
3558 // the highlight away, which is what a press anywhere does.
3559 wallMouse(&tiles, &present, &shared, .{ .kind = .press, .button = 0, .col = 0, .row = 12, .at = 0 });
3560 wallMouse(&tiles, &present, &shared, .{ .kind = .release, .button = 0, .col = 0, .row = 12, .at = 0 });
3561 try std.testing.expectEqual(@as(usize, 0), shared.sel);
3562 try std.testing.expect(shared.drag.range() == null);
3563
3564 // Right and middle stay the terminal's own: its paste and its menu.
3565 wallMouse(&tiles, &present, &shared, .{ .kind = .press, .button = 2, .col = 4, .row = 15, .at = 0 });
3566 wallMouse(&tiles, &present, &shared, .{ .kind = .motion, .button = 34, .col = 9, .row = 17, .at = 0 });
3567 wallMouse(&tiles, &present, &shared, .{ .kind = .release, .button = 2, .col = 9, .row = 17, .at = 0 });
3568 try std.testing.expectEqual(@as(usize, 0), shared.sel);
3569 try std.testing.expect(shared.drag.range() == null);
3570
3571 // A release with no press behind it — the other half arrived while a
3572 // zoom held the terminal, or before this wall started reading at all.
3573 wallMouse(&tiles, &present, &shared, release);
3574 try std.testing.expectEqual(@as(usize, 0), shared.sel);
3575 try std.testing.expect(shared.drag.range() == null);
3576 }
3577
3578 test "a drag at the wall stays in the stripe it started in, and rings for a repaint" {
3579 const p = try std.posix.pipe2(.{ .NONBLOCK = true });
3580 defer std.posix.close(p[0]);
3581 defer std.posix.close(p[1]);
3582 var shared = Shared{ .out_fd = p[1], .size = .{ .cols = 80, .rows = 24 }, .is_tty = true };
3583 var tiles = [_]Tile{
3584 .{ .r = .{ .target = .{ .sock = "/s" }, .label = "a", .session = "a" }, .stripe = .{ .top = 0, .rows = 12 }, .shared = &shared, .idx = 0 },
3585 .{ .r = .{ .target = .{ .sock = "/s" }, .label = "b", .session = "b" }, .stripe = .{ .top = 12, .rows = 12 }, .shared = &shared, .idx = 1 },
3586 };
3587 var present = [_]bool{ true, true };
3588 for (&tiles) |*t| {
3589 const pipe = try std.posix.pipe2(.{ .NONBLOCK = true });
3590 t.wake_r = pipe[0];
3591 t.wake_w = pipe[1];
3592 }
3593 defer for (&tiles) |*t| {
3594 std.posix.close(t.wake_r);
3595 std.posix.close(t.wake_w);
3596 };
3597 tiles[0].win = .{ .history_rows = 100, .win_start = 0, .grid = .{ .cols = 80, .rows = 24 } };
3598 tiles[1].win = .{ .history_rows = 200, .win_start = 3, .grid = .{ .cols = 80, .rows = 24 } };
3599
3600 const gen0 = shared.repaint_gen.load(.acquire);
3601 wallMouse(&tiles, &present, &shared, .{ .kind = .press, .button = 0, .col = 4, .row = 3, .at = 0 });
3602 // A press changes no highlight, so it costs no repaint: a wall that
3603 // bumped the generation on every press would redraw every stripe for
3604 // a click.
3605 try std.testing.expectEqual(gen0, shared.repaint_gen.load(.acquire));
3606
3607 // Down into the NEXT stripe. It is a drag, and its far end stops at
3608 // the last line of the tile it started in — a selection that leaked
3609 // into the neighbour would ask the wrong session for its text.
3610 wallMouse(&tiles, &present, &shared, .{ .kind = .motion, .button = 32, .col = 7, .row = 9, .at = 0 });
3611 const grew = shared.repaint_gen.load(.acquire);
3612 // The move that put a highlight on screen rang for a repaint. The
3613 // PUMPS draw it, so without the bump and the doorbell a drag would
3614 // appear one poll timeout per cell late — or never, over a session
3615 // quiet enough to send no frames of its own.
3616 try std.testing.expectEqual(gen0 + 1, grew);
3617 var bell: [8]u8 = undefined;
3618 try std.testing.expect(try std.posix.read(tiles[0].wake_r, &bell) > 0);
3619 try std.testing.expect(try std.posix.read(tiles[1].wake_r, &bell) > 0);
3620
3621 wallMouse(&tiles, &present, &shared, .{ .kind = .motion, .button = 32, .col = 2, .row = 20, .at = 0 });
3622 const r = shared.drag.range().?;
3623 try std.testing.expectEqual(@as(usize, 0), r.from.tile);
3624 try std.testing.expectEqual(@as(u32, 102), r.from.row);
3625 try std.testing.expectEqual(@as(u32, 108), r.to.row);
3626 try std.testing.expectEqual(@as(u16, 7), r.to.col);
3627 // ...and the move that left the tile changed nothing on screen, so it
3628 // rang for nothing: the far end was already parked at that edge.
3629 try std.testing.expectEqual(grew, shared.repaint_gen.load(.acquire));
3630 try std.testing.expectError(error.WouldBlock, std.posix.read(tiles[0].wake_r, &bell));
3631
3632 // The highlight is one tile's: the stripe it did not start in shows
3633 // nothing, whatever row is asked about.
3634 try std.testing.expect(shared.drag.span(0, 105, 80) != null);
3635 try std.testing.expect(shared.drag.span(1, 105, 80) == null);
3636 }
3637
3638 test "a wall drag posts its selection for the pump, because the keyboard owns no link" {
3639 const p = try std.posix.pipe2(.{ .NONBLOCK = true });
3640 defer std.posix.close(p[0]);
3641 defer std.posix.close(p[1]);
3642 var shared = Shared{ .out_fd = p[1], .size = .{ .cols = 80, .rows = 24 }, .is_tty = true };
3643 var tiles = [_]Tile{
3644 .{ .r = .{ .target = .{ .sock = "/s" }, .label = "a", .session = "a" }, .stripe = .{ .top = 0, .rows = 12 }, .shared = &shared, .idx = 0 },
3645 .{ .r = .{ .target = .{ .sock = "/s" }, .label = "b", .session = "b" }, .stripe = .{ .top = 12, .rows = 12 }, .shared = &shared, .idx = 1 },
3646 }; 2629 };
3647 var present = [_]bool{ true, true }; 2630 var t2 = Tile{
3648 for (&tiles) |*t| { 2631 .r = .{ .target = .{ .sock = "/tmp/y" }, .label = "y", .session = "" },
3649 const pipe = try std.posix.pipe2(.{ .NONBLOCK = true }); 2632 .stripe = .{ .top = 12, .rows = 12 },
3650 t.wake_r = pipe[0]; 2633 .shared = &shared,
3651 t.wake_w = pipe[1]; 2634 .idx = 1,
3652 } 2635 .wake_r = -1,
3653 defer for (&tiles) |*t| { 2636 .wake_w = -1,
3654 std.posix.close(t.wake_r);
3655 std.posix.close(t.wake_w);
3656 }; 2637 };
3657 tiles[0].win = .{ .history_rows = 100, .win_start = 0, .grid = .{ .cols = 80, .rows = 24 } }; 2638 try std.testing.expectEqual(@as(u16, 11), t1.viewRows());
3658 tiles[1].win = .{ .history_rows = 200, .win_start = 3, .grid = .{ .cols = 80, .rows = 24 } }; 2639 try std.testing.expectEqual(@as(u16, 11), t2.viewRows());
3659
3660 // A drag inside stripe b, released.
3661 wallMouse(&tiles, &present, &shared, .{ .kind = .press, .button = 0, .col = 4, .row = 15, .at = 0 });
3662 wallMouse(&tiles, &present, &shared, .{ .kind = .motion, .button = 32, .col = 9, .row = 17, .at = 0 });
3663 // Nothing is asked for until the button comes up: a request per cell
3664 // crossed is a round trip per cell crossed.
3665 try std.testing.expect(takeSelectionReq(&tiles[1]) == null);
3666 // Both doorbells emptied first: the MOTION rang them for the highlight
3667 // it moved, and a bell left in the pipe would answer for the release's
3668 // own ring below.
3669 var bell: [8]u8 = undefined;
3670 for (&tiles) |*t| _ = std.posix.read(t.wake_r, &bell) catch 0;
3671
3672 wallMouse(&tiles, &present, &shared, .{ .kind = .release, .button = 0, .col = 9, .row = 17, .at = 0 });
3673
3674 // Posted on the tile the drag was OVER, not the selected one — the
3675 // keyboard may not touch a Transport, so the ask belongs to the pump
3676 // whose link it will go out on.
3677 try std.testing.expect(takeSelectionReq(&tiles[0]) == null);
3678 const asked = takeSelectionReq(&tiles[1]) orelse return error.NothingAsked;
3679 try std.testing.expectEqual(@as(usize, 1), asked.from.tile);
3680 try std.testing.expectEqual(@as(u32, 205), asked.from.row);
3681 try std.testing.expectEqual(@as(u16, 4), asked.from.col);
3682 try std.testing.expectEqual(@as(u32, 207), asked.to.row);
3683 try std.testing.expectEqual(@as(u16, 9), asked.to.col);
3684 // Taken once. A pump that polled twice must not ask twice.
3685 try std.testing.expect(takeSelectionReq(&tiles[1]) == null);
3686
3687 // The doorbell is what gets it there: a stripe whose session is quiet
3688 // sends no frame of its own, so a pump waiting on its link would sit
3689 // in poll(2) holding an unasked question.
3690 try std.testing.expect(try std.posix.read(tiles[1].wake_r, &bell) > 0);
3691 // The neighbour is not woken: it has no question to ask.
3692 try std.testing.expectError(error.WouldBlock, std.posix.read(tiles[0].wake_r, &bell));
3693
3694 // A CLICK posts nothing. It highlights nothing, so there is no text
3695 // under it to ask about.
3696 wallMouse(&tiles, &present, &shared, .{ .kind = .press, .button = 0, .col = 4, .row = 15, .at = 0 });
3697 wallMouse(&tiles, &present, &shared, .{ .kind = .release, .button = 0, .col = 4, .row = 15, .at = 0 });
3698 try std.testing.expect(takeSelectionReq(&tiles[1]) == null);
3699 }
3700
3701 /// A transport that swallows the pump's frames. What the wall copy tests
3702 /// are about is what came back, not what went out.
3703 const SwallowTransport = struct {
3704 pub fn writeFrame(_: *SwallowTransport, _: proto.MsgType, _: []const u8) !void {}
3705 };
3706
3707 /// One `selection_reply` payload: id, status, watermark, text.
3708 fn replyBytes(
3709 buf: []u8,
3710 id: u32,
3711 status: proto.SelectionStatus,
3712 history_rows: u32,
3713 text: []const u8,
3714 ) []const u8 {
3715 std.mem.writeInt(u32, buf[0..4], id, .little);
3716 buf[4] = @intFromEnum(status);
3717 std.mem.writeInt(u32, buf[5..9], history_rows, .little);
3718 @memcpy(buf[proto.selection_reply_prefix_len..][0..text.len], text);
3719 return buf[0 .. proto.selection_reply_prefix_len + text.len];
3720 } 2640 }
3721 2641
3722 test "a wall drag copies on release, and a stale answer copies nothing" { 2642 test "relayout sets resize_pending on every live tile" {
3723 const alloc = std.testing.allocator; 2643 const alloc = std.testing.allocator;
3724 const p = try std.posix.pipe2(.{ .NONBLOCK = true }); 2644 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false };
3725 defer std.posix.close(p[0]); 2645 const tiles = try alloc.alloc(Tile, 2);
3726 defer std.posix.close(p[1]); 2646 defer alloc.free(tiles);
3727 var shared = Shared{ .out_fd = p[1], .size = .{ .cols = 80, .rows = 24 }, .is_tty = true }; 2647 const present = try alloc.alloc(bool, 2);
3728 var tiles = [_]Tile{ 2648 defer alloc.free(present);
3729 .{ .r = .{ .target = .{ .sock = "/s" }, .label = "a", .session = "a" }, .stripe = .{ .top = 0, .rows = 12 }, .shared = &shared, .idx = 0 }, 2649 present[0] = true;
3730 .{ .r = .{ .target = .{ .sock = "/s" }, .label = "b", .session = "b" }, .stripe = .{ .top = 12, .rows = 12 }, .shared = &shared, .idx = 1 }, 2650 present[1] = true;
3731 }; 2651 for (tiles, 0..) |*t, i| {
3732 var present = [_]bool{ true, true }; 2652 t.* = Tile{
3733 for (&tiles) |*t| { 2653 .r = .{ .target = .{ .sock = "/tmp/x" }, .label = "x", .session = "" },
3734 const pipe = try std.posix.pipe2(.{ .NONBLOCK = true }); 2654 .stripe = .{ .top = 0, .rows = 24 },
3735 t.wake_r = pipe[0]; 2655 .shared = &shared,
3736 t.wake_w = pipe[1]; 2656 .idx = i,
2657 .wake_r = -1,
2658 .wake_w = -1,
2659 };
3737 } 2660 }
3738 defer for (&tiles) |*t| { 2661 relayout(alloc, tiles, present, &shared, 0);
3739 std.posix.close(t.wake_r); 2662 // Two tiles: a label bar appears.
3740 std.posix.close(t.wake_w); 2663 try std.testing.expectEqual(@as(u8, 1), shared.label_rows);
3741 }; 2664 // Every tile is doorbelled: its pump sends the new rect.
3742 tiles[0].win = .{ .history_rows = 100, .win_start = 0, .grid = .{ .cols = 80, .rows = 24 } }; 2665 try std.testing.expect(tiles[0].resize_pending.load(.acquire));
3743 tiles[1].win = .{ .history_rows = 200, .win_start = 3, .grid = .{ .cols = 80, .rows = 24 } }; 2666 try std.testing.expect(tiles[1].resize_pending.load(.acquire));
3744
3745 // The pump's half. A stripe's Core is DEMOTED — it holds no claim and
3746 // paints nothing — which is exactly why the copy cannot go out through
3747 // it and goes out through the wall instead.
3748 var core = try interact.Core.initSized(alloc, -1, p[1], shared.size);
3749 defer core.deinit();
3750 core.rep.history_rows = 200;
3751 var tr: SwallowTransport = .{};
3752 var buf: [8192]u8 = undefined;
3753 var rbuf: [64]u8 = undefined;
3754
3755 // Drag over stripe b, let go, and let its pump ask.
3756 wallMouse(&tiles, &present, &shared, .{ .kind = .press, .button = 0, .col = 4, .row = 15, .at = 0 });
3757 wallMouse(&tiles, &present, &shared, .{ .kind = .motion, .button = 32, .col = 9, .row = 17, .at = 0 });
3758 wallMouse(&tiles, &present, &shared, .{ .kind = .release, .button = 0, .col = 9, .row = 17, .at = 0 });
3759 try core.requestSelection(&tr, takeSelectionReq(&tiles[1]) orelse return error.NothingAsked);
3760 _ = drainWallPipe(p[0], &buf);
3761
3762 const id = core.semantic.pending_selection_id orelse return error.NoPending;
3763 copySelection(&tiles[1], alloc, &core, replyBytes(&rbuf, id, .ok, 200, "hello"));
3764 try std.testing.expectEqualStrings("\x1b]52;c;aGVsbG8=\x07", drainWallPipe(p[0], &buf));
3765
3766 // A reply that outlived its highlight. `relayout` is one of the three
3767 // clears — the stripes are re-cut under the anchor — and after one the
3768 // rows the answer names are on nobody's screen.
3769 wallMouse(&tiles, &present, &shared, .{ .kind = .press, .button = 0, .col = 4, .row = 15, .at = 0 });
3770 wallMouse(&tiles, &present, &shared, .{ .kind = .motion, .button = 32, .col = 9, .row = 17, .at = 0 });
3771 wallMouse(&tiles, &present, &shared, .{ .kind = .release, .button = 0, .col = 9, .row = 17, .at = 0 });
3772 try core.requestSelection(&tr, takeSelectionReq(&tiles[1]) orelse return error.NothingAsked);
3773 _ = drainWallPipe(p[0], &buf);
3774 shared.paint_mu.lock();
3775 shared.drag.clear();
3776 shared.paint_mu.unlock();
3777 const orphan = core.semantic.pending_selection_id orelse return error.NoPending;
3778 copySelection(&tiles[1], alloc, &core, replyBytes(&rbuf, orphan, .ok, 200, "hello"));
3779 try std.testing.expectEqualStrings("", drainWallPipe(p[0], &buf));
3780
3781 // With no terminal on the other end, nothing goes out. A piped `mux`
3782 // writes no terminal state at all — no modes asked for, so no report
3783 // to read and no drag to copy — and this is where that stays true if a
3784 // drag ever reaches here another way.
3785 shared.is_tty = false;
3786 wallMouse(&tiles, &present, &shared, .{ .kind = .press, .button = 0, .col = 4, .row = 15, .at = 0 });
3787 wallMouse(&tiles, &present, &shared, .{ .kind = .motion, .button = 32, .col = 9, .row = 17, .at = 0 });
3788 wallMouse(&tiles, &present, &shared, .{ .kind = .release, .button = 0, .col = 9, .row = 17, .at = 0 });
3789 try core.requestSelection(&tr, takeSelectionReq(&tiles[1]) orelse return error.NothingAsked);
3790 _ = drainWallPipe(p[0], &buf);
3791 const piped = core.semantic.pending_selection_id orelse return error.NoPending;
3792 copySelection(&tiles[1], alloc, &core, replyBytes(&rbuf, piped, .ok, 200, "hello"));
3793 try std.testing.expectEqualStrings("", drainWallPipe(p[0], &buf));
3794 }
3795
3796 test "a zoomed tile copies from its own drag, not the wall's" {
3797 const alloc = std.testing.allocator;
3798 const p = try std.posix.pipe2(.{ .NONBLOCK = true });
3799 defer std.posix.close(p[0]);
3800 defer std.posix.close(p[1]);
3801 var shared = Shared{ .out_fd = p[1], .size = .{ .cols = 80, .rows = 24 }, .is_tty = true };
3802 var tiles = [_]Tile{
3803 .{ .r = .{ .target = .{ .sock = "/s" }, .label = "a", .session = "a" }, .stripe = .{ .top = 0, .rows = 24 }, .shared = &shared, .idx = 0 },
3804 };
3805 const pipe = try std.posix.pipe2(.{ .NONBLOCK = true });
3806 tiles[0].wake_r = pipe[0];
3807 tiles[0].wake_w = pipe[1];
3808 defer std.posix.close(pipe[0]);
3809 defer std.posix.close(pipe[1]);
3810 tiles[0].win = .{ .history_rows = 0, .win_start = 0, .grid = .{ .cols = 80, .rows = 24 } };
3811 shared.zoom.store(0, .release);
3812
3813 var core = try interact.Core.initSized(alloc, -1, p[1], shared.size);
3814 defer core.deinit();
3815 core.is_tty = true;
3816 _ = core.claimTerminal();
3817 var tr: SwallowTransport = .{};
3818 var buf: [8192]u8 = undefined;
3819 var rbuf: [64]u8 = undefined;
3820
3821 // Zoomed, the Core reads the mouse itself and asks from where it
3822 // stands — it is already on the thread that owns the link, so there is
3823 // no relay and nothing lands in `sel_req`.
3824 _ = try core.forward(&tr, "\x1b[<0;3;2M");
3825 _ = try core.forward(&tr, "\x1b[<32;7;3M");
3826 _ = try core.forward(&tr, "\x1b[<0;7;3m");
3827 try std.testing.expect(takeSelectionReq(&tiles[0]) == null);
3828 const id = core.semantic.pending_selection_id orelse return error.NoPending;
3829 _ = drainWallPipe(p[0], &buf);
3830
3831 copySelection(&tiles[0], alloc, &core, replyBytes(&rbuf, id, .ok, 0, "hi"));
3832 try std.testing.expectEqualStrings("\x1b]52;c;aGk=\x07", drainWallPipe(p[0], &buf));
3833
3834 // And a highlight the WALL is holding does not answer for a zoomed
3835 // tile: the screen belongs to the Core, so its drag is the one a reply
3836 // has to still match.
3837 _ = try core.forward(&tr, "\x1b[<0;3;2M");
3838 _ = try core.forward(&tr, "\x1b[<32;7;3M");
3839 _ = try core.forward(&tr, "\x1b[<0;7;3m");
3840 const orphan = core.semantic.pending_selection_id orelse return error.NoPending;
3841 const wall_range = core.drag.range().?;
3842 core.drag.clear();
3843 shared.paint_mu.lock();
3844 shared.drag.press(.{ .row = 1, .col = 2 }, wall_range.from);
3845 shared.drag.motion(.{ .row = 2, .col = 6 }, wall_range.to);
3846 shared.paint_mu.unlock();
3847 _ = drainWallPipe(p[0], &buf);
3848 copySelection(&tiles[0], alloc, &core, replyBytes(&rbuf, orphan, .ok, 0, "hi"));
3849 try std.testing.expectEqualStrings("", drainWallPipe(p[0], &buf));
3850 }
3851
3852 test "a copy too big for OSC 52 is said out loud rather than dropped" {
3853 const alloc = std.testing.allocator;
3854 const p = try std.posix.pipe2(.{ .NONBLOCK = true });
3855 defer std.posix.close(p[0]);
3856 defer std.posix.close(p[1]);
3857 var shared = Shared{ .out_fd = p[1], .size = .{ .cols = 80, .rows = 24 }, .is_tty = true };
3858 var tiles = [_]Tile{
3859 .{ .r = .{ .target = .{ .sock = "/s" }, .label = "a", .session = "a" }, .stripe = .{ .top = 0, .rows = 24 }, .shared = &shared, .idx = 0 },
3860 };
3861 var present = [_]bool{true};
3862 const pipe = try std.posix.pipe2(.{ .NONBLOCK = true });
3863 tiles[0].wake_r = pipe[0];
3864 tiles[0].wake_w = pipe[1];
3865 defer std.posix.close(pipe[0]);
3866 defer std.posix.close(pipe[1]);
3867 tiles[0].win = .{ .history_rows = 0, .win_start = 0, .grid = .{ .cols = 80, .rows = 24 } };
3868
3869 var core = try interact.Core.initSized(alloc, -1, p[1], shared.size);
3870 defer core.deinit();
3871 var tr: SwallowTransport = .{};
3872 var buf: [8192]u8 = undefined;
3873
3874 wallMouse(&tiles, &present, &shared, .{ .kind = .press, .button = 0, .col = 4, .row = 3, .at = 0 });
3875 wallMouse(&tiles, &present, &shared, .{ .kind = .motion, .button = 32, .col = 9, .row = 5, .at = 0 });
3876 wallMouse(&tiles, &present, &shared, .{ .kind = .release, .button = 0, .col = 9, .row = 5, .at = 0 });
3877 try core.requestSelection(&tr, takeSelectionReq(&tiles[0]) orelse return error.NothingAsked);
3878 _ = drainWallPipe(p[0], &buf);
3879
3880 // Bigger than OSC 52 can carry and well inside what the daemon will
3881 // send. `validClipboard` would refuse this without a word, and a copy
3882 // that silently did nothing is the whole reason this message exists.
3883 const fits = proto.clipboard_base64_max / 4 * 3;
3884 const rbuf = try alloc.alloc(u8, proto.selection_reply_prefix_len + fits + 1);
3885 defer alloc.free(rbuf);
3886 const big = try alloc.alloc(u8, fits + 1);
3887 defer alloc.free(big);
3888 @memset(big, 'x');
3889
3890 const id = core.semantic.pending_selection_id orelse return error.NoPending;
3891 copySelection(&tiles[0], alloc, &core, replyBytes(rbuf, id, .ok, 0, big));
3892 const said = drainWallPipe(p[0], &buf);
3893 try std.testing.expect(std.mem.indexOf(u8, said, "too large") != null);
3894 // Refused, not trimmed: a half-copied selection is worse than none,
3895 // because the user finds out when they paste it.
3896 try std.testing.expect(std.mem.indexOf(u8, said, "\x1b]52;") == null);
3897 } 2667 }
3898 2668
3899 test "a stripe paints its own highlight and nobody else's" { 2669 test "a pump's answer that grew the wall re-cuts it; a mere focus move does not" {
3900 const alloc = std.testing.allocator; 2670 const alloc = std.testing.allocator;
3901 const p = try std.posix.pipe2(.{ .NONBLOCK = true }); 2671 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false };
3902 defer std.posix.close(p[0]); 2672 const tiles = try alloc.alloc(Tile, 2);
3903 defer std.posix.close(p[1]); 2673 defer alloc.free(tiles);
3904 var shared = Shared{ .out_fd = p[1], .size = .{ .cols = 20, .rows = 8 }, .is_tty = true }; 2674 const present = try alloc.alloc(bool, 2);
3905 var tiles = [_]Tile{ 2675 defer alloc.free(present);
3906 .{ .r = .{ .target = .{ .sock = "/s" }, .label = "a", .session = "a" }, .stripe = .{ .top = 0, .rows = 4 }, .shared = &shared, .idx = 0 }, 2676 present[0] = true;
3907 .{ .r = .{ .target = .{ .sock = "/s" }, .label = "b", .session = "b" }, .stripe = .{ .top = 4, .rows = 4 }, .shared = &shared, .idx = 1 }, 2677 present[1] = true;
3908 }; 2678 for (tiles, 0..) |*t, i| {
3909 var present = [_]bool{ true, true }; 2679 t.* = Tile{
3910 var eng = try Engine.init(alloc, .{ .cols = 20, .rows = 3 }); 2680 .r = .{ .target = .{ .sock = "/tmp/x" }, .label = "x", .session = "" },
3911 defer eng.deinit(); 2681 .stripe = .{ .top = 0, .rows = 24 },
3912 eng.feed("row-zero\r\nrow-one\r\nrow-two"); 2682 .shared = &shared,
3913 2683 .idx = i,
3914 // Both stripes have painted, so a click resolves. Tile a's window sits 2684 .wake_r = -1,
3915 // at grid row 0 with 100 rows of history behind it. 2685 .wake_w = -1,
3916 tiles[0].win = .{ .history_rows = 100, .win_start = 0, .grid = .{ .cols = 80, .rows = 24 } }; 2686 };
3917 tiles[1].win = .{ .history_rows = 100, .win_start = 0, .grid = .{ .cols = 80, .rows = 24 } }; 2687 }
3918 // Drag across tile a's second content line, columns 2..5. 2688 // Growth: without the re-cut the new tile sits on placeholder geometry
3919 wallMouse(&tiles, &present, &shared, .{ .kind = .press, .button = 0, .col = 2, .row = 2, .at = 0 }); 2689 // and the old tile still owns the whole terminal — the screen keeps
3920 wallMouse(&tiles, &present, &shared, .{ .kind = .motion, .button = 32, .col = 5, .row = 2, .at = 0 }); 2690 // showing tile 0 while the keyboard types into tile 1's rows.
3921 2691 focusAnswer(alloc, tiles, present, &shared, true, 1);
3922 var buf: [8192]u8 = undefined; 2692 try std.testing.expectEqual(@as(u8, 1), shared.label_rows);
3923 try std.testing.expect(paintStripe(&tiles[0], alloc, eng, 100)); 2693 try std.testing.expect(tiles[0].resize_pending.load(.acquire));
3924 const mine = drainWallPipe(p[0], &buf); 2694 try std.testing.expect(tiles[1].resize_pending.load(.acquire));
3925 // Grid row 1 of a's own replica, inverted from column 3 (1-based). 2695 try std.testing.expectEqual(@as(usize, 1), shared.sel);
3926 try std.testing.expectEqual(@as(usize, 1), std.mem.count(u8, mine, "\x1b[7m")); 2696 try std.testing.expect(tiles[1].claim_pending.load(.acquire));
3927 try std.testing.expect(std.mem.indexOf(u8, mine, "\x1b[3G\x1b[0m\x1b[7m") != null); 2697 tiles[0].resize_pending.store(false, .release);
3928 // At the stripe's own rows: content line two is terminal row 3. 2698 tiles[1].resize_pending.store(false, .release);
3929 try std.testing.expect(std.mem.indexOf(u8, mine, "\x1b[3;1H\x1b[2K\x1b[0mro\x1b[3G") != null); 2699 // No growth: a full clear here would flash the wall for a focus move.
3930 2700 focusAnswer(alloc, tiles, present, &shared, false, 0);
3931 // The neighbour paints the same replica at its own offset, with NO 2701 try std.testing.expect(!tiles[0].resize_pending.load(.acquire));
3932 // inversion: the highlight belongs to the tile the press landed in. 2702 try std.testing.expect(!tiles[1].resize_pending.load(.acquire));
3933 try std.testing.expect(paintStripe(&tiles[1], alloc, eng, 100)); 2703 try std.testing.expectEqual(@as(usize, 0), shared.sel);
3934 const theirs = drainWallPipe(p[0], &buf); 2704 try std.testing.expect(tiles[0].claim_pending.load(.acquire));
3935 try std.testing.expect(std.mem.indexOf(u8, theirs, "row-one") != null);
3936 try std.testing.expect(std.mem.indexOf(u8, theirs, "\x1b[7m") == null);
3937 }
3938
3939 test "a reconnect drops the highlight over its own tile, and only its own" {
3940 const p = try std.posix.pipe2(.{ .NONBLOCK = true });
3941 defer std.posix.close(p[0]);
3942 defer std.posix.close(p[1]);
3943 var shared = Shared{ .out_fd = p[1], .size = .{ .cols = 80, .rows = 24 }, .is_tty = true };
3944 var tiles = [_]Tile{
3945 .{ .r = .{ .target = .{ .sock = "/s" }, .label = "a", .session = "a" }, .stripe = .{ .top = 0, .rows = 12 }, .shared = &shared, .idx = 0 },
3946 .{ .r = .{ .target = .{ .sock = "/s" }, .label = "b", .session = "b" }, .stripe = .{ .top = 12, .rows = 12 }, .shared = &shared, .idx = 1 },
3947 };
3948 var present = [_]bool{ true, true };
3949 tiles[0].win = .{ .history_rows = 100, .win_start = 0, .grid = .{ .cols = 80, .rows = 24 } };
3950
3951 wallMouse(&tiles, &present, &shared, .{ .kind = .press, .button = 0, .col = 4, .row = 3, .at = 0 });
3952 wallMouse(&tiles, &present, &shared, .{ .kind = .motion, .button = 32, .col = 7, .row = 5, .at = 0 });
3953 try std.testing.expect(shared.drag.range() != null);
3954
3955 // The NEIGHBOUR reconnects. That says nothing about a selection over a
3956 // session which never went away, and dropping it would make one tile's
3957 // flaky link erase another tile's selection.
3958 dropDragOver(&tiles[1]);
3959 try std.testing.expect(shared.drag.range() != null);
3960
3961 // The tile under the highlight reconnects: the absolute rows it was
3962 // anchored in are about to mean something else.
3963 dropDragOver(&tiles[0]);
3964 try std.testing.expect(shared.drag.range() == null);
3965 }
3966
3967 test "a relayout drops the highlight, because the stripes move under it" {
3968 const alloc = std.testing.allocator;
3969 const p = try std.posix.pipe2(.{ .NONBLOCK = true });
3970 defer std.posix.close(p[0]);
3971 defer std.posix.close(p[1]);
3972 var shared = Shared{ .out_fd = p[1], .size = .{ .cols = 80, .rows = 24 }, .is_tty = true };
3973 var tiles = [_]Tile{
3974 .{ .r = .{ .target = .{ .sock = "/s" }, .label = "a", .session = "a" }, .stripe = .{ .top = 0, .rows = 12 }, .shared = &shared, .idx = 0 },
3975 .{ .r = .{ .target = .{ .sock = "/s" }, .label = "b", .session = "b" }, .stripe = .{ .top = 12, .rows = 12 }, .shared = &shared, .idx = 1 },
3976 };
3977 var present = [_]bool{ true, true };
3978 tiles[0].win = .{ .history_rows = 100, .win_start = 0, .grid = .{ .cols = 80, .rows = 24 } };
3979
3980 wallMouse(&tiles, &present, &shared, .{ .kind = .press, .button = 0, .col = 4, .row = 3, .at = 0 });
3981 wallMouse(&tiles, &present, &shared, .{ .kind = .motion, .button = 32, .col = 7, .row = 5, .at = 0 });
3982 try std.testing.expect(shared.drag.range() != null);
3983
3984 // `x` ends here too, which is why one clear covers both: an anchor
3985 // resolved against the old stripes names a line on a stripe that is
3986 // about to be somewhere else, or gone.
3987 relayout(alloc, &tiles, &present, &shared, 0);
3988 try std.testing.expect(shared.drag.range() == null);
3989 try std.testing.expect(shared.drag.on() == null);
3990 }
3991
3992 test "a zoom move drops what the wall's input filters are holding" {
3993 // The reset used to sit at ONE of six `setZoom` call sites and held
3994 // only because the other five are reachable from an already-zoomed
3995 // wall. It belongs to the TRANSITION: every one of them hands the
3996 // terminal to somebody else, and a chord half typed, a report half
3997 // read or a button still down at the wall is state about a screen that
3998 // has stopped existing.
3999 const p = try std.posix.pipe2(.{ .NONBLOCK = true });
4000 defer std.posix.close(p[0]);
4001 defer std.posix.close(p[1]);
4002 var shared = Shared{ .out_fd = p[1], .size = .{ .cols = 80, .rows = 24 }, .is_tty = true };
4003 var tiles = [_]Tile{
4004 .{ .r = .{ .target = .{ .sock = "/s" }, .label = "a", .session = "a" }, .stripe = .{ .top = 0, .rows = 12 }, .shared = &shared, .idx = 0 },
4005 .{ .r = .{ .target = .{ .sock = "/s" }, .label = "b", .session = "b" }, .stripe = .{ .top = 12, .rows = 12 }, .shared = &shared, .idx = 1 },
4006 };
4007 var present = [_]bool{ true, true };
4008 var input: WallInput = .{};
4009
4010 // A chord waiting for its command key, a report split across the read
4011 // boundary, and a button held down over a stripe.
4012 var chord = [_]u8{0x1c};
4013 _ = input.prefix.feed(&chord);
4014 try std.testing.expect(input.prefix.pending);
4015 var mouse_out: [mailbox_max + interact.MouseFilter.max_held]u8 = undefined;
4016 _ = input.mouse.feed("\x1b[<0;3", &mouse_out);
4017 try std.testing.expect(input.mouse.len > 0);
4018 tiles[1].win = .{ .history_rows = 200, .win_start = 3, .grid = .{ .cols = 80, .rows = 24 } };
4019 wallMouse(&tiles, &present, &shared, .{ .kind = .press, .button = 0, .col = 4, .row = 15, .at = 0 });
4020 wallMouse(&tiles, &present, &shared, .{ .kind = .motion, .button = 32, .col = 9, .row = 17, .at = 0 });
4021 try std.testing.expect(shared.drag.range() != null);
4022
4023 setZoom(&tiles, &shared, &input, 0);
4024
4025 try std.testing.expect(!input.prefix.pending);
4026 try std.testing.expectEqual(@as(usize, 0), input.mouse.len);
4027 // The drag lives in `Shared` so the pumps can paint it, and is dropped
4028 // by the same reset: its anchor names a stripe that has just stopped
4029 // being on the screen.
4030 try std.testing.expect(shared.drag.range() == null);
4031 try std.testing.expect(shared.drag.on() == null);
4032 } 2705 }
4033 2706
4034 test "stripeContentRow: a label bar and the rows outside a stripe name no line" { 2707 test "a one-tile wall draws no label bar and paints row 1" {
4035 const alloc = std.testing.allocator; 2708 const alloc = std.testing.allocator;
4036 const st = try layoutStripes(alloc, 3, 25);
4037 defer alloc.free(st);
4038 // 25 rows over 3 stripes: 9, 8, 8, remainder at the top.
4039 try std.testing.expectEqual(@as(u16, 0), st[0].top);
4040 try std.testing.expectEqual(@as(u16, 9), st[0].rows);
4041
4042 // The bar itself: a click on the label points at no session line, and
4043 // answering with the row under it would move a selection nobody asked
4044 // to move.
4045 try std.testing.expectEqual(@as(?u16, null), stripeContentRow(st[0], 0));
4046 try std.testing.expectEqual(@as(?u16, 0), stripeContentRow(st[0], 1));
4047 try std.testing.expectEqual(@as(?u16, 7), stripeContentRow(st[0], 8));
4048 // Row 9 is the NEXT stripe's bar, not this stripe's ninth content row.
4049 try std.testing.expectEqual(@as(?u16, null), stripeContentRow(st[0], 9));
4050 // ...and nothing above a stripe belongs to it either.
4051 try std.testing.expectEqual(@as(?u16, null), stripeContentRow(st[1], 8));
4052 try std.testing.expectEqual(@as(?u16, 0), stripeContentRow(st[1], 10));
4053 }
4054
4055 test "hitTest names the absolute row under a click, and nothing at all off a stripe" {
4056 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false }; 2709 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false };
4057 var tiles = [_]Tile{ 2710 const tiles = try alloc.alloc(Tile, 1);
4058 .{ .r = .{ .target = .{ .sock = "/s" }, .label = "a", .session = "a" }, .stripe = .{ .top = 0, .rows = 12 }, .shared = &shared, .idx = 0 }, 2711 defer alloc.free(tiles);
4059 .{ .r = .{ .target = .{ .sock = "/s" }, .label = "b", .session = "b" }, .stripe = .{ .top = 12, .rows = 12 }, .shared = &shared, .idx = 1 }, 2712 const present = try alloc.alloc(bool, 1);
2713 defer alloc.free(present);
2714 present[0] = true;
2715 tiles[0] = Tile{
2716 .r = .{ .target = .{ .sock = "/tmp/x" }, .label = "x", .session = "" },
2717 .stripe = .{ .top = 0, .rows = 24 },
2718 .shared = &shared,
2719 .idx = 0,
2720 .wake_r = -1,
2721 .wake_w = -1,
4060 }; 2722 };
4061 // What each pump last painted. The daemon holds 100 rows above tile 2723 relayout(alloc, tiles, present, &shared, 0);
4062 // b's viewport and the stripe was showing that grid from row 5. 2724 // One tile: no label bar, every row is content.
4063 tiles[0].win = .{ .history_rows = 0, .win_start = 0, .grid = .{ .cols = 80, .rows = 24 } }; 2725 try std.testing.expectEqual(@as(u8, 0), shared.label_rows);
4064 tiles[1].win = .{ .history_rows = 100, .win_start = 5, .grid = .{ .cols = 80, .rows = 24 } }; 2726 try std.testing.expectEqual(@as(u16, 24), tiles[0].viewRows());
4065 var present = [_]bool{ true, true };
4066
4067 // Tile b's first content row: terminal row 13, grid row 5, and 100
4068 // retained rows above the viewport.
4069 const first = hitTest(&tiles, &present, &shared, 13, 4) orelse return error.NoHit;
4070 try std.testing.expectEqual(@as(usize, 1), first.tile);
4071 try std.testing.expectEqual(@as(u32, 105), first.row);
4072 // Columns map 1:1 — every painter emits from column 1 with no offset.
4073 try std.testing.expectEqual(@as(u16, 4), first.col);
4074
4075 // ...and its last.
4076 const last = hitTest(&tiles, &present, &shared, 23, 0) orelse return error.NoHit;
4077 try std.testing.expectEqual(@as(u32, 115), last.row);
4078
4079 // Tile a, whose window sits at the grid top with no history behind it.
4080 const top = hitTest(&tiles, &present, &shared, 1, 0) orelse return error.NoHit;
4081 try std.testing.expectEqual(@as(usize, 0), top.tile);
4082 try std.testing.expectEqual(@as(u32, 0), top.row);
4083
4084 // Both label bars: a defined no-op, not the nearest line.
4085 try std.testing.expect(hitTest(&tiles, &present, &shared, 0, 0) == null);
4086 try std.testing.expect(hitTest(&tiles, &present, &shared, 12, 0) == null);
4087 // Past the last stripe — a terminal taller than the wall was cut for.
4088 try std.testing.expect(hitTest(&tiles, &present, &shared, 24, 0) == null);
4089
4090 // A forgotten tile keeps its stale stripe (`relayout` never compacts
4091 // the array), so `present` and not the geometry is what answers here.
4092 present[1] = false;
4093 try std.testing.expect(hitTest(&tiles, &present, &shared, 13, 0) == null);
4094 } 2727 }
4095 2728
4096 test "paintStripe publishes the window it painted" { 2729 test "bare keys reach the focused tile; the prefix does not" {
4097 // A click is resolved on the KEYBOARD thread, which has no replica in 2730 var f: interact.PrefixFilter = .{};
4098 // reach — the `Engine` is the pump's. So the pump has to leave behind 2731 // Bare keys are forwarded to the focused tile.
4099 // the two numbers that name a line, and this asserts they describe the 2732 var keys: [3]u8 = .{ 'a', 'b', 'c' };
4100 // window that actually went to the terminal rather than some other one. 2733 const a = f.feed(&keys);
4101 const alloc = std.testing.allocator; 2734 try std.testing.expectEqualStrings("abc", a.forward);
4102 const p = try std.posix.pipe2(.{ .NONBLOCK = true }); 2735 try std.testing.expectEqual(interact.PrefixFilter.Action.none, a.action);
4103 defer std.posix.close(p[0]); 2736 // A chord consumes its key and produces no forward bytes.
4104 defer std.posix.close(p[1]); 2737 var chord: [2]u8 = .{ 0x1c, 'n' };
4105 2738 const b = f.feed(&chord);
4106 var shared = Shared{ .out_fd = p[1], .size = .{ .cols = 20, .rows = 24 }, .is_tty = true }; 2739 try std.testing.expectEqual(@as(usize, 0), b.forward.len);
4107 var t = Tile{ 2740 try std.testing.expectEqual(interact.PrefixFilter.Action.next_session, b.action);
4108 .r = .{ .target = .{ .sock = "/s" }, .label = "a", .session = "a" }, 2741 }
4109 // A label bar and three content rows. 2742
4110 .stripe = .{ .top = 0, .rows = 4 }, 2743 test "focus follows a click: rectHit picks the tile under a row" {
2744 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false };
2745 shared.label_rows = 1;
2746 var tiles: [2]Tile = undefined;
2747 tiles[0] = Tile{
2748 .r = .{ .target = .{ .sock = "/tmp/x" }, .label = "x", .session = "" },
2749 .stripe = .{ .top = 0, .rows = 12 },
4111 .shared = &shared, 2750 .shared = &shared,
4112 .idx = 0, 2751 .idx = 0,
2752 .wake_r = -1,
2753 .wake_w = -1,
4113 }; 2754 };
4114 2755 tiles[1] = Tile{
4115 const eng = try Engine.init(alloc, .{ .cols = 20, .rows = 5 }); 2756 .r = .{ .target = .{ .sock = "/tmp/y" }, .label = "y", .session = "" },
4116 defer eng.deinit(); 2757 .stripe = .{ .top = 12, .rows = 12 },
4117 eng.feed("row0\r\nrow1\r\nrow2\r\nrow3\r\nrow4"); 2758 .shared = &shared,
4118 2759 .idx = 1,
4119 try std.testing.expect(paintStripe(&t, alloc, eng, 7)); 2760 .wake_r = -1,
4120 // Three content rows over a five-row grid with the cursor on the last: 2761 .wake_w = -1,
4121 // the window is grid rows 2..4. 2762 };
4122 try std.testing.expectEqual(@as(u16, 2), t.win.win_start); 2763 const present = [_]bool{ true, true };
4123 // Carried through untouched — it is the daemon's count, not the 2764 // A click in row 3 hits tile 0.
4124 // replica's, because absolute rows are named from the oldest row the 2765 try std.testing.expectEqual(@as(?usize, 0), rectHit(&tiles, &present, &shared, 3));
4125 // DAEMON still retains. 2766 // A click in row 15 hits tile 1.
4126 try std.testing.expectEqual(@as(u32, 7), t.win.history_rows); 2767 try std.testing.expectEqual(@as(?usize, 1), rectHit(&tiles, &present, &shared, 15));
4127
4128 var buf: [4096]u8 = undefined;
4129 const out = readAvail(p[0], &buf);
4130 try std.testing.expect(std.mem.indexOf(u8, out, "row2") != null);
4131 try std.testing.expect(std.mem.indexOf(u8, out, "row1") == null);
4132 } 2768 }
4133 2769
4134 test "agent channels: slots fill in order, a full table refuses, and a close is announced" { 2770 test "agent channels: slots fill in order, a full table refuses, and a close is announced" {
test/e2e.sh
Old New
@@ -5099,7 +5099,7 @@ ok "Ctrl-\\ prefix: an unknown chord is swallowed, Ctrl-\\ d detaches"
5099 # each switch was a new client process on a new transport. Now `mux` IS the 5099 # each switch was a new client process on a new transport. Now `mux` IS the
5100 # wall: the chord adds a tile and moves the ZOOM to it, and the alternate 5100 # wall: the chord adds a tile and moves the ZOOM to it, and the alternate
5101 # screen was entered once, at startup, and is never left. So the arrival 5101 # screen was entered once, at startup, and is never left. So the arrival
5102 # signal is the zoom's own: `setZoom` clears the screen (`\x1b[2J`) on the 5102 # signal is the zoom's own: `setFocus` clears the screen (`\x1b[2J`) on the
5103 # thread that moves it, before the terminal changes hands. 5103 # thread that moves it, before the terminal changes hands.
5104 # 5104 #
5105 # That needle is not merely "some paint": between the chord and the answer 5105 # That needle is not merely "some paint": between the chord and the answer
@@ -5226,7 +5226,7 @@ ok "Ctrl-\\ c: a new session is created and switched to, the old one intact; a f
5226 # Now the ring moves the ZOOM: sessions 0, 1 and 2 all have tiles (the `c` 5226 # Now the ring moves the ZOOM: sessions 0, 1 and 2 all have tiles (the `c`
5227 # chords made them), their replicas are hot, and the step is a local repaint 5227 # chords made them), their replicas are hot, and the step is a local repaint
5228 # at zero round trips. The alternate screen is entered once and never left, 5228 # at zero round trips. The alternate screen is entered once and never left,
5229 # so the arrival signal is `setZoom`'s screen clear followed by the marker 5229 # so the arrival signal is `setFocus`'s screen clear followed by the marker
5230 # the repaint carries — and the marker is what this leg was always really 5230 # the repaint carries — and the marker is what this leg was always really
5231 # reading the answer off. 5231 # reading the answer off.
5232 # 5232 #
@@ -6059,7 +6059,7 @@ done
6059 # afterwards and repair it, and a pump stalled in a dial backoff makes the 6059 # afterwards and repair it, and a pump stalled in a dial backoff makes the
6060 # window seconds wide. The fix is that the thread MOVING the zoom writes the 6060 # window seconds wide. The fix is that the thread MOVING the zoom writes the
6061 # release itself, under `paint_mu`, before the store the next pump reads 6061 # release itself, under `paint_mu`, before the store the next pump reads
6062 # (wallview `setZoom`), so the order is structural rather than lucky. 6062 # (wallview `setFocus`), so the order is structural rather than lucky.
6063 # 6063 #
6064 # Both needles are exact byte strings that nothing else emits. The claim is 6064 # Both needles are exact byte strings that nothing else emits. The claim is
6065 # the only place those three escapes are adjacent — the level-set that 6065 # the only place those three escapes are adjacent — the level-set that