1507f375
fix: the keyboard re-arms the wall's mouse capture; a parked pump cannot
a73x 2026-09-02 07:00
Commit message
src/tui/interact.zig
| Old | New | ||
|---|---|---|---|
| @@ -610,6 +610,17 @@ const terminal_frame_setup = "\x1b[22;0t\x1b[?1049h\x1b[?25l\x1b[?7l"; | |||
| 610 | /// move the shell's history instead of the view. | 610 | /// move the shell's history instead of the view. |
| 611 | const session_claim = client_mouse_setup; | 611 | const session_claim = client_mouse_setup; |
| 612 | 612 | ||
| 613 | /// The wall's own capture, exported for the KEYBOARD to re-arm right after | ||
| 614 | /// it writes `session_release` on a focus move. The release lowers every | ||
| 615 | /// mouse mode — the wall's three included — and the re-arm used to be the | ||
| 616 | /// incoming tile's claim, a pump-side write. A pump parked in `dial`'s | ||
| 617 | /// backoff never runs its claim, so focusing an unreachable tile left the | ||
| 618 | /// terminal deaf to every later click, including the one that would have | ||
| 619 | /// moved focus back off it. The wall hears clicks for as long as it owns | ||
| 620 | /// the screen, no pump required; a claim that follows re-asserts the same | ||
| 621 | /// modes, which is idempotent. | ||
| 622 | pub const wall_mouse_capture = client_mouse_setup; | ||
| 623 | |||
| 613 | /// The mouse modes the client asks its own terminal for when no application | 624 | /// The mouse modes the client asks its own terminal for when no application |
| 614 | /// in the session wants them: presses (1000) and motion with a button down | 625 | /// in the session wants them: presses (1000) and motion with a button down |
| 615 | /// (1002), in SGR (1006). | 626 | /// (1002), in SGR (1006). |
src/tui/wall_test_wall.zig
| Old | New | ||
|---|---|---|---|
| @@ -1001,6 +1001,52 @@ test "setFocus writes the outgoing tile's release before the store that lets the | |||
| 1001 | try std.testing.expectEqual(@as(usize, 1), shared.sel); | 1001 | try std.testing.expectEqual(@as(usize, 1), shared.sel); |
| 1002 | } | 1002 | } |
| 1003 | 1003 | ||
| 1004 | test "setFocus re-arms the wall's own mouse capture itself — a parked pump cannot" { | ||
| 1005 | // Clicking an [unreachable] tile is a designed act (birthing on one is | ||
| 1006 | // too), and that tile's pump is parked in `dial`'s backoff loop: the | ||
| 1007 | // focus claim that used to re-enable mouse reporting never runs there. | ||
| 1008 | // The release alone then left the terminal deaf to every later click — | ||
| 1009 | // including the one that would have moved focus back off the dead tile | ||
| 1010 | // (found live 2026-09-02: one click on a dark laptop's pane killed the | ||
| 1011 | // mouse for the whole wall until a keyboard chord landed on a live | ||
| 1012 | // tile). The re-arm is the KEYBOARD's own write, after the release and | ||
| 1013 | // before the doorbell, so the wall hears clicks with no pump running. | ||
| 1014 | const p = try std.posix.pipe2(.{ .NONBLOCK = true }); | ||
| 1015 | defer std.posix.close(p[0]); | ||
| 1016 | defer std.posix.close(p[1]); | ||
| 1017 | var shared = Shared{ .out_fd = p[1], .size = .{ .cols = 80, .rows = 24 }, .is_tty = true }; | ||
| 1018 | var tiles: [2]Tile = undefined; | ||
| 1019 | tiles[0] = Tile{ | ||
| 1020 | .r = .{ .target = .{ .sock = "/s" }, .label = "a", .session = "a" }, | ||
| 1021 | .rect = .{ .top = 0, .left = 0, .rows = 12, .cols = 80 }, | ||
| 1022 | .shared = &shared, | ||
| 1023 | .idx = 0, | ||
| 1024 | .wake_r = -1, | ||
| 1025 | .wake_w = -1, | ||
| 1026 | }; | ||
| 1027 | tiles[1] = Tile{ | ||
| 1028 | .r = .{ .target = .{ .sock = "/s" }, .label = "b", .session = "b" }, | ||
| 1029 | .rect = .{ .top = 12, .left = 0, .rows = 12, .cols = 80 }, | ||
| 1030 | .shared = &shared, | ||
| 1031 | .idx = 1, | ||
| 1032 | .wake_r = -1, | ||
| 1033 | .wake_w = p[1], | ||
| 1034 | }; | ||
| 1035 | shared.sel = 0; | ||
| 1036 | var buf: [512]u8 = undefined; | ||
| 1037 | |||
| 1038 | wv.setFocus(&tiles, &shared, 1); | ||
| 1039 | const out = fixture.readAvail(p[0], &buf); | ||
| 1040 | const rel = std.mem.indexOf(u8, out, interact.session_release) orelse | ||
| 1041 | return error.NoReleaseOnFocusMove; | ||
| 1042 | const arm = std.mem.indexOf(u8, out, interact.wall_mouse_capture) orelse | ||
| 1043 | return error.NoWallRearm; | ||
| 1044 | const bell = std.mem.indexOfScalar(u8, out, 0) orelse | ||
| 1045 | return error.NoClaimDoorbell; | ||
| 1046 | try std.testing.expect(rel < arm); | ||
| 1047 | try std.testing.expect(arm < bell); | ||
| 1048 | } | ||
| 1049 | |||
| 1004 | test "a focus move drops what the wall's input filters are holding" { | 1050 | test "a focus move drops what the wall's input filters are holding" { |
| 1005 | // The input-filter state that mattered — the drag — moved off the | 1051 | // The input-filter state that mattered — the drag — moved off the |
| 1006 | // wall's keyboard loop and onto each tile's Core. setFocus drops it the | 1052 | // wall's keyboard loop and onto each tile's Core. setFocus drops it the |
src/tui/wallview.zig
| Old | New | ||
|---|---|---|---|
| @@ -792,6 +792,12 @@ fn moveFocusLocked(tiles: []Tile, shared: *Shared, prev: usize, next: usize) voi | |||
| 792 | // lets the next pump claim — and only when there IS a terminal to change. | 792 | // lets the next pump claim — and only when there IS a terminal to change. |
| 793 | if (shared.is_tty and prev != next and prev < tiles.len and tiles[prev].alive.load(.acquire)) { | 793 | if (shared.is_tty and prev != next and prev < tiles.len and tiles[prev].alive.load(.acquire)) { |
| 794 | proto.writeAllFd(shared.out_fd, interact.session_release) catch {}; | 794 | proto.writeAllFd(shared.out_fd, interact.session_release) catch {}; |
| 795 | // The wall's own click capture goes straight back on, by THIS hand: | ||
| 796 | // the incoming tile's claim used to be the re-arm, and a pump parked | ||
| 797 | // dialling a dead host never claims — one click on an unreachable | ||
| 798 | // pane then deafened the mouse for the whole wall. See | ||
| 799 | // `interact.wall_mouse_capture`. | ||
| 800 | proto.writeAllFd(shared.out_fd, interact.wall_mouse_capture) catch {}; | ||
| 795 | tiles[prev].release_pending.store(true, .release); | 801 | tiles[prev].release_pending.store(true, .release); |
| 796 | ring(&tiles[prev]); | 802 | ring(&tiles[prev]); |
| 797 | } | 803 | } |
test/e2e.sh
| Old | New | ||
|---|---|---|---|
| @@ -170,8 +170,8 @@ done | |||
| 170 | # one of those and adds a convergence point would be pinning a fact every | 170 | # one of those and adds a convergence point would be pinning a fact every |
| 171 | # leg above already establishes. | 171 | # leg above already establishes. |
| 172 | 172 | ||
| 173 | [ "$OK_COUNT" = "106" ] || { | 173 | [ "$OK_COUNT" = "107" ] || { |
| 174 | echo "e2e FAIL: $OK_COUNT scenario checkpoints ran, the pin says 106 —" | 174 | echo "e2e FAIL: $OK_COUNT scenario checkpoints ran, the pin says 107 —" |
| 175 | echo " a scenario was added (update the pin) or silently lost" | 175 | echo " a scenario was added (update the pin) or silently lost" |
| 176 | exit 1 | 176 | exit 1 |
| 177 | } | 177 | } |
test/e2e_08_mouse.sh
| Old | New | ||
|---|---|---|---|
| @@ -567,3 +567,91 @@ assert_stopped "$SOCK42" "$D39PID" "app mouse" "$OUT.zm2stop" | |||
| 567 | D39PID="" | 567 | D39PID="" |
| 568 | ok "an application in the focused tile gets the wheel, and the tile does not" | 568 | ok "an application in the focused tile gets the wheel, and the tile does not" |
| 569 | 569 | ||
| 570 | |||
| 571 | # ---- a click on a dead host's pane must not deafen the wall ------------- | ||
| 572 | # | ||
| 573 | # Found live 2026-09-02: a laptop went dark overnight, its pane came back | ||
| 574 | # [unreachable], and ONE click on it killed the mouse for the whole wall — | ||
| 575 | # the focus move writes `session_release` (every mouse mode off) and the | ||
| 576 | # re-arm was the incoming tile's claim, which a pane with no pump (and a | ||
| 577 | # pump parked in dial) never runs. The keyboard re-arms the wall's own | ||
| 578 | # capture itself now (`interact.wall_mouse_capture`), and this leg walks | ||
| 579 | # the user's exact path: click the dark pane, click BACK, type. | ||
| 580 | # | ||
| 581 | # `click` is the discriminating verb: ptyclient mirrors the mouse modes off | ||
| 582 | # the client's own writes and sends a press only while reporting is ON, | ||
| 583 | # the way a real terminal does. With the re-arm gone, the click back is | ||
| 584 | # bytes a terminal would never send — the verb exits 3 saying so, instead | ||
| 585 | # of this leg green-lighting a wall no mouse can steer. | ||
| 586 | SOCKMC1="${TMPDIR:-/tmp}/muxd-e2e-deadclick1-$$.sock" | ||
| 587 | defer_sock "$SOCKMC1" | ||
| 588 | SOCKMC2="${TMPDIR:-/tmp}/muxd-e2e-deadclick2-$$.sock" | ||
| 589 | defer_sock "$SOCKMC2" | ||
| 590 | MCSTATE="${TMPDIR:-/tmp}/mux-e2e-deadclick-state-$$" | ||
| 591 | defer_rm "$MCSTATE" | ||
| 592 | mkdir -p "$MCSTATE/mux" | ||
| 593 | printf -- '--sock %s\n--sock %s\n' "$SOCKMC1" "$SOCKMC2" > "$MCSTATE/mux/hosts" | ||
| 594 | start_daemon "$SOCKMC1" "$OUT.mc1.d" "dead-click daemon 1 never bound" --shell /bin/sh | ||
| 595 | start_daemon "$SOCKMC2" "$OUT.mc2.d" "dead-click daemon 2 never bound" --shell /bin/sh | ||
| 596 | MC2PID=$DPID | ||
| 597 | # A polled host never CREATES a session, and a fresh daemon has none: host | ||
| 598 | # 2's tile exists only if something attached there first. One attach-and- | ||
| 599 | # detach births its default session; the daemon keeps it. | ||
| 600 | pipe_mux "$OUT.mc0" "$OUT.mc0.err" env XDG_STATE_HOME="$MCSTATE" timeout 40 "$MUX" --sock "$SOCKMC2" | ||
| 601 | pipe_detach | ||
| 602 | |||
| 603 | # Run 1: both hosts up, a wall of two, left on a terminal — the layout | ||
| 604 | # sidecar is what carries host 2's pane into run 2 as a saved pane. | ||
| 605 | set +e | ||
| 606 | XDG_STATE_HOME="$MCSTATE" timeout 60 "$PTYCLIENT" --cols 100 --rows 30 \ | ||
| 607 | --out "$OUT.mc1" --err "$OUT.mc1.err" -- \ | ||
| 608 | "$MUX" --sock "$SOCKMC1" > "$OUT.mc1.pc" 2>&1 <<EOF | ||
| 609 | expect [up] 15000 | ||
| 610 | settle 2500 25000 | ||
| 611 | send \x1cd | ||
| 612 | waitexit 15000 | ||
| 613 | EOF | ||
| 614 | RC=$? | ||
| 615 | set -e | ||
| 616 | rc0 "dead-click: the two-tile wall leg" "$OUT.mc1.pc" "$OUT.mc1.err" | ||
| 617 | # Post-hoc and not an expect: the second tile's bar can paint BEFORE the | ||
| 618 | # first [up] the script matched, and expect(1) never looks behind its | ||
| 619 | # cursor. The capture as a whole is what the sidecar saw. | ||
| 620 | grep -aq 'deadclick2.*\[up\]' "$OUT.mc1" || { | ||
| 621 | echo "e2e FAIL: dead-click: host 2's tile never came up on the wall" | ||
| 622 | exit 1; } | ||
| 623 | |||
| 624 | # Host 2 goes dark the way the laptop did: daemon killed, socket gone. | ||
| 625 | kill -9 "$MC2PID" 2>/dev/null || true | ||
| 626 | wait_pid_gone "$MC2PID" | ||
| 627 | rm -f "$SOCKMC2" | ||
| 628 | |||
| 629 | # Run 2: the restored wall. The dark host's pane stays, wearing the word; | ||
| 630 | # the click lands ON it, the click back must still be heard, and the typed | ||
| 631 | # marker proves the mouse alone steered focus there and home again. | ||
| 632 | set +e | ||
| 633 | XDG_STATE_HOME="$MCSTATE" timeout 60 "$PTYCLIENT" --cols 100 --rows 30 \ | ||
| 634 | --out "$OUT.mc2" --err "$OUT.mc2.err" -- \ | ||
| 635 | "$MUX" --sock "$SOCKMC1" > "$OUT.mc2.pc" 2>&1 <<'EOF' | ||
| 636 | expect [up] 15000 | ||
| 637 | settle 2500 25000 | ||
| 638 | click 75 10 8000 | ||
| 639 | expect \x201\x20\x20--sock 10000 | ||
| 640 | click 25 10 8000 | ||
| 641 | send echo CLICKBACK\r | ||
| 642 | expect CLICKBACK 10000 | ||
| 643 | send \x1cd | ||
| 644 | waitexit 15000 | ||
| 645 | EOF | ||
| 646 | RC=$? | ||
| 647 | set -e | ||
| 648 | rc0 "dead-click: the click-and-return leg" "$OUT.mc2.pc" "$OUT.mc2.err" | ||
| 649 | # The pane wore the word (order-free, like run 1's pin), and the first | ||
| 650 | # click really landed on it: tile 1's bar going to the UNFOCUSED marker is | ||
| 651 | # the in-script expect above, painted only after focus left it. The | ||
| 652 | # marker echo is the verdict for the click back: typed bytes reach a live | ||
| 653 | # shell only if the mouse alone carried focus there and home again. | ||
| 654 | grep -aq '\[unreachable\]' "$OUT.mc2" || { | ||
| 655 | echo "e2e FAIL: dead-click: the dark host's pane never wore unreachable" | ||
| 656 | exit 1; } | ||
| 657 | ok "a click on a dead host's pane leaves the wall's mouse alive" | ||
test/ptyclient.zig
| Old | New | ||
|---|---|---|---|
| @@ -36,12 +36,28 @@ const Expecter = struct { | |||
| 36 | /// Whether the client under test has put this "terminal" into bracketed | 36 | /// Whether the client under test has put this "terminal" into bracketed |
| 37 | /// paste — set by the escapes it wrote, never by what a scenario assumes. | 37 | /// paste — set by the escapes it wrote, never by what a scenario assumes. |
| 38 | bracketed_paste: bool = false, | 38 | bracketed_paste: bool = false, |
| 39 | /// How far into `buf` noteBracketedPaste has already looked. | 39 | /// The mouse-reporting modes, mirrored the same way: a real terminal |
| 40 | /// sends a click only while the application holds reporting ON, and a | ||
| 41 | /// fixture that sends regardless is blind to the whole bug class where | ||
| 42 | /// mux tears the modes down and nothing re-arms them — the class that | ||
| 43 | /// shipped (a focus move onto a dead host's tile deafened the wall's | ||
| 44 | /// mouse; see `interact.wall_mouse_capture`). | ||
| 45 | m1000: bool = false, | ||
| 46 | m1002: bool = false, | ||
| 47 | m1006: bool = false, | ||
| 48 | /// How far into `buf` noteModes has already looked. | ||
| 40 | modes_scanned: usize = 0, | 49 | modes_scanned: usize = 0, |
| 41 | 50 | ||
| 42 | fn feed(self: *Expecter, alloc: std.mem.Allocator, bytes: []const u8) !void { | 51 | fn feed(self: *Expecter, alloc: std.mem.Allocator, bytes: []const u8) !void { |
| 43 | try self.buf.appendSlice(alloc, bytes); | 52 | try self.buf.appendSlice(alloc, bytes); |
| 44 | self.noteBracketedPaste(); | 53 | self.noteModes(); |
| 54 | } | ||
| 55 | |||
| 56 | /// What this "terminal" would report a button press under: a press | ||
| 57 | /// mode (1000 or 1002) and the SGR encoding (1006) — the only wire | ||
| 58 | /// format `click` speaks. | ||
| 59 | fn mouseReporting(self: *const Expecter) bool { | ||
| 60 | return (self.m1000 or self.m1002) and self.m1006; | ||
| 45 | } | 61 | } |
| 46 | 62 | ||
| 47 | /// Track the mode over the WHOLE capture rather than one chunk at a time, | 63 | /// Track the mode over the WHOLE capture rather than one chunk at a time, |
| @@ -58,16 +74,25 @@ const Expecter = struct { | |||
| 58 | /// | 74 | /// |
| 59 | /// A window holding neither escape changes nothing: the mode is sticky | 75 | /// A window holding neither escape changes nothing: the mode is sticky |
| 60 | /// until the client says otherwise, which is what a real terminal does. | 76 | /// until the client says otherwise, which is what a real terminal does. |
| 61 | fn noteBracketedPaste(self: *Expecter) void { | 77 | fn noteModes(self: *Expecter) void { |
| 78 | // Every escape tracked here is 8 bytes, so one 7-byte backup | ||
| 79 | // window covers any split for all of them. | ||
| 62 | const win = self.buf.items[self.modes_scanned -| (BP_SET.len - 1)..]; | 80 | const win = self.buf.items[self.modes_scanned -| (BP_SET.len - 1)..]; |
| 63 | const set = std.mem.lastIndexOf(u8, win, BP_SET); | 81 | trackMode(win, BP_SET, BP_RESET, &self.bracketed_paste); |
| 64 | const reset = std.mem.lastIndexOf(u8, win, BP_RESET); | 82 | trackMode(win, "\x1b[?1000h", "\x1b[?1000l", &self.m1000); |
| 83 | trackMode(win, "\x1b[?1002h", "\x1b[?1002l", &self.m1002); | ||
| 84 | trackMode(win, "\x1b[?1006h", "\x1b[?1006l", &self.m1006); | ||
| 85 | self.modes_scanned = self.buf.items.len; | ||
| 86 | } | ||
| 87 | |||
| 88 | fn trackMode(win: []const u8, set_esc: []const u8, reset_esc: []const u8, state: *bool) void { | ||
| 89 | const set = std.mem.lastIndexOf(u8, win, set_esc); | ||
| 90 | const reset = std.mem.lastIndexOf(u8, win, reset_esc); | ||
| 65 | if (set) |s| { | 91 | if (set) |s| { |
| 66 | self.bracketed_paste = if (reset) |r| s > r else true; | 92 | state.* = if (reset) |r| s > r else true; |
| 67 | } else if (reset != null) { | 93 | } else if (reset != null) { |
| 68 | self.bracketed_paste = false; | 94 | state.* = false; |
| 69 | } | 95 | } |
| 70 | self.modes_scanned = self.buf.items.len; | ||
| 71 | } | 96 | } |
| 72 | 97 | ||
| 73 | /// A paste as this "terminal" would deliver it: markers only when the | 98 | /// A paste as this "terminal" would deliver it: markers only when the |
| @@ -122,6 +147,13 @@ const Verb = union(enum) { | |||
| 122 | expect: struct { needle: []u8, deadline_ms: u64 }, | 147 | expect: struct { needle: []u8, deadline_ms: u64 }, |
| 123 | resize: struct { cols: u16, rows: u16 }, | 148 | resize: struct { cols: u16, rows: u16 }, |
| 124 | settle: struct { quiet_ms: u64, deadline_ms: u64 }, | 149 | settle: struct { quiet_ms: u64, deadline_ms: u64 }, |
| 150 | /// A button press the way a terminal delivers one: SGR press+release at | ||
| 151 | /// COL;ROW (1-based), sent only once the client has mouse reporting ON. | ||
| 152 | /// The deadline is for the ARMING — a claim is a pump-side write and | ||
| 153 | /// may trail the paint — and a deadline that passes with reporting off | ||
| 154 | /// is this fixture saying what a real terminal says by sending nothing: | ||
| 155 | /// the wall has gone deaf to its user's mouse. | ||
| 156 | click: struct { col: u16, row: u16, deadline_ms: u64 }, | ||
| 125 | waitexit: u64, | 157 | waitexit: u64, |
| 126 | 158 | ||
| 127 | /// Exhaustive on purpose: a future arm that owns memory will not | 159 | /// Exhaustive on purpose: a future arm that owns memory will not |
| @@ -131,7 +163,7 @@ const Verb = union(enum) { | |||
| 131 | switch (self) { | 163 | switch (self) { |
| 132 | .send, .paste => |s| alloc.free(s), | 164 | .send, .paste => |s| alloc.free(s), |
| 133 | .expect => |e| alloc.free(e.needle), | 165 | .expect => |e| alloc.free(e.needle), |
| 134 | .resize, .settle, .waitexit => {}, | 166 | .resize, .settle, .click, .waitexit => {}, |
| 135 | } | 167 | } |
| 136 | } | 168 | } |
| 137 | }; | 169 | }; |
| @@ -172,6 +204,13 @@ fn parseLine(alloc: std.mem.Allocator, raw: []const u8) !?Verb { | |||
| 172 | const deadline = std.fmt.parseInt(u64, it.next() orelse return error.BadVerb, 10) catch return error.BadVerb; | 204 | const deadline = std.fmt.parseInt(u64, it.next() orelse return error.BadVerb, 10) catch return error.BadVerb; |
| 173 | if (it.next() != null) return error.BadVerb; | 205 | if (it.next() != null) return error.BadVerb; |
| 174 | return .{ .settle = .{ .quiet_ms = quiet, .deadline_ms = deadline } }; | 206 | return .{ .settle = .{ .quiet_ms = quiet, .deadline_ms = deadline } }; |
| 207 | } else if (std.mem.eql(u8, verb, "click")) { | ||
| 208 | var it = std.mem.tokenizeScalar(u8, rest, ' '); | ||
| 209 | const col = std.fmt.parseInt(u16, it.next() orelse return error.BadVerb, 10) catch return error.BadVerb; | ||
| 210 | const row = std.fmt.parseInt(u16, it.next() orelse return error.BadVerb, 10) catch return error.BadVerb; | ||
| 211 | const ms = std.fmt.parseInt(u64, it.next() orelse return error.BadVerb, 10) catch return error.BadVerb; | ||
| 212 | if (it.next() != null) return error.BadVerb; | ||
| 213 | return .{ .click = .{ .col = col, .row = row, .deadline_ms = ms } }; | ||
| 175 | } else if (std.mem.eql(u8, verb, "waitexit")) { | 214 | } else if (std.mem.eql(u8, verb, "waitexit")) { |
| 176 | const ms = std.fmt.parseInt(u64, rest, 10) catch return error.BadVerb; | 215 | const ms = std.fmt.parseInt(u64, rest, 10) catch return error.BadVerb; |
| 177 | return .{ .waitexit = ms }; | 216 | return .{ .waitexit = ms }; |
| @@ -346,6 +385,31 @@ pub fn main() !void { | |||
| 346 | pty.resize(r.cols, r.rows) catch |e| | 385 | pty.resize(r.cols, r.rows) catch |e| |
| 347 | fatal(EXIT_CHILD_DIED, "verb {d}: TIOCSWINSZ failed: {s}", .{ verb_no, @errorName(e) }); | 386 | fatal(EXIT_CHILD_DIED, "verb {d}: TIOCSWINSZ failed: {s}", .{ verb_no, @errorName(e) }); |
| 348 | }, | 387 | }, |
| 388 | .click => |c| { | ||
| 389 | // Wait for the ARMING, not for bytes: the enables are a | ||
| 390 | // pump's write and may trail whatever paint the script just | ||
| 391 | // matched on. | ||
| 392 | const start = std.time.milliTimestamp(); | ||
| 393 | while (!exp.mouseReporting()) { | ||
| 394 | if (std.time.milliTimestamp() - start > c.deadline_ms) { | ||
| 395 | std.debug.print("ptyclient: verb {d}: click {d};{d} — mouse reporting is off, " ++ | ||
| 396 | "this terminal has nothing to send (the wall is deaf to its user's mouse)\n", .{ verb_no, c.col, c.row }); | ||
| 397 | dumpTail(exp.buf.items); | ||
| 398 | std.process.exit(EXIT_TIMEOUT); | ||
| 399 | } | ||
| 400 | var fds = [_]std.posix.pollfd{ | ||
| 401 | .{ .fd = pty.master, .events = std.posix.POLL.IN, .revents = 0 }, | ||
| 402 | }; | ||
| 403 | _ = try std.posix.poll(&fds, 50); | ||
| 404 | if (!try drain(alloc, &pty, out, &exp)) { | ||
| 405 | std.debug.print("ptyclient: verb {d}: client closed the pty before a click could arm\n", .{verb_no}); | ||
| 406 | std.process.exit(EXIT_CHILD_DIED); | ||
| 407 | } | ||
| 408 | } | ||
| 409 | var report: [64]u8 = undefined; | ||
| 410 | const bytes = std.fmt.bufPrint(&report, "\x1b[<0;{d};{d}M\x1b[<0;{d};{d}m", .{ c.col, c.row, c.col, c.row }) catch unreachable; | ||
| 411 | writeWhole(pty.master, bytes, verb_no); | ||
| 412 | }, | ||
| 349 | .settle => |s| { | 413 | .settle => |s| { |
| 350 | // Quiesce by CONDITION rather than by needle: succeed once | 414 | // Quiesce by CONDITION rather than by needle: succeed once |
| 351 | // the master has gone quiet_ms without a byte. A needle can | 415 | // the master has gone quiet_ms without a byte. A needle can |