f2b711e7
fix: the cursor rests in the focused tile, whoever painted last
a73x 2026-08-24 11:32
Commit message
src/interact.zig
| Old | New | ||
|---|---|---|---|
| @@ -1345,6 +1345,14 @@ pub const Core = struct { | |||
| 1345 | paintOverlay(self.alloc, &self.overlay, self.rep.eng.cursorPos(), self.size, self.row_off, self.out_fd); | 1345 | paintOverlay(self.alloc, &self.overlay, self.rep.eng.cursorPos(), self.size, self.row_off, self.out_fd); |
| 1346 | } | 1346 | } |
| 1347 | 1347 | ||
| 1348 | /// The terminal cursor the painters end on — the same | ||
| 1349 | /// clampCursor-plus-row_off math. An overlay may park ahead; the | ||
| 1350 | /// replica cursor is the approximation. | ||
| 1351 | pub fn screenCursor(self: *Core) Engine.CursorPos { | ||
| 1352 | const c = paint_mod.clampCursor(self.rep.eng.cursorPos(), self.size); | ||
| 1353 | return .{ .x = c.x, .y = c.y + self.row_off }; | ||
| 1354 | } | ||
| 1355 | |||
| 1348 | /// Repaint the rows a drag report changed, and only those. | 1356 | /// Repaint the rows a drag report changed, and only those. |
| 1349 | /// | 1357 | /// |
| 1350 | /// The anchor does not move, so a report that moved the active end | 1358 | /// The anchor does not move, so a report that moved the active end |
src/wallview.zig
| Old | New | ||
|---|---|---|---|
| @@ -224,6 +224,12 @@ const Shared = struct { | |||
| 224 | /// drawn: a pump repainting on a frame must draw the focus the keyboard | 224 | /// drawn: a pump repainting on a frame must draw the focus the keyboard |
| 225 | /// has, not the one it had when the frame arrived. | 225 | /// has, not the one it had when the frame arrived. |
| 226 | sel: usize = 0, | 226 | sel: usize = 0, |
| 227 | /// Where the focused tile's last paint left the cursor on the terminal. | ||
| 228 | /// The cursor belongs to the focus, not to whichever pump painted last: | ||
| 229 | /// an unfocused paint's last act is to put the cursor back here. Under | ||
| 230 | /// `paint_mu` because the painters that read and write it already stand | ||
| 231 | /// there. Home until the focused tile has painted once. | ||
| 232 | cursor: Engine.CursorPos = .{ .x = 0, .y = 0 }, | ||
| 227 | /// One label bar per tile when the wall holds more than one session; a | 233 | /// One label bar per tile when the wall holds more than one session; a |
| 228 | /// one-tile wall owns every row and draws no bar. Set by `relayout`, so | 234 | /// one-tile wall owns every row and draws no bar. Set by `relayout`, so |
| 229 | /// `viewRows` and `core.row_off` agree with what is on the screen. | 235 | /// `viewRows` and `core.row_off` agree with what is on the screen. |
| @@ -249,6 +255,11 @@ const Tile = struct { | |||
| 249 | /// This tile's place in the wall: what the focus and the `1`-`9` | 255 | /// This tile's place in the wall: what the focus and the `1`-`9` |
| 250 | /// jump are indices into. | 256 | /// jump are indices into. |
| 251 | idx: usize, | 257 | idx: usize, |
| 258 | /// This tile's interaction core, set by its OWN pump thread once the | ||
| 259 | /// core exists and dereferenced only inside that pump's paint-end hook. | ||
| 260 | /// No lifetime beyond the pump's scope: the core is freed before the | ||
| 261 | /// pump returns, and no other thread reads this. | ||
| 262 | core: ?*interact.Core = null, | ||
| 252 | /// The state its label last narrated. Owned by the pump but stored | 263 | /// The state its label last narrated. Owned by the pump but stored |
| 253 | /// here (under `paint_mu`) because the KEYBOARD repaints this bar too, | 264 | /// here (under `paint_mu`) because the KEYBOARD repaints this bar too, |
| 254 | /// when the focus moves, and it has no other way to know it. | 265 | /// when the focus moves, and it has no other way to know it. |
| @@ -539,7 +550,18 @@ fn tilePaintBegin(ctx: ?*anyopaque) bool { | |||
| 539 | 550 | ||
| 540 | fn tilePaintEnd(ctx: ?*anyopaque) void { | 551 | fn tilePaintEnd(ctx: ?*anyopaque) void { |
| 541 | const t: *Tile = @ptrCast(@alignCast(ctx.?)); | 552 | const t: *Tile = @ptrCast(@alignCast(ctx.?)); |
| 542 | t.shared.paint_mu.unlock(); | 553 | defer t.shared.paint_mu.unlock(); |
| 554 | // Before releasing the terminal: the cursor belongs to the FOCUSED tile. | ||
| 555 | // A focused paint records where its cursor landed; an unfocused paint's | ||
| 556 | // last act is to put the cursor back there, so a redraw in tile 2 cannot | ||
| 557 | // steal the eye while the keys go to tile 1. | ||
| 558 | if (t.idx == t.shared.sel) { | ||
| 559 | if (t.core) |core| t.shared.cursor = core.screenCursor(); | ||
| 560 | } else { | ||
| 561 | var cbuf: [16]u8 = undefined; | ||
| 562 | const cup = std.fmt.bufPrint(&cbuf, "\x1b[{d};{d}H", .{ t.shared.cursor.y + 1, t.shared.cursor.x + 1 }) catch return; | ||
| 563 | proto.writeAllFd(t.shared.out_fd, cup) catch {}; | ||
| 564 | } | ||
| 543 | } | 565 | } |
| 544 | 566 | ||
| 545 | /// The one place a wall tile puts an attach on the wire. A tile the user | 567 | /// The one place a wall tile puts an attach on the wire. A tile the user |
| @@ -922,6 +944,9 @@ fn pumpTile(t: *Tile) void { | |||
| 922 | // Where this Core's paints land: this tile's rect. The sink admits a | 944 | // Where this Core's paints land: this tile's rect. The sink admits a |
| 923 | // paint whenever the tile has not been forgotten; see `tilePaintBegin`. | 945 | // paint whenever the tile has not been forgotten; see `tilePaintBegin`. |
| 924 | core.sink = .{ .ctx = t, .begin = tilePaintBegin, .end = tilePaintEnd }; | 946 | core.sink = .{ .ctx = t, .begin = tilePaintBegin, .end = tilePaintEnd }; |
| 947 | // The paint-end hook reads the core's screen cursor; only this pump | ||
| 948 | // thread dereferences it, and the core outlives the pump. | ||
| 949 | t.core = &core; | ||
| 925 | // This thread does not own the exit and cannot print the stats line — | 950 | // This thread does not own the exit and cannot print the stats line — |
| 926 | // see `Shared.stats`. | 951 | // see `Shared.stats`. |
| 927 | core.owns_stats = false; | 952 | core.owns_stats = false; |
| @@ -3361,3 +3386,50 @@ test "a view tile's attach makes no size claim; a tile the user asked for does" | |||
| 3361 | try std.testing.expectEqual(@as(u16, 24), entry_req.rows); | 3386 | try std.testing.expectEqual(@as(u16, 24), entry_req.rows); |
| 3362 | try std.testing.expect(!t.resize_pending.load(.acquire)); | 3387 | try std.testing.expect(!t.resize_pending.load(.acquire)); |
| 3363 | } | 3388 | } |
| 3389 | |||
| 3390 | test "the cursor sleeps in the focused tile, whoever painted last" { | ||
| 3391 | // Every painter ends by showing the cursor where its own stripe sits, so | ||
| 3392 | // without an owner the visible cursor lands on whichever pump painted | ||
| 3393 | // last. The fix: a focused paint records its screen cursor in Shared, and | ||
| 3394 | // an unfocused paint's last act is to put the cursor back there. A | ||
| 3395 | // focused tile with no core (the unit-test fixture) records nothing and | ||
| 3396 | // restores nothing — the core is what knows the cursor. | ||
| 3397 | const p = try std.posix.pipe2(.{ .NONBLOCK = true }); | ||
| 3398 | defer std.posix.close(p[0]); | ||
| 3399 | defer std.posix.close(p[1]); | ||
| 3400 | var shared = Shared{ .out_fd = p[1], .size = .{ .cols = 80, .rows = 24 }, .is_tty = true }; | ||
| 3401 | var tiles: [2]Tile = undefined; | ||
| 3402 | tiles[0] = Tile{ | ||
| 3403 | .r = .{ .target = .{ .sock = "/s" }, .label = "a", .session = "a" }, | ||
| 3404 | .stripe = .{ .top = 0, .rows = 12 }, | ||
| 3405 | .shared = &shared, | ||
| 3406 | .idx = 0, | ||
| 3407 | .wake_r = -1, | ||
| 3408 | .wake_w = -1, | ||
| 3409 | }; | ||
| 3410 | tiles[1] = Tile{ | ||
| 3411 | .r = .{ .target = .{ .sock = "/s" }, .label = "b", .session = "b" }, | ||
| 3412 | .stripe = .{ .top = 12, .rows = 12 }, | ||
| 3413 | .shared = &shared, | ||
| 3414 | .idx = 1, | ||
| 3415 | .wake_r = -1, | ||
| 3416 | .wake_w = -1, | ||
| 3417 | }; | ||
| 3418 | shared.sel = 0; | ||
| 3419 | shared.cursor = .{ .x = 3, .y = 5 }; | ||
| 3420 | var buf: [512]u8 = undefined; | ||
| 3421 | |||
| 3422 | // An UNFOCUSED paint (tile 1, core null) ends by restoring the shared | ||
| 3423 | // cursor — exactly the CUP for the focused tile's stored position, and | ||
| 3424 | // nothing else. | ||
| 3425 | _ = tilePaintBegin(&tiles[1]); | ||
| 3426 | tilePaintEnd(&tiles[1]); | ||
| 3427 | try std.testing.expectEqualStrings("\x1b[6;4H", readAvail(p[0], &buf)); | ||
| 3428 | |||
| 3429 | // A FOCUSED paint (tile 0, core null) records nothing and restores | ||
| 3430 | // nothing: no core means the shared cursor is left untouched, and the | ||
| 3431 | // pipe receives no new bytes. | ||
| 3432 | _ = tilePaintBegin(&tiles[0]); | ||
| 3433 | tilePaintEnd(&tiles[0]); | ||
| 3434 | try std.testing.expectEqual(@as(usize, 0), readAvail(p[0], &buf).len); | ||
| 3435 | } | ||
test/e2e.sh
| Old | New | ||
|---|---|---|---|
| @@ -419,6 +419,8 @@ D47PID="" | |||
| 419 | SOCK48="${TMPDIR:-/tmp}/muxd-e2e-agentfwd-$$.sock" | 419 | SOCK48="${TMPDIR:-/tmp}/muxd-e2e-agentfwd-$$.sock" |
| 420 | SOCK51="${TMPDIR:-/tmp}/muxd-e2e-inband-$$.sock" | 420 | SOCK51="${TMPDIR:-/tmp}/muxd-e2e-inband-$$.sock" |
| 421 | D51PID="" | 421 | D51PID="" |
| 422 | SOCK52="${TMPDIR:-/tmp}/muxd-e2e-cursor-$$.sock" | ||
| 423 | D52PID="" | ||
| 422 | AGENT48="${TMPDIR:-/tmp}/mux-e2e-agent-$$.sock" | 424 | AGENT48="${TMPDIR:-/tmp}/mux-e2e-agent-$$.sock" |
| 423 | AGENT48PID="" | 425 | AGENT48PID="" |
| 424 | AGENT48KEY="${TMPDIR:-/tmp}/mux-e2e-agentkey-$$" | 426 | AGENT48KEY="${TMPDIR:-/tmp}/mux-e2e-agentkey-$$" |
| @@ -1261,6 +1263,7 @@ cleanup() { | |||
| 1261 | [ -n "$D42PID" ] && kill "$D42PID" 2>/dev/null || true | 1263 | [ -n "$D42PID" ] && kill "$D42PID" 2>/dev/null || true |
| 1262 | [ -n "$D43PID" ] && kill "$D43PID" 2>/dev/null || true | 1264 | [ -n "$D43PID" ] && kill "$D43PID" 2>/dev/null || true |
| 1263 | [ -n "${D51PID:-}" ] && kill "$D51PID" 2>/dev/null || true | 1265 | [ -n "${D51PID:-}" ] && kill "$D51PID" 2>/dev/null || true |
| 1266 | [ -n "${D52PID:-}" ] && kill "$D52PID" 2>/dev/null || true | ||
| 1264 | # The ssh-agents the forwarding legs start. Not mux processes and so not | 1267 | # The ssh-agents the forwarding legs start. Not mux processes and so not |
| 1265 | # the leak sweep's business, but they are daemons this file forked: left | 1268 | # the leak sweep's business, but they are daemons this file forked: left |
| 1266 | # alive they outlive the suite holding a private key, which is the one | 1269 | # alive they outlive the suite holding a private key, which is the one |
| @@ -1303,6 +1306,7 @@ cleanup() { | |||
| 1303 | [ -S "$SOCK48" ] && "$MUXD" stop --sock "$SOCK48" 2>/dev/null || true | 1306 | [ -S "$SOCK48" ] && "$MUXD" stop --sock "$SOCK48" 2>/dev/null || true |
| 1304 | [ -S "$SOCK49" ] && "$MUXD" stop --sock "$SOCK49" 2>/dev/null || true | 1307 | [ -S "$SOCK49" ] && "$MUXD" stop --sock "$SOCK49" 2>/dev/null || true |
| 1305 | [ -S "$SOCK50" ] && "$MUXD" stop --sock "$SOCK50" 2>/dev/null || true | 1308 | [ -S "$SOCK50" ] && "$MUXD" stop --sock "$SOCK50" 2>/dev/null || true |
| 1309 | [ -S "$SOCK52" ] && "$MUXD" stop --sock "$SOCK52" 2>/dev/null || true | ||
| 1306 | 1310 | ||
| 1307 | # ---- the leak sweep (hygiene kit, 6a) ---- | 1311 | # ---- the leak sweep (hygiene kit, 6a) ---- |
| 1308 | # Here rather than at the bottom of the file, which `set -e` reaches only | 1312 | # Here rather than at the bottom of the file, which `set -e` reaches only |
| @@ -1525,6 +1529,12 @@ cleanup() { | |||
| 1525 | "$OUT.zssta" "$OUT.zsstb" "$OUT.zsstop" "$OUT.zswatch" | 1529 | "$OUT.zssta" "$OUT.zsstb" "$OUT.zsstop" "$OUT.zswatch" |
| 1526 | rm -f "$OUT.swcap" "$OUT.swcap.err" "$OUT.swpc" | 1530 | rm -f "$OUT.swcap" "$OUT.swcap.err" "$OUT.swpc" |
| 1527 | rm -f "$OUT.inband" "$OUT.inband.err" "$OUT.inband.log" "$OUT.inband.d" "$OUT.inbstop" | 1531 | rm -f "$OUT.inband" "$OUT.inband.err" "$OUT.inband.log" "$OUT.inband.d" "$OUT.inbstop" |
| 1532 | # The cursor-ownership leg: its daemon socket, the three session pipe | ||
| 1533 | # clients, the ptyclient capture and the stop witness. | ||
| 1534 | rm -f "$SOCK52" "$OUT.cu.d" \ | ||
| 1535 | "$OUT.cua" "$OUT.cua.err" "$OUT.cub" "$OUT.cub.err" \ | ||
| 1536 | "$OUT.cuc" "$OUT.cuc.err" \ | ||
| 1537 | "$OUT.cucap" "$OUT.cucap.err" "$OUT.cupc" "$OUT.custop" | ||
| 1528 | rm -f "$SOCK50" "$OUT.wmse.d" "$OUT.wmsa" "$OUT.wmsa.err" "$OUT.wmsb" \ | 1538 | rm -f "$SOCK50" "$OUT.wmse.d" "$OUT.wmsa" "$OUT.wmsa.err" "$OUT.wmsb" \ |
| 1529 | "$OUT.wmsb.err" "$OUT.wmcap" "$OUT.wmcap.err" "$OUT.wmpc" \ | 1539 | "$OUT.wmsb.err" "$OUT.wmcap" "$OUT.wmcap.err" "$OUT.wmpc" \ |
| 1530 | "$OUT.wmcap2" "$OUT.wmcap2.err" "$OUT.wmpc2" "$OUT.wmfa" "$OUT.wmfb" \ | 1540 | "$OUT.wmcap2" "$OUT.wmcap2.err" "$OUT.wmpc2" "$OUT.wmfa" "$OUT.wmfb" \ |
| @@ -7263,6 +7273,76 @@ wait_pid_gone "$D42PID" "agent-mute: the session ended and the daemon should fol | |||
| 7263 | D42PID="" | 7273 | D42PID="" |
| 7264 | ok "agent forwarding: a mute offerer is hung up on, then no longer offered (${MUTE2MS}ms)" | 7274 | ok "agent forwarding: a mute offerer is hung up on, then no longer offered (${MUTE2MS}ms)" |
| 7265 | 7275 | ||
| 7276 | # --- the cursor rests in the focused tile, whoever painted last ---------- | ||
| 7277 | # | ||
| 7278 | # Every painter ends by showing the cursor where its own stripe sits, so | ||
| 7279 | # without an owner the visible cursor lands on whichever pump painted last: | ||
| 7280 | # focus on tile 1, a prompt redraw in tile 2, and the eye is told 2 is live | ||
| 7281 | # while the keys go elsewhere. The focused tile's paint now records its | ||
| 7282 | # screen cursor in Shared, and an unfocused paint's last act is to put the | ||
| 7283 | # cursor back there. This leg focuses tile 3 of a three-tile wall and | ||
| 7284 | # asserts the terminal's final cursor position is in tile 3's stripe — | ||
| 7285 | # deterministic, because every paint now ends at the focused tile's cursor. | ||
| 7286 | # | ||
| 7287 | # Three sessions on a daemon of its own, each marked by its shell before the | ||
| 7288 | # wall attaches (the shell-expanded marker trick: a hit is the shell's work, | ||
| 7289 | # never an echo of anything typed here). The ptyclient wall focuses tile 3, | ||
| 7290 | # settles, and detaches; the capture's last cursor-position escape is the | ||
| 7291 | # witness. | ||
| 7292 | "$MUXD" run --sock "$SOCK52" --shell /bin/sh > "$OUT.cu.d" 2>&1 & | ||
| 7293 | D52PID=$! | ||
| 7294 | wait_sock "$SOCK52" "$OUT.cu.d" "cursor-ownership daemon never bound" | ||
| 7295 | |||
| 7296 | pipe_mux "$OUT.cua" "$OUT.cua.err" timeout 40 "$MUX" --sock "$SOCK52" --session a | ||
| 7297 | pipe_send 'printf "ma-%%s\\n" pin\n' | ||
| 7298 | await_out "$OUT.cua" "ma-pin" "cursor: session a's marker never reached the client" | ||
| 7299 | pipe_detach | ||
| 7300 | wait_grid "$SOCK52" "ma-pin" "cursor: session a's marker" a | ||
| 7301 | pipe_mux "$OUT.cub" "$OUT.cub.err" timeout 40 "$MUX" --sock "$SOCK52" --session b | ||
| 7302 | pipe_send 'printf "mb-%%s\\n" pin\n' | ||
| 7303 | await_out "$OUT.cub" "mb-pin" "cursor: session b's marker never reached the client" | ||
| 7304 | pipe_detach | ||
| 7305 | wait_grid "$SOCK52" "mb-pin" "cursor: session b's marker" b | ||
| 7306 | pipe_mux "$OUT.cuc" "$OUT.cuc.err" timeout 40 "$MUX" --sock "$SOCK52" --session c | ||
| 7307 | pipe_send 'printf "mc-%%s\\n" pin\n' | ||
| 7308 | await_out "$OUT.cuc" "mc-pin" "cursor: session c's marker never reached the client" | ||
| 7309 | pipe_detach | ||
| 7310 | wait_grid "$SOCK52" "mc-pin" "cursor: session c's marker" c | ||
| 7311 | |||
| 7312 | set +e | ||
| 7313 | timeout 40 "$PTYCLIENT" --cols 100 --rows 30 --out "$OUT.cucap" --err "$OUT.cucap.err" -- \ | ||
| 7314 | "$MUX" wall --sock "$SOCK52#a" "--sock $SOCK52#b" "--sock $SOCK52#c" > "$OUT.cupc" 2>&1 <<'EOF' | ||
| 7315 | expect mc-pin 20000 | ||
| 7316 | settle 500 15000 | ||
| 7317 | send \x1c3 | ||
| 7318 | settle 500 15000 | ||
| 7319 | send \x1cd | ||
| 7320 | waitexit 10000 | ||
| 7321 | EOF | ||
| 7322 | RC=$? | ||
| 7323 | set -e | ||
| 7324 | [ "$RC" -eq 0 ] || { | ||
| 7325 | echo "e2e FAIL: cursor-ownership: ptyclient leg exited $RC:" | ||
| 7326 | cat "$OUT.cupc" "$OUT.cucap.err"; exit 1; } | ||
| 7327 | # Rows 21-30 are tile 3's stripe (3 tiles on 30 rows: stripes at 1/11/21). | ||
| 7328 | # The focused tile's paint records its cursor; an unfocused paint's last act | ||
| 7329 | # is a bare CUP putting it back there, with no show of its own. So the | ||
| 7330 | # terminal's final cursor position — the last CUP in the capture, show or | ||
| 7331 | # none — rests in tile 3's stripe whichever pump painted last. The last | ||
| 7332 | # CUP+show would be the last painter's OWN cursor (racy); the last CUP of | ||
| 7333 | # any kind is the focused tile's, and that is what the fix owns. | ||
| 7334 | _cup_re=$'\x1b\\[[0-9][0-9]*;[0-9][0-9]*H' | ||
| 7335 | _last_cup=$(grep -ao "$_cup_re" "$OUT.cucap" | tail -1) | ||
| 7336 | # _last_cup is ESC[<row>;<col>H; peel the ESC[ prefix and the ;colH suffix. | ||
| 7337 | _cur_row=${_last_cup#??} | ||
| 7338 | _cur_row=${_cur_row%%;*} | ||
| 7339 | [ -n "$_cur_row" ] && [ "$_cur_row" -ge 21 ] && [ "$_cur_row" -le 30 ] || { | ||
| 7340 | echo "e2e FAIL: cursor-ownership: the last CUP was '$_last_cup' (row ${_cur_row:-none}), want tile 3's stripe (rows 21-30):" | ||
| 7341 | cat "$OUT.cucap"; exit 1; } | ||
| 7342 | assert_stopped "$SOCK52" "$D52PID" "cursor-ownership" "$OUT.custop" | ||
| 7343 | D52PID="" | ||
| 7344 | ok "the cursor rests in the focused tile, whichever pump painted last" | ||
| 7345 | |||
| 7266 | # The long-lived daemon has served every scenario that wanted it; stop it | 7346 | # The long-lived daemon has served every scenario that wanted it; stop it |
| 7267 | # NOW so its allocator verdict is written while the suite is still running | 7347 | # NOW so its allocator verdict is written while the suite is still running |
| 7268 | # and can say so. SIGTERM runs the clean-shutdown path, so the defer chain | 7348 | # and can say so. SIGTERM runs the clean-shutdown path, so the defer chain |
| @@ -7408,8 +7488,8 @@ DPID="" | |||
| 7408 | # it is the one leg that holds an ssh-agent under SIGSTOP, and a trap that | 7488 | # it is the one leg that holds an ssh-agent under SIGSTOP, and a trap that |
| 7409 | # has to CONT before it kills is cheaper to reason about with nothing | 7489 | # has to CONT before it kills is cheaper to reason about with nothing |
| 7410 | # after it. | 7490 | # after it. |
| 7411 | [ "$OK_COUNT" = "61" ] || { | 7491 | [ "$OK_COUNT" = "62" ] || { |
| 7412 | echo "e2e FAIL: $OK_COUNT scenario checkpoints ran, the pin says 61 —" | 7492 | echo "e2e FAIL: $OK_COUNT scenario checkpoints ran, the pin says 62 —" |
| 7413 | echo " a scenario was added (update the pin) or silently lost" | 7493 | echo " a scenario was added (update the pin) or silently lost" |
| 7414 | exit 1 | 7494 | exit 1 |
| 7415 | } | 7495 | } |
| @@ -7417,4 +7497,4 @@ DPID="" | |||
| 7417 | echo "e2e FAIL: $CONV_COUNT convergence points ran, the pin says 37" | 7497 | echo "e2e FAIL: $CONV_COUNT convergence points ran, the pin says 37" |
| 7418 | exit 1 | 7498 | exit 1 |
| 7419 | } | 7499 | } |
| 7420 | echo "e2e OK (60 scenarios, 37 convergence points)" | 7500 | echo "e2e OK (61 scenarios, 37 convergence points)" |