a73x

56a7de8e

feat: the label bar follows the tty, not the tile count

a73x   2026-09-01 07:36

Commit message
feat: the label bar follows the tty, not the tile count

A one-tile wall and a fullscreened pane drew no bar, so the view most
sessions live in was anonymous. Every tty tile now wears its bar; only a
piped mux stays bare, because its byte stream is a script's input.
wallFloors is keyed by the bar for the same reason, the TooSmall degrade
keeps the bar, and the thin-wall host test's defer order is fixed — it
freed tile labels before joining pumps, a use-after-free that was
invisible while a solo pump never painted its bar.

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

test: the convergence check drops the bar it first proves is there

A tty client's render is now the daemon grid plus a label-bar row.
assert_converged_pty asserts the bar IS on the top row — a drop that
could cut a content row would hide the very divergence the diff exists
to catch — then diffs the rows under it via render --drop-top.
Engine.dumpVtFrom pins the byte equivalence: slicing the viewport
formats identically to a grid that never had the row. Drag rows in the
selection legs move down one, the in-band size report reads rows-1, and
the convergence-point pin stays at 38.

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

docs: the always-on label bar, stated where users and history read

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

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 });