a73x

7faf0467

feat: fullscreen is a relayout where the focus takes the terminal

a73x   2026-08-24 18:06

Commit message
feat: fullscreen is a relayout where the focus takes the terminal

`f` toggles a bool on Shared; relayout passes `sel` to flatten when set,
so the focused tile gets the whole terminal and every other gets 0x0.
A 0x0 rect claims nothing — the daemon refuses the resize, so hidden
panes stay attached and paint nothing. Focus moves while fullscreened
re-flatten, so Ctrl-\ 2 gives tile 2 the full rect. `f` again restores
the real layout. The base flat is always computed so `focus_dir` can
read adjacency from the real tree while fullscreened.

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

src/wallview.zig
Old New
@@ -247,7 +247,14 @@ const Shared = struct {
247 /// Relayout replaces it (freeing the old one); the keyboard thread 247 /// Relayout replaces it (freeing the old one); the keyboard thread
248 /// is the sole reader, under the same ownership as `tree`. 248 /// is the sole reader, under the same ownership as `tree`.
249 last_flat: ?layout.Flat = null, 249 last_flat: ?layout.Flat = null,
250 /// The non-fullscreen flatten, kept so `focus_dir` can answer from
251 /// the base layout while fullscreened — the fullscreen flat gives
252 /// every non-focused tile a 0×0 rect, so adjacency on it is null.
253 base_flat: ?layout.Flat = null,
250 flat_alloc: std.mem.Allocator = std.heap.page_allocator, 254 flat_alloc: std.mem.Allocator = std.heap.page_allocator,
255 /// `f` toggles it; relayout passes `sel` to flatten when set, so the
256 /// focused tile takes the whole terminal and every other gets 0×0.
257 fullscreen: bool = false,
251 /// Bumped when the terminal's contents are no longer anybody's paint — 258 /// Bumped when the terminal's contents are no longer anybody's paint —
252 /// a relayout re-cut the stripes, or the focus moved and the newly 259 /// a relayout re-cut the stripes, or the focus moved and the newly
253 /// focused tile owes a full repaint. Pumps compare it against what 260 /// focused tile owes a full repaint. Pumps compare it against what
@@ -1492,7 +1499,9 @@ fn pumpTile(t: *Tile) void {
1492 if (gen != painted_gen) { 1499 if (gen != painted_gen) {
1493 core.drag.clear(); 1500 core.drag.clear();
1494 paintLabel(t, state); 1501 paintLabel(t, state);
1495 core.repaint() catch {}; 1502 // A 0-row rect (fullscreened out) paints nothing: the daemon
1503 // refused the 0×0 resize, so the session keeps its real grid.
1504 if (snap_view_rows > 0) core.repaint() catch {};
1496 painted_gen = gen; 1505 painted_gen = gen;
1497 } 1506 }
1498 } 1507 }
@@ -1549,6 +1558,8 @@ fn focusAnswer(
1549 ) void { 1558 ) void {
1550 if (recut) relayout(alloc, tiles, present, shared, shared.sel); 1559 if (recut) relayout(alloc, tiles, present, shared, shared.sel);
1551 setFocus(tiles, shared, to); 1560 setFocus(tiles, shared, to);
1561 // Fullscreen re-flattens with the new focus so the full rect follows.
1562 if (shared.fullscreen) relayout(alloc, tiles, present, shared, shared.sel);
1552 } 1563 }
1553 1564
1554 /// The whole screen an empty wall gets: one line, at the top, saying the 1565 /// The whole screen an empty wall gets: one line, at the top, saying the
@@ -1606,17 +1617,22 @@ fn relayout(
1606 if (p) live += 1; 1617 if (p) live += 1;
1607 } 1618 }
1608 // A one-tile wall owns every row and draws no label bar; two or more 1619 // A one-tile wall owns every row and draws no label bar; two or more
1609 // tiles each lose their top row to one. Set before the tree is flattened 1620 // tiles each lose their top row to one. Fullscreen is the same: one
1610 // so `viewRows` is right from the first attach, and before the doorbells 1621 // visible pane, no bar — the plain client's byte stream.
1611 // so the pumps read it on the pass they answer. 1622 shared.label_rows = if (shared.fullscreen or live <= 1) 0 else 1;
1612 shared.label_rows = if (live > 1) 1 else 0;
1613 if (shared.is_tty) proto.writeAllFd(shared.out_fd, "\x1b[?25l\x1b[H\x1b[2J") catch {}; 1623 if (shared.is_tty) proto.writeAllFd(shared.out_fd, "\x1b[?25l\x1b[H\x1b[2J") catch {};
1614 if (live == 0) { 1624 if (live == 0) {
1615 paintEmptyWallLocked(shared); 1625 paintEmptyWallLocked(shared);
1616 return; 1626 return;
1617 } 1627 }
1618 if (shared.tree.flatten(alloc, shared.size.rows, shared.size.cols, wallFloors(live), null)) |flat| { 1628 // The base flat (null) is always computed so `focus_dir` can read
1619 // Replace the stored flat: free the old one first, then keep this. 1629 // adjacency from the real layout while fullscreened.
1630 if (shared.tree.flatten(alloc, shared.size.rows, shared.size.cols, wallFloors(live), null)) |base| {
1631 if (shared.base_flat) |*old| old.deinit(shared.flat_alloc);
1632 shared.base_flat = base;
1633 } else |_| {}
1634 const fs_arg: ?u8 = if (shared.fullscreen) @intCast(sel) else null;
1635 if (shared.tree.flatten(alloc, shared.size.rows, shared.size.cols, wallFloors(live), fs_arg)) |flat| {
1620 if (shared.last_flat) |*old| old.deinit(shared.flat_alloc); 1636 if (shared.last_flat) |*old| old.deinit(shared.flat_alloc);
1621 shared.last_flat = flat; 1637 shared.last_flat = flat;
1622 for (tiles, present) |*t, p| { 1638 for (tiles, present) |*t, p| {
@@ -1624,12 +1640,6 @@ fn relayout(
1624 if (flat.rectOf(@intCast(t.idx))) |r| { 1640 if (flat.rectOf(@intCast(t.idx))) |r| {
1625 t.rect = r; 1641 t.rect = r;
1626 } 1642 }
1627 // A rect that moved or reshaped owes the daemon its new size:
1628 // the pump is the transport's only writer, so the doorbell is
1629 // set here and the pump sends the `.resize` from its own pass.
1630 // `viewRows` reads `label_rows` and the rect together, both
1631 // set above and here — so the frame carries the rect this tile
1632 // now claims.
1633 t.resize_pending.store(true, .release); 1643 t.resize_pending.store(true, .release);
1634 } 1644 }
1635 paintRailsLocked(shared, flat); 1645 paintRailsLocked(shared, flat);
@@ -2585,11 +2595,15 @@ pub fn run(alloc: std.mem.Allocator, resolved: []const Resolved, entry: Entry) !
2585 ring(&tiles[z]); 2595 ring(&tiles[z]);
2586 }, 2596 },
2587 .focus_dir => |d| { 2597 .focus_dir => |d| {
2588 if (shared.last_flat) |flat| { 2598 const flat = shared.base_flat orelse shared.last_flat;
2589 if (layout.neighbor(flat, @intCast(z), dirOf(d))) |nb| { 2599 if (flat) |f| {
2600 if (layout.neighbor(f, @intCast(z), dirOf(d))) |nb| {
2590 if (nb < live and present[nb] and nb != z) { 2601 if (nb < live and present[nb] and nb != z) {
2591 last_focus = z; 2602 last_focus = z;
2592 setFocus(tiles[0..live], &shared, nb); 2603 if (shared.fullscreen)
2604 focusAnswer(alloc, tiles[0..live], present[0..live], &shared, false, nb)
2605 else
2606 setFocus(tiles[0..live], &shared, nb);
2593 } 2607 }
2594 } 2608 }
2595 } 2609 }
@@ -2604,14 +2618,19 @@ pub fn run(alloc: std.mem.Allocator, resolved: []const Resolved, entry: Entry) !
2604 tiles[z].ask.store(@intFromEnum(client.SwitchIntent.new), .release); 2618 tiles[z].ask.store(@intFromEnum(client.SwitchIntent.new), .release);
2605 ring(&tiles[z]); 2619 ring(&tiles[z]);
2606 }, 2620 },
2607 .fullscreen, 2621 .fullscreen => {
2622 shared.fullscreen = !shared.fullscreen;
2623 relayout(alloc, tiles[0..live], present[0..live], &shared, shared.sel);
2624 },
2608 .resize, 2625 .resize,
2609 => {}, 2626 => {},
2610 .focus => |idx| { 2627 .focus => |idx| {
2611 // `Ctrl-\ 1-9` focuses tile N (one-based).
2612 if (idx > 0 and idx <= live and present[idx - 1] and idx - 1 != z) { 2628 if (idx > 0 and idx <= live and present[idx - 1] and idx - 1 != z) {
2613 last_focus = z; 2629 last_focus = z;
2614 setFocus(tiles[0..live], &shared, idx - 1); 2630 if (shared.fullscreen)
2631 focusAnswer(alloc, tiles[0..live], present[0..live], &shared, false, idx - 1)
2632 else
2633 setFocus(tiles[0..live], &shared, idx - 1);
2615 } 2634 }
2616 }, 2635 },
2617 .forget => { 2636 .forget => {
@@ -3065,6 +3084,7 @@ test "relayout sets resize_pending on every live tile" {
3065 shared.flat_alloc = alloc; 3084 shared.flat_alloc = alloc;
3066 defer shared.tree.deinit(); 3085 defer shared.tree.deinit();
3067 defer if (shared.last_flat) |*f| f.deinit(alloc); 3086 defer if (shared.last_flat) |*f| f.deinit(alloc);
3087 defer if (shared.base_flat) |*f| f.deinit(alloc);
3068 try shared.tree.addFirst(0); 3088 try shared.tree.addFirst(0);
3069 try shared.tree.insert(0, 1); 3089 try shared.tree.insert(0, 1);
3070 const tiles = try alloc.alloc(Tile, 2); 3090 const tiles = try alloc.alloc(Tile, 2);
@@ -3091,9 +3111,117 @@ test "relayout sets resize_pending on every live tile" {
3091 try std.testing.expect(tiles[1].resize_pending.load(.acquire)); 3111 try std.testing.expect(tiles[1].resize_pending.load(.acquire));
3092 } 3112 }
3093 3113
3114 test "fullscreen gives the focused tile the whole terminal and hides the rest" {
3115 const alloc = std.testing.allocator;
3116 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false };
3117 shared.tree = layout.Tree.init(alloc);
3118 shared.flat_alloc = alloc;
3119 defer shared.tree.deinit();
3120 defer if (shared.last_flat) |*f| f.deinit(alloc);
3121 defer if (shared.base_flat) |*f| f.deinit(alloc);
3122 try shared.tree.addFirst(0);
3123 try shared.tree.insert(0, 1);
3124 const tiles = try alloc.alloc(Tile, 2);
3125 defer alloc.free(tiles);
3126 const present = [_]bool{ true, true };
3127 for (tiles, 0..) |*t, i| {
3128 t.* = Tile{
3129 .r = .{ .target = .{ .sock = "/tmp/x" }, .label = "x", .session = "" },
3130 .rect = .{ .top = 0, .left = 0, .rows = 24, .cols = 80 },
3131 .shared = &shared,
3132 .idx = i,
3133 .wake_r = -1,
3134 .wake_w = -1,
3135 };
3136 }
3137 shared.fullscreen = true;
3138 relayout(alloc, tiles, &present, &shared, 0);
3139 // No label bar: the fullscreened pane is a one-tile wall.
3140 try std.testing.expectEqual(@as(u8, 0), shared.label_rows);
3141 // Tile 0 gets the whole terminal; tile 1 gets nothing.
3142 try std.testing.expectEqual(@as(u16, 24), tiles[0].rect.rows);
3143 try std.testing.expectEqual(@as(u16, 80), tiles[0].rect.cols);
3144 try std.testing.expectEqual(@as(u16, 0), tiles[1].rect.rows);
3145 try std.testing.expectEqual(@as(u16, 0), tiles[1].rect.cols);
3146 // The base flat is kept for neighbor.
3147 try std.testing.expect(shared.base_flat != null);
3148 }
3149
3150 test "toggle off fullscreen restores the real rects" {
3151 const alloc = std.testing.allocator;
3152 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false };
3153 shared.tree = layout.Tree.init(alloc);
3154 shared.flat_alloc = alloc;
3155 defer shared.tree.deinit();
3156 defer if (shared.last_flat) |*f| f.deinit(alloc);
3157 defer if (shared.base_flat) |*f| f.deinit(alloc);
3158 try shared.tree.addFirst(0);
3159 try shared.tree.insert(0, 1);
3160 const tiles = try alloc.alloc(Tile, 2);
3161 defer alloc.free(tiles);
3162 const present = [_]bool{ true, true };
3163 for (tiles, 0..) |*t, i| {
3164 t.* = Tile{
3165 .r = .{ .target = .{ .sock = "/tmp/x" }, .label = "x", .session = "" },
3166 .rect = .{ .top = 0, .left = 0, .rows = 24, .cols = 80 },
3167 .shared = &shared,
3168 .idx = i,
3169 .wake_r = -1,
3170 .wake_w = -1,
3171 };
3172 }
3173 shared.fullscreen = true;
3174 relayout(alloc, tiles, &present, &shared, 0);
3175 shared.fullscreen = false;
3176 relayout(alloc, tiles, &present, &shared, 0);
3177 // Two tiles again: label bar back, both have real rects (stacked:
3178 // full width, half height each).
3179 try std.testing.expectEqual(@as(u8, 1), shared.label_rows);
3180 try std.testing.expectEqual(@as(u16, 80), tiles[0].rect.cols);
3181 try std.testing.expectEqual(@as(u16, 80), tiles[1].rect.cols);
3182 try std.testing.expect(tiles[0].rect.rows < 24);
3183 try std.testing.expect(tiles[1].rect.rows > 0);
3184 }
3185
3186 test "focus_dir while fullscreened follows the focus" {
3187 const alloc = std.testing.allocator;
3188 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false };
3189 shared.tree = layout.Tree.init(alloc);
3190 shared.flat_alloc = alloc;
3191 defer shared.tree.deinit();
3192 defer if (shared.last_flat) |*f| f.deinit(alloc);
3193 defer if (shared.base_flat) |*f| f.deinit(alloc);
3194 try shared.tree.addFirst(0);
3195 try shared.tree.insert(0, 1);
3196 const tiles = try alloc.alloc(Tile, 2);
3197 defer alloc.free(tiles);
3198 const present = [_]bool{ true, true };
3199 for (tiles, 0..) |*t, i| {
3200 t.* = Tile{
3201 .r = .{ .target = .{ .sock = "/tmp/x" }, .label = "x", .session = "" },
3202 .rect = .{ .top = 0, .left = 0, .rows = 24, .cols = 80 },
3203 .shared = &shared,
3204 .idx = i,
3205 .wake_r = -1,
3206 .wake_w = -1,
3207 };
3208 }
3209 shared.fullscreen = true;
3210 relayout(alloc, tiles, &present, &shared, 0);
3211 // Move focus to tile 1 while fullscreened.
3212 focusAnswer(alloc, tiles, &present, &shared, false, 1);
3213 // The full rect followed: tile 1 now owns the terminal.
3214 try std.testing.expectEqual(@as(u16, 24), tiles[1].rect.rows);
3215 try std.testing.expectEqual(@as(u16, 80), tiles[1].rect.cols);
3216 try std.testing.expectEqual(@as(u16, 0), tiles[0].rect.rows);
3217 try std.testing.expectEqual(@as(usize, 1), shared.sel);
3218 }
3219
3094 test "a pump's answer that grew the wall re-cuts it; a mere focus move does not" { 3220 test "a pump's answer that grew the wall re-cuts it; a mere focus move does not" {
3095 const alloc = std.testing.allocator; 3221 const alloc = std.testing.allocator;
3096 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false }; 3222 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false };
3223 defer if (shared.last_flat) |*f| f.deinit(shared.flat_alloc);
3224 defer if (shared.base_flat) |*f| f.deinit(shared.flat_alloc);
3097 const tiles = try alloc.alloc(Tile, 2); 3225 const tiles = try alloc.alloc(Tile, 2);
3098 defer alloc.free(tiles); 3226 defer alloc.free(tiles);
3099 const present = try alloc.alloc(bool, 2); 3227 const present = try alloc.alloc(bool, 2);
@@ -3136,6 +3264,7 @@ test "a one-tile wall draws no label bar and paints row 1" {
3136 shared.flat_alloc = alloc; 3264 shared.flat_alloc = alloc;
3137 defer shared.tree.deinit(); 3265 defer shared.tree.deinit();
3138 defer if (shared.last_flat) |*f| f.deinit(alloc); 3266 defer if (shared.last_flat) |*f| f.deinit(alloc);
3267 defer if (shared.base_flat) |*f| f.deinit(alloc);
3139 try shared.tree.addFirst(0); 3268 try shared.tree.addFirst(0);
3140 const tiles = try alloc.alloc(Tile, 1); 3269 const tiles = try alloc.alloc(Tile, 1);
3141 defer alloc.free(tiles); 3270 defer alloc.free(tiles);
@@ -3340,6 +3469,7 @@ test "a relayout drops every tile's highlight, because the stripes move under it
3340 shared.flat_alloc = alloc; 3469 shared.flat_alloc = alloc;
3341 defer shared.tree.deinit(); 3470 defer shared.tree.deinit();
3342 defer if (shared.last_flat) |*f| f.deinit(alloc); 3471 defer if (shared.last_flat) |*f| f.deinit(alloc);
3472 defer if (shared.base_flat) |*f| f.deinit(alloc);
3343 try shared.tree.addFirst(0); 3473 try shared.tree.addFirst(0);
3344 try shared.tree.insert(0, 1); 3474 try shared.tree.insert(0, 1);
3345 const tiles = try alloc.alloc(Tile, 2); 3475 const tiles = try alloc.alloc(Tile, 2);
test/e2e.sh
Old New
@@ -370,7 +370,9 @@ ZMOUSESH="${TMPDIR:-/tmp}/mux-e2e-wallappmouse-$$.sh"
370 # sub-rect, so its daemon gets a socket of its own for the grid-size 370 # sub-rect, so its daemon gets a socket of its own for the grid-size
371 # reason every wall leg has. 371 # reason every wall leg has.
372 SOCK53="${TMPDIR:-/tmp}/muxd-e2e-sbrect-$$.sock" 372 SOCK53="${TMPDIR:-/tmp}/muxd-e2e-sbrect-$$.sock"
373 SOCK54="${TMPDIR:-/tmp}/muxd-e2e-fullscreen-$$.sock"
373 D54PID="" 374 D54PID=""
375 D55PID=""
374 376
375 # The wall as attach HISTORY (phase 2). A daemon AND a state home of its 377 # The wall as attach HISTORY (phase 2). A daemon AND a state home of its
376 # own, for the dynamic-wall leg's reason turned up one notch: what these 378 # own, for the dynamic-wall leg's reason turned up one notch: what these
@@ -1270,6 +1272,7 @@ cleanup() {
1270 [ -n "$D43PID" ] && kill "$D43PID" 2>/dev/null || true 1272 [ -n "$D43PID" ] && kill "$D43PID" 2>/dev/null || true
1271 [ -n "${D51PID:-}" ] && kill "$D51PID" 2>/dev/null || true 1273 [ -n "${D51PID:-}" ] && kill "$D51PID" 2>/dev/null || true
1272 [ -n "${D52PID:-}" ] && kill "$D52PID" 2>/dev/null || true 1274 [ -n "${D52PID:-}" ] && kill "$D52PID" 2>/dev/null || true
1275 [ -n "${D55PID:-}" ] && kill "$D55PID" 2>/dev/null || true
1273 # The ssh-agents the forwarding legs start. Not mux processes and so not 1276 # The ssh-agents the forwarding legs start. Not mux processes and so not
1274 # the leak sweep's business, but they are daemons this file forked: left 1277 # the leak sweep's business, but they are daemons this file forked: left
1275 # alive they outlive the suite holding a private key, which is the one 1278 # alive they outlive the suite holding a private key, which is the one
@@ -1314,6 +1317,7 @@ cleanup() {
1314 [ -S "$SOCK50" ] && "$MUXD" stop --sock "$SOCK50" 2>/dev/null || true 1317 [ -S "$SOCK50" ] && "$MUXD" stop --sock "$SOCK50" 2>/dev/null || true
1315 [ -S "$SOCK52" ] && "$MUXD" stop --sock "$SOCK52" 2>/dev/null || true 1318 [ -S "$SOCK52" ] && "$MUXD" stop --sock "$SOCK52" 2>/dev/null || true
1316 [ -S "$SOCK53" ] && "$MUXD" stop --sock "$SOCK53" 2>/dev/null || true 1319 [ -S "$SOCK53" ] && "$MUXD" stop --sock "$SOCK53" 2>/dev/null || true
1320 [ -S "$SOCK54" ] && "$MUXD" stop --sock "$SOCK54" 2>/dev/null || true
1317 1321
1318 # ---- the leak sweep (hygiene kit, 6a) ---- 1322 # ---- the leak sweep (hygiene kit, 6a) ----
1319 # Here rather than at the bottom of the file, which `set -e` reaches only 1323 # Here rather than at the bottom of the file, which `set -e` reaches only
@@ -1328,7 +1332,8 @@ cleanup() {
1328 "$D20PID" "$D21PID" "$D22PID" "$D23PID" "$D24PID" "$D25PID" \ 1332 "$D20PID" "$D21PID" "$D22PID" "$D23PID" "$D24PID" "$D25PID" \
1329 "$D26PID" "$D27PID" "$D28PID" "$D29PID" "$D30PID" "$D31PID" \ 1333 "$D26PID" "$D27PID" "$D28PID" "$D29PID" "$D30PID" "$D31PID" \
1330 "$D32PID" "$D33PID" "$D34PID" "$D35PID" "$D36PID" "$D37PID" \ 1334 "$D32PID" "$D33PID" "$D34PID" "$D35PID" "$D36PID" "$D37PID" \
1331 "$D38PID" "$D39PID" "$D40PID" "$D41PID" "$D42PID" "$D43PID" "$D54PID" 1335 "$D38PID" "$D39PID" "$D40PID" "$D41PID" "$D42PID" "$D43PID" "$D54PID" \
1336 "$D55PID"
1332 _leak=0 1337 _leak=0
1333 leak_sweep "$_rc" || _leak=1 1338 leak_sweep "$_rc" || _leak=1
1334 1339
@@ -1397,7 +1402,10 @@ cleanup() {
1397 "$LESSDATA" "$OUT.pgrcap" "$OUT.pgrstop" "$SOCK35" \ 1402 "$LESSDATA" "$OUT.pgrcap" "$OUT.pgrstop" "$SOCK35" \
1398 "$OUT.pipe" "$OUT.pipe.d" "$OUT.pipecap" "$OUT.pipestop" "$SOCK36" \ 1403 "$OUT.pipe" "$OUT.pipe.d" "$OUT.pipecap" "$OUT.pipestop" "$SOCK36" \
1399 "$OUT.sb2.d" "$OUT.sb2a" "$OUT.sb2a.err" "$OUT.sb2b" "$OUT.sb2b.err" \ 1404 "$OUT.sb2.d" "$OUT.sb2a" "$OUT.sb2a.err" "$OUT.sb2b" "$OUT.sb2b.err" \
1400 "$OUT.sb2cap" "$OUT.sb2cap.err" "$OUT.sb2pc" "$OUT.sb2stop" "$SOCK53" 1405 "$OUT.sb2cap" "$OUT.sb2cap.err" "$OUT.sb2pc" "$OUT.sb2stop" "$SOCK53" \
1406 "$OUT.fs.d" "$OUT.fsa" "$OUT.fsa.err" "$OUT.fsb" "$OUT.fsb.err" \
1407 "$OUT.fscap" "$OUT.fscap.err" "$OUT.fspc" "$OUT.fsfa" "$OUT.fsfb" \
1408 "$OUT.fsstop" "$SOCK54"
1401 # ...and the non-tty capture that leg's session feeds. 1409 # ...and the non-tty capture that leg's session feeds.
1402 rm -f "$OUT.nogate" 1410 rm -f "$OUT.nogate"
1403 # ...and its other half: the paste capture and the file nvim wrote, which 1411 # ...and its other half: the paste capture and the file nvim wrote, which
@@ -7457,7 +7465,7 @@ DPID=""
7457 7465
7458 # The pins. Literals, not variables set from counting something else — 7466 # The pins. Literals, not variables set from counting something else —
7459 # "assert the literal, never the constant the code under test reads" 7467 # "assert the literal, never the constant the code under test reads"
7460 # (decisions.md, M10). 64 scenario checkpoints; 36 convergence points. 7468 # (decisions.md, M10). 65 scenario checkpoints; 37 convergence points.
7461 # (The first of these two numbers had drifted to 43 while the pin below 7469 # (The first of these two numbers had drifted to 43 while the pin below
7462 # said 63 — prose is not gated, which is why the pin is.) 7470 # said 63 — prose is not gated, which is why the pin is.)
7463 # Anyone adding a scenario updates these by hand, on purpose. 7471 # Anyone adding a scenario updates these by hand, on purpose.
@@ -7587,8 +7595,81 @@ DPID=""
7587 # it is the one leg that holds an ssh-agent under SIGSTOP, and a trap that 7595 # it is the one leg that holds an ssh-agent under SIGSTOP, and a trap that
7588 # has to CONT before it kills is cheaper to reason about with nothing 7596 # has to CONT before it kills is cheaper to reason about with nothing
7589 # after it. 7597 # after it.
7590 [ "$OK_COUNT" = "63" ] || { 7598
7591 echo "e2e FAIL: $OK_COUNT scenario checkpoints ran, the pin says 63 —" 7599 # ---- fullscreen: f toggles, focus follows, restore -------------------
7600 #
7601 # `f` is a layout op, not a mode: the focused tile takes the whole
7602 # terminal, every other gets 0x0. A 0x0 rect claims nothing, so hidden
7603 # panes stay attached and paint nothing. Focus moves while fullscreened
7604 # re-flatten, so Ctrl-\ 2 gives tile 2 the full rect. `f` again restores
7605 # the real layout. This leg proves all three with `muxa capture` — the
7606 # marker each session received names which tile owned the terminal.
7607 #
7608 # No convergence point: `muxa capture` reads the daemon's grid, not the
7609 # terminal's, and the daemon's grid is the side channel this asserts on.
7610 "$MUXD" run --sock "$SOCK54" --shell /bin/sh > "$OUT.fs.d" 2>&1 &
7611 D55PID=$!
7612 wait_sock "$SOCK54" "$OUT.fs.d" "fullscreen daemon never bound"
7613
7614 pipe_mux "$OUT.fsa" "$OUT.fsa.err" timeout 40 "$MUX" --sock "$SOCK54" --session a
7615 pipe_send 'printf "fs-%%s\\n" init-a\n'
7616 await_out "$OUT.fsa" "fs-init-a" "fullscreen: session a's marker never reached the client"
7617 pipe_detach
7618 wait_grid "$SOCK54" "fs-init-a" "fullscreen: session a's marker" a
7619 pipe_mux "$OUT.fsb" "$OUT.fsb.err" timeout 40 "$MUX" --sock "$SOCK54" --session b
7620 pipe_send 'printf "fs-%%s\\n" init-b\n'
7621 await_out "$OUT.fsb" "fs-init-b" "fullscreen: session b's marker never reached the client"
7622 pipe_detach
7623 wait_grid "$SOCK54" "fs-init-b" "fullscreen: session b's marker" b
7624
7625 set +e
7626 # wide: 80x24 trips the aspect rule (80 >= 48 → beside)
7627 timeout 90 "$PTYCLIENT" --cols 80 --rows 24 --out "$OUT.fscap" --err "$OUT.fscap.err" -- \
7628 "$MUX" wall "--sock $SOCK54#a" "--sock $SOCK54#b" > "$OUT.fspc" 2>&1 <<'EOF'
7629 expect fs-init-b 20000
7630 settle 700 20000
7631 send \x1cf
7632 settle 700 20000
7633 send printf 'fs-%s\n' one\n
7634 expect fs-one 10000
7635 settle 400 15000
7636 send \x1c2
7637 settle 700 20000
7638 send printf 'fs-%s\n' two\n
7639 expect fs-two 10000
7640 settle 400 15000
7641 send \x1cf
7642 settle 700 20000
7643 send \x1cd
7644 waitexit 10000
7645 EOF
7646 RC=$?
7647 set -e
7648 [ "$RC" -eq 0 ] || {
7649 echo "e2e FAIL: fullscreen: ptyclient leg exited $RC:"
7650 cat "$OUT.fspc" "$OUT.fscap.err"; exit 1; }
7651 # fs-one landed in session a (the tile that was fullscreened first);
7652 # fs-two landed in session b (the tile Ctrl-\ 2 moved the full rect to).
7653 timeout 20 "$MUXA" capture --sock "$SOCK54" --session a > "$OUT.fsfa" 2>&1
7654 timeout 20 "$MUXA" capture --sock "$SOCK54" --session b > "$OUT.fsfb" 2>&1
7655 grep -q "fs-one" "$OUT.fsfa" || {
7656 echo "e2e FAIL: fullscreen: session a never got fs-one:"
7657 cat "$OUT.fsfa"; exit 1; }
7658 grep -q "fs-one" "$OUT.fsfb" && {
7659 echo "e2e FAIL: fullscreen: fs-one reached session b, which was hidden:"
7660 cat "$OUT.fsfb"; exit 1; }
7661 grep -q "fs-two" "$OUT.fsfb" || {
7662 echo "e2e FAIL: fullscreen: Ctrl-\\ 2 did not move the full rect to session b:"
7663 cat "$OUT.fsfb"; exit 1; }
7664 grep -q "fs-two" "$OUT.fsfa" && {
7665 echo "e2e FAIL: fullscreen: fs-two reached session a, which was hidden after the focus move:"
7666 cat "$OUT.fsfa"; exit 1; }
7667 assert_stopped "$SOCK54" "$D55PID" "fullscreen" "$OUT.fsstop"
7668 D55PID=""
7669 ok "fullscreen gives the focused tile the terminal; focus follows; f restores"
7670
7671 [ "$OK_COUNT" = "64" ] || {
7672 echo "e2e FAIL: $OK_COUNT scenario checkpoints ran, the pin says 64 —"
7592 echo " a scenario was added (update the pin) or silently lost" 7673 echo " a scenario was added (update the pin) or silently lost"
7593 exit 1 7674 exit 1
7594 } 7675 }
@@ -7596,4 +7677,4 @@ DPID=""
7596 echo "e2e FAIL: $CONV_COUNT convergence points ran, the pin says 37" 7677 echo "e2e FAIL: $CONV_COUNT convergence points ran, the pin says 37"
7597 exit 1 7678 exit 1
7598 } 7679 }
7599 echo "e2e OK (62 scenarios, 37 convergence points)" 7680 echo "e2e OK (63 scenarios, 37 convergence points)"