bd8f4571
fix: the unzoomed wall filters mouse reports instead of typing them
a73x 2026-08-22 13:13
Commit message
src/interact.zig
| Old | New | ||
|---|---|---|---|
| @@ -141,10 +141,16 @@ const stdin_chunk = 16 * 1024; | |||
| 141 | const wheel_rows: u32 = 3; | 141 | const wheel_rows: u32 = 3; |
| 142 | 142 | ||
| 143 | /// Pulls SGR mouse reports out of the stdin stream and turns the wheel ones | 143 | /// Pulls SGR mouse reports out of the stdin stream and turns the wheel ones |
| 144 | /// into scrollback movement. Only runs while no application in the session | 144 | /// into scrollback movement. |
| 145 | /// has asked for the mouse — when one has, its bytes are its own and this | 145 | /// |
| 146 | /// filter is bypassed entirely (and reset, so a report split across that | 146 | /// TWO instances exist, and they are not alternatives. A tile's `Core` owns |
| 147 | /// transition cannot be half-eaten). | 147 | /// one for the bytes a ZOOMED session's terminal delivers; it runs only |
| 148 | /// while no application in that session has asked for the mouse, because | ||
| 149 | /// when one has, the bytes are its own and the filter is bypassed entirely | ||
| 150 | /// (and reset, so a report split across that transition cannot be | ||
| 151 | /// half-eaten). The CLI wall owns the other, in front of its UNZOOMED key | ||
| 152 | /// loop, where there is no session to bypass it for — the modes are the | ||
| 153 | /// wall's own (`wall_mouse_claim`) and every report is the wall's. | ||
| 148 | /// | 154 | /// |
| 149 | /// Only the SGR form (`ESC [ < b ; x ; y M|m`) is recognised, because it is | 155 | /// Only the SGR form (`ESC [ < b ; x ; y M|m`) is recognised, because it is |
| 150 | /// the only form the client ever asks its terminal for (`client_mouse_setup`). | 156 | /// the only form the client ever asks its terminal for (`client_mouse_setup`). |
| @@ -158,10 +164,13 @@ const wheel_rows: u32 = 3; | |||
| 158 | /// leaks through as input. A terminal writes a report with one write, and | 164 | /// leaks through as input. A terminal writes a report with one write, and |
| 159 | /// the pty delivers up to 16 KiB per read, so that split is a theoretical | 165 | /// the pty delivers up to 16 KiB per read, so that split is a theoretical |
| 160 | /// one; a delayed Escape would be an every-session one. | 166 | /// one; a delayed Escape would be an every-session one. |
| 161 | const MouseFilter = struct { | 167 | pub const MouseFilter = struct { |
| 162 | /// Longest report worth holding: `ESC [ <` plus three parameters. A | 168 | /// Longest report worth holding: `ESC [ <` plus three parameters. A |
| 163 | /// candidate that outgrows it was never a mouse report. | 169 | /// candidate that outgrows it was never a mouse report. |
| 164 | const max_held = 24; | 170 | /// |
| 171 | /// Public because a caller has to SIZE the buffer it passes `feed`, and | ||
| 172 | /// the size is `in.len + max_held`. | ||
| 173 | pub const max_held = 24; | ||
| 165 | 174 | ||
| 166 | /// One report the filter understood, in this client's coordinates. | 175 | /// One report the filter understood, in this client's coordinates. |
| 167 | /// | 176 | /// |
| @@ -189,7 +198,7 @@ const MouseFilter = struct { | |||
| 189 | /// and strand the drag state machine mid-drag. | 198 | /// and strand the drag state machine mid-drag. |
| 190 | const max_events = stdin_chunk / 10 + 1; | 199 | const max_events = stdin_chunk / 10 + 1; |
| 191 | 200 | ||
| 192 | const Out = struct { | 201 | pub const Out = struct { |
| 193 | /// The bytes that were not mouse reports, in order. | 202 | /// The bytes that were not mouse reports, in order. |
| 194 | forward: []const u8, | 203 | forward: []const u8, |
| 195 | /// Net wheel notches: positive is up, into history. | 204 | /// Net wheel notches: positive is up, into history. |
| @@ -203,7 +212,7 @@ const MouseFilter = struct { | |||
| 203 | len: usize = 0, | 212 | len: usize = 0, |
| 204 | events: [max_events]Event = undefined, | 213 | events: [max_events]Event = undefined, |
| 205 | 214 | ||
| 206 | fn reset(self: *MouseFilter) void { | 215 | pub fn reset(self: *MouseFilter) void { |
| 207 | self.len = 0; | 216 | self.len = 0; |
| 208 | } | 217 | } |
| 209 | 218 | ||
| @@ -211,7 +220,7 @@ const MouseFilter = struct { | |||
| 211 | /// `in.len + max_held` — a candidate held from the previous read is | 220 | /// `in.len + max_held` — a candidate held from the previous read is |
| 212 | /// handed back ahead of this chunk's bytes when it turns out not to | 221 | /// handed back ahead of this chunk's bytes when it turns out not to |
| 213 | /// have been a report after all. | 222 | /// have been a report after all. |
| 214 | fn feed(self: *MouseFilter, in: []const u8, out: []u8) Out { | 223 | pub fn feed(self: *MouseFilter, in: []const u8, out: []u8) Out { |
| 215 | var kept: usize = 0; | 224 | var kept: usize = 0; |
| 216 | var wheel: i32 = 0; | 225 | var wheel: i32 = 0; |
| 217 | var evs: usize = 0; | 226 | var evs: usize = 0; |
src/wallview.zig
| Old | New | ||
|---|---|---|---|
| @@ -791,12 +791,18 @@ fn endWith(t: *Tile, reason: EndReason, code: u8) void { | |||
| 791 | /// So the mailbox carries whatever the terminal wrote, minus the chord, and | 791 | /// So the mailbox carries whatever the terminal wrote, minus the chord, and |
| 792 | /// `interact.Core.forward` takes it apart on the other side. | 792 | /// `interact.Core.forward` takes it apart on the other side. |
| 793 | /// | 793 | /// |
| 794 | /// Nothing arriving here while the wall is UNZOOMED can be a mouse report: | 794 | /// Nothing arriving here is an UNZOOMED wall's mouse report, and that is a |
| 795 | /// the wall asks its own terminal for no mouse mode, and a promoted tile's | 795 | /// routing fact rather than an absence now that there are such reports. The |
| 796 | /// modes come off at the demote (`Core.releaseTerminal`). A report in | 796 | /// wall holds mouse modes of its own for its whole life |
| 797 | /// flight across that boundary lands in the unzoomed key loop instead, | 797 | /// (`interact.wall_mouse_claim`) and reads what they produce itself, in the |
| 798 | /// where its bytes are selection keys or nothing — the wall's input, never | 798 | /// key loop, through a `MouseFilter` of its own. This function is reached |
| 799 | /// a session's. | 799 | /// only on the zoomed branch of that loop, so a report that gets here |
| 800 | /// belongs to a session by construction. | ||
| 801 | /// | ||
| 802 | /// A report in flight across the boundary lands on whichever side the zoom | ||
| 803 | /// was on when the read returned: the wall's filter eats it, or the | ||
| 804 | /// session's does. Neither side is ever handed the other's, which is what | ||
| 805 | /// keeps a wall drag from becoming tile input. | ||
| 800 | /// | 806 | /// |
| 801 | /// A chunk that does not fit is dropped WHOLE. The obvious alternative — | 807 | /// A chunk that does not fit is dropped WHOLE. The obvious alternative — |
| 802 | /// copy what fits — splices: the head of one read lands in the mailbox, the | 808 | /// copy what fits — splices: the head of one read lands in the mailbox, the |
| @@ -2601,6 +2607,14 @@ pub fn run(alloc: std.mem.Allocator, resolved: []const Resolved, entry: Entry) ! | |||
| 2601 | // nothing); zoomed IN the terminal belongs to the session and only the | 2607 | // nothing); zoomed IN the terminal belongs to the session and only the |
| 2602 | // `Ctrl-\` chord layer is held back. | 2608 | // `Ctrl-\` chord layer is held back. |
| 2603 | var prefix: interact.PrefixFilter = .{}; | 2609 | var prefix: interact.PrefixFilter = .{}; |
| 2610 | // The wall's own mouse filter — the second in the process, and not an | ||
| 2611 | // alternative to the first: a promoted tile's `Core` runs one over the | ||
| 2612 | // bytes it is handed zoomed, this one runs over the bytes that arrive | ||
| 2613 | // UNZOOMED, which the wall's own modes are what produce. | ||
| 2614 | var mouse: interact.MouseFilter = .{}; | ||
| 2615 | // `feed` hands a candidate held across the previous read back ahead of | ||
| 2616 | // this chunk, so its room is a whole chunk plus that hold. | ||
| 2617 | var mouse_out: [mailbox_max + interact.MouseFilter.max_held]u8 = undefined; | ||
| 2604 | // Where `Ctrl-\ l` goes back to. Keyboard-thread state: no pump reads | 2618 | // Where `Ctrl-\ l` goes back to. Keyboard-thread state: no pump reads |
| 2605 | // it, and no lock guards it, because nothing else writes it. | 2619 | // it, and no lock guards it, because nothing else writes it. |
| 2606 | var last_zoom: ?usize = null; | 2620 | var last_zoom: ?usize = null; |
| @@ -2768,7 +2782,23 @@ pub fn run(alloc: std.mem.Allocator, resolved: []const Resolved, entry: Entry) ! | |||
| 2768 | continue; | 2782 | continue; |
| 2769 | } | 2783 | } |
| 2770 | 2784 | ||
| 2771 | for (b[0..n]) |key| { | 2785 | // Unzoomed, this terminal reports the mouse on the WALL's own claim, |
| 2786 | // so a read here can carry SGR reports as well as keys. They come | ||
| 2787 | // out AHEAD of the byte loop because a report is made of characters | ||
| 2788 | // the wall acts on: its parameters are decimal digits, and a digit | ||
| 2789 | // is a jump key in `selectKey`. One wheel spin over a stripe would | ||
| 2790 | // otherwise walk the selection at random. | ||
| 2791 | const report = mouse.feed(b[0..n], &mouse_out); | ||
| 2792 | // Both halves are dropped, by decision and not by omission. There | ||
| 2793 | // is no wall-level scrollback for a notch to move, and a press, a | ||
| 2794 | // drag or a release has nothing behind it yet — a user who sees the | ||
| 2795 | // wall answer the mouse at all will spin the wheel over a stripe, | ||
| 2796 | // and until something is listening, nothing happening is the honest | ||
| 2797 | // answer to that. | ||
| 2798 | _ = report.wheel; | ||
| 2799 | _ = report.events; | ||
| 2800 | |||
| 2801 | for (report.forward) |key| { | ||
| 2772 | if (key == 'q' or key == 0x1c) break :keys; | 2802 | if (key == 'q' or key == 0x1c) break :keys; |
| 2773 | // Both spellings of Enter: ICRNL is off, so a Return arrives | 2803 | // Both spellings of Enter: ICRNL is off, so a Return arrives |
| 2774 | // as CR, but a script or a paste can just as easily send LF. | 2804 | // as CR, but a script or a paste can just as easily send LF. |
| @@ -2784,8 +2814,11 @@ pub fn run(alloc: std.mem.Allocator, resolved: []const Resolved, entry: Entry) ! | |||
| 2784 | setZoom(tiles[0..live], &shared, shared.sel); | 2814 | setZoom(tiles[0..live], &shared, shared.sel); |
| 2785 | // The rest of this read was typed at the WALL, before the | 2815 | // The rest of this read was typed at the WALL, before the |
| 2786 | // terminal changed hands — it is not the session's input. | 2816 | // terminal changed hands — it is not the session's input. |
| 2787 | // The prefix layer starts clean for the same reason. | 2817 | // The prefix layer starts clean for the same reason, and so |
| 2818 | // does the mouse filter: anything it is still holding is | ||
| 2819 | // the head of a report from the read being dropped here. | ||
| 2788 | prefix = .{}; | 2820 | prefix = .{}; |
| 2821 | mouse.reset(); | ||
| 2789 | continue :keys; | 2822 | continue :keys; |
| 2790 | } | 2823 | } |
| 2791 | // `x` forgets the selected tile: off the wall file, off this | 2824 | // `x` forgets the selected tile: off the wall file, off this |
| @@ -2888,6 +2921,40 @@ fn allPresent(comptime n: usize) [n]bool { | |||
| 2888 | return [_]bool{true} ** n; | 2921 | return [_]bool{true} ** n; |
| 2889 | } | 2922 | } |
| 2890 | 2923 | ||
| 2924 | test "a mouse report at the unzoomed wall is filtered out, never typed as a wall key" { | ||
| 2925 | // The hazard first, so the filter is measured against something real | ||
| 2926 | // rather than asserted into existence. An SGR report's parameters are | ||
| 2927 | // decimal digits and its final byte is a letter; on the wall a digit is | ||
| 2928 | // a jump key and `x` forgets a tile. Fed raw, this one report — button | ||
| 2929 | // 0 pressed at column 3, row 1 — moves the selection twice. | ||
| 2930 | const report = "\x1b[<0;3;1M"; | ||
| 2931 | const present = [_]bool{ true, true, true }; | ||
| 2932 | var jumped: usize = 0; | ||
| 2933 | for (report) |k| { | ||
| 2934 | if (selectKey(&present, 0, k)) |_| jumped += 1; | ||
| 2935 | } | ||
| 2936 | try std.testing.expect(jumped > 0); | ||
| 2937 | |||
| 2938 | // ...and through the filter the wall now runs its unzoomed reads | ||
| 2939 | // through, the same bytes reach the byte loop as nothing at all. | ||
| 2940 | var mouse: interact.MouseFilter = .{}; | ||
| 2941 | var out: [64]u8 = undefined; | ||
| 2942 | const got = mouse.feed(report, &out); | ||
| 2943 | try std.testing.expectEqual(@as(usize, 0), got.forward.len); | ||
| 2944 | // Seen, not merely swallowed. A filter that dropped the bytes and | ||
| 2945 | // reported nothing would pass the line above and leave the slice that | ||
| 2946 | // turns a click into a stripe selection with nothing to build on. | ||
| 2947 | try std.testing.expectEqual(@as(usize, 1), got.events.len); | ||
| 2948 | try std.testing.expectEqual(@as(u16, 2), got.events[0].col); | ||
| 2949 | try std.testing.expectEqual(@as(u16, 0), got.events[0].row); | ||
| 2950 | |||
| 2951 | // Ordinary keys are untouched by the same call — the filter has to be | ||
| 2952 | // safe to put in front of EVERY unzoomed read, not just the ones | ||
| 2953 | // carrying a report. | ||
| 2954 | const mixed = mouse.feed("1\x1b[<0;3;1Mj", &out); | ||
| 2955 | try std.testing.expectEqualStrings("1j", mixed.forward); | ||
| 2956 | } | ||
| 2957 | |||
| 2891 | test "selectKey: j/k and n/p wrap at both ends" { | 2958 | test "selectKey: j/k and n/p wrap at both ends" { |
| 2892 | const p3 = allPresent(3); | 2959 | const p3 = allPresent(3); |
| 2893 | try std.testing.expectEqual(@as(?usize, 1), selectKey(&p3, 0, 'j')); | 2960 | try std.testing.expectEqual(@as(?usize, 1), selectKey(&p3, 0, 'j')); |
test/e2e.sh
| Old | New | ||
|---|---|---|---|
| @@ -464,6 +464,12 @@ AGENT49BKEY="${TMPDIR:-/tmp}/mux-e2e-agent2bkey-$$" | |||
| 464 | D43PID="" | 464 | D43PID="" |
| 465 | FLIPAPID="" | 465 | FLIPAPID="" |
| 466 | 466 | ||
| 467 | # The wall's own mouse claim needs a daemon of its own for the same reason | ||
| 468 | # every other leg does: the wall scenario above stops its daemon on the way | ||
| 469 | # out, so there is nothing left to reuse. | ||
| 470 | SOCK50="${TMPDIR:-/tmp}/muxd-e2e-wallmouse-$$.sock" | ||
| 471 | D48PID="" | ||
| 472 | |||
| 467 | # One counter out of a MUX_PREDICT_STATS line. The client prints exactly one | 473 | # One counter out of a MUX_PREDICT_STATS line. The client prints exactly one |
| 468 | # such line on exit; every field is a key=value pair, so a rename or reorder | 474 | # such line on exit; every field is a key=value pair, so a rename or reorder |
| 469 | # in the client shows up here as an empty read rather than a wrong number. | 475 | # in the client shows up here as an empty read rather than a wrong number. |
| @@ -1316,6 +1322,7 @@ cleanup() { | |||
| 1316 | [ -S "$SOCK47" ] && "$MUXD" stop --sock "$SOCK47" 2>/dev/null || true | 1322 | [ -S "$SOCK47" ] && "$MUXD" stop --sock "$SOCK47" 2>/dev/null || true |
| 1317 | [ -S "$SOCK48" ] && "$MUXD" stop --sock "$SOCK48" 2>/dev/null || true | 1323 | [ -S "$SOCK48" ] && "$MUXD" stop --sock "$SOCK48" 2>/dev/null || true |
| 1318 | [ -S "$SOCK49" ] && "$MUXD" stop --sock "$SOCK49" 2>/dev/null || true | 1324 | [ -S "$SOCK49" ] && "$MUXD" stop --sock "$SOCK49" 2>/dev/null || true |
| 1325 | [ -S "$SOCK50" ] && "$MUXD" stop --sock "$SOCK50" 2>/dev/null || true | ||
| 1319 | 1326 | ||
| 1320 | # ---- the leak sweep (hygiene kit, 6a) ---- | 1327 | # ---- the leak sweep (hygiene kit, 6a) ---- |
| 1321 | # Here rather than at the bottom of the file, which `set -e` reaches only | 1328 | # Here rather than at the bottom of the file, which `set -e` reaches only |
| @@ -1542,6 +1549,10 @@ cleanup() { | |||
| 1542 | rm -f "$OUT.zsa" "$OUT.zsa.err" "$OUT.zsb" "$OUT.zsb.err" "$OUT.zscap" \ | 1549 | rm -f "$OUT.zsa" "$OUT.zsa.err" "$OUT.zsb" "$OUT.zsb.err" "$OUT.zscap" \ |
| 1543 | "$OUT.zscap.err" "$OUT.zs.d" "$OUT.zsfa" "$OUT.zsfb" "$OUT.zspc" \ | 1550 | "$OUT.zscap.err" "$OUT.zs.d" "$OUT.zsfa" "$OUT.zsfb" "$OUT.zspc" \ |
| 1544 | "$OUT.zssta" "$OUT.zsstb" "$OUT.zsstop" "$OUT.zswatch" | 1551 | "$OUT.zssta" "$OUT.zsstb" "$OUT.zsstop" "$OUT.zswatch" |
| 1552 | rm -f "$SOCK50" "$OUT.wmse.d" "$OUT.wmsa" "$OUT.wmsa.err" "$OUT.wmsb" \ | ||
| 1553 | "$OUT.wmsb.err" "$OUT.wmcap" "$OUT.wmcap.err" "$OUT.wmpc" \ | ||
| 1554 | "$OUT.wmcap2" "$OUT.wmcap2.err" "$OUT.wmpc2" "$OUT.wmfa" "$OUT.wmfb" \ | ||
| 1555 | "$OUT.wmstop" | ||
| 1545 | # The convergence files a FAILING assert_converged leaves behind | 1556 | # The convergence files a FAILING assert_converged leaves behind |
| 1546 | # (.render/.dump/.rvt/.dvt/.diff for that capture) are deliberately not | 1557 | # (.render/.dump/.rvt/.dvt/.diff for that capture) are deliberately not |
| 1547 | # chased here: on a failing run they are the evidence. | 1558 | # chased here: on a failing run they are the evidence. |
| @@ -5672,13 +5683,17 @@ ok "the wheel scrolls back and returns to live, and never reaches the pty" | |||
| 5672 | # `set mouse=a` set — before becoming cat, so every wheel byte is the | 5683 | # `set mouse=a` set — before becoming cat, so every wheel byte is the |
| 5673 | # application's and the client must hand them over untouched. | 5684 | # application's and the client must hand them over untouched. |
| 5674 | # | 5685 | # |
| 5675 | # 1002 is the mode that makes this leg sharp: the client's OWN capture | 5686 | # 1003 is the mode that makes this leg sharp, and it is 1003 rather |
| 5676 | # set is 1000+1006, so a `?1002h` on this terminal can only have come | 5687 | # than 1002 because the client's own capture set is 1000+1002+1006 — |
| 5677 | # from mirroring what the session asked for. | 5688 | # a `?1002h` here would be written whether the session asked or not, |
| 5689 | # and asserting on it would be asserting nothing. 1003 (report motion | ||
| 5690 | # with no button down) is a mode the client never wants for itself, so | ||
| 5691 | # a `?1003h` on this terminal can only be the session's, mirrored. The | ||
| 5692 | # script asks for it on top of vim's `set mouse=a` set. | ||
| 5678 | cat > "$MOUSESH" <<'EOF' | 5693 | cat > "$MOUSESH" <<'EOF' |
| 5679 | #!/bin/sh | 5694 | #!/bin/sh |
| 5680 | seq 1 100 | 5695 | seq 1 100 |
| 5681 | printf '\033[?1000h\033[?1002h\033[?1006h' | 5696 | printf '\033[?1000h\033[?1002h\033[?1003h\033[?1006h' |
| 5682 | printf 'app-holds-the-mouse\n' | 5697 | printf 'app-holds-the-mouse\n' |
| 5683 | exec /bin/cat | 5698 | exec /bin/cat |
| 5684 | EOF | 5699 | EOF |
| @@ -5716,9 +5731,10 @@ grep -qF -- "[<64;10;5M" "$OUT.msecap" || { | |||
| 5716 | echo "e2e FAIL: app mouse: the wheel never reached the application's pty:" | 5731 | echo "e2e FAIL: app mouse: the wheel never reached the application's pty:" |
| 5717 | cat "$OUT.msecap"; exit 1; } | 5732 | cat "$OUT.msecap"; exit 1; } |
| 5718 | # The mirror: this terminal was asked for the session's modes, not the | 5733 | # The mirror: this terminal was asked for the session's modes, not the |
| 5719 | # client's own set. | 5734 | # client's own set — and 1003 is outside that set, so this cannot pass on |
| 5720 | grep -qaF "$(printf '\033[?1002h')" "$OUT.mse" || { | 5735 | # the client's own behalf. |
| 5721 | echo "e2e FAIL: app mouse: the client never mirrored the session's drag mode"; exit 1; } | 5736 | grep -qaF "$(printf '\033[?1003h')" "$OUT.mse" || { |
| 5737 | echo "e2e FAIL: app mouse: the client never mirrored the session's any-motion mode"; exit 1; } | ||
| 5722 | # And the negative that makes the pair a pair: this session has the same | 5738 | # And the negative that makes the pair a pair: this session has the same |
| 5723 | # 77 rows of history as (a), and the same wheel byte moved none of it. | 5739 | # 77 rows of history as (a), and the same wheel byte moved none of it. |
| 5724 | grep -qF -- "60" "$OUT.mse" && { | 5740 | grep -qF -- "60" "$OUT.mse" && { |
| @@ -5985,10 +6001,10 @@ done | |||
| 5985 | # (wallview `setZoom`), so the order is structural rather than lucky. | 6001 | # (wallview `setZoom`), so the order is structural rather than lucky. |
| 5986 | # | 6002 | # |
| 5987 | # Both needles are exact byte strings that nothing else emits. The claim is | 6003 | # Both needles are exact byte strings that nothing else emits. The claim is |
| 5988 | # the only place those two escapes are adjacent — the level-set that follows | 6004 | # the only place those three escapes are adjacent — the level-set that |
| 5989 | # it spells the same modes with three others in between — and the release is | 6005 | # follows it puts `?1003l` and `?1005l` between the last two — and the |
| 5990 | # a nine-escape run written whole. | 6006 | # release is a nine-escape run written whole. |
| 5991 | _ZS_CLAIM=$(printf '\033[?1000h\033[?1006h') | 6007 | _ZS_CLAIM=$(printf '\033[?1000h\033[?1002h\033[?1006h') |
| 5992 | _ZS_REL=$(printf '\033[?2004l\033[?9l\033[?1000l\033[?1002l\033[?1003l\033[?1005l\033[?1006l\033[?1015l\033[?1016l') | 6008 | _ZS_REL=$(printf '\033[?2004l\033[?9l\033[?1000l\033[?1002l\033[?1003l\033[?1005l\033[?1006l\033[?1015l\033[?1016l') |
| 5993 | # Byte offsets, merged and sorted, then read as a word. `grep -oba` is what | 6009 | # Byte offsets, merged and sorted, then read as a word. `grep -oba` is what |
| 5994 | # makes this possible at all: the capture is binary, these are escape | 6010 | # makes this possible at all: the capture is binary, these are escape |
| @@ -5996,13 +6012,20 @@ _ZS_REL=$(printf '\033[?2004l\033[?9l\033[?1000l\033[?1002l\033[?1003l\033[?1005 | |||
| 5996 | _zs_order=$( { grep -obaF -- "$_ZS_CLAIM" "$OUT.zscap" | sed 's/:.*/ C/' || true; \ | 6012 | _zs_order=$( { grep -obaF -- "$_ZS_CLAIM" "$OUT.zscap" | sed 's/:.*/ C/' || true; \ |
| 5997 | grep -obaF -- "$_ZS_REL" "$OUT.zscap" | sed 's/:.*/ R/' || true; } \ | 6013 | grep -obaF -- "$_ZS_REL" "$OUT.zscap" | sed 's/:.*/ R/' || true; } \ |
| 5998 | | sort -n | awk '{printf "%s", $2}' ) | 6014 | | sort -n | awk '{printf "%s", $2}' ) |
| 5999 | # CRCRCR: three handovers, each release before the next claim. The trailing | 6015 | # CCRCRCRCR, and every letter is accounted for. The WALL claims these modes |
| 6000 | # R the wall's own exit writes is not asserted here — the no-leaked-mode leg | 6016 | # for itself now — it reads mouse reports while unzoomed — so the run opens |
| 6001 | # owns that one. | 6017 | # with its claim (C) before Enter's promote claims on top (C). Then the |
| 6018 | # three handovers, each release before the next claim (RC RC), then the | ||
| 6019 | # unzoom's release followed by the wall taking its own modes back (RC), and | ||
| 6020 | # last the R of the wall's exit. | ||
| 6021 | # | ||
| 6022 | # The property under test is unchanged and is the adjacency: no C may sit | ||
| 6023 | # where an R belongs, because a claim landing before the outgoing release | ||
| 6024 | # is a session that spends its turn with the mouse off. | ||
| 6002 | case "$_zs_order" in | 6025 | case "$_zs_order" in |
| 6003 | CRCRCR*) ;; | 6026 | CCRCRCRCR*) ;; |
| 6004 | *) | 6027 | *) |
| 6005 | echo "e2e FAIL: zoom skip: the terminal handover was $_zs_order, not CRCRCR —" | 6028 | echo "e2e FAIL: zoom skip: the terminal handover was $_zs_order, not CCRCRCRCR —" |
| 6006 | echo " an incoming tile claimed the terminal before the outgoing one" | 6029 | echo " an incoming tile claimed the terminal before the outgoing one" |
| 6007 | echo " released it, so the release unset the modes the claim had just" | 6030 | echo " released it, so the release unset the modes the claim had just" |
| 6008 | echo " set and the zoomed session lost its mouse:" | 6031 | echo " set and the zoomed session lost its mouse:" |
| @@ -6197,11 +6220,13 @@ ok "a zoomed tile scrolls its session's history on the wheel, and the pty sees n | |||
| 6197 | grep -q "wallwheel" "$OUT.zwcapg" && { | 6220 | grep -q "wallwheel" "$OUT.zwcapg" && { |
| 6198 | echo "e2e FAIL: zoom wheel: the DEMOTED wall forwarded keystrokes to the session:" | 6221 | echo "e2e FAIL: zoom wheel: the DEMOTED wall forwarded keystrokes to the session:" |
| 6199 | cat "$OUT.zwcapg"; exit 1; } | 6222 | cat "$OUT.zwcapg"; exit 1; } |
| 6200 | # ...and the terminal was given back its mouse. `?1000l` is written exactly | 6223 | # ...and the SESSION's mouse was taken off it. `?1000l` is written exactly |
| 6201 | # twice on this run — once by the demote and once by the wall's own exit — | 6224 | # twice on this run — once by the demote and once by the wall's own exit — |
| 6202 | # and the promote writes only `h`s, so a count of one is a demote that left | 6225 | # and the promote writes only `h`s, so a count of one is a demote that left |
| 6203 | # the user's terminal reporting clicks into their shell for the rest of the | 6226 | # the session's modes on the user's terminal for the rest of the wall's |
| 6204 | # wall's life. Counted with `grep -o`: these are escape sequences inside a | 6227 | # life. What the demote then puts back is the WALL's own capture set, which |
| 6228 | # is the wall reading mouse reports on its own account and not a leak; the | ||
| 6229 | # exit's teardown is what has to leave the terminal clean. Counted with `grep -o`: these are escape sequences inside a | ||
| 6205 | # paint, with no newlines to make a line count mean anything. | 6230 | # paint, with no newlines to make a line count mean anything. |
| 6206 | _zw_off=$(grep -oa "$(printf '\033')\[?1000l" "$OUT.zwcap" | wc -l) | 6231 | _zw_off=$(grep -oa "$(printf '\033')\[?1000l" "$OUT.zwcap" | wc -l) |
| 6207 | [ "$_zw_off" -ge 2 ] || { | 6232 | [ "$_zw_off" -ge 2 ] || { |
| @@ -6210,7 +6235,7 @@ _zw_off=$(grep -oa "$(printf '\033')\[?1000l" "$OUT.zwcap" | wc -l) | |||
| 6210 | cat "$OUT.zwpc"; exit 1; } | 6235 | cat "$OUT.zwpc"; exit 1; } |
| 6211 | assert_stopped "$SOCK41" "$D38PID" "zoom wheel" "$OUT.zwstop" | 6236 | assert_stopped "$SOCK41" "$D38PID" "zoom wheel" "$OUT.zwstop" |
| 6212 | D38PID="" | 6237 | D38PID="" |
| 6213 | ok "a demoted tile forwards nothing and leaves no mouse mode on the wall's terminal" | 6238 | ok "a demoted tile forwards nothing and leaves no SESSION mouse mode on the wall's terminal" |
| 6214 | 6239 | ||
| 6215 | # ---- an application in a ZOOMED tile owns the mouse --------------------- | 6240 | # ---- an application in a ZOOMED tile owns the mouse --------------------- |
| 6216 | # | 6241 | # |
| @@ -6221,16 +6246,18 @@ ok "a demoted tile forwards nothing and leaves no mouse mode on the wall's termi | |||
| 6221 | # — and the promote is answered by `resyncSnapshot`, which carries no modes | 6246 | # — and the promote is answered by `resyncSnapshot`, which carries no modes |
| 6222 | # at all. So the handover cannot come from a frame: it has to come from what | 6247 | # at all. So the handover cannot come from a frame: it has to come from what |
| 6223 | # the Core already knew, level-set onto the terminal at the moment of the | 6248 | # the Core already knew, level-set onto the terminal at the moment of the |
| 6224 | # claim. Measured before that existed: `?1002h` never reached this capture | 6249 | # claim. Measured before that existed: the session's own mode never reached |
| 6225 | # and the wheel was eaten as scrollback. | 6250 | # this capture and the wheel was eaten as scrollback. |
| 6226 | # | 6251 | # |
| 6227 | # 1002 is what makes the leg sharp. The wall asks its terminal for NO mouse | 6252 | # 1003 is what makes the leg sharp, and it has to be 1003: the wall claims |
| 6228 | # mode, and a promoted tile's own capture set is 1000+1006, so a `?1002h` on | 6253 | # 1000+1002+1006 for itself and a promoted tile claims the same set, so |
| 6229 | # this terminal can only be the session's, mirrored. | 6254 | # either of those would appear on this terminal with no session involved. |
| 6255 | # 1003 is in nobody's own set, so a `?1003h` here can only be the session's, | ||
| 6256 | # mirrored. | ||
| 6230 | cat > "$ZMOUSESH" <<'EOF' | 6257 | cat > "$ZMOUSESH" <<'EOF' |
| 6231 | #!/bin/sh | 6258 | #!/bin/sh |
| 6232 | seq 1 100 | sed 's/^/mln/' | 6259 | seq 1 100 | sed 's/^/mln/' |
| 6233 | printf '\033[?1000h\033[?1002h\033[?1006h' | 6260 | printf '\033[?1000h\033[?1002h\033[?1003h\033[?1006h' |
| 6234 | printf 'mapp-holds-the-mouse\n' | 6261 | printf 'mapp-holds-the-mouse\n' |
| 6235 | exec /bin/cat | 6262 | exec /bin/cat |
| 6236 | EOF | 6263 | EOF |
| @@ -6273,9 +6300,9 @@ grep -qF -- "[<64;10;5M" "$OUT.zm2capg" || { | |||
| 6273 | echo "e2e FAIL: zoom app mouse: the wheel never reached the application's pty:" | 6300 | echo "e2e FAIL: zoom app mouse: the wheel never reached the application's pty:" |
| 6274 | cat "$OUT.zm2capg"; exit 1; } | 6301 | cat "$OUT.zm2capg"; exit 1; } |
| 6275 | # The mirror: this terminal was asked for the SESSION's modes at the | 6302 | # The mirror: this terminal was asked for the SESSION's modes at the |
| 6276 | # promote, not for the tile's own set. | 6303 | # promote, not for the tile's own set — and 1003 is outside that set. |
| 6277 | grep -qaF "$(printf '\033')[?1002h" "$OUT.zm2cap" || { | 6304 | grep -qaF "$(printf '\033')[?1003h" "$OUT.zm2cap" || { |
| 6278 | echo "e2e FAIL: zoom app mouse: the promote never mirrored the session's drag mode:" | 6305 | echo "e2e FAIL: zoom app mouse: the promote never mirrored the session's any-motion mode:" |
| 6279 | cat "$OUT.zm2pc"; exit 1; } | 6306 | cat "$OUT.zm2pc"; exit 1; } |
| 6280 | # And the negative that makes the pair a pair: this session has the same 77 | 6307 | # And the negative that makes the pair a pair: this session has the same 77 |
| 6281 | # rows of history as the leg above, and the same wheel byte moved none of it. | 6308 | # rows of history as the leg above, and the same wheel byte moved none of it. |
| @@ -7510,6 +7537,105 @@ kill "$AGENT49BPID" 2>/dev/null || true | |||
| 7510 | AGENT49BPID="" | 7537 | AGENT49BPID="" |
| 7511 | ok "agent forwarding: the agent that answers is whoever typed last" | 7538 | ok "agent forwarding: the agent that answers is whoever typed last" |
| 7512 | 7539 | ||
| 7540 | # ---- a mouse report at the UNZOOMED wall is discarded, not typed -------- | ||
| 7541 | # | ||
| 7542 | # The wall asks its own terminal for mouse reporting now, so reports reach | ||
| 7543 | # the unzoomed key loop where nothing used to be able to. That loop reads | ||
| 7544 | # every byte as a wall key, and an SGR report is made of characters it acts | ||
| 7545 | # on: `\x1b[<0;2;1M` carries a `2`, and a digit jumps the selection to that | ||
| 7546 | # tile. Left unfiltered, one click or one wheel spin over a stripe walks the | ||
| 7547 | # selection somewhere the user did not put it, and the next Enter zooms the | ||
| 7548 | # wrong session. | ||
| 7549 | # | ||
| 7550 | # Which tile the selection is ON is not read off the label bar here — that | ||
| 7551 | # would be asserting against a paint. It is read off the DAEMON: Enter zooms | ||
| 7552 | # whatever is selected, so a marker typed afterwards lands in the selected | ||
| 7553 | # session and `muxa capture` says which one that was. The wall painting is | ||
| 7554 | # not in the loop at all. | ||
| 7555 | # | ||
| 7556 | # Two runs, because a negative that cannot fail is not a test. The first | ||
| 7557 | # sends the reports and the second sends a bare `2` in their place; if `2` | ||
| 7558 | # did not move the selection, the pair would pass having proved nothing | ||
| 7559 | # about the filter. | ||
| 7560 | "$MUXD" run --sock "$SOCK50" --shell /bin/sh > "$OUT.wmse.d" 2>&1 & | ||
| 7561 | D48PID=$! | ||
| 7562 | wait_sock "$SOCK50" "$OUT.wmse.d" "wall-mouse daemon never bound" | ||
| 7563 | |||
| 7564 | { printf 'printf "wma-%%s\\n" pin\n'; sleep 2; printf '\034\034'; } | \ | ||
| 7565 | timeout 40 "$MUX" --sock "$SOCK50" --session a > "$OUT.wmsa" 2> "$OUT.wmsa.err" | ||
| 7566 | wait_grid "$SOCK50" "wma-pin" "wall mouse: session a's marker" a | ||
| 7567 | { printf 'printf "wmb-%%s\\n" pin\n'; sleep 2; printf '\034\034'; } | \ | ||
| 7568 | timeout 40 "$MUX" --sock "$SOCK50" --session b > "$OUT.wmsb" 2> "$OUT.wmsb.err" | ||
| 7569 | wait_grid "$SOCK50" "wmb-pin" "wall mouse: session b's marker" b | ||
| 7570 | |||
| 7571 | # A press, a drag and a release at column 3, then a wheel notch — every | ||
| 7572 | # shape the filter has to swallow. The wall opens with tile 1 (session a) | ||
| 7573 | # selected, and each report ENDS on a `2`: that is not decoration. The key | ||
| 7574 | # loop acts on every digit as it passes, so a report ending on `1` would | ||
| 7575 | # jump to b and back to a and leave the selection where it started — | ||
| 7576 | # passing this leg with the filter deleted. Measured, not reasoned about: | ||
| 7577 | # with `\x1b[<0;2;1M` this leg passed against a loop that read the reports | ||
| 7578 | # as keys. Row 2 is the only parameter free to carry that `2`, since | ||
| 7579 | # column 3 and the button words have to spell something else. | ||
| 7580 | set +e | ||
| 7581 | timeout 60 "$PTYCLIENT" --cols 100 --rows 30 --out "$OUT.wmcap" --err "$OUT.wmcap.err" -- \ | ||
| 7582 | "$MUX" wall "--sock $SOCK50#a" "--sock $SOCK50#b" > "$OUT.wmpc" 2>&1 <<'EOF' | ||
| 7583 | expect wmb-pin 20000 | ||
| 7584 | settle 700 20000 | ||
| 7585 | send \x1b[<0;3;2M\x1b[<32;3;2M\x1b[<0;3;2m\x1b[<64;3;2M | ||
| 7586 | settle 700 20000 | ||
| 7587 | send \r | ||
| 7588 | settle 700 20000 | ||
| 7589 | send printf 'wm-%s\\n' one\n | ||
| 7590 | expect wm-one 15000 | ||
| 7591 | settle 400 15000 | ||
| 7592 | send \x1cd | ||
| 7593 | waitexit 10000 | ||
| 7594 | EOF | ||
| 7595 | RC=$? | ||
| 7596 | set -e | ||
| 7597 | [ "$RC" -eq 0 ] || { | ||
| 7598 | echo "e2e FAIL: wall mouse: ptyclient leg exited $RC (did the wall survive a mouse report?):" | ||
| 7599 | cat "$OUT.wmpc"; exit 1; } | ||
| 7600 | timeout 20 "$MUXA" capture --sock "$SOCK50" --session a > "$OUT.wmfa" 2>&1 | ||
| 7601 | timeout 20 "$MUXA" capture --sock "$SOCK50" --session b > "$OUT.wmfb" 2>&1 | ||
| 7602 | grep -q "wm-one" "$OUT.wmfa" || { | ||
| 7603 | echo "e2e FAIL: wall mouse: the selection left session a, so a report was read as keys:" | ||
| 7604 | cat "$OUT.wmfa"; exit 1; } | ||
| 7605 | grep -q "wm-one" "$OUT.wmfb" && { | ||
| 7606 | echo "e2e FAIL: wall mouse: a mouse report jumped the selection to session b:" | ||
| 7607 | cat "$OUT.wmfb"; exit 1; } | ||
| 7608 | |||
| 7609 | # The control. Same wall, same Enter, and a `2` where the reports were: this | ||
| 7610 | # one MUST land in b, or the negative above was a digit that never worked. | ||
| 7611 | set +e | ||
| 7612 | timeout 60 "$PTYCLIENT" --cols 100 --rows 30 --out "$OUT.wmcap2" --err "$OUT.wmcap2.err" -- \ | ||
| 7613 | "$MUX" wall "--sock $SOCK50#a" "--sock $SOCK50#b" > "$OUT.wmpc2" 2>&1 <<'EOF' | ||
| 7614 | expect wmb-pin 20000 | ||
| 7615 | settle 700 20000 | ||
| 7616 | send 2 | ||
| 7617 | settle 700 20000 | ||
| 7618 | send \r | ||
| 7619 | settle 700 20000 | ||
| 7620 | send printf 'wm-%s\\n' two\n | ||
| 7621 | expect wm-two 15000 | ||
| 7622 | settle 400 15000 | ||
| 7623 | send \x1cd | ||
| 7624 | waitexit 10000 | ||
| 7625 | EOF | ||
| 7626 | RC=$? | ||
| 7627 | set -e | ||
| 7628 | [ "$RC" -eq 0 ] || { | ||
| 7629 | echo "e2e FAIL: wall mouse: control leg exited $RC (did a bare 2 move the selection?):" | ||
| 7630 | cat "$OUT.wmpc2"; exit 1; } | ||
| 7631 | timeout 20 "$MUXA" capture --sock "$SOCK50" --session b > "$OUT.wmfb" 2>&1 | ||
| 7632 | grep -q "wm-two" "$OUT.wmfb" || { | ||
| 7633 | echo "e2e FAIL: wall mouse: a bare 2 did not move the selection, so the leg above proves nothing:" | ||
| 7634 | cat "$OUT.wmfb"; exit 1; } | ||
| 7635 | assert_stopped "$SOCK50" "$D48PID" "wall mouse" "$OUT.wmstop" | ||
| 7636 | D48PID="" | ||
| 7637 | ok "a mouse report at the unzoomed wall is swallowed; the same digit typed alone still jumps" | ||
| 7638 | |||
| 7513 | 7639 | ||
| 7514 | # The long-lived daemon has served every scenario that wanted it; stop it | 7640 | # The long-lived daemon has served every scenario that wanted it; stop it |
| 7515 | # NOW so its allocator verdict is written while the suite is still running | 7641 | # NOW so its allocator verdict is written while the suite is still running |
| @@ -7618,9 +7744,16 @@ DPID="" | |||
| 7618 | # since both replicate the same session and hold both answers alike. The | 7744 | # since both replicate the same session and hold both answers alike. The |
| 7619 | # 59th is the `-A` preflight, and no convergence point because it never | 7745 | # 59th is the `-A` preflight, and no convergence point because it never |
| 7620 | # attaches: what it asserts on is an exit code and a message, before any | 7746 | # attaches: what it asserts on is an exit code and a message, before any |
| 7621 | # transport exists to converge. | 7747 | # transport exists to converge. The 61st is the mouse report at the |
| 7622 | [ "$OK_COUNT" = "60" ] || { | 7748 | # unzoomed wall, and no convergence point because its subject is WHICH |
| 7623 | echo "e2e FAIL: $OK_COUNT scenario checkpoints ran, the pin says 60 —" | 7749 | # SESSION a keystroke reached — read off two daemon captures, one of which |
| 7750 | # must not contain it — and a client grid compared against the daemon's | ||
| 7751 | # says nothing about where the wall decided to send input. It is last in | ||
| 7752 | # the file rather than beside the other wall legs on purpose: the ordinals | ||
| 7753 | # in this paragraph are positions, so a scenario inserted in the middle | ||
| 7754 | # renumbers every sentence after it. | ||
| 7755 | [ "$OK_COUNT" = "61" ] || { | ||
| 7756 | echo "e2e FAIL: $OK_COUNT scenario checkpoints ran, the pin says 61 —" | ||
| 7624 | echo " a scenario was added (update the pin) or silently lost" | 7757 | echo " a scenario was added (update the pin) or silently lost" |
| 7625 | exit 1 | 7758 | exit 1 |
| 7626 | } | 7759 | } |
| @@ -7628,4 +7761,4 @@ DPID="" | |||
| 7628 | echo "e2e FAIL: $CONV_COUNT convergence points ran, the pin says 35" | 7761 | echo "e2e FAIL: $CONV_COUNT convergence points ran, the pin says 35" |
| 7629 | exit 1 | 7762 | exit 1 |
| 7630 | } | 7763 | } |
| 7631 | echo "e2e OK (60 scenarios, 35 convergence points)" | 7764 | echo "e2e OK (61 scenarios, 35 convergence points)" |