e6fff02f
fix: a tile's application reads mouse reports in its own coordinates
a73x 2026-09-03 05:20
Commit message
docs/decisions.md
| Old | New | ||
|---|---|---|---|
| @@ -8072,3 +8072,59 @@ leg that a pty run under a fresh state dir leaves no layout behind. | |||
| 8072 | convergence points, up from what the old model needed, and the leg that | 8072 | convergence points, up from what the old model needed, and the leg that |
| 8073 | states the whole change in one sentence is "two walls on the same daemons | 8073 | states the whole change in one sentence is "two walls on the same daemons |
| 8074 | are two layouts; neither learns of the other's panes" in the hosts group. | 8074 | are two layouts; neither learns of the other's panes" in the hosts group. |
| 8075 | |||
| 8076 | ## 2026-09-03 — a tile's application reads mouse reports in its own coordinates | ||
| 8077 | |||
| 8078 | **The escape.** A tile whose application had asked for the mouse got every | ||
| 8079 | SGR report exactly as the terminal wrote it, in the terminal's numbering. | ||
| 8080 | `interact.Core.forward` gated on `appMouse()` and passed the bytes through, | ||
| 8081 | and every pane that did not start at the screen's origin sent its | ||
| 8082 | application rows and columns it did not have: on a live wall (foot, | ||
| 8083 | 269x76, four tiles) a press at terminal row 60 reached an nvim of 37 rows | ||
| 8084 | and a fullscreen Claude Code the same way, and neither could select at | ||
| 8085 | all. Found by `strace -f -e trace=read,write` on the wall — the raw | ||
| 8086 | `\e[<0;90;60M` read from fd 0 was the byte string written to the tile's | ||
| 8087 | daemon socket. The same wall's remote tiles selected fine, which is what | ||
| 8088 | made it look like a version split: that daemon held no mouse bits for its | ||
| 8089 | sessions, so mux's own selection ran there, and the alt-screen "box stays | ||
| 8090 | put" behaviour was the tell. | ||
| 8091 | |||
| 8092 | **Why every gate was green.** Each app-mouse leg was a wall of ONE tile — | ||
| 8093 | `no_saved_tree`, a single `--sock` — and every unit test of the forward | ||
| 8094 | path was a pipe client or an origin Core. The mux-selection tests all sit | ||
| 8095 | at `drag_row_off`/`drag_col_off`, so the hit-test had the offset right | ||
| 8096 | from the start; only the hand-over path never met an offset. The working | ||
| 8097 | rule that N=1 and offset=0 are extra cases, never the baseline, was | ||
| 8098 | followed on one side of the `appMouse()` branch and not the other. | ||
| 8099 | |||
| 8100 | **The rule now.** `interact.relocateReports` is the one place a forwarded | ||
| 8101 | report is spelled: the Core runs the same `MouseFilter.feed` on both sides | ||
| 8102 | of the branch, and on the application's side re-emits each report with | ||
| 8103 | `row_off`/`col_off` taken off, in the read's original order among the | ||
| 8104 | keys. A wheel report became an event of its own kind (`Event.Kind.wheel`) | ||
| 8105 | so the filter could hand it back for re-spelling; `dragReports` ignores it, | ||
| 8106 | which is where the old "a wheel is never an event" guarantee lives now. | ||
| 8107 | Two choices in it: | ||
| 8108 | |||
| 8109 | - A coordinate outside the pane CLAMPS to the pane's edge rather than | ||
| 8110 | dropping the report. A real terminal reports a drag that left its window | ||
| 8111 | at the edge it left by, and dropping would leave the application holding | ||
| 8112 | a button the hand released over a neighbour. The edge is what the pane | ||
| 8113 | shows — the smaller of the grid and the clip — like `hitTest`. | ||
| 8114 | - Pixel reports (1016) pass untouched. A cell origin cannot come off a | ||
| 8115 | pixel, and a wrong translation is worse than none. Translating them | ||
| 8116 | needs the terminal's cell size, which the wall does not ask for; open. | ||
| 8117 | |||
| 8118 | The filter's hold now stays armed across the hand-over from selection to | ||
| 8119 | application, so a report split across two reads is whole for whichever | ||
| 8120 | side reads it; the reset survives only for the pipe client, whose reads | ||
| 8121 | hold no reports at all. | ||
| 8122 | |||
| 8123 | **What moved.** A wall on a terminal sits under its label bar, so the two | ||
| 8124 | one-tile app-mouse legs now expect row 4 for a report on terminal row 5 — | ||
| 8125 | they were pinning the bug. The new leg stacks two tiles and presses in the | ||
| 8126 | lower one: terminal row 16 reaches the application as row 3, and `mux a | ||
| 8127 | status` says the stripe is 11 rows so the arithmetic is the layout's, not | ||
| 8128 | the comment's. Graded by mutation: with the row translation capped at one | ||
| 8129 | row every older leg passes and only the stacked leg fails. The suite's | ||
| 8130 | scenario pin goes from 111 to 112. | ||
src/tui/interact.zig
| Old | New | ||
|---|---|---|---|
| @@ -450,7 +450,10 @@ pub const MouseFilter = struct { | |||
| 450 | /// `button` is the SGR word verbatim: what a click MEANS differs between | 450 | /// `button` is the SGR word verbatim: what a click MEANS differs between |
| 451 | /// the wall's keyboard and a focused tile, so decoding is the driver's. | 451 | /// the wall's keyboard and a focused tile, so decoding is the driver's. |
| 452 | pub const Event = struct { | 452 | pub const Event = struct { |
| 453 | pub const Kind = enum { press, motion, release }; | 453 | /// `wheel` is a report a driver re-emits and never selects on: the |
| 454 | /// notch it means is already counted in `Out.wheel`, and a | ||
| 455 | /// selection that scrolled as it grew would be one nobody made. | ||
| 456 | pub const Kind = enum { press, motion, release, wheel }; | ||
| 454 | 457 | ||
| 455 | kind: Kind, | 458 | kind: Kind, |
| 456 | button: u16, | 459 | button: u16, |
| @@ -543,14 +546,14 @@ pub const MouseFilter = struct { | |||
| 543 | return .{ .forward = out[0..kept], .wheel = wheel, .events = self.events[0..evs] }; | 546 | return .{ .forward = out[0..kept], .wheel = wheel, .events = self.events[0..evs] }; |
| 544 | } | 547 | } |
| 545 | 548 | ||
| 546 | /// The event one complete SGR report means, or null for one that means | 549 | /// The event one complete SGR report means, or null for one this |
| 547 | /// nothing to a driver: a wheel report (already spent as a notch by | 550 | /// terminal malformed. A wheel report comes back as `.wheel`, its notch |
| 548 | /// `wheelNotches` — a driver seeing it here too would scroll and | 551 | /// already spent by `wheelNotches`: a driver that scrolls on the notch |
| 549 | /// select at once) or a report this terminal malformed. | 552 | /// must not also select on the event, and a driver that hands the |
| 553 | /// mouse to an application must still pass the wheel along. | ||
| 550 | fn decodeEvent(seq: []const u8) ?Event { | 554 | fn decodeEvent(seq: []const u8) ?Event { |
| 551 | var it = std.mem.splitScalar(u8, seq[3 .. seq.len - 1], ';'); | 555 | var it = std.mem.splitScalar(u8, seq[3 .. seq.len - 1], ';'); |
| 552 | const button = std.fmt.parseInt(u16, it.first(), 10) catch return null; | 556 | const button = std.fmt.parseInt(u16, it.first(), 10) catch return null; |
| 553 | if (button & 0x40 != 0) return null; | ||
| 554 | const col = std.fmt.parseInt(u16, it.next() orelse return null, 10) catch return null; | 557 | const col = std.fmt.parseInt(u16, it.next() orelse return null, 10) catch return null; |
| 555 | const row = std.fmt.parseInt(u16, it.next() orelse return null, 10) catch return null; | 558 | const row = std.fmt.parseInt(u16, it.next() orelse return null, 10) catch return null; |
| 556 | // One-based on the wire. A zero is a terminal talking nonsense, and | 559 | // One-based on the wire. A zero is a terminal talking nonsense, and |
| @@ -562,6 +565,8 @@ pub const MouseFilter = struct { | |||
| 562 | // bits claim; only a press can also be a drag (bit 5). | 565 | // bits claim; only a press can also be a drag (bit 5). |
| 563 | .kind = if (seq[seq.len - 1] == 'm') | 566 | .kind = if (seq[seq.len - 1] == 'm') |
| 564 | .release | 567 | .release |
| 568 | else if (button & 0x40 != 0) | ||
| 569 | .wheel | ||
| 565 | else if (button & 0x20 != 0) | 570 | else if (button & 0x20 != 0) |
| 566 | .motion | 571 | .motion |
| 567 | else | 572 | else |
| @@ -1224,6 +1229,11 @@ pub const Core = struct { | |||
| 1224 | /// The mouse filter's scratch — sized to hold one read of stdin plus | 1229 | /// The mouse filter's scratch — sized to hold one read of stdin plus |
| 1225 | /// whatever a previous read left mid-report (see MouseFilter.feed). | 1230 | /// whatever a previous read left mid-report (see MouseFilter.feed). |
| 1226 | mouse_buf: [stdin_chunk + MouseFilter.max_held]u8 = undefined, | 1231 | mouse_buf: [stdin_chunk + MouseFilter.max_held]u8 = undefined, |
| 1232 | /// The read rebuilt with its reports in the pane's coordinates, for | ||
| 1233 | /// the application that owns the mouse. Sized like `mouse_buf`: a | ||
| 1234 | /// relocated report is never longer than the one it replaces, since | ||
| 1235 | /// an origin only comes off a coordinate and a clamp only lowers it. | ||
| 1236 | mouse_out: [stdin_chunk + MouseFilter.max_held]u8 = undefined, | ||
| 1227 | 1237 | ||
| 1228 | /// Born at a size the driver measured: the Engine has to be born at | 1238 | /// Born at a size the driver measured: the Engine has to be born at |
| 1229 | /// the size the first paint clips to. | 1239 | /// the size the first paint clips to. |
| @@ -1686,13 +1696,31 @@ pub const Core = struct { | |||
| 1686 | // repaint below is spent only on a highlight that actually moved. | 1696 | // repaint below is spent only on a highlight that actually moved. |
| 1687 | // A drag reports on every cell the pointer crosses. | 1697 | // A drag reports on every cell the pointer crosses. |
| 1688 | const was = self.drag.range(); | 1698 | const was = self.drag.range(); |
| 1689 | if (self.claim == .none or self.semantic.terminal_modes.appMouse()) { | 1699 | if (self.claim == .none) { |
| 1690 | self.mouse.reset(); | 1700 | self.mouse.reset(); |
| 1701 | self.drag.clear(); | ||
| 1702 | } else if (self.semantic.terminal_modes.appMouse()) { | ||
| 1691 | // The application asked for the mouse, so it gets the drag and | 1703 | // The application asked for the mouse, so it gets the drag and |
| 1692 | // there is no mux selection; Shift+drag stays the terminal's own | 1704 | // there is no mux selection; Shift+drag stays the terminal's own |
| 1693 | // escape hatch. CLEARED, not ignored: a selection made before the | 1705 | // escape hatch. CLEARED, not ignored: a selection made before the |
| 1694 | // ask would sit inverted on a screen that is no longer its own. | 1706 | // ask would sit inverted on a screen that is no longer its own. |
| 1695 | self.drag.clear(); | 1707 | self.drag.clear(); |
| 1708 | // What it gets is the report in ITS coordinates. The terminal | ||
| 1709 | // numbers the whole screen, and a tile that does not start at | ||
| 1710 | // the screen's origin is a pane whose application has no row 60 | ||
| 1711 | // in its 37: passed through as read, every press in such a tile | ||
| 1712 | // landed outside the application's grid and did nothing (found | ||
| 1713 | // on a live wall, 2026-09-02). Pixel reports are left alone — | ||
| 1714 | // a cell origin cannot come off a pixel — and so are the X10, | ||
| 1715 | // UTF-8 and urxvt spellings, which the filter does not read; an | ||
| 1716 | // application that asks for the mouse without 1006 still gets | ||
| 1717 | // the screen's numbering. The filter's hold stays armed across | ||
| 1718 | // the handover from selection, so a report split across two | ||
| 1719 | // reads is whole for whichever side reads it. | ||
| 1720 | if (!self.semantic.terminal_modes.mouse_sgr_pixels) { | ||
| 1721 | const m = self.mouse.feed(typed, &self.mouse_buf); | ||
| 1722 | keys = self.relocateReports(m, &self.mouse_out); | ||
| 1723 | } | ||
| 1696 | } else { | 1724 | } else { |
| 1697 | const m = self.mouse.feed(typed, &self.mouse_buf); | 1725 | const m = self.mouse.feed(typed, &self.mouse_buf); |
| 1698 | keys = m.forward; | 1726 | keys = m.forward; |
| @@ -1801,6 +1829,7 @@ pub const Core = struct { | |||
| 1801 | .selection => |r| done = r, | 1829 | .selection => |r| done = r, |
| 1802 | .nothing, .click => {}, | 1830 | .nothing, .click => {}, |
| 1803 | }, | 1831 | }, |
| 1832 | .wheel => {}, | ||
| 1804 | } | 1833 | } |
| 1805 | } | 1834 | } |
| 1806 | return done; | 1835 | return done; |
| @@ -1853,6 +1882,37 @@ pub const Core = struct { | |||
| 1853 | return .{ .text = reply.text }; | 1882 | return .{ .text = reply.text }; |
| 1854 | } | 1883 | } |
| 1855 | 1884 | ||
| 1885 | /// The read rebuilt for an application that owns the mouse: the keys as | ||
| 1886 | /// they came, and each report re-spelled with the pane's origin taken | ||
| 1887 | /// off its coordinates. A coordinate outside the pane CLAMPS to its | ||
| 1888 | /// edge rather than dropping the report, as a terminal reports a drag | ||
| 1889 | /// that left its window — dropping would leave the application holding | ||
| 1890 | /// a button the hand released over a neighbour. The edge is what the | ||
| 1891 | /// pane SHOWS, the smaller of the grid and the clip, like `hitTest`. | ||
| 1892 | fn relocateReports(self: *Core, m: MouseFilter.Out, out: []u8) []const u8 { | ||
| 1893 | const rows = @min(@as(u16, @intCast(self.rep.eng.term.rows)), self.size.rows); | ||
| 1894 | const cols = @min(@as(u16, @intCast(self.rep.eng.term.cols)), self.size.cols); | ||
| 1895 | var len: usize = 0; | ||
| 1896 | var from: usize = 0; | ||
| 1897 | for (m.events) |ev| { | ||
| 1898 | @memcpy(out[len..][0 .. ev.at - from], m.forward[from..ev.at]); | ||
| 1899 | len += ev.at - from; | ||
| 1900 | from = ev.at; | ||
| 1901 | const col = @min(ev.col -| self.col_off, cols -| 1); | ||
| 1902 | const row = @min(ev.row -| self.row_off, rows -| 1); | ||
| 1903 | const spelt = std.fmt.bufPrint(out[len..], "\x1b[<{d};{d};{d}{c}", .{ | ||
| 1904 | ev.button, | ||
| 1905 | col + 1, | ||
| 1906 | row + 1, | ||
| 1907 | @as(u8, if (ev.kind == .release) 'm' else 'M'), | ||
| 1908 | }) catch unreachable; | ||
| 1909 | len += spelt.len; | ||
| 1910 | } | ||
| 1911 | @memcpy(out[len..][0 .. m.forward.len - from], m.forward[from..]); | ||
| 1912 | len += m.forward.len - from; | ||
| 1913 | return out[0..len]; | ||
| 1914 | } | ||
| 1915 | |||
| 1856 | /// Which line of this session a report landed on, or null. The mapping is | 1916 | /// Which line of this session a report landed on, or null. The mapping is |
| 1857 | /// trivial, which is why selection lives in the Core: a terminal cell is a | 1917 | /// trivial, which is why selection lives in the Core: a terminal cell is a |
| 1858 | /// grid cell less the tile's origin, plus the history under it. | 1918 | /// grid cell less the tile's origin, plus the history under it. |
| @@ -2615,24 +2675,50 @@ test "interact: a button press becomes an event, still never reaching the pty" { | |||
| 2615 | try std.testing.expectEqual(@as(u16, 11), r.events[0].row); | 2675 | try std.testing.expectEqual(@as(u16, 11), r.events[0].row); |
| 2616 | } | 2676 | } |
| 2617 | 2677 | ||
| 2618 | test "interact: a wheel report stays a notch and never becomes an event" { | 2678 | test "interact: a wheel report is a notch and a wheel event, never a press" { |
| 2619 | var f: MouseFilter = .{}; | 2679 | var f: MouseFilter = .{}; |
| 2620 | var out: [64]u8 = undefined; | 2680 | var out: [64]u8 = undefined; |
| 2621 | 2681 | ||
| 2622 | // Button 64 is wheel-up. It has always been a notch; what it must not | 2682 | // Button 64 is wheel-up. It has always been a notch; what it must not |
| 2623 | // ALSO be is an event, or a driver would scroll the view and move a | 2683 | // ALSO be is a press, or a driver would scroll the view and move a |
| 2624 | // selection endpoint on the same turn of the wheel. | 2684 | // selection endpoint on the same turn of the wheel. It is an event of |
| 2685 | // its own kind so the driver that hands the mouse to an application | ||
| 2686 | // can re-spell it in the pane's coordinates like any other report. | ||
| 2625 | const up = f.feed("\x1b[<64;1;1M", &out); | 2687 | const up = f.feed("\x1b[<64;1;1M", &out); |
| 2626 | try std.testing.expectEqual(@as(i32, 1), up.wheel); | 2688 | try std.testing.expectEqual(@as(i32, 1), up.wheel); |
| 2627 | try std.testing.expectEqual(@as(usize, 0), up.events.len); | 2689 | try std.testing.expectEqual(@as(usize, 1), up.events.len); |
| 2690 | try std.testing.expectEqual(MouseFilter.Event.Kind.wheel, up.events[0].kind); | ||
| 2628 | 2691 | ||
| 2629 | // 65 is wheel-down, 66/67 the horizontal pair this client does not | 2692 | // 65 is wheel-down, 66/67 the horizontal pair this client does not |
| 2630 | // handle — all wheel, all silent here. | 2693 | // handle — all wheel, none of them a press or a motion. |
| 2631 | for ([_][]const u8{ "\x1b[<65;1;1M", "\x1b[<66;1;1M", "\x1b[<67;1;1M" }) |seq| { | 2694 | for ([_][]const u8{ "\x1b[<65;1;1M", "\x1b[<66;1;1M", "\x1b[<67;1;1M" }) |seq| { |
| 2632 | try std.testing.expectEqual(@as(usize, 0), f.feed(seq, &out).events.len); | 2695 | const r = f.feed(seq, &out); |
| 2696 | try std.testing.expectEqual(@as(usize, 1), r.events.len); | ||
| 2697 | try std.testing.expectEqual(MouseFilter.Event.Kind.wheel, r.events[0].kind); | ||
| 2633 | } | 2698 | } |
| 2634 | } | 2699 | } |
| 2635 | 2700 | ||
| 2701 | test "interact: a wheel turned mid-drag moves no endpoint of the selection" { | ||
| 2702 | const alloc = std.testing.allocator; | ||
| 2703 | const p = try std.posix.pipe2(.{ .NONBLOCK = true }); | ||
| 2704 | defer std.posix.close(p[0]); | ||
| 2705 | defer std.posix.close(p[1]); | ||
| 2706 | var core = try dragFixture(alloc, p[1]); | ||
| 2707 | defer core.deinit(); | ||
| 2708 | var tr: NullTransport = .{}; | ||
| 2709 | var buf: [8192]u8 = undefined; | ||
| 2710 | _ = drainPipe(p[0], &buf); | ||
| 2711 | |||
| 2712 | // The wheel is an event now, so this is where the old filter-level | ||
| 2713 | // silence has to be said again: the drag's far end is where the hand | ||
| 2714 | // last moved, not where the wheel turned. | ||
| 2715 | try mouse(&core, &tr, 0, 3, 2, 'M'); | ||
| 2716 | try mouse(&core, &tr, 32, 7, 2, 'M'); | ||
| 2717 | try mouse(&core, &tr, 64, 12, 4, 'M'); | ||
| 2718 | try std.testing.expectEqual(@as(u16, 6), core.drag.range().?.to.col); | ||
| 2719 | try std.testing.expectEqual(@as(u32, 1), core.drag.range().?.to.row); | ||
| 2720 | } | ||
| 2721 | |||
| 2636 | test "interact: one drag is press, motion and release, in the order typed" { | 2722 | test "interact: one drag is press, motion and release, in the order typed" { |
| 2637 | var f: MouseFilter = .{}; | 2723 | var f: MouseFilter = .{}; |
| 2638 | var out: [64]u8 = undefined; | 2724 | var out: [64]u8 = undefined; |
| @@ -3721,6 +3807,101 @@ test "interact: the application that asked for the mouse gets the drag, and mux | |||
| 3721 | try std.testing.expectError(error.WouldBlock, std.posix.read(p[0], &buf)); | 3807 | try std.testing.expectError(error.WouldBlock, std.posix.read(p[0], &buf)); |
| 3722 | } | 3808 | } |
| 3723 | 3809 | ||
| 3810 | /// A transport that keeps the keystroke frames — what `forward` sends the | ||
| 3811 | /// SESSION — for the tests whose subject is the bytes an application reads. | ||
| 3812 | const InputTransport = struct { | ||
| 3813 | buf: [256]u8 = undefined, | ||
| 3814 | len: usize = 0, | ||
| 3815 | |||
| 3816 | fn writeFrame(self: *InputTransport, ty: proto.MsgType, payload: []const u8) !void { | ||
| 3817 | if (ty != .input) return; | ||
| 3818 | @memcpy(self.buf[self.len..][0..payload.len], payload); | ||
| 3819 | self.len += payload.len; | ||
| 3820 | } | ||
| 3821 | |||
| 3822 | fn take(self: *InputTransport) []const u8 { | ||
| 3823 | defer self.len = 0; | ||
| 3824 | return self.buf[0..self.len]; | ||
| 3825 | } | ||
| 3826 | }; | ||
| 3827 | |||
| 3828 | /// The fixture with its application holding the mouse, which is the case | ||
| 3829 | /// where the report goes to the session instead of into a selection. | ||
| 3830 | fn appMouseFixture(alloc: std.mem.Allocator, out_fd: std.posix.fd_t) !Core { | ||
| 3831 | var core = try dragFixture(alloc, out_fd); | ||
| 3832 | core.semantic.terminal_modes = .{ | ||
| 3833 | .bracketed_paste = false, | ||
| 3834 | .mouse_normal = true, | ||
| 3835 | .mouse_button = true, | ||
| 3836 | .mouse_sgr = true, | ||
| 3837 | }; | ||
| 3838 | return core; | ||
| 3839 | } | ||
| 3840 | |||
| 3841 | test "interact: the application's report is in the pane's coordinates, not the screen's" { | ||
| 3842 | const alloc = std.testing.allocator; | ||
| 3843 | const p = try std.posix.pipe2(.{ .NONBLOCK = true }); | ||
| 3844 | defer std.posix.close(p[0]); | ||
| 3845 | defer std.posix.close(p[1]); | ||
| 3846 | var core = try appMouseFixture(alloc, p[1]); | ||
| 3847 | defer core.deinit(); | ||
| 3848 | var tr: InputTransport = .{}; | ||
| 3849 | |||
| 3850 | // The terminal reports where the hand went on ITS screen. The | ||
| 3851 | // application has a 20x6 grid that starts four rows down and twelve | ||
| 3852 | // columns in, so a report it can act on names the same cell in the | ||
| 3853 | // grid's own numbering — press, drag, release and wheel alike. | ||
| 3854 | try mouse(&core, &tr, 0, 3, 2, 'M'); | ||
| 3855 | try std.testing.expectEqualStrings("\x1b[<0;3;2M", tr.take()); | ||
| 3856 | try mouse(&core, &tr, 32, 4, 2, 'M'); | ||
| 3857 | try std.testing.expectEqualStrings("\x1b[<32;4;2M", tr.take()); | ||
| 3858 | try mouse(&core, &tr, 0, 4, 2, 'm'); | ||
| 3859 | try std.testing.expectEqualStrings("\x1b[<0;4;2m", tr.take()); | ||
| 3860 | try mouse(&core, &tr, 64, 3, 2, 'M'); | ||
| 3861 | try std.testing.expectEqualStrings("\x1b[<64;3;2M", tr.take()); | ||
| 3862 | |||
| 3863 | // Keys sharing the read with a report keep their order around it. | ||
| 3864 | _ = try core.forward(&tr, "a\x1b[<0;15;6Mb"); | ||
| 3865 | try std.testing.expectEqualStrings("a\x1b[<0;3;2Mb", tr.take()); | ||
| 3866 | } | ||
| 3867 | |||
| 3868 | test "interact: a report past the pane's edge clamps to the edge, as a terminal clamps at its own" { | ||
| 3869 | const alloc = std.testing.allocator; | ||
| 3870 | const p = try std.posix.pipe2(.{ .NONBLOCK = true }); | ||
| 3871 | defer std.posix.close(p[0]); | ||
| 3872 | defer std.posix.close(p[1]); | ||
| 3873 | var core = try appMouseFixture(alloc, p[1]); | ||
| 3874 | defer core.deinit(); | ||
| 3875 | var tr: InputTransport = .{}; | ||
| 3876 | |||
| 3877 | // A drag that leaves the pane keeps reporting, at the edge it left | ||
| 3878 | // by: a real terminal reports a drag past its window the same way, and | ||
| 3879 | // dropping the report instead would leave the application holding a | ||
| 3880 | // button the hand has let go of. Screen (1,1) is above and left of the | ||
| 3881 | // pane; pane column 30 and row 9 are past its 20x6. | ||
| 3882 | _ = try core.forward(&tr, "\x1b[<32;1;1M"); | ||
| 3883 | try std.testing.expectEqualStrings("\x1b[<32;1;1M", tr.take()); | ||
| 3884 | try mouse(&core, &tr, 32, 30, 9, 'M'); | ||
| 3885 | try std.testing.expectEqualStrings("\x1b[<32;20;6M", tr.take()); | ||
| 3886 | } | ||
| 3887 | |||
| 3888 | test "interact: a pixel report is the terminal's to place and passes untouched" { | ||
| 3889 | const alloc = std.testing.allocator; | ||
| 3890 | const p = try std.posix.pipe2(.{ .NONBLOCK = true }); | ||
| 3891 | defer std.posix.close(p[0]); | ||
| 3892 | defer std.posix.close(p[1]); | ||
| 3893 | var core = try appMouseFixture(alloc, p[1]); | ||
| 3894 | defer core.deinit(); | ||
| 3895 | core.semantic.terminal_modes.mouse_sgr_pixels = true; | ||
| 3896 | var tr: InputTransport = .{}; | ||
| 3897 | |||
| 3898 | // Pixel coordinates are not cells, so the pane's cell origin cannot | ||
| 3899 | // come off them; an application that asked for pixels reads the | ||
| 3900 | // screen's. A wrong translation here would be worse than none. | ||
| 3901 | _ = try core.forward(&tr, "\x1b[<0;150;60M"); | ||
| 3902 | try std.testing.expectEqualStrings("\x1b[<0;150;60M", tr.take()); | ||
| 3903 | } | ||
| 3904 | |||
| 3724 | test "interact: a drag while scrolled back selects nothing" { | 3905 | test "interact: a drag while scrolled back selects nothing" { |
| 3725 | const alloc = std.testing.allocator; | 3906 | const alloc = std.testing.allocator; |
| 3726 | const p = try std.posix.pipe2(.{ .NONBLOCK = true }); | 3907 | const p = try std.posix.pipe2(.{ .NONBLOCK = true }); |
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" = "111" ] || { | 173 | [ "$OK_COUNT" = "112" ] || { |
| 174 | echo "e2e FAIL: $OK_COUNT scenario checkpoints ran, the pin says 111 —" | 174 | echo "e2e FAIL: $OK_COUNT scenario checkpoints ran, the pin says 112 —" |
| 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 | ||
|---|---|---|---|
| @@ -54,6 +54,10 @@ SOCK42="${TMPDIR:-/tmp}/muxd-e2e-wallappmouse-$$.sock" | |||
| 54 | defer_sock "$SOCK42" | 54 | defer_sock "$SOCK42" |
| 55 | ZM2STATE="${TMPDIR:-/tmp}/mux-e2e-wallappmouse-state-$$" | 55 | ZM2STATE="${TMPDIR:-/tmp}/mux-e2e-wallappmouse-state-$$" |
| 56 | defer_rm "$ZM2STATE" | 56 | defer_rm "$ZM2STATE" |
| 57 | SOCK77="${TMPDIR:-/tmp}/muxd-e2e-offorigin-$$.sock" | ||
| 58 | defer_sock "$SOCK77" | ||
| 59 | ZOSTATE="${TMPDIR:-/tmp}/mux-e2e-offorigin-state-$$" | ||
| 60 | defer_rm "$ZOSTATE" | ||
| 57 | ZMOUSESH="${TMPDIR:-/tmp}/mux-e2e-wallappmouse-$$.sh" | 61 | ZMOUSESH="${TMPDIR:-/tmp}/mux-e2e-wallappmouse-$$.sh" |
| 58 | defer_rm "$ZMOUSESH" | 62 | defer_rm "$ZMOUSESH" |
| 59 | 63 | ||
| @@ -155,14 +159,14 @@ wait_grid "$SOCK34" "app-holds-the-mouse" "app-mouse session never armed" | |||
| 155 | # The modes are already set when this client attaches, so they arrive in | 159 | # The modes are already set when this client attaches, so they arrive in |
| 156 | # the attach's own term_modes and there is no race to settle for. The echo | 160 | # the attach's own term_modes and there is no race to settle for. The echo |
| 157 | # is the assertion: cat is in canonical mode with ECHOCTL, so bytes that | 161 | # is the assertion: cat is in canonical mode with ECHOCTL, so bytes that |
| 158 | # reach the pty come back as `^[[<64;10;5M` and bytes that do not, do not. | 162 | # reach the pty come back as `^[[<64;10;4M` (one row up: a wall on a terminal sits under its label bar) and bytes that do not, do not. |
| 159 | set +e | 163 | set +e |
| 160 | hostroom appmouse | 164 | hostroom appmouse |
| 161 | XDG_STATE_HOME="$HOSTROOM" timeout 40 "$PTYCLIENT" --cols 80 --rows 24 --out "$OUT.mse" --err "$OUT.mse.err" \ | 165 | XDG_STATE_HOME="$HOSTROOM" timeout 40 "$PTYCLIENT" --cols 80 --rows 24 --out "$OUT.mse" --err "$OUT.mse.err" \ |
| 162 | -- "$MUX" --sock "$SOCK34" > "$OUT.mse.log" 2>&1 <<'EOF' | 166 | -- "$MUX" --sock "$SOCK34" > "$OUT.mse.log" 2>&1 <<'EOF' |
| 163 | expect app-holds-the-mouse 15000 | 167 | expect app-holds-the-mouse 15000 |
| 164 | send \x1b[<64;10;5M | 168 | send \x1b[<64;10;5M |
| 165 | expect [<64;10;5M 15000 | 169 | expect [<64;10;4M 15000 |
| 166 | send \x1cd | 170 | send \x1cd |
| 167 | waitexit 10000 | 171 | waitexit 10000 |
| 168 | EOF | 172 | EOF |
| @@ -172,7 +176,7 @@ rc0 "app mouse: ptyclient leg exited $RC (did the wheel reach the app?):" "$OUT. | |||
| 172 | # The session's own grid, not just the client's screen: the echo is the pty | 176 | # The session's own grid, not just the client's screen: the echo is the pty |
| 173 | # saying it received the bytes. | 177 | # saying it received the bytes. |
| 174 | timeout 20 "$MUX" a capture --sock "$SOCK34" > "$OUT.msecap" 2>&1 | 178 | timeout 20 "$MUX" a capture --sock "$SOCK34" > "$OUT.msecap" 2>&1 |
| 175 | grep -qF -- "[<64;10;5M" "$OUT.msecap" || { | 179 | grep -qF -- "[<64;10;4M" "$OUT.msecap" || { |
| 176 | echo "e2e FAIL: app mouse: the wheel never reached the application's pty:" | 180 | echo "e2e FAIL: app mouse: the wheel never reached the application's pty:" |
| 177 | cat "$OUT.msecap"; exit 1; } | 181 | cat "$OUT.msecap"; exit 1; } |
| 178 | # The mirror: this terminal was asked for the session's modes, not the | 182 | # The mirror: this terminal was asked for the session's modes, not the |
| @@ -528,9 +532,12 @@ start_daemon "$SOCK42" "$OUT.zm2.d" "app-mouse daemon never bound" --shell "$ZMO | |||
| 528 | D39PID=$DPID | 532 | D39PID=$DPID |
| 529 | wait_grid "$SOCK42" "mapp-holds-the-mouse" "app mouse: session never armed" | 533 | wait_grid "$SOCK42" "mapp-holds-the-mouse" "app mouse: session never armed" |
| 530 | # The echo is the assertion: cat is in canonical mode with ECHOCTL, so bytes | 534 | # The echo is the assertion: cat is in canonical mode with ECHOCTL, so bytes |
| 531 | # that reach the pty come back as `^[[<64;10;5M` and bytes that do not, do | 535 | # that reach the pty come back as `^[[<64;10;4M` and bytes that do not, do |
| 532 | # not. The tile claims the terminal on its first pass, so the modes are | 536 | # not. The tile claims the terminal on its first pass, so the modes are |
| 533 | # level-set before the wheel is sent. | 537 | # level-set before the wheel is sent. Row 5 on the terminal is row 4 of the |
| 538 | # application: a tile on a terminal sits under its label bar, and the | ||
| 539 | # report reaches the pty in the application's own numbering, not the | ||
| 540 | # screen's. The leg below moves the tile further from the origin. | ||
| 534 | set +e | 541 | set +e |
| 535 | no_saved_tree "$ZM2STATE" | 542 | no_saved_tree "$ZM2STATE" |
| 536 | XDG_STATE_HOME="$ZM2STATE" timeout 90 "$PTYCLIENT" --cols 80 --rows 24 \ | 543 | XDG_STATE_HOME="$ZM2STATE" timeout 90 "$PTYCLIENT" --cols 80 --rows 24 \ |
| @@ -539,18 +546,18 @@ XDG_STATE_HOME="$ZM2STATE" timeout 90 "$PTYCLIENT" --cols 80 --rows 24 \ | |||
| 539 | expect mapp-holds-the-mouse 20000 | 546 | expect mapp-holds-the-mouse 20000 |
| 540 | settle 700 20000 | 547 | settle 700 20000 |
| 541 | send \x1b[<64;10;5M | 548 | send \x1b[<64;10;5M |
| 542 | expect [<64;10;5M 15000 | 549 | expect [<64;10;4M 15000 |
| 543 | settle 500 15000 | 550 | settle 500 15000 |
| 544 | send \x1cd | 551 | send \x1cd |
| 545 | waitexit 10000 | 552 | waitexit 10000 |
| 546 | EOF | 553 | EOF |
| 547 | RC=$? | 554 | RC=$? |
| 548 | set -e | 555 | set -e |
| 549 | rc0 "app mouse: ptyclient leg exited $RC (did the wheel reach the app?):" "$OUT.zm2pc" | 556 | rc0 "app mouse: ptyclient leg exited $RC (did the wheel reach the app, one row up?):" "$OUT.zm2pc" |
| 550 | # The session's own grid, not just this terminal: the echo is the pty saying | 557 | # The session's own grid, not just this terminal: the echo is the pty saying |
| 551 | # it received the bytes. | 558 | # it received the bytes. |
| 552 | timeout 20 "$MUX" a capture --sock "$SOCK42" > "$OUT.zm2capg" 2>&1 | 559 | timeout 20 "$MUX" a capture --sock "$SOCK42" > "$OUT.zm2capg" 2>&1 |
| 553 | grep -qF -- "[<64;10;5M" "$OUT.zm2capg" || { | 560 | grep -qF -- "[<64;10;4M" "$OUT.zm2capg" || { |
| 554 | echo "e2e FAIL: app mouse: the wheel never reached the application's pty:" | 561 | echo "e2e FAIL: app mouse: the wheel never reached the application's pty:" |
| 555 | cat "$OUT.zm2capg"; exit 1; } | 562 | cat "$OUT.zm2capg"; exit 1; } |
| 556 | # The mirror: this terminal was asked for the SESSION's modes at the claim, | 563 | # The mirror: this terminal was asked for the SESSION's modes at the claim, |
| @@ -568,6 +575,63 @@ D39PID="" | |||
| 568 | ok "an application in the focused tile gets the wheel, and the tile does not" | 575 | ok "an application in the focused tile gets the wheel, and the tile does not" |
| 569 | 576 | ||
| 570 | 577 | ||
| 578 | # ---- an application in a tile off the origin reads its own rows --------- | ||
| 579 | # | ||
| 580 | # The leg above holds the tile one label bar from the origin; this one puts | ||
| 581 | # it in the LOWER stripe of a stacked wall, where a report passed through | ||
| 582 | # in the terminal's numbering names a row the application does not have. | ||
| 583 | # That is how it shipped: on a live wall every press in a bottom tile | ||
| 584 | # reached vim and Claude Code as a row past their grid, and neither could | ||
| 585 | # select (2026-09-02). Every earlier app-mouse leg was a wall of one tile, | ||
| 586 | # which is the fixture holding the offset at zero. | ||
| 587 | # | ||
| 588 | # 80x24 cut into two stripes is 12 rows each, one of them the label bar, so | ||
| 589 | # session b's grid is 11 rows starting at terminal row 14 — `mux a status` | ||
| 590 | # says the 11, which is what pins the arithmetic to the layout rather than | ||
| 591 | # to this comment. Terminal row 16 is then the application's row 3. | ||
| 592 | start_daemon "$SOCK77" "$OUT.zo.d" "off-origin daemon never bound" --shell "$ZMOUSESH" | ||
| 593 | D77PID=$DPID | ||
| 594 | wait_grid "$SOCK77" "mapp-holds-the-mouse" "off-origin: session 0 never armed" | ||
| 595 | pipe_mux "$OUT.zob" "$OUT.zob.err" env XDG_STATE_HOME="$ZOSTATE" timeout 40 "$MUX" --sock "$SOCK77" --session b | ||
| 596 | await_out "$OUT.zob" "mapp-holds-the-mouse" "off-origin: session b never armed" | ||
| 597 | pipe_detach | ||
| 598 | wait_grid "$SOCK77" "mapp-holds-the-mouse" "off-origin: session b's marker" b | ||
| 599 | set +e | ||
| 600 | seed_layout "$ZOSTATE" stacked "--sock $SOCK77#0" "--sock $SOCK77#b" | ||
| 601 | XDG_STATE_HOME="$ZOSTATE" timeout 90 "$PTYCLIENT" --cols 80 --rows 24 \ | ||
| 602 | --out "$OUT.zocap" --err "$OUT.zocap.err" -- \ | ||
| 603 | "$MUX" --sock "$SOCK77" > "$OUT.zopc" 2>&1 <<'EOF' | ||
| 604 | expect mapp-holds-the-mouse 20000 | ||
| 605 | settle 700 20000 | ||
| 606 | send \x1cn | ||
| 607 | settle 700 20000 | ||
| 608 | send \x1b[<0;10;16M | ||
| 609 | expect [<0;10;3M 15000 | ||
| 610 | settle 500 15000 | ||
| 611 | send \x1cd | ||
| 612 | waitexit 10000 | ||
| 613 | EOF | ||
| 614 | RC=$? | ||
| 615 | set -e | ||
| 616 | rc0 "off-origin: ptyclient leg exited $RC (did the press reach the lower tile's app on its own row?):" "$OUT.zopc" | ||
| 617 | timeout 20 "$MUX" a status --sock "$SOCK77" --session b > "$OUT.zost" 2>&1 | ||
| 618 | grep -q '"rows":11' "$OUT.zost" || { | ||
| 619 | echo "e2e FAIL: off-origin: session b is not the 11-row lower stripe this leg's arithmetic assumes:" | ||
| 620 | cat "$OUT.zost"; exit 1; } | ||
| 621 | # The session's own grid says which pty the bytes reached, and in what | ||
| 622 | # numbering: the lower tile's, on its row 3, and never the upper tile's. | ||
| 623 | timeout 20 "$MUX" a capture --sock "$SOCK77" --session b > "$OUT.zocapb" 2>&1 | ||
| 624 | grep -qF -- "[<0;10;3M" "$OUT.zocapb" || { | ||
| 625 | echo "e2e FAIL: off-origin: the press never reached the lower tile's pty on its own row 3:" | ||
| 626 | cat "$OUT.zocapb"; exit 1; } | ||
| 627 | timeout 20 "$MUX" a capture --sock "$SOCK77" > "$OUT.zocapa" 2>&1 | ||
| 628 | grep -qF -- "[<0;10;" "$OUT.zocapa" && { | ||
| 629 | echo "e2e FAIL: off-origin: the press reached the upper tile, which was not focused:" | ||
| 630 | cat "$OUT.zocapa"; exit 1; } | ||
| 631 | assert_stopped "$SOCK77" "$D77PID" "off-origin" "$OUT.zostop" | ||
| 632 | D77PID="" | ||
| 633 | ok "an application in a tile off the origin reads the press on its own row" | ||
| 634 | |||
| 571 | # ---- a click on a dead host's pane must not deafen the wall ------------- | 635 | # ---- a click on a dead host's pane must not deafen the wall ------------- |
| 572 | # | 636 | # |
| 573 | # Found live 2026-09-02: a laptop went dark overnight, its pane came back | 637 | # Found live 2026-09-02: a laptop went dark overnight, its pane came back |