a73x

5b4cbbe7

refactor: derive the label bar from the tty at one point

a73x   2026-09-01 08:44

Commit message
refactor: derive the label bar from the tty at one point

label_rows was a Shared field assigned the same expression at three
sites (startup, relayout, birth), and wallFloors was keyed by a bool
ten call sites each spelled as is_tty. The rule is a pure function of
the tty, constant for the life of the process, so the field becomes
Shared.labelRows() and the production floors go through floorsOf(),
which keys off that one function. No site can now drift from the rule,
and the next bar-adjacent change edits one place instead of eleven.

Test fixtures that hand-set label_rows now state is_tty instead — the
scenario, not the derived value. One of them (rectHit) had set the two
inconsistently, which the derived method makes unrepresentable.

No behavior change: unit suite, make check and two full e2e runs
(100 scenarios, 38 convergence points) green.

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

src/tui/wall_host.zig
Old New
@@ -306,7 +306,7 @@ fn dressSilent(w: Wall, hi: usize) void {
306 } 306 }
307 // A pending pane has no pump, so no doorbell reaches its bar: the 307 // A pending pane has no pump, so no doorbell reaches its bar: the
308 // keyboard is the only thread that can repaint one. 308 // keyboard is the only thread that can repaint one.
309 if (dressed and w.shared.label_rows != 0) wv.paintDeadBarsLocked(w.liveTiles()); 309 if (dressed and w.shared.labelRows() != 0) wv.paintDeadBarsLocked(w.liveTiles());
310 } 310 }
311 311
312 /// One host's list, applied to the wall. The keyboard thread only: it is 312 /// One host's list, applied to the wall. The keyboard thread only: it is
src/tui/wall_layout.zig
Old New
@@ -26,6 +26,13 @@ pub fn wallFloors(bar: bool) layout.Floors {
26 }; 26 };
27 } 27 }
28 28
29 /// The floors for THIS wall: `wallFloors` keyed by the one bar rule,
30 /// `Shared.labelRows`, so a production call site cannot key the floor off
31 /// anything else and cut stripes the painted bars then overflow.
32 pub fn floorsOf(shared: *const Shared) layout.Floors {
33 return wallFloors(shared.labelRows() != 0);
34 }
35
29 /// The root container's orientation for N tiles at a given terminal size: 36 /// The root container's orientation for N tiles at a given terminal size:
30 /// `.beside` when the terminal is wide enough that columns are the natural 37 /// `.beside` when the terminal is wide enough that columns are the natural
31 /// cut, `.stacked` otherwise. The 2x corrects for cell shape — a terminal 38 /// cut, `.stacked` otherwise. The 2x corrects for cell shape — a terminal
@@ -62,7 +69,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 69 // Focus gains from the sibling toward `ld`; if none there (edge
63 // pane), try the opposite side — gaining from either sibling 70 // pane), try the opposite side — gaining from either sibling
64 // widens or tallens the focus. 71 // widens or tallens the focus.
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)) { 72 if (w.shared.tree.resize(w.alloc, w.shared.size.rows, w.shared.size.cols, floorsOf(w.shared), focus_tile, ld, 1)) {
66 moved = true; 73 moved = true;
67 } else { 74 } else {
68 const opp = switch (ld) { 75 const opp = switch (ld) {
@@ -71,7 +78,7 @@ pub fn doResize(w: Wall, sel: usize, d: interact.PrefixFilter.Dir) bool {
71 .left => layout.Dir.right, 78 .left => layout.Dir.right,
72 .up => layout.Dir.down, 79 .up => layout.Dir.down,
73 }; 80 };
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); 81 moved = w.shared.tree.resize(w.alloc, w.shared.size.rows, w.shared.size.cols, floorsOf(w.shared), focus_tile, opp, 1);
75 } 82 }
76 } else { 83 } else {
77 // Shrink: a neighbor on the same axis gains a cell from focus. 84 // Shrink: a neighbor on the same axis gains a cell from focus.
@@ -84,11 +91,11 @@ pub fn doResize(w: Wall, sel: usize, d: interact.PrefixFilter.Dir) bool {
84 .down => layout.Dir.up, 91 .down => layout.Dir.up,
85 }; 92 };
86 if (layout.neighbor(flat, focus_tile, ld)) |nb| { 93 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.shared.is_tty), nb, opp, 1); 94 moved = w.shared.tree.resize(w.alloc, w.shared.size.rows, w.shared.size.cols, floorsOf(w.shared), nb, opp, 1);
88 } 95 }
89 if (!moved) { 96 if (!moved) {
90 if (layout.neighbor(flat, focus_tile, opp)) |nb| { 97 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.shared.is_tty), nb, ld, 1); 98 moved = w.shared.tree.resize(w.alloc, w.shared.size.rows, w.shared.size.cols, floorsOf(w.shared), nb, ld, 1);
92 } 99 }
93 } 100 }
94 } 101 }
@@ -124,12 +131,6 @@ pub fn relayout(w: Wall, sel: usize) void {
124 for (w.livePresent()) |p| { 131 for (w.livePresent()) |p| {
125 if (p) live += 1; 132 if (p) live += 1;
126 } 133 }
127 // The bar follows the tty, not the tile count: it is what names the
128 // session on screen, and hiding it when one tile was visible left a
129 // solo or fullscreened session anonymous. A piped `mux` never draws
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);
133 if (w.shared.is_tty) proto.writeAllFd(w.shared.out_fd, "\x1b[?25l\x1b[H\x1b[2J") catch {}; 134 if (w.shared.is_tty) proto.writeAllFd(w.shared.out_fd, "\x1b[?25l\x1b[H\x1b[2J") catch {};
134 // The screen the popup was on has just been cleared, so the next paint 135 // The screen the popup was on has just been cleared, so the next paint
135 // owes it however unchanged its rows are. 136 // owes it however unchanged its rows are.
@@ -140,12 +141,12 @@ pub fn relayout(w: Wall, sel: usize) void {
140 } 141 }
141 // The base flat (null) is always computed so `focus_dir` can read 142 // The base flat (null) is always computed so `focus_dir` can read
142 // adjacency from the real layout while fullscreened. 143 // adjacency from the real layout while fullscreened.
143 if (w.shared.tree.flatten(w.alloc, w.shared.size.rows, w.shared.size.cols, wallFloors(w.shared.is_tty), null)) |base| { 144 if (w.shared.tree.flatten(w.alloc, w.shared.size.rows, w.shared.size.cols, floorsOf(w.shared), null)) |base| {
144 if (w.shared.base_flat) |*old| old.deinit(w.shared.flat_alloc); 145 if (w.shared.base_flat) |*old| old.deinit(w.shared.flat_alloc);
145 w.shared.base_flat = base; 146 w.shared.base_flat = base;
146 } else |_| {} 147 } else |_| {}
147 const fs_arg: ?u8 = if (w.shared.fullscreen) @intCast(sel) else null; 148 const fs_arg: ?u8 = if (w.shared.fullscreen) @intCast(sel) else null;
148 var cut = w.shared.tree.flatten(w.alloc, w.shared.size.rows, w.shared.size.cols, wallFloors(w.shared.is_tty), fs_arg); 149 var cut = w.shared.tree.flatten(w.alloc, w.shared.size.rows, w.shared.size.cols, floorsOf(w.shared), fs_arg);
149 if (cut) |_| {} else |e| { 150 if (cut) |_| {} else |e| {
150 // A split or a resize key is an OPERATION the user asked for, and 151 // A split or a resize key is an OPERATION the user asked for, and
151 // refusing leaves the screen as it was. A SIGWINCH is not: the terminal 152 // refusing leaves the screen as it was. A SIGWINCH is not: the terminal
@@ -153,7 +154,7 @@ pub fn relayout(w: Wall, sel: usize) void {
153 // bottom of a cleared screen. Degrade to the focused tile whole and the 154 // bottom of a cleared screen. Degrade to the focused tile whole and the
154 // rest at 0x0. The TREE is untouched, so growing back re-cuts. 155 // rest at 0x0. The TREE is untouched, so growing back re-cuts.
155 if (e == error.TooSmall and fs_arg == null) { 156 if (e == error.TooSmall and fs_arg == null) {
156 if (w.shared.tree.flatten(w.alloc, w.shared.size.rows, w.shared.size.cols, wallFloors(w.shared.is_tty), @intCast(sel))) |only| { 157 if (w.shared.tree.flatten(w.alloc, w.shared.size.rows, w.shared.size.cols, floorsOf(w.shared), @intCast(sel))) |only| {
157 cut = only; 158 cut = only;
158 } else |_| {} 159 } else |_| {}
159 } 160 }
@@ -179,7 +180,7 @@ pub fn relayout(w: Wall, sel: usize) void {
179 if (p) wv.ring(t); 180 if (p) wv.ring(t);
180 } 181 }
181 // ...except the tiles with no pump left to hear it: their bars are the 182 // ...except the tiles with no pump left to hear it: their bars are the
182 // keyboard's. No `label_rows` guard, unlike `setFocus` — the screen was 183 // keyboard's. No `labelRows` guard, unlike `setFocus` — the screen was
183 // just cleared, and that bar is all that says the target refused. 184 // just cleared, and that bar is all that says the target refused.
184 wv.paintDeadBarsLocked(w.liveTiles()); 185 wv.paintDeadBarsLocked(w.liveTiles());
185 } 186 }
@@ -418,7 +419,7 @@ fn seedAttempt(
418 return null; 419 return null;
419 }; 420 };
420 } 421 }
421 if (parsed.tree.flatten(alloc, shared.size.rows, shared.size.cols, wallFloors(shared.is_tty), null)) |flat| { 422 if (parsed.tree.flatten(alloc, shared.size.rows, shared.size.cols, floorsOf(shared), null)) |flat| {
422 var f = flat; 423 var f = flat;
423 f.deinit(alloc); 424 f.deinit(alloc);
424 } else |_| { 425 } else |_| {
src/tui/wall_pump.zig
Old New
@@ -406,7 +406,7 @@ pub fn takePass(t: *Tile) Pass {
406 .left = t.rect.left, 406 .left = t.rect.left,
407 .rows = t.rect.rows, 407 .rows = t.rect.rows,
408 .cols = t.rect.cols, 408 .cols = t.rect.cols,
409 .label_rows = t.shared.label_rows, 409 .label_rows = t.shared.labelRows(),
410 .term_cols = t.shared.size.cols, 410 .term_cols = t.shared.size.cols,
411 .term_rows = t.shared.size.rows, 411 .term_rows = t.shared.size.rows,
412 .resize = owed, 412 .resize = owed,
src/tui/wall_test_layout.zig
Old New
@@ -55,8 +55,7 @@ test "the tree refuses a multi-tile cut too thin for the daemon's row floor" {
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);
58 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 6 }, .is_tty = false }; 58 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 6 }, .is_tty = true };
59 shared.label_rows = 1; // two tiles draw a bar
60 var t0 = Tile{ 59 var t0 = Tile{
61 .r = .{ .target = .{ .sock = "/tmp/x" }, .label = "x", .session = "" }, 60 .r = .{ .target = .{ .sock = "/tmp/x" }, .label = "x", .session = "" },
62 .rect = f.rectOf(0).?, 61 .rect = f.rectOf(0).?,
@@ -85,9 +84,8 @@ test "the tree refuses a multi-tile cut too thin for the daemon's row floor" {
85 } 84 }
86 85
87 test "viewRows: a bar-less wall keeps every row, a barred tile loses one" { 86 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 };
89 // No bar (a pipe): the tile claims every row. 87 // No bar (a pipe): the tile claims every row.
90 shared.label_rows = 0; 88 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false };
91 var t0 = Tile{ 89 var t0 = Tile{
92 .r = .{ .target = .{ .sock = "/tmp/x" }, .label = "x", .session = "" }, 90 .r = .{ .target = .{ .sock = "/tmp/x" }, .label = "x", .session = "" },
93 .rect = .{ .top = 0, .left = 0, .rows = 24, .cols = 80 }, 91 .rect = .{ .top = 0, .left = 0, .rows = 24, .cols = 80 },
@@ -99,7 +97,7 @@ test "viewRows: a bar-less wall keeps every row, a barred tile loses one" {
99 try std.testing.expectEqual(@as(u16, 24), t0.viewRows()); 97 try std.testing.expectEqual(@as(u16, 24), t0.viewRows());
100 98
101 // Under a bar (any tty wall): each tile loses one row to it. 99 // Under a bar (any tty wall): each tile loses one row to it.
102 shared.label_rows = 1; 100 shared.is_tty = true;
103 var t1 = Tile{ 101 var t1 = Tile{
104 .r = .{ .target = .{ .sock = "/tmp/x" }, .label = "x", .session = "" }, 102 .r = .{ .target = .{ .sock = "/tmp/x" }, .label = "x", .session = "" },
105 .rect = .{ .top = 0, .left = 0, .rows = 12, .cols = 80 }, 103 .rect = .{ .top = 0, .left = 0, .rows = 12, .cols = 80 },
@@ -148,7 +146,7 @@ test "relayout sets resize_pending on every live tile" {
148 } 146 }
149 wall_layout.relayout(fixture.wallAll(alloc, tiles, present, &shared), 0); 147 wall_layout.relayout(fixture.wallAll(alloc, tiles, present, &shared), 0);
150 // A tty wall draws its label bar. 148 // A tty wall draws its label bar.
151 try std.testing.expectEqual(@as(u8, 1), shared.label_rows); 149 try std.testing.expectEqual(@as(u8, 1), shared.labelRows());
152 // Every tile is doorbelled: its pump sends the new rect. 150 // Every tile is doorbelled: its pump sends the new rect.
153 try std.testing.expect(tiles[0].resize_pending); 151 try std.testing.expect(tiles[0].resize_pending);
154 try std.testing.expect(tiles[1].resize_pending); 152 try std.testing.expect(tiles[1].resize_pending);
@@ -209,7 +207,7 @@ test "a relayout landing mid-pass is still sent" {
209 while (w.passes.load(.acquire) < n + 1) {} 207 while (w.passes.load(.acquire) < n + 1) {}
210 208
211 shared.paint_mu.lock(); 209 shared.paint_mu.lock();
212 const want = ResizeWitness.pack(tiles[0].rect.cols, tiles[0].rect.rows -| shared.label_rows); 210 const want = ResizeWitness.pack(tiles[0].rect.cols, tiles[0].rect.rows -| shared.labelRows());
213 shared.paint_mu.unlock(); 211 shared.paint_mu.unlock();
214 // The claim: a quiet wall and a quiet pump agree on the grid. A 212 // The claim: a quiet wall and a quiet pump agree on the grid. A
215 // doorbell consumed apart from the rect it describes breaks this — 213 // doorbell consumed apart from the rect it describes breaks this —
@@ -283,7 +281,7 @@ test "a terminal too small for the cut falls back to the focused pane, and grows
283 // — a rect that paints nothing and claims no size — and the 281 // — a rect that paints nothing and claims no size — and the
284 // tree untouched underneath. The bar stays: it follows the 282 // tree untouched underneath. The bar stays: it follows the
285 // tty, and the one visible pane still deserves its name. 283 // tty, and the one visible pane still deserves its name.
286 try std.testing.expectEqual(@as(u16, 1), shared.label_rows); 284 try std.testing.expectEqual(@as(u16, 1), shared.labelRows());
287 try std.testing.expectEqual(@as(u16, 20), tiles[0].rect.rows); 285 try std.testing.expectEqual(@as(u16, 20), tiles[0].rect.rows);
288 try std.testing.expectEqual(@as(u16, 80), tiles[0].rect.cols); 286 try std.testing.expectEqual(@as(u16, 80), tiles[0].rect.cols);
289 try std.testing.expectEqual(@as(u16, 0), tiles[0].rect.top); 287 try std.testing.expectEqual(@as(u16, 0), tiles[0].rect.top);
@@ -300,7 +298,7 @@ test "a terminal too small for the cut falls back to the focused pane, and grows
300 try std.testing.expectEqual(@as(usize, 0), std.mem.trim(u8, l, " ").len); 298 try std.testing.expectEqual(@as(usize, 0), std.mem.trim(u8, l, " ").len);
301 } 299 }
302 } else { 300 } else {
303 try std.testing.expectEqual(@as(u16, 1), shared.label_rows); 301 try std.testing.expectEqual(@as(u16, 1), shared.labelRows());
304 // Seven bars, each on the first row of the pane it names. 302 // Seven bars, each on the first row of the pane it names.
305 for (tiles) |*t| { 303 for (tiles) |*t| {
306 const l = WallScreen.line(dump, t.rect.top) orelse return error.NoSuchRow; 304 const l = WallScreen.line(dump, t.rect.top) orelse return error.NoSuchRow;
@@ -336,7 +334,7 @@ test "fullscreen gives the focused tile the whole terminal and hides the rest" {
336 shared.fullscreen = true; 334 shared.fullscreen = true;
337 wall_layout.relayout(fixture.wallAll(alloc, tiles, &present, &shared), 0); 335 wall_layout.relayout(fixture.wallAll(alloc, tiles, &present, &shared), 0);
338 // No label bar on a pipe, fullscreened or not — the bar is the tty's. 336 // No label bar on a pipe, fullscreened or not — the bar is the tty's.
339 try std.testing.expectEqual(@as(u8, 0), shared.label_rows); 337 try std.testing.expectEqual(@as(u8, 0), shared.labelRows());
340 // Tile 0 gets the whole terminal; tile 1 gets nothing. 338 // Tile 0 gets the whole terminal; tile 1 gets nothing.
341 try std.testing.expectEqual(@as(u16, 24), tiles[0].rect.rows); 339 try std.testing.expectEqual(@as(u16, 24), tiles[0].rect.rows);
342 try std.testing.expectEqual(@as(u16, 80), tiles[0].rect.cols); 340 try std.testing.expectEqual(@as(u16, 80), tiles[0].rect.cols);
@@ -375,7 +373,7 @@ test "toggle off fullscreen restores the real rects" {
375 wall_layout.relayout(fixture.wallAll(alloc, tiles, &present, &shared), 0); 373 wall_layout.relayout(fixture.wallAll(alloc, tiles, &present, &shared), 0);
376 // Two tiles again: the bar (never gone on a tty) and both tiles' 374 // Two tiles again: the bar (never gone on a tty) and both tiles'
377 // real rects are back (stacked: full width, half height each). 375 // real rects are back (stacked: full width, half height each).
378 try std.testing.expectEqual(@as(u8, 1), shared.label_rows); 376 try std.testing.expectEqual(@as(u8, 1), shared.labelRows());
379 try std.testing.expectEqual(@as(u16, 80), tiles[0].rect.cols); 377 try std.testing.expectEqual(@as(u16, 80), tiles[0].rect.cols);
380 try std.testing.expectEqual(@as(u16, 80), tiles[1].rect.cols); 378 try std.testing.expectEqual(@as(u16, 80), tiles[1].rect.cols);
381 try std.testing.expect(tiles[0].rect.rows < 24); 379 try std.testing.expect(tiles[0].rect.rows < 24);
@@ -409,17 +407,17 @@ test "the label bar follows the tty, not the tile count" {
409 }; 407 };
410 // One tile on a tty: the bar stays, and the session runs a row short. 408 // 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); 409 wall_layout.relayout(fixture.wallAll(alloc, tiles, &present, &shared), 0);
412 try std.testing.expectEqual(@as(u16, 1), shared.label_rows); 410 try std.testing.expectEqual(@as(u16, 1), shared.labelRows());
413 try std.testing.expectEqual(@as(u16, 23), tiles[0].viewRows()); 411 try std.testing.expectEqual(@as(u16, 23), tiles[0].viewRows());
414 // Fullscreen is still a tty view, so the zoomed tile keeps its name. 412 // Fullscreen is still a tty view, so the zoomed tile keeps its name.
415 shared.fullscreen = true; 413 shared.fullscreen = true;
416 wall_layout.relayout(fixture.wallAll(alloc, tiles, &present, &shared), 0); 414 wall_layout.relayout(fixture.wallAll(alloc, tiles, &present, &shared), 0);
417 try std.testing.expectEqual(@as(u16, 1), shared.label_rows); 415 try std.testing.expectEqual(@as(u16, 1), shared.labelRows());
418 // A pipe draws no bar whatever the count. 416 // A pipe draws no bar whatever the count.
419 shared.fullscreen = false; 417 shared.fullscreen = false;
420 shared.is_tty = false; 418 shared.is_tty = false;
421 wall_layout.relayout(fixture.wallAll(alloc, tiles, &present, &shared), 0); 419 wall_layout.relayout(fixture.wallAll(alloc, tiles, &present, &shared), 0);
422 try std.testing.expectEqual(@as(u16, 0), shared.label_rows); 420 try std.testing.expectEqual(@as(u16, 0), shared.labelRows());
423 } 421 }
424 422
425 test "focus_dir while fullscreened follows the focus" { 423 test "focus_dir while fullscreened follows the focus" {
@@ -577,7 +575,7 @@ test "a pump's answer that grew the wall re-cuts it; a mere focus move does not"
577 // and the old tile still owns the whole terminal — the screen keeps 575 // and the old tile still owns the whole terminal — the screen keeps
578 // showing tile 0 while the keyboard types into tile 1's rows. 576 // showing tile 0 while the keyboard types into tile 1's rows.
579 wv.focusAnswer(fixture.wallAll(alloc, tiles, present, &shared), true, 1); 577 wv.focusAnswer(fixture.wallAll(alloc, tiles, present, &shared), true, 1);
580 try std.testing.expectEqual(@as(u8, 1), shared.label_rows); 578 try std.testing.expectEqual(@as(u8, 1), shared.labelRows());
581 try std.testing.expect(tiles[0].resize_pending); 579 try std.testing.expect(tiles[0].resize_pending);
582 try std.testing.expect(tiles[1].resize_pending); 580 try std.testing.expect(tiles[1].resize_pending);
583 try std.testing.expectEqual(@as(usize, 1), shared.sel); 581 try std.testing.expectEqual(@as(usize, 1), shared.sel);
@@ -616,7 +614,7 @@ test "a piped wall draws no label bar and paints row 1" {
616 }; 614 };
617 wall_layout.relayout(fixture.wallAll(alloc, tiles, present, &shared), 0); 615 wall_layout.relayout(fixture.wallAll(alloc, tiles, present, &shared), 0);
618 // A pipe: no label bar, every row is content the script can read. 616 // A pipe: no label bar, every row is content the script can read.
619 try std.testing.expectEqual(@as(u8, 0), shared.label_rows); 617 try std.testing.expectEqual(@as(u8, 0), shared.labelRows());
620 try std.testing.expectEqual(@as(u16, 24), tiles[0].viewRows()); 618 try std.testing.expectEqual(@as(u16, 24), tiles[0].viewRows());
621 } 619 }
622 620
src/tui/wall_test_pump.zig
Old New
@@ -445,8 +445,7 @@ test "a view tile's attach makes no size claim; a tile the user asked for does"
445 // a tile the user asked for (creates = true) claims its rect in the 445 // a tile the user asked for (creates = true) claims its rect in the
446 // attach itself. 446 // attach itself.
447 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false }; 447 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false };
448 // A one-tile wall: no label bar, so viewRows is the whole stripe. 448 // A piped wall draws no bar, so viewRows is the whole stripe.
449 shared.label_rows = 0;
450 var t = Tile{ 449 var t = Tile{
451 .r = .{ .target = .{ .sock = "/tmp/x" }, .label = "x", .session = "", .agent = false }, 450 .r = .{ .target = .{ .sock = "/tmp/x" }, .label = "x", .session = "", .agent = false },
452 .rect = .{ .top = 0, .left = 0, .rows = 24, .cols = 80 }, 451 .rect = .{ .top = 0, .left = 0, .rows = 24, .cols = 80 },
@@ -495,8 +494,7 @@ test "a newborn tile's first claim is its real stripe, not the placeholder" {
495 // placeholder stripe silently freezes the session on a stale grid. 494 // placeholder stripe silently freezes the session on a stale grid.
496 // The tile's stripe must be what relayout would give it BEFORE the 495 // The tile's stripe must be what relayout would give it BEFORE the
497 // pump's first sendAttach. 496 // pump's first sendAttach.
498 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false }; 497 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = true };
499 shared.label_rows = 1; // two tiles draw a bar
500 // Real stripe for the second tile of a 2-tile wall on 24 rows. 498 // Real stripe for the second tile of a 2-tile wall on 24 rows.
501 var t = Tile{ 499 var t = Tile{
502 .r = .{ .target = .{ .sock = "/tmp/x" }, .label = "x", .session = "", .agent = false }, 500 .r = .{ .target = .{ .sock = "/tmp/x" }, .label = "x", .session = "", .agent = false },
src/tui/wall_test_wall.zig
Old New
@@ -48,9 +48,9 @@ test "birthTile: a chord-born tile creates and offers no agent" {
48 try std.testing.expectEqual(@as(?usize, 0), tiles[at].born_from); 48 try std.testing.expectEqual(@as(?usize, 0), tiles[at].born_from);
49 try std.testing.expect(present[1]); 49 try std.testing.expect(present[1]);
50 try std.testing.expectEqual(@as(usize, 2), live); 50 try std.testing.expectEqual(@as(usize, 2), live);
51 // Two tiles draw a bar, and the bar is set at birth so viewRows is 51 // A tty wall draws a bar — derived from `is_tty`, so a birth cannot
52 // right from the first attach. 52 // forget it and viewRows is right from the first attach.
53 try std.testing.expectEqual(@as(u16, 1), shared.label_rows); 53 try std.testing.expectEqual(@as(u16, 1), shared.labelRows());
54 } 54 }
55 55
56 test "the empty wall advertises the key the picker really answers to" { 56 test "the empty wall advertises the key the picker really answers to" {
@@ -887,8 +887,7 @@ test "bare keys reach the focused tile; the prefix does not" {
887 } 887 }
888 888
889 test "focus follows a click: rectHit picks the tile under a row and column" { 889 test "focus follows a click: rectHit picks the tile under a row and column" {
890 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false }; 890 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = true };
891 shared.label_rows = 1;
892 var tiles: [2]Tile = undefined; 891 var tiles: [2]Tile = undefined;
893 tiles[0] = Tile{ 892 tiles[0] = Tile{
894 .r = .{ .target = .{ .sock = "/tmp/x" }, .label = "x", .session = "" }, 893 .r = .{ .target = .{ .sock = "/tmp/x" }, .label = "x", .session = "" },
@@ -1023,7 +1022,6 @@ test "setFocus: the marker moves between two tiles with no pump to move it" {
1023 defer std.posix.close(p[0]); 1022 defer std.posix.close(p[0]);
1024 defer std.posix.close(p[1]); 1023 defer std.posix.close(p[1]);
1025 var shared = Shared{ .out_fd = p[1], .size = .{ .cols = 80, .rows = 24 }, .is_tty = true }; 1024 var shared = Shared{ .out_fd = p[1], .size = .{ .cols = 80, .rows = 24 }, .is_tty = true };
1026 shared.label_rows = 1; // two tiles draw a bar
1027 var tiles: [3]Tile = undefined; 1025 var tiles: [3]Tile = undefined;
1028 tiles[0] = Tile{ 1026 tiles[0] = Tile{
1029 .r = .{ .target = .{ .sock = "/s" }, .label = "one", .session = "a" }, 1027 .r = .{ .target = .{ .sock = "/s" }, .label = "one", .session = "a" },
@@ -1085,7 +1083,6 @@ test "the bar's digit is the tile's chord: idx + 1, and a two-digit tile still s
1085 defer std.posix.close(p[0]); 1083 defer std.posix.close(p[0]);
1086 defer std.posix.close(p[1]); 1084 defer std.posix.close(p[1]);
1087 var shared = Shared{ .out_fd = p[1], .size = .{ .cols = 80, .rows = 24 }, .is_tty = true }; 1085 var shared = Shared{ .out_fd = p[1], .size = .{ .cols = 80, .rows = 24 }, .is_tty = true };
1088 shared.label_rows = 1;
1089 shared.sel = 0; 1086 shared.sel = 0;
1090 var tiles: [3]Tile = undefined; 1087 var tiles: [3]Tile = undefined;
1091 const labels = [_][]const u8{ "one", "two", "twelve" }; 1088 const labels = [_][]const u8{ "one", "two", "twelve" };
@@ -1158,7 +1155,6 @@ test "tileBanner: a prompt wider than its pane shows the tail inside the pane" {
1158 const pipe = try std.posix.pipe(); 1155 const pipe = try std.posix.pipe();
1159 defer std.posix.close(pipe[0]); 1156 defer std.posix.close(pipe[0]);
1160 var shared = Shared{ .out_fd = pipe[1], .size = .{ .cols = 80, .rows = 24 }, .is_tty = true }; 1157 var shared = Shared{ .out_fd = pipe[1], .size = .{ .cols = 80, .rows = 24 }, .is_tty = true };
1161 shared.label_rows = 1;
1162 var t = Tile{ 1158 var t = Tile{
1163 .r = .{ .target = .{ .sock = "/tmp/x" }, .label = "x", .session = "" }, 1159 .r = .{ .target = .{ .sock = "/tmp/x" }, .label = "x", .session = "" },
1164 .rect = .{ .top = 2, .left = 5, .rows = 5, .cols = 10 }, 1160 .rect = .{ .top = 2, .left = 5, .rows = 5, .cols = 10 },
@@ -1175,7 +1171,7 @@ test "tileBanner: a prompt wider than its pane shows the tail inside the pane" {
1175 var out: [256]u8 = undefined; 1171 var out: [256]u8 = undefined;
1176 const n = try std.posix.read(pipe[0], &out); 1172 const n = try std.posix.read(pipe[0], &out);
1177 const got = out[0..n]; 1173 const got = out[0..n];
1178 // The tile's own corner: rect.top + label_rows + 1, rect.left + 1. 1174 // The tile's own corner: rect.top + labelRows + 1, rect.left + 1.
1179 try std.testing.expect(std.mem.indexOf(u8, got, "\x1b[4;6H") != null); 1175 try std.testing.expect(std.mem.indexOf(u8, got, "\x1b[4;6H") != null);
1180 const from = std.mem.indexOf(u8, got, "\x1b[7m").? + 4; 1176 const from = std.mem.indexOf(u8, got, "\x1b[7m").? + 4;
1181 const to = std.mem.indexOf(u8, got, "\x1b[0m").?; 1177 const to = std.mem.indexOf(u8, got, "\x1b[0m").?;
src/tui/wallview.zig
Old New
@@ -121,13 +121,9 @@ 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 on a tty, however many tiles there are; a
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.
127 label_rows: u16 = 1,
128 /// The container tree that owns every tile's rect. The keyboard thread 124 /// The container tree that owns every tile's rect. The keyboard thread
129 /// mutates it under `paint_mu` — the same single-writer rule as `sel` 125 /// mutates it under `paint_mu` — the same single-writer rule as `sel`
130 /// and `label_rows` — and relayout flattens it to read rects. 126 /// — and relayout flattens it to read rects.
131 tree: layout.Tree = layout.Tree.init(std.heap.page_allocator), 127 tree: layout.Tree = layout.Tree.init(std.heap.page_allocator),
132 /// The last flatten result, kept so `focus_dir` can read adjacency 128 /// The last flatten result, kept so `focus_dir` can read adjacency
133 /// without re-flattening under the keyboard's `paint_mu` hold. 129 /// without re-flattening under the keyboard's `paint_mu` hold.
@@ -163,6 +159,18 @@ pub const Shared = struct {
163 /// the socket would land on a shared /tmp, answerable by any local user. 159 /// the socket would land on a shared /tmp, answerable by any local user.
164 prompts: ?*askpass.Listener = null, 160 prompts: ?*askpass.Listener = null,
165 prompt_exe: []const u8 = "", 161 prompt_exe: []const u8 = "",
162
163 /// One label bar per tile on a tty, however many tiles there are: the
164 /// bar is what names a session on screen, and hiding it when one tile
165 /// was visible left a solo or fullscreened session anonymous. A piped
166 /// wall owns every row and draws none — its byte stream is a script's
167 /// input, and a bar in it would be bytes the session never wrote.
168 /// Derived, never assigned: `viewRows`, `core.row_off` and the layout
169 /// floors (`wall_layout.floorsOf`) all read this one function, so no
170 /// site can drift from the rule the way three assignment copies could.
171 pub fn labelRows(s: *const Shared) u16 {
172 return @intFromBool(s.is_tty);
173 }
166 }; 174 };
167 175
168 /// Opens the prompt box on whatever ssh is asking, if anything is. False 176 /// Opens the prompt box on whatever ssh is asking, if anything is. False
@@ -342,7 +350,7 @@ pub const Tile = struct {
342 release_pending: std.atomic.Value(bool) = std.atomic.Value(bool).init(false), 350 release_pending: std.atomic.Value(bool) = std.atomic.Value(bool).init(false),
343 351
344 pub fn viewRows(t: *const Tile) u16 { 352 pub fn viewRows(t: *const Tile) u16 {
345 return t.rect.rows -| t.shared.label_rows; 353 return t.rect.rows -| t.shared.labelRows();
346 } 354 }
347 355
348 pub fn viewCols(t: *const Tile) u16 { 356 pub fn viewCols(t: *const Tile) u16 {
@@ -481,7 +489,7 @@ pub fn paintLabel(t: *Tile, state: State) void {
481 t.shared.paint_mu.lock(); 489 t.shared.paint_mu.lock();
482 defer t.shared.paint_mu.unlock(); 490 defer t.shared.paint_mu.unlock();
483 t.state = state; 491 t.state = state;
484 if (t.shared.label_rows != 0) paintLabelLocked(t); 492 if (t.shared.labelRows() != 0) paintLabelLocked(t);
485 } 493 }
486 494
487 /// Bounded by `cols` AND by `buf`: `cols` alone overran past 250 columns. 495 /// Bounded by `cols` AND by `buf`: `cols` alone overran past 250 columns.
@@ -506,7 +514,7 @@ pub fn labelText(
506 } 514 }
507 515
508 /// The label bar: inverse, full width, `N> LABEL [state]`, truncated at the 516 /// 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 517 /// terminal edge. Caller holds `paint_mu`. Only called when `labelRows` is
510 /// nonzero — a piped wall owns every row and has no bar. 518 /// nonzero — a piped wall owns every row and has no bar.
511 pub fn paintLabelLocked(t: *Tile) void { 519 pub fn paintLabelLocked(t: *Tile) void {
512 if (popupOpen(t.shared)) return; 520 if (popupOpen(t.shared)) return;
@@ -571,7 +579,7 @@ pub fn tileBanner(t: *Tile, text: []const u8) void {
571 // banner that lost the race for `paint_mu` lands on a box row, and 579 // banner that lost the race for `paint_mu` lands on a box row, and
572 // `picker_stamp` suppresses the repaint that would repair it. 580 // `picker_stamp` suppresses the repaint that would repair it.
573 if (popupOpen(t.shared)) return; 581 if (popupOpen(t.shared)) return;
574 paint.paintBanner(t.shared.out_fd, t.rect.cols, shown, t.rect.top + t.shared.label_rows, t.rect.left); 582 paint.paintBanner(t.shared.out_fd, t.rect.cols, shown, t.rect.top + t.shared.labelRows(), t.rect.left);
575 } 583 }
576 584
577 /// Never blocks and never reports: a full pipe is a bell already ringing. 585 /// Never blocks and never reports: a full pipe is a bell already ringing.
@@ -691,10 +699,10 @@ pub fn setFocus(tiles: []Tile, shared: *Shared, next: usize) void {
691 // tiles immediate, and the rest follow on their next poll timeout. 699 // tiles immediate, and the rest follow on their next poll timeout.
692 _ = shared.repaint_gen.fetchAdd(1, .release); 700 _ = shared.repaint_gen.fetchAdd(1, .release);
693 for (tiles) |*t| ring(t); 701 for (tiles) |*t| ring(t);
694 // Only where there IS a bar: a piped wall sets `label_rows` to zero, 702 // Only where there IS a bar: a piped wall's `labelRows` is zero, and
695 // and a bar painted there lands on a content row the tile that owns 703 // a bar painted there lands on a content row the tile that owns the
696 // the screen has already written. 704 // screen has already written.
697 if (shared.label_rows != 0) paintDeadBarsLocked(tiles); 705 if (shared.labelRows() != 0) paintDeadBarsLocked(tiles);
698 } 706 }
699 707
700 /// Re-cut before the move: `setFocus` releases the outgoing session 708 /// Re-cut before the move: `setFocus` releases the outgoing session
@@ -970,16 +978,14 @@ fn birthTileOrRefuse(w: Wall, b: Birth) !usize {
970 w.alloc, 978 w.alloc,
971 w.shared.size.rows, 979 w.shared.size.rows,
972 w.shared.size.cols, 980 w.shared.size.cols,
973 wall_layout.wallFloors(w.shared.is_tty), 981 wall_layout.floorsOf(w.shared),
974 null, 982 null,
975 ); 983 );
976 defer flat.deinit(w.alloc); 984 defer flat.deinit(w.alloc);
977 const new_rect = flat.rectOf(@intCast(at)) orelse return error.NoRect; 985 const new_rect = flat.rectOf(@intCast(at)) orelse return error.NoRect;
978 // The real rect, not a placeholder: the daemon refuses creates under 986 // The real rect, not a placeholder: the daemon refuses creates under
979 // `min_session_rows`, and a 2-row rect on a live session is destructive 987 // `min_session_rows`, and a 2-row rect on a live session is destructive
980 // under latest-wins. `label_rows` too, so `viewRows` is right at attach — 988 // under latest-wins.
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 989 // 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 990 // 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 991 // is overwritten, so the last thing that can fail here still fails
@@ -1460,7 +1466,7 @@ pub fn run(alloc: std.mem.Allocator, host_specs: []const HostSpec, entry: Entry)
1460 alloc, 1466 alloc,
1461 size.rows, 1467 size.rows,
1462 size.cols, 1468 size.cols,
1463 wall_layout.wallFloors(shared.is_tty), 1469 wall_layout.floorsOf(&shared),
1464 null, 1470 null,
1465 ) catch { 1471 ) catch {
1466 std.debug.print("mux: terminal too small\n", .{}); 1472 std.debug.print("mux: terminal too small\n", .{});
@@ -1601,9 +1607,6 @@ pub fn run(alloc: std.mem.Allocator, host_specs: []const HostSpec, entry: Entry)
1601 alloc.free(pl.panes); 1607 alloc.free(pl.panes);
1602 seed_plan = null; 1608 seed_plan = null;
1603 } 1609 }
1604 // The bar follows the tty, not the tile count (`relayout` states why).
1605 // Set before the pumps start so `viewRows` is right on the first attach.
1606 shared.label_rows = @intFromBool(shared.is_tty);
1607 for (tiles[0..live]) |*t| spawnPump(t); 1610 for (tiles[0..live]) |*t| spawnPump(t);
1608 // A wall with no tile yet paints its one line rather than nothing (a 1611 // A wall with no tile yet paints its one line rather than nothing (a
1609 // blank terminal with no cursor reads as hung), and a seeded wall 1612 // blank terminal with no cursor reads as hung), and a seeded wall