56a7de8e
feat: the label bar follows the tty, not the tile count
a73x 2026-09-01 07:36
Commit message
README.md
| Old | New | ||
|---|---|---|---|
| @@ -65,7 +65,10 @@ adding HOST to the file if it is new, with the rest of the list on the wall | |||
| 65 | behind it. Every host dials at once, in the background, so a slow ssh | 65 | behind it. Every host dials at once, in the background, so a slow ssh |
| 66 | somewhere else never holds up the one you asked for; `Ctrl-\` `w` zooms back | 66 | somewhere else never holds up the one you asked for; `Ctrl-\` `w` zooms back |
| 67 | out to see them. A wall of one tile still feels like a plain client, because | 67 | out to see them. A wall of one tile still feels like a plain client, because |
| 68 | a wall of one tile is what a plain client always was. | 68 | a wall of one tile is what a plain client always was — except that on a |
| 69 | terminal every tile wears its label bar, one tile or many, so the zoomed | ||
| 70 | view keeps the session's name and state in sight. Only a piped `mux` is | ||
| 71 | bare bytes. | ||
| 69 | 72 | ||
| 70 | A split births a session: not a window onto an existing one, but its own | 73 | A split births a session: not a window onto an existing one, but its own |
| 71 | shell on the focused tile's daemon, laid out beside or below the focus. The | 74 | shell on the focused tile's daemon, laid out beside or below the focus. The |
docs/decisions.md
| Old | New | ||
|---|---|---|---|
| @@ -7701,3 +7701,34 @@ forbid. All of that is gone, along with the `Folder` enum, `folderOf`, | |||
| 7701 | invariants in CLAUDE.md are unchanged and `mux_core.wasm` still links | 7701 | invariants in CLAUDE.md are unchanged and `mux_core.wasm` still links |
| 7702 | `term` + the client core with no tty anywhere, which is the check that | 7702 | `term` + the client core with no tty anywhere, which is the check that |
| 7703 | actually exercises the separation. | 7703 | actually exercises the separation. |
| 7704 | |||
| 7705 | ## 2026-09-01 — the label bar follows the tty, not the tile count | ||
| 7706 | |||
| 7707 | A one-tile wall and a fullscreened pane drew no label bar, so the very view | ||
| 7708 | most sessions live in — `mux TARGET`, zoomed — was anonymous: nothing on | ||
| 7709 | screen said which host or session you were typing into. The rule is now that | ||
| 7710 | the bar is a property of being on a terminal: every tty tile wears one, | ||
| 7711 | one tile or many, fullscreen included, and only a piped `mux` stays bare — | ||
| 7712 | its byte stream is a script's input, and a bar in it would be bytes the | ||
| 7713 | session never wrote. | ||
| 7714 | |||
| 7715 | - **The cost is one row.** A solo session on an N-row terminal runs at N-1 | ||
| 7716 | rows, and `wall_pump`'s `owns_screen` pass-through never engages on a tty | ||
| 7717 | (painting is always clipped). The piped client keeps pass-through and full | ||
| 7718 | height, so nothing script-facing changed. | ||
| 7719 | - **`wallFloors` is keyed by the bar, not by `live > 1`**: on a tty every | ||
| 7720 | stripe owes `min_session_rows` plus the bar row, so a 4-row terminal that | ||
| 7721 | used to hold one bare pane still holds one pane — barred. | ||
| 7722 | - **The TooSmall degrade keeps the bar.** The one visible pane still | ||
| 7723 | deserves its name; only the rects degrade. | ||
| 7724 | - **The e2e convergence check learned the bar.** A tty client's render is | ||
| 7725 | the daemon grid plus a bar row, so `assert_converged_pty` proves the bar | ||
| 7726 | IS on row 1 first — a drop that could cut a content row would hide the | ||
| 7727 | divergence the diff exists to catch — then diffs the rows under it via | ||
| 7728 | `render --drop-top 1`. `Engine.dumpVtFrom` pins the byte equivalence: | ||
| 7729 | slicing the viewport formats identically to a grid that never had the | ||
| 7730 | row. The convergence-point pin stays at 38. | ||
| 7731 | - **Found by the change, kept as a lesson:** the thin-wall host test freed | ||
| 7732 | tile labels before joining pumps (defers run last-declared-first) — a | ||
| 7733 | use-after-free that was invisible while a solo pump never painted its | ||
| 7734 | bar, and a segfault the moment it did. | ||
src/engine/engine.zig
| Old | New | ||
|---|---|---|---|
| @@ -325,6 +325,15 @@ pub const Engine = struct { | |||
| 325 | return self.formatSelection(alloc, "", self.viewportSelection()); | 325 | return self.formatSelection(alloc, "", self.viewportSelection()); |
| 326 | } | 326 | } |
| 327 | 327 | ||
| 328 | /// `dumpVt` from row `y0` down: the viewport with its top rows cut off, | ||
| 329 | /// formatted exactly as a grid of that height would be. The e2e render | ||
| 330 | /// fixture uses it to drop a tty client's label bar, so the rows under | ||
| 331 | /// the bar stay byte-diffable against the daemon's own dump. | ||
| 332 | pub fn dumpVtFrom(self: *Engine, alloc: std.mem.Allocator, y0: u16) ![]u8 { | ||
| 333 | std.debug.assert(y0 < self.term.rows); | ||
| 334 | return self.formatSelection(alloc, "", self.viewportRows(y0, @intCast(self.term.rows - 1))); | ||
| 335 | } | ||
| 336 | |||
| 328 | /// One viewport row (0-based), self-contained: leading SGR reset, no | 337 | /// One viewport row (0-based), self-contained: leading SGR reset, no |
| 329 | /// trailing newline. Delta payloads are built from these. | 338 | /// trailing newline. Delta payloads are built from these. |
| 330 | pub fn dumpVtRow(self: *Engine, alloc: std.mem.Allocator, y: u16) ![]u8 { | 339 | pub fn dumpVtRow(self: *Engine, alloc: std.mem.Allocator, y: u16) ![]u8 { |
| @@ -677,6 +686,34 @@ pub const Engine = struct { | |||
| 677 | } | 686 | } |
| 678 | }; | 687 | }; |
| 679 | 688 | ||
| 689 | test "Engine: dumpVtFrom of the rows under a bar equals dumpVt of the bare grid" { | ||
| 690 | // The equivalence the e2e convergence check stands on: a tty client's | ||
| 691 | // screen is one label-bar row on top of the session grid, so the | ||
| 692 | // harness renders the client stream, drops the bar, and diffs the rest | ||
| 693 | // against the daemon's dump. That diff is byte-exact only if slicing | ||
| 694 | // the viewport formats identically to a grid that never had the row. | ||
| 695 | const alloc = std.testing.allocator; | ||
| 696 | var barred = try Engine.init(alloc, .{ .cols = 20, .rows = 3 }); | ||
| 697 | defer barred.deinit(); | ||
| 698 | barred.feed("\x1b[7m 1> x [up]\x1b[0m\r\n\x1b[31mred\x1b[0m row\r\nplain"); | ||
| 699 | var bare = try Engine.init(alloc, .{ .cols = 20, .rows = 2 }); | ||
| 700 | defer bare.deinit(); | ||
| 701 | bare.feed("\x1b[31mred\x1b[0m row\r\nplain"); | ||
| 702 | |||
| 703 | const sliced = try barred.dumpVtFrom(alloc, 1); | ||
| 704 | defer alloc.free(sliced); | ||
| 705 | const whole = try bare.dumpVt(alloc); | ||
| 706 | defer alloc.free(whole); | ||
| 707 | try std.testing.expectEqualStrings(whole, sliced); | ||
| 708 | |||
| 709 | // From row 0 it is dumpVt itself. | ||
| 710 | const all = try barred.dumpVtFrom(alloc, 0); | ||
| 711 | defer alloc.free(all); | ||
| 712 | const vt_dump = try barred.dumpVt(alloc); | ||
| 713 | defer alloc.free(vt_dump); | ||
| 714 | try std.testing.expectEqualStrings(vt_dump, all); | ||
| 715 | } | ||
| 716 | |||
| 680 | test "Engine: selection extraction normalizes direction and unwraps soft wraps" { | 717 | test "Engine: selection extraction normalizes direction and unwraps soft wraps" { |
| 681 | const alloc = std.testing.allocator; | 718 | const alloc = std.testing.allocator; |
| 682 | var e = try Engine.init(alloc, .{ .cols = 5, .rows = 3 }); | 719 | var e = try Engine.init(alloc, .{ .cols = 5, .rows = 3 }); |
src/tui/interact.zig
| Old | New | ||
|---|---|---|---|
| @@ -1106,9 +1106,9 @@ pub const Core = struct { | |||
| 1106 | /// would be blanked by a whole-line clear, so every painter addresses | 1106 | /// would be blanked by a whole-line clear, so every painter addresses |
| 1107 | /// its own column origin. The plain client and `mux a` leave it 0. | 1107 | /// its own column origin. The plain client and `mux a` leave it 0. |
| 1108 | col_off: u16 = 0, | 1108 | col_off: u16 = 0, |
| 1109 | /// Whether this Core may clear the whole screen. A plain client or a | 1109 | /// Whether this Core may clear the whole screen. A piped wall owns |
| 1110 | /// one-tile wall owns every row; a tile among neighbours does not. Set | 1110 | /// every row; a tile on a tty sits under its label bar and does not. |
| 1111 | /// by the driver that knows the layout, not inferred from `row_off`. | 1111 | /// Set by the driver that knows the layout, not inferred from `row_off`. |
| 1112 | owns_screen: bool = true, | 1112 | owns_screen: bool = true, |
| 1113 | /// The replay core (replica.zig). Public because the driver reads it: a | 1113 | /// The replay core (replica.zig). Public because the driver reads it: a |
| 1114 | /// reconnect quotes `last_seq`/`session_epoch`, and `state_since_attach` | 1114 | /// reconnect quotes `last_seq`/`session_epoch`, and `state_since_attach` |
src/tui/wall_layout.zig
| Old | New | ||
|---|---|---|---|
| @@ -15,13 +15,13 @@ const Shared = wv.Shared; | |||
| 15 | const Tile = wv.Tile; | 15 | const Tile = wv.Tile; |
| 16 | const Wall = wv.Wall; | 16 | const Wall = wv.Wall; |
| 17 | 17 | ||
| 18 | /// The daemon's row floor plus the label-bar arithmetic: a one-tile wall | 18 | /// The daemon's row floor plus the label-bar arithmetic. The bar follows |
| 19 | /// draws no bar so its floor is `min_session_rows`; two or more tiles each | 19 | /// the tty, not the tile count (`relayout`), so the caller says whether one |
| 20 | /// lose a row to a bar, so each stripe must hold that floor PLUS the bar | 20 | /// is drawn: under a bar each stripe must hold the floor PLUS that row, or |
| 21 | /// or the daemon drops the resize and the tile freezes on a stale grid. | 21 | /// the daemon drops the resize and the tile freezes on a stale grid. |
| 22 | pub fn wallFloors(live: usize) layout.Floors { | 22 | pub fn wallFloors(bar: bool) layout.Floors { |
| 23 | return .{ | 23 | return .{ |
| 24 | .rows = proto.min_session_rows + @as(u16, @intFromBool(live > 1)), | 24 | .rows = proto.min_session_rows + @as(u16, @intFromBool(bar)), |
| 25 | .cols = proto.min_session_cols, | 25 | .cols = proto.min_session_cols, |
| 26 | }; | 26 | }; |
| 27 | } | 27 | } |
| @@ -62,7 +62,7 @@ pub fn doResize(w: Wall, sel: usize, d: interact.PrefixFilter.Dir) bool { | |||
| 62 | // Focus gains from the sibling toward `ld`; if none there (edge | 62 | // Focus gains from the sibling toward `ld`; if none there (edge |
| 63 | // pane), try the opposite side — gaining from either sibling | 63 | // pane), try the opposite side — gaining from either sibling |
| 64 | // widens or tallens the focus. | 64 | // widens or tallens the focus. |
| 65 | if (w.shared.tree.resize(w.alloc, w.shared.size.rows, w.shared.size.cols, wallFloors(w.liveTiles().len), focus_tile, ld, 1)) { | 65 | if (w.shared.tree.resize(w.alloc, w.shared.size.rows, w.shared.size.cols, wallFloors(w.shared.is_tty), focus_tile, ld, 1)) { |
| 66 | moved = true; | 66 | moved = true; |
| 67 | } else { | 67 | } else { |
| 68 | const opp = switch (ld) { | 68 | const opp = switch (ld) { |
| @@ -71,7 +71,7 @@ pub fn doResize(w: Wall, sel: usize, d: interact.PrefixFilter.Dir) bool { | |||
| 71 | .left => layout.Dir.right, | 71 | .left => layout.Dir.right, |
| 72 | .up => layout.Dir.down, | 72 | .up => layout.Dir.down, |
| 73 | }; | 73 | }; |
| 74 | moved = w.shared.tree.resize(w.alloc, w.shared.size.rows, w.shared.size.cols, wallFloors(w.liveTiles().len), focus_tile, opp, 1); | 74 | moved = w.shared.tree.resize(w.alloc, w.shared.size.rows, w.shared.size.cols, wallFloors(w.shared.is_tty), focus_tile, opp, 1); |
| 75 | } | 75 | } |
| 76 | } else { | 76 | } else { |
| 77 | // Shrink: a neighbor on the same axis gains a cell from focus. | 77 | // Shrink: a neighbor on the same axis gains a cell from focus. |
| @@ -84,11 +84,11 @@ pub fn doResize(w: Wall, sel: usize, d: interact.PrefixFilter.Dir) bool { | |||
| 84 | .down => layout.Dir.up, | 84 | .down => layout.Dir.up, |
| 85 | }; | 85 | }; |
| 86 | if (layout.neighbor(flat, focus_tile, ld)) |nb| { | 86 | if (layout.neighbor(flat, focus_tile, ld)) |nb| { |
| 87 | moved = w.shared.tree.resize(w.alloc, w.shared.size.rows, w.shared.size.cols, wallFloors(w.liveTiles().len), nb, opp, 1); | 87 | moved = w.shared.tree.resize(w.alloc, w.shared.size.rows, w.shared.size.cols, wallFloors(w.shared.is_tty), nb, opp, 1); |
| 88 | } | 88 | } |
| 89 | if (!moved) { | 89 | if (!moved) { |
| 90 | if (layout.neighbor(flat, focus_tile, opp)) |nb| { | 90 | if (layout.neighbor(flat, focus_tile, opp)) |nb| { |
| 91 | moved = w.shared.tree.resize(w.alloc, w.shared.size.rows, w.shared.size.cols, wallFloors(w.liveTiles().len), nb, ld, 1); | 91 | moved = w.shared.tree.resize(w.alloc, w.shared.size.rows, w.shared.size.cols, wallFloors(w.shared.is_tty), nb, ld, 1); |
| 92 | } | 92 | } |
| 93 | } | 93 | } |
| 94 | } | 94 | } |
| @@ -124,10 +124,12 @@ pub fn relayout(w: Wall, sel: usize) void { | |||
| 124 | for (w.livePresent()) |p| { | 124 | for (w.livePresent()) |p| { |
| 125 | if (p) live += 1; | 125 | if (p) live += 1; |
| 126 | } | 126 | } |
| 127 | // A one-tile wall owns every row and draws no label bar; two or more | 127 | // The bar follows the tty, not the tile count: it is what names the |
| 128 | // tiles each lose their top row to one. Fullscreen is the same: one | 128 | // session on screen, and hiding it when one tile was visible left a |
| 129 | // visible pane, no bar — the plain client's byte stream. | 129 | // solo or fullscreened session anonymous. A piped `mux` never draws |
| 130 | w.shared.label_rows = if (w.shared.fullscreen or live <= 1) 0 else 1; | 130 | // one — its byte stream is a script's input, and a bar in it would be |
| 131 | // bytes the session never wrote. | ||
| 132 | w.shared.label_rows = @intFromBool(w.shared.is_tty); | ||
| 131 | if (w.shared.is_tty) proto.writeAllFd(w.shared.out_fd, "\x1b[?25l\x1b[H\x1b[2J") catch {}; | 133 | if (w.shared.is_tty) proto.writeAllFd(w.shared.out_fd, "\x1b[?25l\x1b[H\x1b[2J") catch {}; |
| 132 | // The screen the popup was on has just been cleared, so the next paint | 134 | // The screen the popup was on has just been cleared, so the next paint |
| 133 | // owes it however unchanged its rows are. | 135 | // owes it however unchanged its rows are. |
| @@ -138,12 +140,12 @@ pub fn relayout(w: Wall, sel: usize) void { | |||
| 138 | } | 140 | } |
| 139 | // The base flat (null) is always computed so `focus_dir` can read | 141 | // The base flat (null) is always computed so `focus_dir` can read |
| 140 | // adjacency from the real layout while fullscreened. | 142 | // adjacency from the real layout while fullscreened. |
| 141 | if (w.shared.tree.flatten(w.alloc, w.shared.size.rows, w.shared.size.cols, wallFloors(live), null)) |base| { | 143 | if (w.shared.tree.flatten(w.alloc, w.shared.size.rows, w.shared.size.cols, wallFloors(w.shared.is_tty), null)) |base| { |
| 142 | if (w.shared.base_flat) |*old| old.deinit(w.shared.flat_alloc); | 144 | if (w.shared.base_flat) |*old| old.deinit(w.shared.flat_alloc); |
| 143 | w.shared.base_flat = base; | 145 | w.shared.base_flat = base; |
| 144 | } else |_| {} | 146 | } else |_| {} |
| 145 | const fs_arg: ?u8 = if (w.shared.fullscreen) @intCast(sel) else null; | 147 | const fs_arg: ?u8 = if (w.shared.fullscreen) @intCast(sel) else null; |
| 146 | var cut = w.shared.tree.flatten(w.alloc, w.shared.size.rows, w.shared.size.cols, wallFloors(live), fs_arg); | 148 | var cut = w.shared.tree.flatten(w.alloc, w.shared.size.rows, w.shared.size.cols, wallFloors(w.shared.is_tty), fs_arg); |
| 147 | if (cut) |_| {} else |e| { | 149 | if (cut) |_| {} else |e| { |
| 148 | // A split or a resize key is an OPERATION the user asked for, and | 150 | // A split or a resize key is an OPERATION the user asked for, and |
| 149 | // refusing leaves the screen as it was. A SIGWINCH is not: the terminal | 151 | // refusing leaves the screen as it was. A SIGWINCH is not: the terminal |
| @@ -151,11 +153,8 @@ pub fn relayout(w: Wall, sel: usize) void { | |||
| 151 | // bottom of a cleared screen. Degrade to the focused tile whole and the | 153 | // bottom of a cleared screen. Degrade to the focused tile whole and the |
| 152 | // rest at 0x0. The TREE is untouched, so growing back re-cuts. | 154 | // rest at 0x0. The TREE is untouched, so growing back re-cuts. |
| 153 | if (e == error.TooSmall and fs_arg == null) { | 155 | if (e == error.TooSmall and fs_arg == null) { |
| 154 | if (w.shared.tree.flatten(w.alloc, w.shared.size.rows, w.shared.size.cols, wallFloors(live), @intCast(sel))) |only| { | 156 | if (w.shared.tree.flatten(w.alloc, w.shared.size.rows, w.shared.size.cols, wallFloors(w.shared.is_tty), @intCast(sel))) |only| { |
| 155 | cut = only; | 157 | cut = only; |
| 156 | // One visible pane draws no bar. Left at 1, `viewRows` | ||
| 157 | // would owe the daemon a row the tile does not have. | ||
| 158 | w.shared.label_rows = 0; | ||
| 159 | } else |_| {} | 158 | } else |_| {} |
| 160 | } | 159 | } |
| 161 | } | 160 | } |
| @@ -180,8 +179,8 @@ pub fn relayout(w: Wall, sel: usize) void { | |||
| 180 | if (p) wv.ring(t); | 179 | if (p) wv.ring(t); |
| 181 | } | 180 | } |
| 182 | // ...except the tiles with no pump left to hear it: their bars are the | 181 | // ...except the tiles with no pump left to hear it: their bars are the |
| 183 | // keyboard's. No `label_rows` guard, unlike `setFocus` — the screen was just | 182 | // keyboard's. No `label_rows` guard, unlike `setFocus` — the screen was |
| 184 | // cleared, so on a one-tile wall that bar is all that says the target refused. | 183 | // just cleared, and that bar is all that says the target refused. |
| 185 | wv.paintDeadBarsLocked(w.liveTiles()); | 184 | wv.paintDeadBarsLocked(w.liveTiles()); |
| 186 | } | 185 | } |
| 187 | 186 | ||
| @@ -419,7 +418,7 @@ fn seedAttempt( | |||
| 419 | return null; | 418 | return null; |
| 420 | }; | 419 | }; |
| 421 | } | 420 | } |
| 422 | if (parsed.tree.flatten(alloc, shared.size.rows, shared.size.cols, wallFloors(total), null)) |flat| { | 421 | if (parsed.tree.flatten(alloc, shared.size.rows, shared.size.cols, wallFloors(shared.is_tty), null)) |flat| { |
| 423 | var f = flat; | 422 | var f = flat; |
| 424 | f.deinit(alloc); | 423 | f.deinit(alloc); |
| 425 | } else |_| { | 424 | } else |_| { |
src/tui/wall_pump.zig
| Old | New | ||
|---|---|---|---|
| @@ -359,8 +359,9 @@ fn redial( | |||
| 359 | transport.close(); | 359 | transport.close(); |
| 360 | state.* = .reconnecting; | 360 | state.* = .reconnecting; |
| 361 | wv.paintLabel(t, state.*); | 361 | wv.paintLabel(t, state.*); |
| 362 | // The one-tile wall has no label bar to read this off. `banner` is gated | 362 | // The bar above already says `reconnecting`; the banner repeats it in |
| 363 | // on the sink, so a stripe's redial writes nothing here. | 363 | // the tile's corner, where the eye is. `banner` is gated on the sink, |
| 364 | // so a redial a relayout superseded writes nothing here. | ||
| 364 | core.banner("[reconnecting]"); | 365 | core.banner("[reconnecting]"); |
| 365 | // The resync's own paint is what will arrive, so a history page held | 366 | // The resync's own paint is what will arrive, so a history page held |
| 366 | // here would be silently replaced a moment later. | 367 | // here would be silently replaced a moment later. |
src/tui/wall_test_host.zig
| Old | New | ||
|---|---|---|---|
| @@ -367,8 +367,9 @@ test "applyHostList: a wall too thin for a second pane takes what fits and retai | |||
| 367 | const alloc = std.testing.allocator; | 367 | const alloc = std.testing.allocator; |
| 368 | var shared: Shared = undefined; | 368 | var shared: Shared = undefined; |
| 369 | fixture.stoppedWall(alloc, &shared); | 369 | fixture.stoppedWall(alloc, &shared); |
| 370 | // Four rows: one pane fits (`min_session_rows`), two never can — each | 370 | // Four rows on a tty: one pane fits (`min_session_rows` plus its label |
| 371 | // would owe a label bar on top of that floor. | 371 | // bar), two never can — each would owe a bar on top of that floor. |
| 372 | shared.is_tty = true; | ||
| 372 | shared.size = .{ .cols = 80, .rows = 4 }; | 373 | shared.size = .{ .cols = 80, .rows = 4 }; |
| 373 | defer shared.tree.deinit(); | 374 | defer shared.tree.deinit(); |
| 374 | defer if (shared.last_flat) |*f| f.deinit(alloc); | 375 | defer if (shared.last_flat) |*f| f.deinit(alloc); |
| @@ -376,11 +377,14 @@ test "applyHostList: a wall too thin for a second pane takes what fits and retai | |||
| 376 | var tiles: [4]Tile = undefined; | 377 | var tiles: [4]Tile = undefined; |
| 377 | var present = [_]bool{false} ** 4; | 378 | var present = [_]bool{false} ** 4; |
| 378 | var live: usize = 0; | 379 | var live: usize = 0; |
| 379 | defer fixture.endPumps(tiles[0..live]); | 380 | // Freed only after `endPumps` (defers run last-declared-first): a |
| 381 | // pump's first act is to paint its `connecting` bar, and on a tty | ||
| 382 | // every wall has bars, so the label must outlive the thread. | ||
| 380 | defer for (tiles[0..live]) |*t| { | 383 | defer for (tiles[0..live]) |*t| { |
| 381 | alloc.free(t.r.session); | 384 | alloc.free(t.r.session); |
| 382 | alloc.free(t.r.label); | 385 | alloc.free(t.r.label); |
| 383 | }; | 386 | }; |
| 387 | defer fixture.endPumps(tiles[0..live]); | ||
| 384 | var table = [_]Host{fixture.testHost(&shared, "box", "/tmp/box.sock")}; | 388 | var table = [_]Host{fixture.testHost(&shared, "box", "/tmp/box.sock")}; |
| 385 | fixture.setList(&table[0], "a\nb\n"); | 389 | fixture.setList(&table[0], "a\nb\n"); |
| 386 | 390 | ||
src/tui/wall_test_layout.zig
| Old | New | ||
|---|---|---|---|
| @@ -21,7 +21,7 @@ test "the tree's stacked cut splits rows with the remainder at the top" { | |||
| 21 | try tree.addFirst(0); | 21 | try tree.addFirst(0); |
| 22 | try tree.insert(0, 1); | 22 | try tree.insert(0, 1); |
| 23 | try tree.insert(1, 2); | 23 | try tree.insert(1, 2); |
| 24 | var f = try tree.flatten(alloc, 25, 80, wall_layout.wallFloors(3), null); | 24 | var f = try tree.flatten(alloc, 25, 80, wall_layout.wallFloors(true), null); |
| 25 | defer f.deinit(alloc); | 25 | defer f.deinit(alloc); |
| 26 | try std.testing.expectEqual(layout.Rect{ .top = 0, .left = 0, .rows = 9, .cols = 80 }, f.rectOf(0).?); | 26 | try std.testing.expectEqual(layout.Rect{ .top = 0, .left = 0, .rows = 9, .cols = 80 }, f.rectOf(0).?); |
| 27 | try std.testing.expectEqual(layout.Rect{ .top = 9, .left = 0, .rows = 8, .cols = 80 }, f.rectOf(1).?); | 27 | try std.testing.expectEqual(layout.Rect{ .top = 9, .left = 0, .rows = 8, .cols = 80 }, f.rectOf(1).?); |
| @@ -35,7 +35,7 @@ test "the tree refuses a wall that cannot show a content row" { | |||
| 35 | // 12 tiles over 23 rows: 1 row each — a label with no content. | 35 | // 12 tiles over 23 rows: 1 row each — a label with no content. |
| 36 | try tree.addFirst(0); | 36 | try tree.addFirst(0); |
| 37 | for (1..12) |i| try tree.insert(@intCast(i - 1), @intCast(i)); | 37 | for (1..12) |i| try tree.insert(@intCast(i - 1), @intCast(i)); |
| 38 | try std.testing.expectError(error.TooSmall, tree.flatten(alloc, 23, 80, wall_layout.wallFloors(12), null)); | 38 | try std.testing.expectError(error.TooSmall, tree.flatten(alloc, 23, 80, wall_layout.wallFloors(true), null)); |
| 39 | } | 39 | } |
| 40 | 40 | ||
| 41 | test "the tree refuses a multi-tile cut too thin for the daemon's row floor" { | 41 | test "the tree refuses a multi-tile cut too thin for the daemon's row floor" { |
| @@ -47,11 +47,11 @@ test "the tree refuses a multi-tile cut too thin for the daemon's row floor" { | |||
| 47 | // Two tiles on five rows: each rect is two rows, and under a label | 47 | // Two tiles on five rows: each rect is two rows, and under a label |
| 48 | // bar that is one content row — below `min_session_rows`, so the | 48 | // bar that is one content row — below `min_session_rows`, so the |
| 49 | // daemon refuses the resize and the tile freezes on a stale grid. | 49 | // daemon refuses the resize and the tile freezes on a stale grid. |
| 50 | try std.testing.expectError(error.TooSmall, tree.flatten(alloc, 5, 80, wall_layout.wallFloors(2), null)); | 50 | try std.testing.expectError(error.TooSmall, tree.flatten(alloc, 5, 80, wall_layout.wallFloors(true), null)); |
| 51 | try std.testing.expectError(error.TooSmall, tree.flatten(alloc, 4, 80, wall_layout.wallFloors(2), null)); | 51 | try std.testing.expectError(error.TooSmall, tree.flatten(alloc, 4, 80, wall_layout.wallFloors(true), null)); |
| 52 | // Six rows is the first cut that fits: three a rect, two of them | 52 | // Six rows is the first cut that fits: three a rect, two of them |
| 53 | // content under the bar — exactly the daemon's row floor. | 53 | // content under the bar — exactly the daemon's row floor. |
| 54 | var f = try tree.flatten(alloc, 6, 80, wall_layout.wallFloors(2), null); | 54 | var f = try tree.flatten(alloc, 6, 80, wall_layout.wallFloors(true), null); |
| 55 | defer f.deinit(alloc); | 55 | defer f.deinit(alloc); |
| 56 | try std.testing.expectEqual(@as(u16, 3), f.rectOf(0).?.rows); | 56 | try std.testing.expectEqual(@as(u16, 3), f.rectOf(0).?.rows); |
| 57 | try std.testing.expectEqual(@as(u16, 3), f.rectOf(1).?.rows); | 57 | try std.testing.expectEqual(@as(u16, 3), f.rectOf(1).?.rows); |
| @@ -75,18 +75,18 @@ test "the tree refuses a multi-tile cut too thin for the daemon's row floor" { | |||
| 75 | }; | 75 | }; |
| 76 | try std.testing.expectEqual(proto.min_session_rows, t0.viewRows()); | 76 | try std.testing.expectEqual(proto.min_session_rows, t0.viewRows()); |
| 77 | try std.testing.expectEqual(proto.min_session_rows, t1.viewRows()); | 77 | try std.testing.expectEqual(proto.min_session_rows, t1.viewRows()); |
| 78 | // A one-tile wall draws no bar, so its floor is the daemon's alone. | 78 | // A piped wall draws no bar, so its floor is the daemon's alone. |
| 79 | var one = layout.Tree.init(alloc); | 79 | var one = layout.Tree.init(alloc); |
| 80 | defer one.deinit(); | 80 | defer one.deinit(); |
| 81 | try one.addFirst(0); | 81 | try one.addFirst(0); |
| 82 | var of = try one.flatten(alloc, 2, 80, wall_layout.wallFloors(1), null); | 82 | var of = try one.flatten(alloc, 2, 80, wall_layout.wallFloors(false), null); |
| 83 | defer of.deinit(alloc); | 83 | defer of.deinit(alloc); |
| 84 | try std.testing.expectEqual(@as(u16, 2), of.rectOf(0).?.rows); | 84 | try std.testing.expectEqual(@as(u16, 2), of.rectOf(0).?.rows); |
| 85 | } | 85 | } |
| 86 | 86 | ||
| 87 | test "viewRows: a one-tile wall keeps every row, a multi-tile wall loses the label bar" { | 87 | test "viewRows: a bar-less wall keeps every row, a barred tile loses one" { |
| 88 | var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false }; | 88 | var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false }; |
| 89 | // A one-tile wall: no label bar, the tile claims every row. | 89 | // No bar (a pipe): the tile claims every row. |
| 90 | shared.label_rows = 0; | 90 | shared.label_rows = 0; |
| 91 | var t0 = Tile{ | 91 | var t0 = Tile{ |
| 92 | .r = .{ .target = .{ .sock = "/tmp/x" }, .label = "x", .session = "" }, | 92 | .r = .{ .target = .{ .sock = "/tmp/x" }, .label = "x", .session = "" }, |
| @@ -98,7 +98,7 @@ test "viewRows: a one-tile wall keeps every row, a multi-tile wall loses the lab | |||
| 98 | }; | 98 | }; |
| 99 | try std.testing.expectEqual(@as(u16, 24), t0.viewRows()); | 99 | try std.testing.expectEqual(@as(u16, 24), t0.viewRows()); |
| 100 | 100 | ||
| 101 | // A two-tile wall: each tile loses one row to the label bar. | 101 | // Under a bar (any tty wall): each tile loses one row to it. |
| 102 | shared.label_rows = 1; | 102 | shared.label_rows = 1; |
| 103 | var t1 = Tile{ | 103 | var t1 = Tile{ |
| 104 | .r = .{ .target = .{ .sock = "/tmp/x" }, .label = "x", .session = "" }, | 104 | .r = .{ .target = .{ .sock = "/tmp/x" }, .label = "x", .session = "" }, |
| @@ -122,7 +122,7 @@ test "viewRows: a one-tile wall keeps every row, a multi-tile wall loses the lab | |||
| 122 | 122 | ||
| 123 | test "relayout sets resize_pending on every live tile" { | 123 | test "relayout sets resize_pending on every live tile" { |
| 124 | const alloc = std.testing.allocator; | 124 | const alloc = std.testing.allocator; |
| 125 | var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false }; | 125 | var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = true }; |
| 126 | shared.tree = layout.Tree.init(alloc); | 126 | shared.tree = layout.Tree.init(alloc); |
| 127 | shared.flat_alloc = alloc; | 127 | shared.flat_alloc = alloc; |
| 128 | defer shared.tree.deinit(); | 128 | defer shared.tree.deinit(); |
| @@ -147,7 +147,7 @@ test "relayout sets resize_pending on every live tile" { | |||
| 147 | }; | 147 | }; |
| 148 | } | 148 | } |
| 149 | wall_layout.relayout(fixture.wallAll(alloc, tiles, present, &shared), 0); | 149 | wall_layout.relayout(fixture.wallAll(alloc, tiles, present, &shared), 0); |
| 150 | // Two tiles: a label bar appears. | 150 | // A tty wall draws its label bar. |
| 151 | try std.testing.expectEqual(@as(u8, 1), shared.label_rows); | 151 | try std.testing.expectEqual(@as(u8, 1), shared.label_rows); |
| 152 | // Every tile is doorbelled: its pump sends the new rect. | 152 | // Every tile is doorbelled: its pump sends the new rect. |
| 153 | try std.testing.expect(tiles[0].resize_pending); | 153 | try std.testing.expect(tiles[0].resize_pending); |
| @@ -281,17 +281,24 @@ test "a terminal too small for the cut falls back to the focused pane, and grows | |||
| 281 | if (rows == 20) { | 281 | if (rows == 20) { |
| 282 | // The degrade: the focused pane whole, every other pane at 0x0 | 282 | // The degrade: the focused pane whole, every other pane at 0x0 |
| 283 | // — a rect that paints nothing and claims no size — and the | 283 | // — a rect that paints nothing and claims no size — and the |
| 284 | // tree untouched underneath. | 284 | // tree untouched underneath. The bar stays: it follows the |
| 285 | try std.testing.expectEqual(@as(u16, 0), shared.label_rows); | 285 | // tty, and the one visible pane still deserves its name. |
| 286 | try std.testing.expectEqual(@as(u16, 1), shared.label_rows); | ||
| 286 | try std.testing.expectEqual(@as(u16, 20), tiles[0].rect.rows); | 287 | try std.testing.expectEqual(@as(u16, 20), tiles[0].rect.rows); |
| 287 | try std.testing.expectEqual(@as(u16, 80), tiles[0].rect.cols); | 288 | try std.testing.expectEqual(@as(u16, 80), tiles[0].rect.cols); |
| 288 | try std.testing.expectEqual(@as(u16, 0), tiles[0].rect.top); | 289 | try std.testing.expectEqual(@as(u16, 0), tiles[0].rect.top); |
| 289 | for (tiles[1..]) |*t| try std.testing.expectEqual(@as(u16, 0), t.rect.rows); | 290 | for (tiles[1..]) |*t| try std.testing.expectEqual(@as(u16, 0), t.rect.rows); |
| 290 | // One pane, so no bars: every row of the grid the wall itself | 291 | // The focused pane's bar is the only ink: a hidden tile's |
| 291 | // wrote is blank, hidden tiles included. | 292 | // 0-wide rect can paint no bar, so every other row the wall |
| 293 | // itself wrote is blank. | ||
| 294 | const l0 = WallScreen.line(dump, 0) orelse return error.NoSuchRow; | ||
| 295 | try std.testing.expect(std.mem.indexOf(u8, l0, tiles[0].r.label) != null); | ||
| 292 | var it = std.mem.splitScalar(u8, dump, '\n'); | 296 | var it = std.mem.splitScalar(u8, dump, '\n'); |
| 293 | while (it.next()) |l| | 297 | var row: usize = 0; |
| 298 | while (it.next()) |l| : (row += 1) { | ||
| 299 | if (row == 0) continue; | ||
| 294 | try std.testing.expectEqual(@as(usize, 0), std.mem.trim(u8, l, " ").len); | 300 | try std.testing.expectEqual(@as(usize, 0), std.mem.trim(u8, l, " ").len); |
| 301 | } | ||
| 295 | } else { | 302 | } else { |
| 296 | try std.testing.expectEqual(@as(u16, 1), shared.label_rows); | 303 | try std.testing.expectEqual(@as(u16, 1), shared.label_rows); |
| 297 | // Seven bars, each on the first row of the pane it names. | 304 | // Seven bars, each on the first row of the pane it names. |
| @@ -328,7 +335,7 @@ test "fullscreen gives the focused tile the whole terminal and hides the rest" { | |||
| 328 | } | 335 | } |
| 329 | shared.fullscreen = true; | 336 | shared.fullscreen = true; |
| 330 | wall_layout.relayout(fixture.wallAll(alloc, tiles, &present, &shared), 0); | 337 | wall_layout.relayout(fixture.wallAll(alloc, tiles, &present, &shared), 0); |
| 331 | // No label bar: the fullscreened pane is a one-tile wall. | 338 | // No label bar on a pipe, fullscreened or not — the bar is the tty's. |
| 332 | try std.testing.expectEqual(@as(u8, 0), shared.label_rows); | 339 | try std.testing.expectEqual(@as(u8, 0), shared.label_rows); |
| 333 | // Tile 0 gets the whole terminal; tile 1 gets nothing. | 340 | // Tile 0 gets the whole terminal; tile 1 gets nothing. |
| 334 | try std.testing.expectEqual(@as(u16, 24), tiles[0].rect.rows); | 341 | try std.testing.expectEqual(@as(u16, 24), tiles[0].rect.rows); |
| @@ -341,7 +348,7 @@ test "fullscreen gives the focused tile the whole terminal and hides the rest" { | |||
| 341 | 348 | ||
| 342 | test "toggle off fullscreen restores the real rects" { | 349 | test "toggle off fullscreen restores the real rects" { |
| 343 | const alloc = std.testing.allocator; | 350 | const alloc = std.testing.allocator; |
| 344 | var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false }; | 351 | var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = true }; |
| 345 | shared.tree = layout.Tree.init(alloc); | 352 | shared.tree = layout.Tree.init(alloc); |
| 346 | shared.flat_alloc = alloc; | 353 | shared.flat_alloc = alloc; |
| 347 | defer shared.tree.deinit(); | 354 | defer shared.tree.deinit(); |
| @@ -366,8 +373,8 @@ test "toggle off fullscreen restores the real rects" { | |||
| 366 | wall_layout.relayout(fixture.wallAll(alloc, tiles, &present, &shared), 0); | 373 | wall_layout.relayout(fixture.wallAll(alloc, tiles, &present, &shared), 0); |
| 367 | shared.fullscreen = false; | 374 | shared.fullscreen = false; |
| 368 | wall_layout.relayout(fixture.wallAll(alloc, tiles, &present, &shared), 0); | 375 | wall_layout.relayout(fixture.wallAll(alloc, tiles, &present, &shared), 0); |
| 369 | // Two tiles again: label bar back, both have real rects (stacked: | 376 | // Two tiles again: the bar (never gone on a tty) and both tiles' |
| 370 | // full width, half height each). | 377 | // real rects are back (stacked: full width, half height each). |
| 371 | try std.testing.expectEqual(@as(u8, 1), shared.label_rows); | 378 | try std.testing.expectEqual(@as(u8, 1), shared.label_rows); |
| 372 | try std.testing.expectEqual(@as(u16, 80), tiles[0].rect.cols); | 379 | try std.testing.expectEqual(@as(u16, 80), tiles[0].rect.cols); |
| 373 | try std.testing.expectEqual(@as(u16, 80), tiles[1].rect.cols); | 380 | try std.testing.expectEqual(@as(u16, 80), tiles[1].rect.cols); |
| @@ -375,6 +382,46 @@ test "toggle off fullscreen restores the real rects" { | |||
| 375 | try std.testing.expect(tiles[1].rect.rows > 0); | 382 | try std.testing.expect(tiles[1].rect.rows > 0); |
| 376 | } | 383 | } |
| 377 | 384 | ||
| 385 | test "the label bar follows the tty, not the tile count" { | ||
| 386 | // The bar is what names the session on screen, so hiding it when one | ||
| 387 | // tile is visible left a zoomed or solo session anonymous. On a tty it | ||
| 388 | // is always drawn — one tile, many, or fullscreen. A piped `mux` never | ||
| 389 | // draws one: its byte stream is a script's input, and a bar in it | ||
| 390 | // would be bytes the session never wrote. | ||
| 391 | const alloc = std.testing.allocator; | ||
| 392 | var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = true }; | ||
| 393 | shared.tree = layout.Tree.init(alloc); | ||
| 394 | shared.flat_alloc = alloc; | ||
| 395 | defer shared.tree.deinit(); | ||
| 396 | defer if (shared.last_flat) |*f| f.deinit(alloc); | ||
| 397 | defer if (shared.base_flat) |*f| f.deinit(alloc); | ||
| 398 | try shared.tree.addFirst(0); | ||
| 399 | const tiles = try alloc.alloc(Tile, 1); | ||
| 400 | defer alloc.free(tiles); | ||
| 401 | var present = [_]bool{true}; | ||
| 402 | tiles[0] = Tile{ | ||
| 403 | .r = .{ .target = .{ .sock = "/tmp/x" }, .label = "x", .session = "" }, | ||
| 404 | .rect = .{ .top = 0, .left = 0, .rows = 24, .cols = 80 }, | ||
| 405 | .shared = &shared, | ||
| 406 | .idx = 0, | ||
| 407 | .wake_r = -1, | ||
| 408 | .wake_w = -1, | ||
| 409 | }; | ||
| 410 | // One tile on a tty: the bar stays, and the session runs a row short. | ||
| 411 | wall_layout.relayout(fixture.wallAll(alloc, tiles, &present, &shared), 0); | ||
| 412 | try std.testing.expectEqual(@as(u16, 1), shared.label_rows); | ||
| 413 | try std.testing.expectEqual(@as(u16, 23), tiles[0].viewRows()); | ||
| 414 | // Fullscreen is still a tty view, so the zoomed tile keeps its name. | ||
| 415 | shared.fullscreen = true; | ||
| 416 | wall_layout.relayout(fixture.wallAll(alloc, tiles, &present, &shared), 0); | ||
| 417 | try std.testing.expectEqual(@as(u16, 1), shared.label_rows); | ||
| 418 | // A pipe draws no bar whatever the count. | ||
| 419 | shared.fullscreen = false; | ||
| 420 | shared.is_tty = false; | ||
| 421 | wall_layout.relayout(fixture.wallAll(alloc, tiles, &present, &shared), 0); | ||
| 422 | try std.testing.expectEqual(@as(u16, 0), shared.label_rows); | ||
| 423 | } | ||
| 424 | |||
| 378 | test "focus_dir while fullscreened follows the focus" { | 425 | test "focus_dir while fullscreened follows the focus" { |
| 379 | const alloc = std.testing.allocator; | 426 | const alloc = std.testing.allocator; |
| 380 | var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false }; | 427 | var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false }; |
| @@ -507,7 +554,7 @@ test "resize refuses while fullscreened" { | |||
| 507 | 554 | ||
| 508 | test "a pump's answer that grew the wall re-cuts it; a mere focus move does not" { | 555 | test "a pump's answer that grew the wall re-cuts it; a mere focus move does not" { |
| 509 | const alloc = std.testing.allocator; | 556 | const alloc = std.testing.allocator; |
| 510 | var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false }; | 557 | var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = true }; |
| 511 | defer if (shared.last_flat) |*f| f.deinit(shared.flat_alloc); | 558 | defer if (shared.last_flat) |*f| f.deinit(shared.flat_alloc); |
| 512 | defer if (shared.base_flat) |*f| f.deinit(shared.flat_alloc); | 559 | defer if (shared.base_flat) |*f| f.deinit(shared.flat_alloc); |
| 513 | const tiles = try alloc.alloc(Tile, 2); | 560 | const tiles = try alloc.alloc(Tile, 2); |
| @@ -545,7 +592,7 @@ test "a pump's answer that grew the wall re-cuts it; a mere focus move does not" | |||
| 545 | try std.testing.expect(tiles[0].claim_pending.load(.acquire)); | 592 | try std.testing.expect(tiles[0].claim_pending.load(.acquire)); |
| 546 | } | 593 | } |
| 547 | 594 | ||
| 548 | test "a one-tile wall draws no label bar and paints row 1" { | 595 | test "a piped wall draws no label bar and paints row 1" { |
| 549 | const alloc = std.testing.allocator; | 596 | const alloc = std.testing.allocator; |
| 550 | var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false }; | 597 | var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false }; |
| 551 | shared.tree = layout.Tree.init(alloc); | 598 | shared.tree = layout.Tree.init(alloc); |
| @@ -568,7 +615,7 @@ test "a one-tile wall draws no label bar and paints row 1" { | |||
| 568 | .wake_w = -1, | 615 | .wake_w = -1, |
| 569 | }; | 616 | }; |
| 570 | wall_layout.relayout(fixture.wallAll(alloc, tiles, present, &shared), 0); | 617 | wall_layout.relayout(fixture.wallAll(alloc, tiles, present, &shared), 0); |
| 571 | // One tile: no label bar, every row is content. | 618 | // A pipe: no label bar, every row is content the script can read. |
| 572 | try std.testing.expectEqual(@as(u8, 0), shared.label_rows); | 619 | try std.testing.expectEqual(@as(u8, 0), shared.label_rows); |
| 573 | try std.testing.expectEqual(@as(u16, 24), tiles[0].viewRows()); | 620 | try std.testing.expectEqual(@as(u16, 24), tiles[0].viewRows()); |
| 574 | } | 621 | } |
| @@ -758,7 +805,7 @@ test "seed: the saved cut is the tree before any host has answered - orientation | |||
| 758 | try std.testing.expectEqual(@as(usize, 0), plan.panes[0].?.host); | 805 | try std.testing.expectEqual(@as(usize, 0), plan.panes[0].?.host); |
| 759 | try std.testing.expectEqualStrings("a", plan.panes[0].?.session); | 806 | try std.testing.expectEqualStrings("a", plan.panes[0].?.session); |
| 760 | try std.testing.expectEqual(@as(usize, 1), plan.panes[2].?.host); | 807 | try std.testing.expectEqual(@as(usize, 1), plan.panes[2].?.host); |
| 761 | var f = try shared.tree.flatten(alloc, 30, 100, wall_layout.wallFloors(3), null); | 808 | var f = try shared.tree.flatten(alloc, 30, 100, wall_layout.wallFloors(true), null); |
| 762 | defer f.deinit(alloc); | 809 | defer f.deinit(alloc); |
| 763 | // The saved orientation: pane 0 beside the stack of 1 over 2. The old | 810 | // The saved orientation: pane 0 beside the stack of 1 over 2. The old |
| 764 | // heal lost both to the default cut; the rects are the pin. | 811 | // heal lost both to the default cut; the rects are the pin. |
| @@ -867,6 +914,9 @@ test "seed: more leaves than the terminal can cut seeds what fits and counts the | |||
| 867 | // Nine stacked panes at 3 rows each need 27; give them 13 rows, which | 914 | // Nine stacked panes at 3 rows each need 27; give them 13 rows, which |
| 868 | // holds four. The boot must not refuse - relayout's TooSmall degrade | 915 | // holds four. The boot must not refuse - relayout's TooSmall degrade |
| 869 | // never runs on the first flatten, so the seed carries its own. | 916 | // never runs on the first flatten, so the seed carries its own. |
| 917 | // A tty, as every restoring wall is: only a terminal saves a sidecar, | ||
| 918 | // and the bar row is part of what each pane must clear. | ||
| 919 | shared.is_tty = true; | ||
| 870 | shared.size = .{ .cols = 100, .rows = 13 }; | 920 | shared.size = .{ .cols = 100, .rows = 13 }; |
| 871 | defer shared.tree.deinit(); | 921 | defer shared.tree.deinit(); |
| 872 | var table = [_]wall_host.Host{ | 922 | var table = [_]wall_host.Host{ |
src/tui/wall_test_wall.zig
| Old | New | ||
|---|---|---|---|
| @@ -18,7 +18,7 @@ const Tile = wv.Tile; | |||
| 18 | 18 | ||
| 19 | test "birthTile: a chord-born tile creates and offers no agent" { | 19 | test "birthTile: a chord-born tile creates and offers no agent" { |
| 20 | const alloc = std.testing.allocator; | 20 | const alloc = std.testing.allocator; |
| 21 | var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false }; | 21 | var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = true }; |
| 22 | defer shared.tree.deinit(); | 22 | defer shared.tree.deinit(); |
| 23 | try shared.tree.addFirst(0); | 23 | try shared.tree.addFirst(0); |
| 24 | var tiles: [2]Tile = undefined; | 24 | var tiles: [2]Tile = undefined; |
| @@ -233,10 +233,10 @@ test "n/p walk the wall's tiles: a hole is stepped over, and a wall of one has n | |||
| 233 | test "birthTile: no room is null and the tree is left as it was" { | 233 | test "birthTile: no room is null and the tree is left as it was" { |
| 234 | const alloc = std.testing.allocator; | 234 | const alloc = std.testing.allocator; |
| 235 | // Four rows cannot hold two stacked tiles under a bar: `wallFloors` | 235 | // Four rows cannot hold two stacked tiles under a bar: `wallFloors` |
| 236 | // asks min_session_rows + 1 of each. The leaf count pins the undo — | 236 | // asks min_session_rows + 1 of each on a tty. The leaf count pins the |
| 237 | // a birth that refused and kept its insert would leave the tree one | 237 | // undo — a birth that refused and kept its insert would leave the tree |
| 238 | // leaf wider than the wall. | 238 | // one leaf wider than the wall. |
| 239 | var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 4 }, .is_tty = false }; | 239 | var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 4 }, .is_tty = true }; |
| 240 | defer shared.tree.deinit(); | 240 | defer shared.tree.deinit(); |
| 241 | try shared.tree.addFirst(0); | 241 | try shared.tree.addFirst(0); |
| 242 | var tiles: [2]Tile = undefined; | 242 | var tiles: [2]Tile = undefined; |
| @@ -314,7 +314,7 @@ test "birthTile: a beside wall admits more panes than rows/3" { | |||
| 314 | try std.testing.expectEqual(@as(usize, n), shared.tree.count()); | 314 | try std.testing.expectEqual(@as(usize, n), shared.tree.count()); |
| 315 | // Admitted AND habitable: every pane keeps the full height and clears | 315 | // Admitted AND habitable: every pane keeps the full height and clears |
| 316 | // the column floor, which is what makes the refusal wrong. | 316 | // the column floor, which is what makes the refusal wrong. |
| 317 | const flat = try shared.tree.flatten(alloc, 24, 200, wall_layout.wallFloors(n), null); | 317 | const flat = try shared.tree.flatten(alloc, 24, 200, wall_layout.wallFloors(true), null); |
| 318 | defer flat.deinit(alloc); | 318 | defer flat.deinit(alloc); |
| 319 | try std.testing.expectEqual(@as(usize, n), flat.placed.len); | 319 | try std.testing.expectEqual(@as(usize, n), flat.placed.len); |
| 320 | for (flat.placed) |p| { | 320 | for (flat.placed) |p| { |
src/tui/wallview.zig
| Old | New | ||
|---|---|---|---|
| @@ -121,8 +121,8 @@ pub const Shared = struct { | |||
| 121 | /// Where the focused tile's last paint left the cursor. The cursor | 121 | /// Where the focused tile's last paint left the cursor. The cursor |
| 122 | /// belongs to the focus: an unfocused paint's last act puts it back here. | 122 | /// belongs to the focus: an unfocused paint's last act puts it back here. |
| 123 | cursor: Engine.CursorPos = .{ .x = 0, .y = 0 }, | 123 | cursor: Engine.CursorPos = .{ .x = 0, .y = 0 }, |
| 124 | /// One label bar per tile when the wall holds more than one session; a | 124 | /// One label bar per tile on a tty, however many tiles there are; a |
| 125 | /// one-tile wall owns every row and draws no bar. Set by `relayout`, so | 125 | /// piped wall owns every row and draws no bar. Set by `relayout`, so |
| 126 | /// `viewRows` and `core.row_off` agree with what is on the screen. | 126 | /// `viewRows` and `core.row_off` agree with what is on the screen. |
| 127 | label_rows: u16 = 1, | 127 | label_rows: u16 = 1, |
| 128 | /// The container tree that owns every tile's rect. The keyboard thread | 128 | /// The container tree that owns every tile's rect. The keyboard thread |
| @@ -476,7 +476,7 @@ fn stepLive(tiles: []const Tile, present: []const bool, sel: usize, forward: boo | |||
| 476 | } | 476 | } |
| 477 | 477 | ||
| 478 | /// The state is remembered on the tile: the keyboard repaints this bar | 478 | /// The state is remembered on the tile: the keyboard repaints this bar |
| 479 | /// with no frame, and only when the wall shows more than one tile. | 479 | /// with no frame, and only on a tty — a piped wall has no bar to paint. |
| 480 | pub fn paintLabel(t: *Tile, state: State) void { | 480 | pub fn paintLabel(t: *Tile, state: State) void { |
| 481 | t.shared.paint_mu.lock(); | 481 | t.shared.paint_mu.lock(); |
| 482 | defer t.shared.paint_mu.unlock(); | 482 | defer t.shared.paint_mu.unlock(); |
| @@ -507,7 +507,7 @@ pub fn labelText( | |||
| 507 | 507 | ||
| 508 | /// The label bar: inverse, full width, `N> LABEL [state]`, truncated at the | 508 | /// The label bar: inverse, full width, `N> LABEL [state]`, truncated at the |
| 509 | /// terminal edge. Caller holds `paint_mu`. Only called when `label_rows` is | 509 | /// terminal edge. Caller holds `paint_mu`. Only called when `label_rows` is |
| 510 | /// nonzero — a one-tile wall owns every row and has no bar. | 510 | /// nonzero — a piped wall owns every row and has no bar. |
| 511 | pub fn paintLabelLocked(t: *Tile) void { | 511 | pub fn paintLabelLocked(t: *Tile) void { |
| 512 | if (popupOpen(t.shared)) return; | 512 | if (popupOpen(t.shared)) return; |
| 513 | // ASCII, not an arrow glyph: this bar is byte-truncated, greppable in | 513 | // ASCII, not an arrow glyph: this bar is byte-truncated, greppable in |
| @@ -691,9 +691,9 @@ pub fn setFocus(tiles: []Tile, shared: *Shared, next: usize) void { | |||
| 691 | // tiles immediate, and the rest follow on their next poll timeout. | 691 | // tiles immediate, and the rest follow on their next poll timeout. |
| 692 | _ = shared.repaint_gen.fetchAdd(1, .release); | 692 | _ = shared.repaint_gen.fetchAdd(1, .release); |
| 693 | for (tiles) |*t| ring(t); | 693 | for (tiles) |*t| ring(t); |
| 694 | // Only where there IS a bar: fullscreen and a one-tile wall both set | 694 | // Only where there IS a bar: a piped wall sets `label_rows` to zero, |
| 695 | // `label_rows` to zero, and a bar painted there lands on a content row | 695 | // and a bar painted there lands on a content row the tile that owns |
| 696 | // the tile that owns the screen has already written. | 696 | // the screen has already written. |
| 697 | if (shared.label_rows != 0) paintDeadBarsLocked(tiles); | 697 | if (shared.label_rows != 0) paintDeadBarsLocked(tiles); |
| 698 | } | 698 | } |
| 699 | 699 | ||
| @@ -948,7 +948,6 @@ fn birthTileOrRefuse(w: Wall, b: Birth) !usize { | |||
| 948 | // renumbering: the tiles that stayed keep the digit their user learned. | 948 | // renumbering: the tiles that stayed keep the digit their user learned. |
| 949 | const reuse = freeSlot(w.liveTiles(), w.livePresent()); | 949 | const reuse = freeSlot(w.liveTiles(), w.livePresent()); |
| 950 | if (reuse == null and w.live.* >= max_tiles) return error.WallFull; | 950 | if (reuse == null and w.live.* >= max_tiles) return error.WallFull; |
| 951 | const new_live = presentCount(w.livePresent()) + 1; | ||
| 952 | // "Does it fit" has ONE owner, and it is the tree: insert, flatten, | 951 | // "Does it fit" has ONE owner, and it is the tree: insert, flatten, |
| 953 | // and undo the insert when flatten refuses. Row arithmetic here | 952 | // and undo the insert when flatten refuses. Row arithmetic here |
| 954 | // capped every terminal at rows/3 panes however wide, because it | 953 | // capped every terminal at rows/3 panes however wide, because it |
| @@ -971,15 +970,16 @@ fn birthTileOrRefuse(w: Wall, b: Birth) !usize { | |||
| 971 | w.alloc, | 970 | w.alloc, |
| 972 | w.shared.size.rows, | 971 | w.shared.size.rows, |
| 973 | w.shared.size.cols, | 972 | w.shared.size.cols, |
| 974 | wall_layout.wallFloors(new_live), | 973 | wall_layout.wallFloors(w.shared.is_tty), |
| 975 | null, | 974 | null, |
| 976 | ); | 975 | ); |
| 977 | defer flat.deinit(w.alloc); | 976 | defer flat.deinit(w.alloc); |
| 978 | const new_rect = flat.rectOf(@intCast(at)) orelse return error.NoRect; | 977 | const new_rect = flat.rectOf(@intCast(at)) orelse return error.NoRect; |
| 979 | // The real rect, not a placeholder: the daemon refuses creates under | 978 | // The real rect, not a placeholder: the daemon refuses creates under |
| 980 | // `min_session_rows`, and a 2-row rect on a live session is destructive | 979 | // `min_session_rows`, and a 2-row rect on a live session is destructive |
| 981 | // under latest-wins. `label_rows` too, so `viewRows` is right at attach. | 980 | // under latest-wins. `label_rows` too, so `viewRows` is right at attach — |
| 982 | w.shared.label_rows = if (new_live > 1) 1 else 0; | 981 | // the bar follows the tty, not the tile count (`relayout`). |
| 982 | w.shared.label_rows = @intFromBool(w.shared.is_tty); | ||
| 983 | // Past every refusal: the tile is the wall's now, so the copies a pump | 983 | // Past every refusal: the tile is the wall's now, so the copies a pump |
| 984 | // will hold for its whole life are worth making. Made BEFORE the slot | 984 | // will hold for its whole life are worth making. Made BEFORE the slot |
| 985 | // is overwritten, so the last thing that can fail here still fails | 985 | // is overwritten, so the last thing that can fail here still fails |
| @@ -1456,12 +1456,11 @@ pub fn run(alloc: std.mem.Allocator, host_specs: []const HostSpec, entry: Entry) | |||
| 1456 | null; | 1456 | null; |
| 1457 | const seeded = seed_plan != null; | 1457 | const seeded = seed_plan != null; |
| 1458 | if (seed_plan == null and has_entry) shared.tree.addFirst(0) catch return 2; | 1458 | if (seed_plan == null and has_entry) shared.tree.addFirst(0) catch return 2; |
| 1459 | const boot_tiles: usize = if (seed_plan) |pl| pl.panes.len else @intFromBool(has_entry); | ||
| 1460 | const init_flat = shared.tree.flatten( | 1459 | const init_flat = shared.tree.flatten( |
| 1461 | alloc, | 1460 | alloc, |
| 1462 | size.rows, | 1461 | size.rows, |
| 1463 | size.cols, | 1462 | size.cols, |
| 1464 | wall_layout.wallFloors(boot_tiles), | 1463 | wall_layout.wallFloors(shared.is_tty), |
| 1465 | null, | 1464 | null, |
| 1466 | ) catch { | 1465 | ) catch { |
| 1467 | std.debug.print("mux: terminal too small\n", .{}); | 1466 | std.debug.print("mux: terminal too small\n", .{}); |
| @@ -1602,10 +1601,9 @@ pub fn run(alloc: std.mem.Allocator, host_specs: []const HostSpec, entry: Entry) | |||
| 1602 | alloc.free(pl.panes); | 1601 | alloc.free(pl.panes); |
| 1603 | seed_plan = null; | 1602 | seed_plan = null; |
| 1604 | } | 1603 | } |
| 1605 | // A one-tile wall owns every row and draws no label bar; two or more | 1604 | // The bar follows the tty, not the tile count (`relayout` states why). |
| 1606 | // tiles each lose their top row to one. Set before the pumps start so | 1605 | // Set before the pumps start so `viewRows` is right on the first attach. |
| 1607 | // `viewRows` is right on the first attach. | 1606 | shared.label_rows = @intFromBool(shared.is_tty); |
| 1608 | shared.label_rows = if (w.live.* > 1) 1 else 0; | ||
| 1609 | for (tiles[0..live]) |*t| spawnPump(t); | 1607 | for (tiles[0..live]) |*t| spawnPump(t); |
| 1610 | // A wall with no tile yet paints its one line rather than nothing (a | 1608 | // A wall with no tile yet paints its one line rather than nothing (a |
| 1611 | // blank terminal with no cursor reads as hung), and a seeded wall | 1609 | // blank terminal with no cursor reads as hung), and a seeded wall |
test/e2e_02_predict.sh
| Old | New | ||
|---|---|---|---|
| @@ -542,7 +542,7 @@ RC=$? | |||
| 542 | set -e | 542 | set -e |
| 543 | [ "$RC" -eq 0 ] || { | 543 | [ "$RC" -eq 0 ] || { |
| 544 | echo "e2e FAIL: tp2a ptyclient exited $RC:"; cat "$OUT.tp2a.log"; exit 1; } | 544 | echo "e2e FAIL: tp2a ptyclient exited $RC:"; cat "$OUT.tp2a.log"; exit 1; } |
| 545 | assert_converged "$OUT.tp2a" "$SOCK12" "pty attach at 100x30" 100 30 | 545 | assert_converged_pty "$OUT.tp2a" "$SOCK12" "pty attach at 100x30" 100 30 |
| 546 | 546 | ||
| 547 | # Resize UP, not down: a replica LARGER than the grid is invisible (the | 547 | # Resize UP, not down: a replica LARGER than the grid is invisible (the |
| 548 | # paint clips at min(replica, tty) either way), so only a replica left | 548 | # paint clips at min(replica, tty) either way), so only a replica left |
| @@ -635,7 +635,7 @@ set -e | |||
| 635 | # because a 28-row replica has only 28 rows to repaint from, and the | 635 | # because a 28-row replica has only 28 rows to repaint from, and the |
| 636 | # 95-wide row still WRAPPED at 90 into "...0" + "00007" while the daemon | 636 | # 95-wide row still WRAPPED at 90 into "...0" + "00007" while the daemon |
| 637 | # had rejoined it into one row at 100. | 637 | # had rejoined it into one row at 100. |
| 638 | assert_converged "$OUT.tp2b" "$SOCK12" "pty resize mid-session" 100 30 | 638 | assert_converged_pty "$OUT.tp2b" "$SOCK12" "pty resize mid-session" 100 30 |
| 639 | rm_swept "$OUT.tp2a" "$OUT.tp2a.err" "$OUT.tp2a.log" \ | 639 | rm_swept "$OUT.tp2a" "$OUT.tp2a.err" "$OUT.tp2a.log" \ |
| 640 | "$OUT.tp2b" "$OUT.tp2b.err" "$OUT.tp2b.log" "$OUT.tp2.d" | 640 | "$OUT.tp2b" "$OUT.tp2b.err" "$OUT.tp2b.log" "$OUT.tp2.d" |
| 641 | # Closed here like every other per-scenario daemon, not left to the trap: | 641 | # Closed here like every other per-scenario daemon, not left to the trap: |
| @@ -790,7 +790,7 @@ want_stat "$OUT.tp1.err" displayed 1 "pty scroll reconnect" | |||
| 790 | want_stat "$OUT.tp1.err" confirmed 1 "pty scroll reconnect" | 790 | want_stat "$OUT.tp1.err" confirmed 1 "pty scroll reconnect" |
| 791 | want_stat "$OUT.tp1.err" contradicted 0 "pty scroll reconnect" | 791 | want_stat "$OUT.tp1.err" contradicted 0 "pty scroll reconnect" |
| 792 | want_stat "$OUT.tp1.err" suppressed 0 "pty scroll reconnect" | 792 | want_stat "$OUT.tp1.err" suppressed 0 "pty scroll reconnect" |
| 793 | assert_converged "$OUT.tp1" "$SOCK13" "pty scroll reconnect" | 793 | assert_converged_pty "$OUT.tp1" "$SOCK13" "pty scroll reconnect" 80 24 |
| 794 | 794 | ||
| 795 | # The doctored control, extended to a pty capture: this capture's byte shape | 795 | # The doctored control, extended to a pty capture: this capture's byte shape |
| 796 | # (alt screen, banner paints, a history page) exists nowhere else in the | 796 | # (alt screen, banner paints, a history page) exists nowhere else in the |
test/e2e_03_side.sh
| Old | New | ||
|---|---|---|---|
| @@ -224,7 +224,7 @@ PACMD=$(tr '\0' ' ' < "/proc/$PAPID/cmdline") | |||
| 224 | echo "e2e FAIL: the auto-started daemon's argv is [$PACMD]," | 224 | echo "e2e FAIL: the auto-started daemon's argv is [$PACMD]," |
| 225 | echo " want [mux d start --sock $SOCK15 ]" | 225 | echo " want [mux d start --sock $SOCK15 ]" |
| 226 | exit 1; } | 226 | exit 1; } |
| 227 | assert_converged "$OUT.pa" "$SOCK15" "local mux auto-start" | 227 | assert_converged_pty "$OUT.pa" "$SOCK15" "local mux auto-start" 80 24 |
| 228 | # Same teardown, same reasons — and the stderr is captured rather than | 228 | # Same teardown, same reasons — and the stderr is captured rather than |
| 229 | # discarded, so this leg pins the stopped line too. $OUT.stop is reused | 229 | # discarded, so this leg pins the stopped line too. $OUT.stop is reused |
| 230 | # deliberately: the proxy arc removed it above, and it is in the trap's | 230 | # deliberately: the proxy arc removed it above, and it is in the trap's |
test/e2e_11_select.sh
| Old | New | ||
|---|---|---|---|
| @@ -163,9 +163,10 @@ ok "a click in a beside pane finds it by column, not just row" | |||
| 163 | # | 163 | # |
| 164 | # A one-tile wall, because that is where the geometry is knowable: a wall | 164 | # A one-tile wall, because that is where the geometry is knowable: a wall |
| 165 | # over a daemon holding ONE session is one tile whose rect is the whole | 165 | # over a daemon holding ONE session is one tile whose rect is the whole |
| 166 | # terminal (no label bar), so a terminal row IS a grid row and the text can | 166 | # terminal, under the label bar every tty wall wears — grid row N paints |
| 167 | # be put on one by name. Its own daemon for exactly that: the cluster's | 167 | # at terminal row N+1, so the drag rows below are the content row plus |
| 168 | # has two sessions and would cut two stripes. | 168 | # one. Its own daemon for exactly that: the cluster's has two sessions |
| 169 | # and would cut two stripes. | ||
| 169 | # | 170 | # |
| 170 | # The session is given its content with an explicit cursor address | 171 | # The session is given its content with an explicit cursor address |
| 171 | # rather than by printing lines, so the row this drag crosses does not | 172 | # rather than by printing lines, so the row this drag crosses does not |
| @@ -184,9 +185,9 @@ settle 900 20000 | |||
| 184 | send printf '\\033[2J\\033[9;1HZZ-%s' COPYME\n | 185 | send printf '\\033[2J\\033[9;1HZZ-%s' COPYME\n |
| 185 | expect ZZ-COPYME 15000 | 186 | expect ZZ-COPYME 15000 |
| 186 | settle 700 20000 | 187 | settle 700 20000 |
| 187 | send \x1b[<0;3;9M\x1b[<0;3;9m | 188 | send \x1b[<0;3;10M\x1b[<0;3;10m |
| 188 | settle 500 15000 | 189 | settle 500 15000 |
| 189 | send \x1b[<0;1;9M\x1b[<32;9;9M\x1b[<0;9;9m | 190 | send \x1b[<0;1;10M\x1b[<32;9;10M\x1b[<0;9;10m |
| 190 | expect \x1b]52;c;WlotQ09QWU1F 15000 | 191 | expect \x1b]52;c;WlotQ09QWU1F 15000 |
| 191 | send \x1cd | 192 | send \x1cd |
| 192 | waitexit 10000 | 193 | waitexit 10000 |
| @@ -220,7 +221,8 @@ SELCOPIES=$(grep -ao "$(printf '\033]52;')" "$OUT.selcap" | wc -l) | |||
| 220 | # | 221 | # |
| 221 | # The same one-session daemon the copy leg used: `converged_quiet` dumps | 222 | # The same one-session daemon the copy leg used: `converged_quiet` dumps |
| 222 | # the daemon's default grid, so this client's screen has to be that grid | 223 | # the daemon's default grid, so this client's screen has to be that grid |
| 223 | # and nothing else — one tile, no bar, no neighbour. | 224 | # under its label bar and nothing else — one tile, no neighbour, and the |
| 225 | # drag rows are again the content row plus one. | ||
| 224 | # | 226 | # |
| 225 | # Asserted to fail, per the wan.sh rule: with `snapWide` stubbed out to | 227 | # Asserted to fail, per the wan.sh rule: with `snapWide` stubbed out to |
| 226 | # return its arguments, this leg renders `w 漢字x-WIDEMARK` against the | 228 | # return its arguments, this leg renders `w 漢字x-WIDEMARK` against the |
| @@ -241,9 +243,9 @@ settle 900 20000 | |||
| 241 | send printf '\\033[2J\\033[3;1Hw\\346\\274\\242\\345\\255\\227x-WIDEMARK'\n | 243 | send printf '\\033[2J\\033[3;1Hw\\346\\274\\242\\345\\255\\227x-WIDEMARK'\n |
| 242 | expect WIDEMARK 15000 | 244 | expect WIDEMARK 15000 |
| 243 | settle 700 20000 | 245 | settle 700 20000 |
| 244 | send \x1b[<0;3;3M\x1b[<32;4;3M\x1b[<0;4;3m | 246 | send \x1b[<0;3;4M\x1b[<32;4;4M\x1b[<0;4;4m |
| 245 | settle 700 20000 | 247 | settle 700 20000 |
| 246 | send \x1b[<0;1;3M\x1b[<0;1;3m | 248 | send \x1b[<0;1;4M\x1b[<0;1;4m |
| 247 | settle 700 20000 | 249 | settle 700 20000 |
| 248 | send \x1cd | 250 | send \x1cd |
| 249 | waitexit 10000 | 251 | waitexit 10000 |
| @@ -255,7 +257,7 @@ set -e | |||
| 255 | # The client's own stderr as well as the fixture's: the fixture can only | 257 | # The client's own stderr as well as the fixture's: the fixture can only |
| 256 | # report THAT the client closed the pty, and the reason is over here. | 258 | # report THAT the client closed the pty, and the reason is over here. |
| 257 | cat "$OUT.swpc" "$OUT.swcap.err"; exit 1; } | 259 | cat "$OUT.swpc" "$OUT.swcap.err"; exit 1; } |
| 258 | assert_converged "$OUT.swcap" "$SOCKSEL" "wide drag" 40 12 | 260 | assert_converged_pty "$OUT.swcap" "$SOCKSEL" "wide drag" 40 12 |
| 259 | ok "a drag across wide cells leaves the client's screen converged" | 261 | ok "a drag across wide cells leaves the client's screen converged" |
| 260 | 262 | ||
| 261 | assert_stopped "$SOCKSEL" "$D49PID" "selection" "$OUT.selstop" | 263 | assert_stopped "$SOCKSEL" "$D49PID" "selection" "$OUT.selstop" |
| @@ -282,6 +284,8 @@ defer_sock "$SOCK51" | |||
| 282 | # so the report — INPUT to the app — lands on the grid as text. Two reports | 284 | # so the report — INPUT to the app — lands on the grid as text. Two reports |
| 283 | # are asserted, because they are two code paths: the one the mode-set | 285 | # are asserted, because they are two code paths: the one the mode-set |
| 284 | # itself owes (the app sizes itself from it), and the one each resize owes. | 286 | # itself owes (the app sizes itself from it), and the one each resize owes. |
| 287 | # The reported rows are the terminal's minus the label bar every tty wall | ||
| 288 | # wears — the session's own size, which is what the app must paint to. | ||
| 285 | IBSTATE="${TMPDIR:-/tmp}/mux-e2e-inband-state-$$" | 289 | IBSTATE="${TMPDIR:-/tmp}/mux-e2e-inband-state-$$" |
| 286 | defer_rm "$IBSTATE" | 290 | defer_rm "$IBSTATE" |
| 287 | start_daemon "$SOCK51" "$OUT.inband.d" "in-band daemon never bound" --shell /bin/sh | 291 | start_daemon "$SOCK51" "$OUT.inband.d" "in-band daemon never bound" --shell /bin/sh |
| @@ -293,9 +297,9 @@ XDG_STATE_HOME="$IBSTATE" timeout 40 "$PTYCLIENT" --cols 100 --rows 30 \ | |||
| 293 | expect \x1b[?1049h 15000 | 297 | expect \x1b[?1049h 15000 |
| 294 | settle 400 15000 | 298 | settle 400 15000 |
| 295 | send printf '\\033[?2048h'; cat -v\n | 299 | send printf '\\033[?2048h'; cat -v\n |
| 296 | expect [48;30;100;0;0t 10000 | 300 | expect [48;29;100;0;0t 10000 |
| 297 | resize 120 40 | 301 | resize 120 40 |
| 298 | expect [48;40;120;0;0t 10000 | 302 | expect [48;39;120;0;0t 10000 |
| 299 | settle 500 15000 | 303 | settle 500 15000 |
| 300 | send \x1c\x1c | 304 | send \x1c\x1c |
| 301 | waitexit 10000 | 305 | waitexit 10000 |
test/e2e_lib.sh
| Old | New | ||
|---|---|---|---|
| @@ -778,15 +778,19 @@ proxy_pid() { | |||
| 778 | # while too large only adds trailing blanks that the normalization strips, | 778 | # while too large only adds trailing blanks that the normalization strips, |
| 779 | # and passes. Pass the size the scenario actually ran at. | 779 | # and passes. Pass the size the scenario actually ran at. |
| 780 | converged_quiet() { | 780 | converged_quiet() { |
| 781 | _co="$1"; _cs="$2"; _sz="" | 781 | _co="$1"; _cs="$2"; _sz=""; _drop="" |
| 782 | # An `if` rather than `[ ... ] && _sz=...` so it stays correct if it | 782 | # An `if` rather than `[ ... ] && _sz=...` so it stays correct if it |
| 783 | # ever ends up the last command in this function: there, a false guard | 783 | # ever ends up the last command in this function: there, a false guard |
| 784 | # would be the function's exit status under `set -e` and a 2-argument | 784 | # would be the function's exit status under `set -e` and a 2-argument |
| 785 | # call would report a divergence it never looked for. | 785 | # call would report a divergence it never looked for. |
| 786 | if [ $# -ge 4 ]; then _sz="--cols $3 --rows $4"; fi | 786 | if [ $# -ge 4 ]; then _sz="--cols $3 --rows $4"; fi |
| 787 | # $_sz is two flags or nothing, never data. | 787 | # A tty client's top row is the wall's label bar, which the daemon |
| 788 | # grid never held; a fifth argument says how many such rows to cut | ||
| 789 | # before diffing. assert_converged_pty proves the bar exists first. | ||
| 790 | if [ $# -ge 5 ]; then _drop="--drop-top $5"; fi | ||
| 791 | # $_sz/$_drop are flags or nothing, never data. | ||
| 788 | # shellcheck disable=SC2086 | 792 | # shellcheck disable=SC2086 |
| 789 | "$RENDER" $_sz < "$_co" > "$_co.render" || return 1 | 793 | "$RENDER" $_sz $_drop < "$_co" > "$_co.render" || return 1 |
| 790 | "$MUX" d dump --sock "$_cs" > "$_co.dump" || return 1 | 794 | "$MUX" d dump --sock "$_cs" > "$_co.dump" || return 1 |
| 791 | # Trailing whitespace is a formatting difference between two correct | 795 | # Trailing whitespace is a formatting difference between two correct |
| 792 | # grids (padded vs unpadded row ends), not a divergence. | 796 | # grids (padded vs unpadded row ends), not a divergence. |
| @@ -800,7 +804,7 @@ converged_quiet() { | |||
| 800 | # overlay-never-becomes-state invariant. | 804 | # overlay-never-becomes-state invariant. |
| 801 | # Same as above: flags or nothing. | 805 | # Same as above: flags or nothing. |
| 802 | # shellcheck disable=SC2086 | 806 | # shellcheck disable=SC2086 |
| 803 | "$RENDER" --vt $_sz < "$_co" > "$_co.rvt" || return 1 | 807 | "$RENDER" --vt $_sz $_drop < "$_co" > "$_co.rvt" || return 1 |
| 804 | "$MUX" d dump --vt --sock "$_cs" > "$_co.dvt" || return 1 | 808 | "$MUX" d dump --vt --sock "$_cs" > "$_co.dvt" || return 1 |
| 805 | cmp -s "$_co.dvt" "$_co.rvt" || return 1 | 809 | cmp -s "$_co.dvt" "$_co.rvt" || return 1 |
| 806 | rm -f "$_co.render" "$_co.dump" "$_co.render.n" "$_co.dump.n" \ | 810 | rm -f "$_co.render" "$_co.dump" "$_co.render.n" "$_co.dump.n" \ |
| @@ -815,7 +819,7 @@ CONV_COUNT=0 | |||
| 815 | assert_converged() { | 819 | assert_converged() { |
| 816 | CONV_COUNT=$((CONV_COUNT + 1)) | 820 | CONV_COUNT=$((CONV_COUNT + 1)) |
| 817 | if [ $# -ge 5 ]; then | 821 | if [ $# -ge 5 ]; then |
| 818 | converged_quiet "$1" "$2" "$4" "$5" | 822 | converged_quiet "$1" "$2" "$4" "$5" ${6:+"$6"} |
| 819 | else | 823 | else |
| 820 | converged_quiet "$1" "$2" | 824 | converged_quiet "$1" "$2" |
| 821 | fi || { | 825 | fi || { |
| @@ -835,6 +839,22 @@ assert_converged() { | |||
| 835 | } | 839 | } |
| 836 | } | 840 | } |
| 837 | 841 | ||
| 842 | # assert_converged_pty CLIENT_OUT SOCK NAME COLS ROWS — a TTY client's | ||
| 843 | # convergence. The wall paints a label bar on the tile's top row (the bar | ||
| 844 | # follows the tty), so the session grid is the ROWS-1 rows under it. | ||
| 845 | # Assert the bar really is there FIRST — a --drop-top that cut a content | ||
| 846 | # row would hide the very divergence the diff exists to catch — then | ||
| 847 | # converge on the rows below. Counted in CONV_COUNT via assert_converged. | ||
| 848 | assert_converged_pty() { | ||
| 849 | "$RENDER" --cols "$4" --rows "$5" < "$1" > "$1.bar" || { | ||
| 850 | echo "e2e FAIL: $3: render failed on the tty capture"; exit 1; } | ||
| 851 | head -1 "$1.bar" | grep -Eq '[0-9]+> .+ \[' || { | ||
| 852 | echo "e2e FAIL: $3: no label bar on the tty client's top row; got:" | ||
| 853 | head -3 "$1.bar"; exit 1; } | ||
| 854 | rm -f "$1.bar" | ||
| 855 | assert_converged "$1" "$2" "$3" "$4" "$5" 1 | ||
| 856 | } | ||
| 857 | |||
| 838 | # assert_ws_converged WSOUT SOCK LABEL [SESSION] — the WebSocket leg's | 858 | # assert_ws_converged WSOUT SOCK LABEL [SESSION] — the WebSocket leg's |
| 839 | # convergence check. WSOUT is the stand-in's `dumpexit` file: its replica's | 859 | # convergence check. WSOUT is the stand-in's `dumpexit` file: its replica's |
| 840 | # grid, in `mux d dump`'s own format, built from frames that crossed the hub. | 860 | # grid, in `mux d dump`'s own format, built from frames that crossed the hub. |
test/render.zig
| Old | New | ||
|---|---|---|---|
| @@ -26,6 +26,19 @@ fn feedForFinalGrid(eng: *Engine, stream: []const u8) void { | |||
| 26 | } | 26 | } |
| 27 | } | 27 | } |
| 28 | 28 | ||
| 29 | /// The first `n` lines of a plain dump, gone — the label-bar rows a tty | ||
| 30 | /// client owns and the daemon grid never held. Returns the tail of `s`, | ||
| 31 | /// or the empty slice when fewer than `n` lines exist. | ||
| 32 | fn dropTop(s: []const u8, n: usize) []const u8 { | ||
| 33 | var rest = s; | ||
| 34 | var left = n; | ||
| 35 | while (left > 0) : (left -= 1) { | ||
| 36 | const nl = std.mem.indexOfScalar(u8, rest, '\n') orelse return rest[rest.len..]; | ||
| 37 | rest = rest[nl + 1 ..]; | ||
| 38 | } | ||
| 39 | return rest; | ||
| 40 | } | ||
| 41 | |||
| 29 | fn writeAll(fd: std.posix.fd_t, bytes: []const u8) !void { | 42 | fn writeAll(fd: std.posix.fd_t, bytes: []const u8) !void { |
| 30 | var off: usize = 0; | 43 | var off: usize = 0; |
| 31 | while (off < bytes.len) off += try std.posix.write(fd, bytes[off..]); | 44 | while (off < bytes.len) off += try std.posix.write(fd, bytes[off..]); |
| @@ -34,7 +47,7 @@ fn writeAll(fd: std.posix.fd_t, bytes: []const u8) !void { | |||
| 34 | fn usage() u8 { | 47 | fn usage() u8 { |
| 35 | writeAll( | 48 | writeAll( |
| 36 | std.posix.STDERR_FILENO, | 49 | std.posix.STDERR_FILENO, |
| 37 | "usage: render [--cols N] [--rows M] [--vt] < client-stdout-capture\n", | 50 | "usage: render [--cols N] [--rows M] [--vt] [--drop-top N] < client-stdout-capture\n", |
| 38 | ) catch {}; | 51 | ) catch {}; |
| 39 | return 2; | 52 | return 2; |
| 40 | } | 53 | } |
| @@ -47,6 +60,7 @@ pub fn main() !u8 { | |||
| 47 | var cols: u16 = 80; | 60 | var cols: u16 = 80; |
| 48 | var rows: u16 = 24; | 61 | var rows: u16 = 24; |
| 49 | var vt_mode = false; | 62 | var vt_mode = false; |
| 63 | var drop_top: u16 = 0; | ||
| 50 | 64 | ||
| 51 | const args = try std.process.argsAlloc(alloc); | 65 | const args = try std.process.argsAlloc(alloc); |
| 52 | defer std.process.argsFree(alloc, args); | 66 | defer std.process.argsFree(alloc, args); |
| @@ -61,10 +75,14 @@ pub fn main() !u8 { | |||
| 61 | } else if (std.mem.eql(u8, a, "--rows") and i + 1 < args.len) { | 75 | } else if (std.mem.eql(u8, a, "--rows") and i + 1 < args.len) { |
| 62 | i += 1; | 76 | i += 1; |
| 63 | rows = std.fmt.parseInt(u16, args[i], 10) catch return usage(); | 77 | rows = std.fmt.parseInt(u16, args[i], 10) catch return usage(); |
| 78 | } else if (std.mem.eql(u8, a, "--drop-top") and i + 1 < args.len) { | ||
| 79 | i += 1; | ||
| 80 | drop_top = std.fmt.parseInt(u16, args[i], 10) catch return usage(); | ||
| 64 | } else { | 81 | } else { |
| 65 | return usage(); | 82 | return usage(); |
| 66 | } | 83 | } |
| 67 | } | 84 | } |
| 85 | if (drop_top >= rows) return usage(); | ||
| 68 | 86 | ||
| 69 | var stream: std.ArrayList(u8) = .empty; | 87 | var stream: std.ArrayList(u8) = .empty; |
| 70 | defer stream.deinit(alloc); | 88 | defer stream.deinit(alloc); |
| @@ -79,18 +97,32 @@ pub fn main() !u8 { | |||
| 79 | defer eng.deinit(); | 97 | defer eng.deinit(); |
| 80 | feedForFinalGrid(eng, stream.items); | 98 | feedForFinalGrid(eng, stream.items); |
| 81 | 99 | ||
| 100 | // --drop-top cuts the label-bar rows a tty client paints above the | ||
| 101 | // session: the rows below stay byte-diffable against the daemon's | ||
| 102 | // shorter grid (Engine.dumpVtFrom pins the vt equivalence). | ||
| 82 | const out: []const u8 = if (vt_mode) | 103 | const out: []const u8 = if (vt_mode) |
| 83 | try eng.dumpVt(alloc) | 104 | try eng.dumpVtFrom(alloc, drop_top) |
| 84 | else | 105 | else |
| 85 | try eng.dumpPlain(alloc); | 106 | try eng.dumpPlain(alloc); |
| 86 | defer alloc.free(out); | 107 | defer alloc.free(out); |
| 87 | try writeAll(std.posix.STDOUT_FILENO, out); | 108 | try writeAll(std.posix.STDOUT_FILENO, if (vt_mode) out else dropTop(out, drop_top)); |
| 88 | // mux d dump appends one newline after the payload (main.zig dump()); | 109 | // mux d dump appends one newline after the payload (main.zig dump()); |
| 89 | // matching it exactly is what makes the two outputs diffable. | 110 | // matching it exactly is what makes the two outputs diffable. |
| 90 | try writeAll(std.posix.STDOUT_FILENO, "\n"); | 111 | try writeAll(std.posix.STDOUT_FILENO, "\n"); |
| 91 | return 0; | 112 | return 0; |
| 92 | } | 113 | } |
| 93 | 114 | ||
| 115 | test "render: dropTop cuts whole leading lines and nothing else" { | ||
| 116 | // The plain half of --drop-top: a tty client's label bar is the first | ||
| 117 | // grid row, and the suite diffs the rows under it against the daemon. | ||
| 118 | try std.testing.expectEqualStrings("b\nc", dropTop("bar\nb\nc", 1)); | ||
| 119 | try std.testing.expectEqualStrings("c", dropTop("bar\nb\nc", 2)); | ||
| 120 | // Dropping everything (or more) leaves the empty grid, not a crash. | ||
| 121 | try std.testing.expectEqualStrings("", dropTop("bar\nb\nc", 3)); | ||
| 122 | try std.testing.expectEqualStrings("", dropTop("bar\nb\nc", 9)); | ||
| 123 | try std.testing.expectEqualStrings("bar\nb\nc", dropTop("bar\nb\nc", 0)); | ||
| 124 | } | ||
| 125 | |||
| 94 | test "render: a stream that never touches the alternate screen renders as-is" { | 126 | test "render: a stream that never touches the alternate screen renders as-is" { |
| 95 | const alloc = std.testing.allocator; | 127 | const alloc = std.testing.allocator; |
| 96 | const e = try Engine.init(alloc, .{ .cols = 80, .rows = 24 }); | 128 | const e = try Engine.init(alloc, .{ .cols = 80, .rows = 24 }); |