31771807
feat: every tile claims its rect; focus replaces zoom
a73x 2026-08-24 11:32
Commit message
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, ¬ice_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, ¬ice_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 |