a73x

d6bede8d

feat: Ctrl-\ r trades cells between panes

a73x   2026-08-24 18:06

Commit message
feat: Ctrl-\ r trades cells between panes

The resize arm calls layout.resize through doResize, which maps the
spec's shrink/grow semantics onto layout.resize's gain-only contract:
grow keys (l, j) call resize on the focus directly; shrink keys (h, k)
find a neighbor on the same axis and grow it at the focus's expense.
Fullscreen refuses silently — a hidden layout resizing invisibly is
surprise, not power.

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

docscheck.budget
Old New
@@ -27,7 +27,7 @@ shellint.zig 0
27 sockpath.zig 0 27 sockpath.zig 0
28 spawn.zig 0 28 spawn.zig 0
29 testtmp.zig 0 29 testtmp.zig 0
30 wallview.zig 694 30 wallview.zig 858
31 wall.zig 0 31 wall.zig 0
32 wasm_core.zig 0 32 wasm_core.zig 0
33 webhub_main.zig 0 33 webhub_main.zig 0
src/wallview.zig
Old New
@@ -125,6 +125,64 @@ fn dirOf(d: interact.PrefixFilter.Dir) layout.Dir {
125 }; 125 };
126 } 126 }
127 127
128 /// layout.resize always GAINS focus cells; shrink = grow a neighbor at
129 /// focus's expense. Fullscreen refuses — a hidden layout resizing
130 /// invisibly is surprise, not power.
131 fn doResize(
132 alloc: std.mem.Allocator,
133 tiles: []Tile,
134 present: []const bool,
135 shared: *Shared,
136 sel: usize,
137 d: interact.PrefixFilter.Dir,
138 ) bool {
139 if (shared.fullscreen) return false;
140 const ld = dirOf(d);
141 const grow = switch (ld) {
142 .right, .down => true,
143 .left, .up => false,
144 };
145 const flat = shared.base_flat orelse shared.last_flat orelse return false;
146 const focus_tile: u8 = @intCast(sel);
147 var moved = false;
148 if (grow) {
149 // Focus gains from the sibling toward `ld`; if none there (edge
150 // pane), try the opposite side — gaining from either sibling
151 // widens or tallens the focus.
152 if (shared.tree.resize(alloc, shared.size.rows, shared.size.cols, wallFloors(tiles.len), focus_tile, ld, 1)) {
153 moved = true;
154 } else {
155 const opp = switch (ld) {
156 .right => layout.Dir.left,
157 .down => layout.Dir.up,
158 .left => layout.Dir.right,
159 .up => layout.Dir.down,
160 };
161 moved = shared.tree.resize(alloc, shared.size.rows, shared.size.cols, wallFloors(tiles.len), focus_tile, opp, 1);
162 }
163 } else {
164 // Shrink: a neighbor on the same axis gains a cell from focus.
165 // Try the side the key points at first, then the opposite — a
166 // pane pressed against one wall can still shrink toward the other.
167 const opp = switch (ld) {
168 .left => layout.Dir.right,
169 .up => layout.Dir.down,
170 .right => layout.Dir.left,
171 .down => layout.Dir.up,
172 };
173 if (layout.neighbor(flat, focus_tile, ld)) |nb| {
174 moved = shared.tree.resize(alloc, shared.size.rows, shared.size.cols, wallFloors(tiles.len), nb, opp, 1);
175 }
176 if (!moved) {
177 if (layout.neighbor(flat, focus_tile, opp)) |nb| {
178 moved = shared.tree.resize(alloc, shared.size.rows, shared.size.cols, wallFloors(tiles.len), nb, ld, 1);
179 }
180 }
181 }
182 if (moved) relayout(alloc, tiles, present, shared, sel);
183 return moved;
184 }
185
128 const State = enum { 186 const State = enum {
129 connecting, 187 connecting,
130 up, 188 up,
@@ -2622,8 +2680,9 @@ pub fn run(alloc: std.mem.Allocator, resolved: []const Resolved, entry: Entry) !
2622 shared.fullscreen = !shared.fullscreen; 2680 shared.fullscreen = !shared.fullscreen;
2623 relayout(alloc, tiles[0..live], present[0..live], &shared, shared.sel); 2681 relayout(alloc, tiles[0..live], present[0..live], &shared, shared.sel);
2624 }, 2682 },
2625 .resize, 2683 .resize => |d| {
2626 => {}, 2684 _ = doResize(alloc, tiles[0..live], present[0..live], &shared, z, d);
2685 },
2627 .focus => |idx| { 2686 .focus => |idx| {
2628 if (idx > 0 and idx <= live and present[idx - 1] and idx - 1 != z) { 2687 if (idx > 0 and idx <= live and present[idx - 1] and idx - 1 != z) {
2629 last_focus = z; 2688 last_focus = z;
@@ -3217,6 +3276,102 @@ test "focus_dir while fullscreened follows the focus" {
3217 try std.testing.expectEqual(@as(usize, 1), shared.sel); 3276 try std.testing.expectEqual(@as(usize, 1), shared.sel);
3218 } 3277 }
3219 3278
3279 test "resize: l grows the focused pane, h shrinks it" {
3280 // Spec: h shrinks width, l grows width. layout.resize always makes
3281 // the focus GAIN cells, so shrink = grow a neighbor at focus's
3282 // expense. Two beside panes at 80x24 (floors {3,2}): equal split is
3283 // 39/40 (rail takes 1). l moves the rail right; h moves it left.
3284 const alloc = std.testing.allocator;
3285 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false };
3286 shared.tree = layout.Tree.init(alloc);
3287 shared.flat_alloc = alloc;
3288 defer shared.tree.deinit();
3289 defer if (shared.last_flat) |*f| f.deinit(alloc);
3290 defer if (shared.base_flat) |*f| f.deinit(alloc);
3291 try shared.tree.addFirst(0);
3292 try shared.tree.splitRight(0, 1);
3293 const tiles = try alloc.alloc(Tile, 2);
3294 defer alloc.free(tiles);
3295 const present = [_]bool{ true, true };
3296 for (tiles, 0..) |*t, i| {
3297 t.* = Tile{
3298 .r = .{ .target = .{ .sock = "/tmp/x" }, .label = "x", .session = "" },
3299 .rect = .{ .top = 0, .left = 0, .rows = 24, .cols = 80 },
3300 .shared = &shared,
3301 .idx = i,
3302 .wake_r = -1,
3303 .wake_w = -1,
3304 };
3305 }
3306 relayout(alloc, tiles, &present, &shared, 0);
3307 const before = tiles[0].rect.cols;
3308 // l (grow width): focus 0 gains from its right sibling.
3309 try std.testing.expect(doResize(alloc, tiles, &present, &shared, 0, .right));
3310 try std.testing.expect(tiles[0].rect.cols > before);
3311 // h (shrink width): the right neighbor gains a cell back from focus.
3312 try std.testing.expect(doResize(alloc, tiles, &present, &shared, 0, .left));
3313 try std.testing.expectEqual(before, tiles[0].rect.cols);
3314 }
3315
3316 test "resize: j grows height, k shrinks height" {
3317 const alloc = std.testing.allocator;
3318 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false };
3319 shared.tree = layout.Tree.init(alloc);
3320 shared.flat_alloc = alloc;
3321 defer shared.tree.deinit();
3322 defer if (shared.last_flat) |*f| f.deinit(alloc);
3323 defer if (shared.base_flat) |*f| f.deinit(alloc);
3324 try shared.tree.addFirst(0);
3325 try shared.tree.splitBelow(0, 1);
3326 const tiles = try alloc.alloc(Tile, 2);
3327 defer alloc.free(tiles);
3328 const present = [_]bool{ true, true };
3329 for (tiles, 0..) |*t, i| {
3330 t.* = Tile{
3331 .r = .{ .target = .{ .sock = "/tmp/x" }, .label = "x", .session = "" },
3332 .rect = .{ .top = 0, .left = 0, .rows = 24, .cols = 80 },
3333 .shared = &shared,
3334 .idx = i,
3335 .wake_r = -1,
3336 .wake_w = -1,
3337 };
3338 }
3339 relayout(alloc, tiles, &present, &shared, 0);
3340 const before = tiles[0].rect.rows;
3341 try std.testing.expect(doResize(alloc, tiles, &present, &shared, 0, .down));
3342 try std.testing.expect(tiles[0].rect.rows > before);
3343 try std.testing.expect(doResize(alloc, tiles, &present, &shared, 0, .up));
3344 try std.testing.expectEqual(before, tiles[0].rect.rows);
3345 }
3346
3347 test "resize refuses while fullscreened" {
3348 const alloc = std.testing.allocator;
3349 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false };
3350 shared.tree = layout.Tree.init(alloc);
3351 shared.flat_alloc = alloc;
3352 defer shared.tree.deinit();
3353 defer if (shared.last_flat) |*f| f.deinit(alloc);
3354 defer if (shared.base_flat) |*f| f.deinit(alloc);
3355 try shared.tree.addFirst(0);
3356 try shared.tree.splitRight(0, 1);
3357 const tiles = try alloc.alloc(Tile, 2);
3358 defer alloc.free(tiles);
3359 const present = [_]bool{ true, true };
3360 for (tiles, 0..) |*t, i| {
3361 t.* = Tile{
3362 .r = .{ .target = .{ .sock = "/tmp/x" }, .label = "x", .session = "" },
3363 .rect = .{ .top = 0, .left = 0, .rows = 24, .cols = 80 },
3364 .shared = &shared,
3365 .idx = i,
3366 .wake_r = -1,
3367 .wake_w = -1,
3368 };
3369 }
3370 shared.fullscreen = true;
3371 relayout(alloc, tiles, &present, &shared, 0);
3372 try std.testing.expect(!doResize(alloc, tiles, &present, &shared, 0, .right));
3373 }
3374
3220 test "a pump's answer that grew the wall re-cuts it; a mere focus move does not" { 3375 test "a pump's answer that grew the wall re-cuts it; a mere focus move does not" {
3221 const alloc = std.testing.allocator; 3376 const alloc = std.testing.allocator;
3222 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false }; 3377 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false };
test/e2e.sh
Old New
@@ -371,8 +371,10 @@ ZMOUSESH="${TMPDIR:-/tmp}/mux-e2e-wallappmouse-$$.sh"
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 SOCK54="${TMPDIR:-/tmp}/muxd-e2e-fullscreen-$$.sock"
374 SOCK55="${TMPDIR:-/tmp}/muxd-e2e-resize-$$.sock"
374 D54PID="" 375 D54PID=""
375 D55PID="" 376 D55PID=""
377 D56PID=""
376 378
377 # The wall as attach HISTORY (phase 2). A daemon AND a state home of its 379 # The wall as attach HISTORY (phase 2). A daemon AND a state home of its
378 # own, for the dynamic-wall leg's reason turned up one notch: what these 380 # own, for the dynamic-wall leg's reason turned up one notch: what these
@@ -1318,6 +1320,7 @@ cleanup() {
1318 [ -S "$SOCK52" ] && "$MUXD" stop --sock "$SOCK52" 2>/dev/null || true 1320 [ -S "$SOCK52" ] && "$MUXD" stop --sock "$SOCK52" 2>/dev/null || true
1319 [ -S "$SOCK53" ] && "$MUXD" stop --sock "$SOCK53" 2>/dev/null || true 1321 [ -S "$SOCK53" ] && "$MUXD" stop --sock "$SOCK53" 2>/dev/null || true
1320 [ -S "$SOCK54" ] && "$MUXD" stop --sock "$SOCK54" 2>/dev/null || true 1322 [ -S "$SOCK54" ] && "$MUXD" stop --sock "$SOCK54" 2>/dev/null || true
1323 [ -S "$SOCK55" ] && "$MUXD" stop --sock "$SOCK55" 2>/dev/null || true
1321 1324
1322 # ---- the leak sweep (hygiene kit, 6a) ---- 1325 # ---- the leak sweep (hygiene kit, 6a) ----
1323 # Here rather than at the bottom of the file, which `set -e` reaches only 1326 # Here rather than at the bottom of the file, which `set -e` reaches only
@@ -1333,7 +1336,7 @@ cleanup() {
1333 "$D26PID" "$D27PID" "$D28PID" "$D29PID" "$D30PID" "$D31PID" \ 1336 "$D26PID" "$D27PID" "$D28PID" "$D29PID" "$D30PID" "$D31PID" \
1334 "$D32PID" "$D33PID" "$D34PID" "$D35PID" "$D36PID" "$D37PID" \ 1337 "$D32PID" "$D33PID" "$D34PID" "$D35PID" "$D36PID" "$D37PID" \
1335 "$D38PID" "$D39PID" "$D40PID" "$D41PID" "$D42PID" "$D43PID" "$D54PID" \ 1338 "$D38PID" "$D39PID" "$D40PID" "$D41PID" "$D42PID" "$D43PID" "$D54PID" \
1336 "$D55PID" 1339 "$D55PID" "$D56PID"
1337 _leak=0 1340 _leak=0
1338 leak_sweep "$_rc" || _leak=1 1341 leak_sweep "$_rc" || _leak=1
1339 1342
@@ -1405,7 +1408,10 @@ cleanup() {
1405 "$OUT.sb2cap" "$OUT.sb2cap.err" "$OUT.sb2pc" "$OUT.sb2stop" "$SOCK53" \ 1408 "$OUT.sb2cap" "$OUT.sb2cap.err" "$OUT.sb2pc" "$OUT.sb2stop" "$SOCK53" \
1406 "$OUT.fs.d" "$OUT.fsa" "$OUT.fsa.err" "$OUT.fsb" "$OUT.fsb.err" \ 1409 "$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" \ 1410 "$OUT.fscap" "$OUT.fscap.err" "$OUT.fspc" "$OUT.fsfa" "$OUT.fsfb" \
1408 "$OUT.fsstop" "$SOCK54" 1411 "$OUT.fsstop" "$SOCK54" \
1412 "$OUT.rsz.d" "$OUT.rszcap" "$OUT.rszcap.err" "$OUT.rszpc" \
1413 "$OUT.rsza" "$OUT.rsza.err" "$OUT.rszb" "$OUT.rszb.err" \
1414 "$OUT.rszsta" "$OUT.rszstb" "$OUT.rszstop" "$SOCK55"
1409 # ...and the non-tty capture that leg's session feeds. 1415 # ...and the non-tty capture that leg's session feeds.
1410 rm -f "$OUT.nogate" 1416 rm -f "$OUT.nogate"
1411 # ...and its other half: the paste capture and the file nvim wrote, which 1417 # ...and its other half: the paste capture and the file nvim wrote, which
@@ -7668,8 +7674,89 @@ assert_stopped "$SOCK54" "$D55PID" "fullscreen" "$OUT.fsstop"
7668 D55PID="" 7674 D55PID=""
7669 ok "fullscreen gives the focused tile the terminal; focus follows; f restores" 7675 ok "fullscreen gives the focused tile the terminal; focus follows; f restores"
7670 7676
7671 [ "$OK_COUNT" = "64" ] || { 7677 # ---- Ctrl-\ r trades cells between panes -----------------------------
7672 echo "e2e FAIL: $OK_COUNT scenario checkpoints ran, the pin says 64 —" 7678 #
7679 # Two beside panes at 80x24 (aspect rule → beside). Ctrl-\ r enters
7680 # resize mode; lll grows the focused pane's width by 3 cells, moving the
7681 # rail 3 columns right. The rail is a reverse-video space painted at a
7682 # CUP column; grep the column before and after. Esc exits resize mode,
7683 # and typed prose lands in the focused session (marker echo).
7684 #
7685 # No convergence point: the rail column is a terminal-capture assertion,
7686 # not a daemon-grid comparison.
7687 "$MUXD" run --sock "$SOCK55" --shell /bin/sh > "$OUT.rsz.d" 2>&1 &
7688 D56PID=$!
7689 wait_sock "$SOCK55" "$OUT.rsz.d" "resize daemon never bound"
7690
7691 pipe_mux "$OUT.rsza" "$OUT.rsza.err" timeout 40 "$MUX" --sock "$SOCK55" --session a
7692 pipe_send 'printf "rsz-%%s\\n" init-a\n'
7693 await_out "$OUT.rsza" "rsz-init-a" "resize: session a's marker never reached the client"
7694 pipe_detach
7695 wait_grid "$SOCK55" "rsz-init-a" "resize: session a's marker" a
7696 pipe_mux "$OUT.rszb" "$OUT.rszb.err" timeout 40 "$MUX" --sock "$SOCK55" --session b
7697 pipe_send 'printf "rsz-%%s\\n" init-b\n'
7698 await_out "$OUT.rszb" "rsz-init-b" "resize: session b's marker never reached the client"
7699 pipe_detach
7700 wait_grid "$SOCK55" "rsz-init-b" "resize: session b's marker" b
7701
7702 set +e
7703 # 80x24: aspect rule makes beside; two panes split 39/40 with the rail
7704 # at column 40 (1-indexed). Three l presses grow the left pane by 3,
7705 # moving the rail to column 43.
7706 timeout 90 "$PTYCLIENT" --cols 80 --rows 24 --out "$OUT.rszcap" --err "$OUT.rszcap.err" -- \
7707 "$MUX" wall "--sock $SOCK55#a" "--sock $SOCK55#b" > "$OUT.rszpc" 2>&1 <<'EOF'
7708 expect rsz-init-b 20000
7709 settle 700 20000
7710 send \x1cr
7711 settle 300 10000
7712 send l
7713 settle 300 10000
7714 send l
7715 settle 300 10000
7716 send l
7717 settle 500 15000
7718 send \x1b
7719 settle 300 10000
7720 send printf 'rsz-%s\n' grown\n
7721 expect rsz-grown 10000
7722 settle 400 15000
7723 send \x1cd
7724 waitexit 10000
7725 EOF
7726 RC=$?
7727 set -e
7728 [ "$RC" -eq 0 ] || {
7729 echo "e2e FAIL: resize: ptyclient leg exited $RC:"
7730 cat "$OUT.rszpc" "$OUT.rszcap.err"; exit 1; }
7731 # The rail paints as ESC[row;colH followed by ESC[7m (reverse video).
7732 # Extract the column from every CUP that precedes a reverse-video
7733 # space — the rail's column — and verify it moved right by 3.
7734 _rail_re=$'\x1b\\[[0-9][0-9]*;[0-9][0-9]*H\x1b\\[7m'
7735 # Before resize: rail at column 40 (1-indexed). After lll: column 43.
7736 # Grab all rail CUP columns; the last one is the post-resize position.
7737 _rail_cols=$(grep -ao "$_rail_re" "$OUT.rszcap" | \
7738 sed 's/.*\x1b\[[0-9]*;\([0-9]*\)H\x1b\[7m/\1/' | sort -n)
7739 _last_rail=$(echo "$_rail_cols" | tail -1)
7740 [ -n "$_last_rail" ] || {
7741 echo "e2e FAIL: resize: no rail glyph found in the capture:"
7742 cat "$OUT.rszcap"; exit 1; }
7743 # 39 cols + rail at 40 initially; 42 cols + rail at 43 after lll.
7744 [ "$_last_rail" -ge 43 ] || {
7745 echo "e2e FAIL: resize: rail column is $_last_rail, want >=43 (40 + 3 grows):"
7746 cat "$OUT.rszcap"; exit 1; }
7747 # Esc ended resize mode and typed prose reached the focused session.
7748 timeout 20 "$MUXA" capture --sock "$SOCK55" --session a > "$OUT.rszsta" 2>&1
7749 timeout 20 "$MUXA" capture --sock "$SOCK55" --session b > "$OUT.rszstb" 2>&1
7750 if ! grep -q "rsz-grown" "$OUT.rszsta" && ! grep -q "rsz-grown" "$OUT.rszstb"; then
7751 echo "e2e FAIL: resize: rsz-grown reached neither session after Esc:"
7752 cat "$OUT.rszsta" "$OUT.rszstb"; exit 1
7753 fi
7754 assert_stopped "$SOCK55" "$D56PID" "resize" "$OUT.rszstop"
7755 D56PID=""
7756 ok "Ctrl-\\ r trades cells between panes; Esc returns to prose"
7757
7758 [ "$OK_COUNT" = "65" ] || {
7759 echo "e2e FAIL: $OK_COUNT scenario checkpoints ran, the pin says 65 —"
7673 echo " a scenario was added (update the pin) or silently lost" 7760 echo " a scenario was added (update the pin) or silently lost"
7674 exit 1 7761 exit 1
7675 } 7762 }
@@ -7677,4 +7764,4 @@ ok "fullscreen gives the focused tile the terminal; focus follows; f restores"
7677 echo "e2e FAIL: $CONV_COUNT convergence points ran, the pin says 37" 7764 echo "e2e FAIL: $CONV_COUNT convergence points ran, the pin says 37"
7678 exit 1 7765 exit 1
7679 } 7766 }
7680 echo "e2e OK (63 scenarios, 37 convergence points)" 7767 echo "e2e OK (64 scenarios, 37 convergence points)"