a73x

e439be23

feat: a drag copies the text it crossed

a73x   2026-08-22 13:13

Commit message
feat: a drag copies the text it crossed

Copy on release, which is tmux's `copy-pipe-and-cancel` and the reason
this feature has no copy chord: `Ctrl+Shift+C` cannot reach a tty
application at all, since `Ctrl-C` is `letter & 0x1f` and the encoding
has no bit for Shift. A click asks for nothing — it highlights nothing,
so there is no text under it to want, and a request per click is a round
trip every time the user puts a selection away.

Zoomed, `Core.forward` already runs on the tile's pump, the thread that
owns this transport, so the ask goes out from where it stands. The
WALL's keyboard is the half that cannot: one owning thread per
transport, and a reconnect swaps the struct out from under the pump. So
the release posts the range and rings, and the pump asks, receives on
its own link and writes. The doorbell is not decoration — a release
moves no highlight, so the generation bump and the round of rings a
MOTION does never happen, and a pump over a session quiet enough to send
nothing would sit in poll(2) holding an unasked question. The request is
posted on the tile the drag was over, not on the selected one: a drag
never moves the selection, and it is the drag's own session that has the
text.

Four ways an answer can be the wrong answer, and the Core owns all four
because it is the half that knows what it asked.

The WATERMARK, sampled at the ASK from the replica the coordinates were
resolved against. Absolute rows count from the oldest RETAINED row, so
an eviction between the ask and the answer renames the space and the
text comes back `.ok`, valid UTF-8 and for lines nobody pointed at. The
reply carries the daemon's count for comparison; a watermark read out of
the reply would compare it against itself. Strictly lower and not
unequal: ordinary output raises the count without moving row zero, and
an equality test would refuse every copy taken from a session still
writing.

The HIGHLIGHT the ask came from, which the caller supplies rather than
this reading it — zoomed it is the Core's own drag, at the wall it is
the wall's, and a reply that outlived either is a copy the user cannot
see and did not ask for. A demote clears it too: a tile whose zoom moved
on paints nothing, so an inversion left standing would reappear over the
next zoom's rows.

The SIZE, which is the live bug this could have shipped into. The daemon
sends up to a megabyte and OSC 52 stops at 64 KiB of base64 — four bytes
per three, so about 48 KiB of text — and `validClipboard` refuses the
rest with `appendHostEffect` writing nothing and saying nothing. Between
those two numbers a copy would look exactly like a copy that worked.
Refused OUT LOUD, and never truncated: half a selection is worse than
none, because the user finds out when they paste it. At the wall that
refusal is a banner, top-right, which unzoomed is the first stripe's
label bar — written over by the next repaint of that bar, the right
lifetime for a sentence the user needs to read once. `Core.banner` could
not say it, because a demoted tile's sink refuses that too.

And the daemon's own refusals, which are not the same kind of thing.
`.invalid` is this client sending coordinates it should not have;
`.unavailable` is a session with no screen. Neither is a sentence worth
putting over the user's work. So `selection_reply` leaves the Core as
`.not_mine`, the shape `sessions_reply` already has: the Core answers
what the reply MEANS and the driver answers where it goes, because at
the wall the tile that asked is a demoted stripe and the clipboard
belongs to the wall's screen.

A posted request is deliberately NOT cleared when the drag is: the
answer's staleness has one owner, the range test, and a second clear
point would be the same rule in two places, free to disagree. The cost
is a round trip nobody reads, on a relayout that landed inside one.

The e2e leg is the first end-to-end proof this feature does what it was
asked for. The highlight legs could not have one — a highlight is a
PAINT and those legs read the daemon, never the screen — but a copy
leaves as OSC 52 on the CLIENT's tty, which is exactly what the fixture
holding that tty can read, so the assertion is the copied text itself.
The typed line says `ZZ-%s` and the printed one says `ZZ-COPYME`, so the
expect cannot be satisfied by the shell echoing the command back — the
hazard that would let this leg pass with the whole feature deleted.
Measured: with the ask deleted the leg exits 3. The capture is COUNTED
and not grepped, and the number is one; measured too, since a second
write makes it say 2.

The README's two false sentences go with it. "Clicks and drags with
nothing asking for them are discarded rather than typed at your shell"
was M1's promise and is now half of one — a drag is not discarded, it
selects. And "selecting text with the mouse needs Shift held down" was
the whole problem this feature was filed against. What replaces them
says what the user gets and what they do not: the copy comes off the
daemon's grid, so it survives a soft wrap and works from a remote box
where the terminal's own selection can only see the one screenful mux
painted; the visible screen only, so scrolled back selects nothing; and
Shift+drag is still the terminal's own for the two cases mux's is not,
with the soft-wrap split named as the price.

Patch: 063cec67
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GiryvVYESzBqqtf5cH1uoA

README.md
Old New
@@ -62,6 +62,7 @@ Zoomed in — the session has the terminal, and only the prefix is held back:
62 | `Ctrl-\` `l` | skip back to the last tile you zoomed (out, if that was this one) | 62 | `Ctrl-\` `l` | skip back to the last tile you zoomed (out, if that was this one) |
63 | `Shift+PageUp` / `Shift+PageDown` | scrollback, a screen at a time (any other key returns to live) | 63 | `Shift+PageUp` / `Shift+PageDown` | scrollback, a screen at a time (any other key returns to live) |
64 | mouse wheel | scrollback, three rows a notch (arrow keys to a full-screen app) | 64 | mouse wheel | scrollback, three rows a notch (arrow keys to a full-screen app) |
65 | drag with the left button | select what it crosses, copy on release |
65 | any key not behind `Ctrl-\` | goes to the session | 66 | any key not behind `Ctrl-\` | goes to the session |
66 67
67 Zoomed out — the wall reads the keys and nothing reaches a session: 68 Zoomed out — the wall reads the keys and nothing reaches a session:
@@ -93,11 +94,21 @@ not ask for the mouse — `less`, `man`, most pagers — it becomes arrow keys,
93 so the application scrolls the way it always does. And an application that 94 so the application scrolls the way it always does. And an application that
94 DID ask the terminal for mouse reporting — vim with `set mouse=a`, htop — 95 DID ask the terminal for mouse reporting — vim with `set mouse=a`, htop —
95 gets every notch, click and drag forwarded to it untouched, with 96 gets every notch, click and drag forwarded to it untouched, with
96 `Shift+PageUp` still reaching mux's own scrollback. Clicks and drags with 97 `Shift+PageUp` still reaching mux's own scrollback.
97 nothing asking for them are discarded rather than typed at your shell. 98
98 99 A drag with the left button is mux's own. It highlights what it crosses and
99 Selecting text with the mouse needs Shift held down while mux is running, 100 copies it to your terminal's clipboard when you let go — no key to press,
100 because a terminal that is reporting the mouse is not selecting with it. 101 the way tmux does it. That copy comes from the daemon's grid, so it is
102 right across soft-wrapped lines, and it works from a remote box over QUIC,
103 where your terminal's own selection can only see the one screenful mux
104 painted. On the wall a drag stays inside the stripe it started in, and a
105 plain click moves the selection to that stripe.
106
107 Only the visible screen: a drag while scrolled back selects nothing, and a
108 drag in an application that asked for the mouse belongs to the application.
109 Shift+drag is still your terminal's own selection for both of those — it
110 splits soft-wrapped lines at the window edge, which is the one thing it
111 does worse.
101 112
102 `mux` again reattaches — full TUI screens included. Kill the client with 113 `mux` again reattaches — full TUI screens included. Kill the client with
103 `kill -9` if you like; the session doesn't care. 114 `kill -9` if you like; the session doesn't care.
@@ -383,8 +394,8 @@ kills the daemon with them), though not a reboot.
383 `muxd run` refuses a socket another daemon already owns; there is no 394 `muxd run` refuses a socket another daemon already owns; there is no
384 socket-stealing. 395 socket-stealing.
385 396
386 Copy and paste work through the session: an application's OSC 52 write 397 Copy and paste work through the session: a mux drag and an application's own
387 reaches your terminal's clipboard (including from a remote box over QUIC, 398 OSC 52 write both reach your terminal's clipboard (including from a remote box over QUIC,
388 where nothing else can), a paste arrives bracketed when the application 399 where nothing else can), a paste arrives bracketed when the application
389 asked for bracketed paste, the window title follows the session, and a 400 asked for bracketed paste, the window title follows the session, and a
390 bell rings. The clipboard READ direction (`OSC 52` query) is refused 401 bell rings. The clipboard READ direction (`OSC 52` query) is refused
src/interact.zig
Old New
@@ -722,6 +722,34 @@ fn appendHostEffect(
722 } 722 }
723 } 723 }
724 724
725 /// Put a mux selection's text on the HOST terminal's clipboard.
726 ///
727 /// Through `appendHostEffect` and not a second OSC 52 of its own: the
728 /// target check, the alphabet check and the all-or-nothing shape are that
729 /// function's, and a hand-rolled write here would be a second place for
730 /// them to be got wrong. `c` is the clipboard proper — what tmux's
731 /// `set-clipboard external` sets, and what a paste reads back.
732 ///
733 /// `owns_terminal` is TRUE unconditionally, and that is not the claim gate
734 /// being skipped. The claim answers whether a SESSION's clipboard event may
735 /// reach a terminal this tile does not hold; this text is the user's own
736 /// drag on the screen in front of them, and at the wall the tile answering
737 /// it is a demoted stripe every time. The caller gates on `is_tty` instead
738 /// — a piped `mux` claims no mouse modes and can have no drag to copy.
739 pub fn writeSelectionCopy(
740 alloc: std.mem.Allocator,
741 out_fd: std.posix.fd_t,
742 text: []const u8,
743 ) !void {
744 const b64 = try alloc.alloc(u8, std.base64.standard.Encoder.calcSize(text.len));
745 defer alloc.free(b64);
746 _ = std.base64.standard.Encoder.encode(b64, text);
747 try writeSideChannel(alloc, out_fd, true, client_core.Effect, .{ .clipboard_set = .{
748 .target = 'c',
749 .base64 = b64,
750 } }, appendHostEffect);
751 }
752
725 /// Render a term_title frame as the OSC 0 write it implies. 753 /// Render a term_title frame as the OSC 0 write it implies.
726 /// 754 ///
727 /// Refuses any byte below 0x20 or the DEL at 0x7f. Such a byte terminates 755 /// Refuses any byte below 0x20 or the DEL at 0x7f. Such a byte terminates
@@ -1063,6 +1091,28 @@ pub const Step = enum { ok, lost };
1063 /// routing: `frame` is what turns it into a `Routed` the driver reads. 1091 /// routing: `frame` is what turns it into a `Routed` the driver reads.
1064 const Pass = enum { carry_on, skip }; 1092 const Pass = enum { carry_on, skip };
1065 1093
1094 /// What a `selection_reply` turned out to be worth — `Core.selectionCopy`'s
1095 /// answer, and the whole of what a driver has to decide about.
1096 pub const Copy = union(enum) {
1097 /// Nothing to do, and nothing to say. Somebody else's reply, a reply
1098 /// whose highlight is gone, a selection that came back empty, or a
1099 /// refusal the user cannot act on: `.invalid` is this client sending
1100 /// coordinates the daemon could not use, and `.unavailable` is a
1101 /// session with no screen to read — neither is a sentence worth
1102 /// putting over the user's work.
1103 none,
1104 /// The selected text, borrowing the frame payload and valid only while
1105 /// it is.
1106 text: []const u8,
1107 /// `.ok`, and past what OSC 52 can carry. Said out loud rather than
1108 /// dropped: `client_core.validClipboard` refuses an oversized payload
1109 /// and `appendHostEffect` refuses by writing NOTHING, so a copy that
1110 /// stopped here would look exactly like a copy that worked. Never
1111 /// truncated either — half a selection is worse than a refused one,
1112 /// because the user finds out when they paste it.
1113 too_large,
1114 };
1115
1066 /// What one frame turned out to be, once the Core has done its half of it. 1116 /// What one frame turned out to be, once the Core has done its half of it.
1067 /// 1117 ///
1068 /// The exhaustive switch over `proto.MsgType` lives in `Core.frame` and 1118 /// The exhaustive switch over `proto.MsgType` lives in `Core.frame` and
@@ -1093,6 +1143,13 @@ pub const Routed = enum {
1093 /// merely relabels a tile — so the Core names them and stops. The agent 1143 /// merely relabels a tile — so the Core names them and stops. The agent
1094 /// channel frames join them for the same reason from the other end: the 1144 /// channel frames join them for the same reason from the other end: the
1095 /// driver holds the local socket they belong to. 1145 /// driver holds the local socket they belong to.
1146 ///
1147 /// `selection_reply` is here for a narrower reason and is the one the
1148 /// driver must call BACK about: `selectionCopy` correlates it, tests the
1149 /// watermark and refuses an oversized one, but the terminal it copies
1150 /// to is the wall's and the tile that asked is usually a demoted
1151 /// stripe. Same shape as `sessions_reply`, which the driver reads with
1152 /// `client.ringNeighbour`.
1096 not_mine, 1153 not_mine,
1097 }; 1154 };
1098 1155
@@ -1225,6 +1282,22 @@ pub const Core = struct {
1225 /// sees a report at all: unzoomed, the drag belongs to the wall's own 1282 /// sees a report at all: unzoomed, the drag belongs to the wall's own
1226 /// keyboard loop, over its own hit-test. 1283 /// keyboard loop, over its own hit-test.
1227 drag: select.Drag = .{}, 1284 drag: select.Drag = .{},
1285 /// The last id this Core put on the wire. Per-Core and not global: one
1286 /// link carries one conversation, and `client_core` correlates against
1287 /// the pending one it was handed.
1288 sel_id: u32 = 0,
1289 /// `history_rows` as of the request now in flight, and the whole of the
1290 /// staleness test. Absolute rows count from the OLDEST RETAINED row, so
1291 /// an eviction between the ask and the answer renames the coordinate
1292 /// space and yields text that is `.ok`, valid UTF-8, and not what was
1293 /// highlighted. Ordinary output RAISES the count without moving row
1294 /// zero, so only a LOWER one is evidence (`protocol.SelectionReply`).
1295 sel_watermark: u32 = 0,
1296 /// The selection the request was taken from, compared against what is
1297 /// still held when the answer comes back. A reply that outlived its own
1298 /// highlight — a relayout, a forget, a zoom, a resync — is text for
1299 /// rows nobody is looking at any more.
1300 sel_range: ?select.Range = null,
1228 /// Scroll mode: 0 = live; N = viewing the screenful whose bottom sits N 1301 /// Scroll mode: 0 = live; N = viewing the screenful whose bottom sits N
1229 /// rows above live. Rows rather than pages because the wheel moves by a 1302 /// rows above live. Rows rather than pages because the wheel moves by a
1230 /// few lines and the keys move by a screen — one unit that expresses 1303 /// few lines and the keys move by a screen — one unit that expresses
@@ -1409,6 +1482,12 @@ pub const Core = struct {
1409 const held = self.claim; 1482 const held = self.claim;
1410 self.claim = .none; 1483 self.claim = .none;
1411 self.dropScrollView(); 1484 self.dropScrollView();
1485 // The highlight goes with the screen it was drawn on. A demoted
1486 // tile paints nothing, so an inversion kept here would be invisible
1487 // until the tile was zoomed again and then reappear over rows the
1488 // user chose in another session's lifetime — and a reply still in
1489 // flight would copy text for them.
1490 self.drag.clear();
1412 if (undo == .already_written) return; 1491 if (undo == .already_written) return;
1413 switch (held) { 1492 switch (held) {
1414 .none => {}, 1493 .none => {},
@@ -1658,10 +1737,11 @@ pub const Core = struct {
1658 effect, 1737 effect,
1659 appendHostEffect, 1738 appendHostEffect,
1660 ), 1739 ),
1661 // Nothing to write: a `.reply` is an answer to the DAEMON, and 1740 // Nothing to write: a `.reply` answers something this client
1662 // `Core.frame` never routes one here — `.selection_reply` is in 1741 // ASKED for, and the one there is goes out through
1663 // the `.skip` set above. Named rather than swept so that a 1742 // `selectionCopy`, which the driver calls with the selection
1664 // decoder that starts producing one is an edit here. 1743 // still on screen. Nothing routes a reply here. Named rather
1744 // than swept so that a second kind of reply is an edit here.
1665 .reply => {}, 1745 .reply => {},
1666 } 1746 }
1667 } 1747 }
@@ -1743,6 +1823,11 @@ pub const Core = struct {
1743 return .handled; 1823 return .handled;
1744 }, 1824 },
1745 .exit_status, .taken_over, .sessions_reply => return .not_mine, 1825 .exit_status, .taken_over, .sessions_reply => return .not_mine,
1826 // The correlation, the watermark and the size refusal are the
1827 // Core's (`selectionCopy`); WHERE the text goes is not. At the
1828 // wall the tile that asked is a demoted stripe every time, and
1829 // the clipboard it copies to belongs to the wall's screen.
1830 .selection_reply => return .not_mine,
1746 // The driver owns local fds and the Core owns the replica, so 1831 // The driver owns local fds and the Core owns the replica, so
1747 // an agent channel is never the Core's: these bytes go to a 1832 // an agent channel is never the Core's: these bytes go to a
1748 // socket, not the grid. 1833 // socket, not the grid.
@@ -1758,7 +1843,6 @@ pub const Core = struct {
1758 .cmd_state, 1843 .cmd_state,
1759 .await_reply, 1844 .await_reply,
1760 .status_reply, 1845 .status_reply,
1761 .selection_reply,
1762 .dump_reply, 1846 .dump_reply,
1763 .attach, 1847 .attach,
1764 .input, 1848 .input,
@@ -1805,6 +1889,10 @@ pub const Core = struct {
1805 // with the escape deleted. 1889 // with the escape deleted.
1806 var keys = typed; 1890 var keys = typed;
1807 var wheel: i32 = 0; 1891 var wheel: i32 = 0;
1892 // A selection this read finished, asked for below rather than here
1893 // so the ask happens once for the read and outside the branch that
1894 // decides who the mouse belongs to.
1895 var copy: ?select.Range = null;
1808 // What is on screen before this read's reports are read, so the 1896 // What is on screen before this read's reports are read, so the
1809 // repaint below is spent only on a highlight that actually moved. 1897 // repaint below is spent only on a highlight that actually moved.
1810 // A drag reports on every cell the pointer crosses. 1898 // A drag reports on every cell the pointer crosses.
@@ -1823,13 +1911,18 @@ pub const Core = struct {
1823 const m = self.mouse.feed(typed, &self.mouse_buf); 1911 const m = self.mouse.feed(typed, &self.mouse_buf);
1824 keys = m.forward; 1912 keys = m.forward;
1825 wheel = m.wheel; 1913 wheel = m.wheel;
1826 self.dragReports(m.events); 1914 copy = self.dragReports(m.events);
1827 } 1915 }
1828 // This Core owns its own screen and its own thread — a tile's pump 1916 // This Core owns its own screen and its own thread — a tile's pump
1829 // is what runs `forward` — so the highlight is painted here rather 1917 // is what runs `forward` — so the highlight is painted here rather
1830 // than left in shared state for somebody else to draw. The wall's 1918 // than left in shared state for somebody else to draw. The wall's
1831 // unzoomed loop cannot do that and does not (`wallview.paintStripe`). 1919 // unzoomed loop cannot do that and does not (`wallview.paintStripe`).
1832 if (!std.meta.eql(was, self.drag.range())) try self.paintFull(); 1920 if (!std.meta.eql(was, self.drag.range())) try self.paintFull();
1921 // Drag copies on release, tmux's `copy-pipe-and-cancel` rule and
1922 // the reason there is no copy chord: `Ctrl+Shift+C` cannot reach a
1923 // tty application at all. This thread owns the transport, so the
1924 // ask goes out from where it stands — no relay, unlike the wall's.
1925 if (copy) |r| self.requestSelection(transport, r) catch return .lost;
1833 1926
1834 // Alternate scroll (tmux calls it that; DEC 1007 is the terminal's 1927 // Alternate scroll (tmux calls it that; DEC 1007 is the terminal's
1835 // own version). The alt screen has no scrollback — `historyRows` 1928 // own version). The alt screen has no scrollback — `historyRows`
@@ -1935,16 +2028,83 @@ pub const Core = struct {
1935 /// between this driver and the wall's: at the wall a click moves the 2028 /// between this driver and the wall's: at the wall a click moves the
1936 /// selection between stripes, and a zoomed tile is the only session on 2029 /// selection between stripes, and a zoomed tile is the only session on
1937 /// its screen, so there is nothing for a click to select. 2030 /// its screen, so there is nothing for a click to select.
1938 fn dragReports(self: *Core, events: []const MouseFilter.Event) void { 2031 ///
2032 /// What comes back is the selection a release FINISHED, for the caller
2033 /// to ask the daemon about — the copy is `forward`'s to send because
2034 /// the transport is. One read can hold more than one release; the last
2035 /// is the answer, which is also what `client_core.beginSelection`'s
2036 /// latest-wins would make of two requests in a row.
2037 fn dragReports(self: *Core, events: []const MouseFilter.Event) ?select.Range {
2038 var done: ?select.Range = null;
1939 for (events) |ev| { 2039 for (events) |ev| {
1940 if (ev.button & 0b11 != 0) continue; 2040 if (ev.button & 0b11 != 0) continue;
1941 const cell: select.Cell = .{ .row = ev.row, .col = ev.col }; 2041 const cell: select.Cell = .{ .row = ev.row, .col = ev.col };
1942 switch (ev.kind) { 2042 switch (ev.kind) {
1943 .press => self.drag.press(cell, self.hitTest(ev)), 2043 .press => self.drag.press(cell, self.hitTest(ev)),
1944 .motion => self.drag.motion(cell, self.hitTest(ev)), 2044 .motion => self.drag.motion(cell, self.hitTest(ev)),
1945 .release => _ = self.drag.release(), 2045 .release => switch (self.drag.release()) {
2046 .selection => |r| done = r,
2047 .nothing, .click => {},
2048 },
1946 } 2049 }
1947 } 2050 }
2051 return done;
2052 }
2053
2054 /// Ask the daemon for the text under a finished selection.
2055 ///
2056 /// The zoomed driver calls this from `forward`, which already runs on
2057 /// the tile's pump — the thread that owns this transport. The WALL's
2058 /// keyboard may not: a `Transport` has exactly one owning thread, so it
2059 /// posts the range and its pump calls this instead
2060 /// (`wallview.wallMouse`).
2061 pub fn requestSelection(self: *Core, transport: anytype, r: select.Range) !void {
2062 self.sel_id +%= 1;
2063 // Sampled HERE, from the replica the coordinates were resolved
2064 // against, and not from the reply: see `sel_watermark`.
2065 self.sel_watermark = self.rep.history_rows;
2066 self.sel_range = r;
2067 const req = self.semantic.beginSelection(.{
2068 .id = self.sel_id,
2069 .anchor = .{ .row = r.from.row, .col = r.from.col },
2070 .active = .{ .row = r.to.row, .col = r.to.col },
2071 });
2072 try transport.writeFrame(.selection_req, &req);
2073 }
2074
2075 /// What a `selection_reply` is worth, given the selection that is still
2076 /// held — `Core.drag` zoomed, `wallview.Shared.drag` at the wall, which
2077 /// is why the caller supplies it rather than this reading one.
2078 ///
2079 /// The correlation is `client_core`'s, unchanged: a reply that answers
2080 /// no pending request, or answers one this drag already replaced, is
2081 /// `.ignored` there and never reaches the three tests below.
2082 pub fn selectionCopy(self: *Core, payload: []const u8, held: ?select.Range) Copy {
2083 const reply = switch (self.semantic.receive(.selection_reply, payload)) {
2084 .reply => |r| r.selection,
2085 else => return .none,
2086 };
2087 // The eviction test. STRICTLY lower, because ordinary output raises
2088 // the count without moving row zero — an equality test would refuse
2089 // every copy taken from a session that is still writing.
2090 if (reply.history_rows < self.sel_watermark) return .none;
2091 // The highlight the ask was taken from is gone or has moved on, so
2092 // whatever came back describes rows nobody is looking at.
2093 if (!std.meta.eql(held, self.sel_range)) return .none;
2094 switch (reply.status) {
2095 .ok => {},
2096 .too_large => return .too_large,
2097 .invalid, .unavailable => return .none,
2098 }
2099 if (reply.text.len == 0) return .none;
2100 // The gap this feature could have shipped into: the daemon will
2101 // send up to `selection_text_max` (1 MiB) and OSC 52 stops at
2102 // `clipboard_base64_max` (64 KiB) of BASE64, four bytes per three.
2103 // Everything between the two would round-trip `.ok` and copy
2104 // nothing at all.
2105 if (std.base64.standard.Encoder.calcSize(reply.text.len) > proto.clipboard_base64_max)
2106 return .too_large;
2107 return .{ .text = reply.text };
1948 } 2108 }
1949 2109
1950 /// Which line of this session a report landed on, or null for a report 2110 /// Which line of this session a report landed on, or null for a report
@@ -3069,6 +3229,21 @@ const NullTransport = struct {
3069 fn writeFrame(_: *NullTransport, _: proto.MsgType, _: []const u8) !void {} 3229 fn writeFrame(_: *NullTransport, _: proto.MsgType, _: []const u8) !void {}
3070 }; 3230 };
3071 3231
3232 /// A transport that keeps the selection requests and swallows the rest —
3233 /// for the tests whose subject is what went on the WIRE rather than what
3234 /// went on the screen. `forward` also writes keystrokes, and those are
3235 /// nobody's business here.
3236 const SelectionTransport = struct {
3237 n: usize = 0,
3238 buf: [proto.selection_req_len]u8 = undefined,
3239
3240 fn writeFrame(self: *SelectionTransport, ty: proto.MsgType, payload: []const u8) !void {
3241 if (ty != .selection_req or payload.len != self.buf.len) return;
3242 self.n += 1;
3243 @memcpy(&self.buf, payload);
3244 }
3245 };
3246
3072 /// A zoomed Core on a pipe, with a claim, ready to be dragged over. 3247 /// A zoomed Core on a pipe, with a claim, ready to be dragged over.
3073 fn dragFixture(alloc: std.mem.Allocator, out_fd: std.posix.fd_t) !Core { 3248 fn dragFixture(alloc: std.mem.Allocator, out_fd: std.posix.fd_t) !Core {
3074 var core = try Core.initSized(alloc, -1, out_fd, .{ .cols = 20, .rows = 6 }); 3249 var core = try Core.initSized(alloc, -1, out_fd, .{ .cols = 20, .rows = 6 });
@@ -3296,7 +3471,234 @@ test "interact: a row past the grid names no line, and a column past it clamps"
3296 try std.testing.expectEqual(@as(u16, 2), r.from.col); 3471 try std.testing.expectEqual(@as(u16, 2), r.from.col);
3297 } 3472 }
3298 3473
3299 test "interact: only lifecycle and agent-channel frames come back to the driver" { 3474 test "interact: a release at a zoomed tile asks the daemon for what it highlighted" {
3475 const alloc = std.testing.allocator;
3476 const p = try std.posix.pipe2(.{ .NONBLOCK = true });
3477 defer std.posix.close(p[0]);
3478 defer std.posix.close(p[1]);
3479 var core = try dragFixture(alloc, p[1]);
3480 defer core.deinit();
3481 var tr: SelectionTransport = .{};
3482 var buf: [8192]u8 = undefined;
3483 _ = drainPipe(p[0], &buf);
3484
3485 // History behind the session, which is every session that has been
3486 // used: with none retained a grid row and an absolute row are the same
3487 // integer and the request could be built out of either.
3488 core.rep.history_rows = 500;
3489
3490 _ = try core.forward(&tr, "\x1b[<0;3;2M");
3491 _ = try core.forward(&tr, "\x1b[<32;7;3M");
3492 // Nothing is asked for while the button is still down: the far end of
3493 // the drag is still moving, and a request per cell crossed is a round
3494 // trip per cell crossed.
3495 try std.testing.expectEqual(@as(usize, 0), tr.n);
3496
3497 _ = try core.forward(&tr, "\x1b[<0;7;3m");
3498 try std.testing.expectEqual(@as(usize, 1), tr.n);
3499 const req = try proto.decodeSelectionReq(&tr.buf);
3500 try std.testing.expectEqual(@as(u32, 501), req.anchor.row);
3501 try std.testing.expectEqual(@as(u16, 2), req.anchor.col);
3502 try std.testing.expectEqual(@as(u32, 502), req.active.row);
3503 try std.testing.expectEqual(@as(u16, 6), req.active.col);
3504 try std.testing.expectEqual(@as(?u32, req.id), core.semantic.pending_selection_id);
3505 // The watermark comes from the replica the coordinates were resolved
3506 // against, not from whatever the reply says.
3507 try std.testing.expectEqual(@as(u32, 500), core.sel_watermark);
3508
3509 // A click asks for nothing. It highlights nothing either, so there is
3510 // no text under it to want — and a round trip per click is a round
3511 // trip per time the user puts a selection away.
3512 _ = try core.forward(&tr, "\x1b[<0;3;2M");
3513 _ = try core.forward(&tr, "\x1b[<0;3;2m");
3514 try std.testing.expectEqual(@as(usize, 1), tr.n);
3515 }
3516
3517 /// One `selection_reply` payload, laid out by hand: id, status, watermark,
3518 /// then the text (`protocol.encodeSelectionReply`). Written out rather than
3519 /// encoded so a change to that layout shows up here as a failing test and
3520 /// not as a test that quietly agrees with itself.
3521 fn replyBytes(
3522 buf: []u8,
3523 id: u32,
3524 status: proto.SelectionStatus,
3525 history_rows: u32,
3526 text: []const u8,
3527 ) []const u8 {
3528 std.mem.writeInt(u32, buf[0..4], id, .little);
3529 buf[4] = @intFromEnum(status);
3530 std.mem.writeInt(u32, buf[5..9], history_rows, .little);
3531 @memcpy(buf[proto.selection_reply_prefix_len..][0..text.len], text);
3532 return buf[0 .. proto.selection_reply_prefix_len + text.len];
3533 }
3534
3535 /// Drag over the fixture and let go, leaving one request in flight. Returns
3536 /// the selection that is now held, which is what a reply is judged against.
3537 fn askedFor(core: *Core, tr: *SelectionTransport) !select.Range {
3538 _ = try core.forward(tr, "\x1b[<0;3;2M");
3539 _ = try core.forward(tr, "\x1b[<32;7;3M");
3540 _ = try core.forward(tr, "\x1b[<0;7;3m");
3541 return core.drag.range().?;
3542 }
3543
3544 test "interact: a reply is the text that was asked for, or it is nothing" {
3545 const alloc = std.testing.allocator;
3546 const p = try std.posix.pipe2(.{ .NONBLOCK = true });
3547 defer std.posix.close(p[0]);
3548 defer std.posix.close(p[1]);
3549 var core = try dragFixture(alloc, p[1]);
3550 defer core.deinit();
3551 var tr: SelectionTransport = .{};
3552 var buf: [8192]u8 = undefined;
3553 _ = drainPipe(p[0], &buf);
3554 core.rep.history_rows = 500;
3555
3556 var rbuf: [64]u8 = undefined;
3557 var held = try askedFor(&core, &tr);
3558 const id = core.semantic.pending_selection_id.?;
3559
3560 // The reply this request asked for.
3561 switch (core.selectionCopy(replyBytes(&rbuf, id, .ok, 500, "row-one"), held)) {
3562 .text => |text| try std.testing.expectEqualStrings("row-one", text),
3563 else => return error.ExpectedText,
3564 }
3565
3566 // A watermark LOWER than the one sampled at the ask: the daemon evicted
3567 // a page in between, so absolute row zero is a different line and the
3568 // text is for rows nobody pointed at. `.ok`, valid UTF-8, and wrong.
3569 held = try askedFor(&core, &tr);
3570 const evicted = replyBytes(&rbuf, core.semantic.pending_selection_id.?, .ok, 499, "wrong-rows");
3571 try std.testing.expectEqual(Copy.none, core.selectionCopy(evicted, held));
3572
3573 // A HIGHER one is ordinary output, which appends below and renames
3574 // nothing. Refusing it would refuse every copy from a live session.
3575 held = try askedFor(&core, &tr);
3576 const grew = replyBytes(&rbuf, core.semantic.pending_selection_id.?, .ok, 900, "row-one");
3577 switch (core.selectionCopy(grew, held)) {
3578 .text => |text| try std.testing.expectEqualStrings("row-one", text),
3579 else => return error.ExpectedText,
3580 }
3581
3582 // A reply that outlived its own highlight. Whatever cleared it — a
3583 // relayout, a forget, a zoom, a resync — the rows it names are not on
3584 // anybody's screen now, and a copy from it is a copy the user did not
3585 // ask for and cannot see.
3586 _ = try askedFor(&core, &tr);
3587 const orphan = replyBytes(&rbuf, core.semantic.pending_selection_id.?, .ok, 500, "row-one");
3588 try std.testing.expectEqual(Copy.none, core.selectionCopy(orphan, null));
3589
3590 // ...and a reply that arrived over a DIFFERENT selection is the same
3591 // thing said the other way: the highlight moved on before the answer
3592 // came back.
3593 held = try askedFor(&core, &tr);
3594 var other = held;
3595 other.to.row += 1;
3596 const stale = replyBytes(&rbuf, core.semantic.pending_selection_id.?, .ok, 500, "row-one");
3597 try std.testing.expectEqual(Copy.none, core.selectionCopy(stale, other));
3598
3599 // Somebody else's id — a reply to a request this drag replaced.
3600 held = try askedFor(&core, &tr);
3601 const wrong_id = replyBytes(&rbuf, core.semantic.pending_selection_id.? +% 1, .ok, 500, "row-one");
3602 try std.testing.expectEqual(Copy.none, core.selectionCopy(wrong_id, held));
3603
3604 // An empty selection is nothing to put on a clipboard. OSC 52 with an
3605 // empty payload is the CLEAR form, so proxying it would wipe whatever
3606 // the human last copied (`client_core.validClipboard`).
3607 held = try askedFor(&core, &tr);
3608 const empty = replyBytes(&rbuf, core.semantic.pending_selection_id.?, .ok, 500, "");
3609 try std.testing.expectEqual(Copy.none, core.selectionCopy(empty, held));
3610
3611 // The daemon's own refusals. Neither is a sentence the user can act on.
3612 for ([_]proto.SelectionStatus{ .invalid, .unavailable }) |status| {
3613 held = try askedFor(&core, &tr);
3614 const refused = replyBytes(&rbuf, core.semantic.pending_selection_id.?, status, 500, "");
3615 try std.testing.expectEqual(Copy.none, core.selectionCopy(refused, held));
3616 }
3617 }
3618
3619 test "interact: a demote drops the highlight, and the reply it was waiting for" {
3620 const alloc = std.testing.allocator;
3621 const p = try std.posix.pipe2(.{ .NONBLOCK = true });
3622 defer std.posix.close(p[0]);
3623 defer std.posix.close(p[1]);
3624 var core = try dragFixture(alloc, p[1]);
3625 defer core.deinit();
3626 var tr: SelectionTransport = .{};
3627 var buf: [8192]u8 = undefined;
3628 _ = drainPipe(p[0], &buf);
3629
3630 _ = try askedFor(&core, &tr);
3631 var rbuf: [64]u8 = undefined;
3632 const id = core.semantic.pending_selection_id.?;
3633
3634 // The zoom moved on. This Core's screen belongs to somebody else now —
3635 // the wall's stripes, or another tile — so the inversion it was
3636 // holding is over rows nobody can see, and the answer still in flight
3637 // is for a highlight that has stopped existing.
3638 core.releaseTerminal(.already_written);
3639 try std.testing.expect(core.drag.range() == null);
3640 const late = replyBytes(&rbuf, id, .ok, 500, "row-one");
3641 try std.testing.expectEqual(Copy.none, core.selectionCopy(late, core.drag.range()));
3642 }
3643
3644 test "interact: a selection too big for OSC 52 is refused out loud, never trimmed" {
3645 const alloc = std.testing.allocator;
3646 const p = try std.posix.pipe2(.{ .NONBLOCK = true });
3647 defer std.posix.close(p[0]);
3648 defer std.posix.close(p[1]);
3649 var core = try dragFixture(alloc, p[1]);
3650 defer core.deinit();
3651 var tr: SelectionTransport = .{};
3652 var buf: [8192]u8 = undefined;
3653 _ = drainPipe(p[0], &buf);
3654
3655 // Base64 is 4 bytes per 3, so the 64 KiB cap on the encoded form
3656 // (`protocol.clipboard_base64_max`) is reached by three quarters of it
3657 // in text — a long way under the 1 MiB the daemon will happily send
3658 // (`protocol.selection_text_max`), which is the whole reason this gap
3659 // exists to fall into.
3660 const fits = proto.clipboard_base64_max / 4 * 3;
3661 const rbuf = try alloc.alloc(u8, proto.selection_reply_prefix_len + fits + 1);
3662 defer alloc.free(rbuf);
3663 const big = try alloc.alloc(u8, fits + 1);
3664 defer alloc.free(big);
3665 @memset(big, 'x');
3666
3667 var held = try askedFor(&core, &tr);
3668 const at_cap = replyBytes(rbuf, core.semantic.pending_selection_id.?, .ok, 0, big[0..fits]);
3669 switch (core.selectionCopy(at_cap, held)) {
3670 .text => |text| try std.testing.expectEqual(fits, text.len),
3671 else => return error.ExpectedText,
3672 }
3673
3674 // One byte more, and `validClipboard` would refuse the encoding — with
3675 // `appendHostEffect` writing nothing and saying nothing, which is the
3676 // silence this exists to break.
3677 held = try askedFor(&core, &tr);
3678 const over = replyBytes(rbuf, core.semantic.pending_selection_id.?, .ok, 0, big);
3679 try std.testing.expectEqual(Copy.too_large, core.selectionCopy(over, held));
3680
3681 // The daemon's own cap, for a selection past `selection_text_max`. It
3682 // arrives with no text at all, and it means the same thing to the user.
3683 held = try askedFor(&core, &tr);
3684 const daemons = replyBytes(rbuf, core.semantic.pending_selection_id.?, .too_large, 0, "");
3685 try std.testing.expectEqual(Copy.too_large, core.selectionCopy(daemons, held));
3686 }
3687
3688 test "interact: the copy leaves as OSC 52, through the one writer of it" {
3689 const alloc = std.testing.allocator;
3690 const p = try std.posix.pipe2(.{ .NONBLOCK = true });
3691 defer std.posix.close(p[0]);
3692 defer std.posix.close(p[1]);
3693 var buf: [8192]u8 = undefined;
3694
3695 try writeSelectionCopy(alloc, p[1], "hi");
3696 // `c` is the clipboard, which is what tmux's `set-clipboard external`
3697 // sets and what a paste reads back.
3698 try std.testing.expectEqualStrings("\x1b]52;c;aGk=\x07", drainPipe(p[0], &buf));
3699 }
3700
3701 test "interact: only what the driver has an answer of its own for comes back" {
3300 const alloc = std.testing.allocator; 3702 const alloc = std.testing.allocator;
3301 // No terminal at either end: every paint and every side channel is 3703 // No terminal at either end: every paint and every side channel is
3302 // refused, so what this exercises is the ROUTING and nothing else. 3704 // refused, so what this exercises is the ROUTING and nothing else.
@@ -3304,7 +3706,8 @@ test "interact: only lifecycle and agent-channel frames come back to the driver"
3304 defer core.deinit(); 3706 defer core.deinit();
3305 3707
3306 // The set, named. Adding to it is asking every driver to learn a new 3708 // The set, named. Adding to it is asking every driver to learn a new
3307 // meaning, so it is pinned rather than described. 3709 // meaning, so it is pinned rather than described. `selection_reply` is
3710 // the one the Core has already half-answered — see `Routed.not_mine`.
3308 const drivers_own = [_]proto.MsgType{ 3711 const drivers_own = [_]proto.MsgType{
3309 .exit_status, 3712 .exit_status,
3310 .taken_over, 3713 .taken_over,
@@ -3312,6 +3715,7 @@ test "interact: only lifecycle and agent-channel frames come back to the driver"
3312 .agent_open, 3715 .agent_open,
3313 .agent_data, 3716 .agent_data,
3314 .agent_close, 3717 .agent_close,
3718 .selection_reply,
3315 }; 3719 };
3316 for (drivers_own) |t| { 3720 for (drivers_own) |t| {
3317 try std.testing.expectEqual(Routed.not_mine, try core.frame(t, "")); 3721 try std.testing.expectEqual(Routed.not_mine, try core.frame(t, ""));
src/wallview.zig
Old New
@@ -480,6 +480,19 @@ const Tile = struct {
480 /// line as soon as the session scrolled the window under it. 480 /// line as soon as the session scrolled the window under it.
481 win: Window = .{}, 481 win: Window = .{},
482 482
483 /// A finished wall drag waiting for this tile's PUMP to ask the daemon
484 /// what is under it.
485 ///
486 /// The keyboard is the thread that reads the mouse and it may not touch
487 /// a `Transport` — one owning thread per transport, and a reconnect
488 /// swaps the struct out from under the pump. So it posts the range and
489 /// rings, and the pump sends, receives on its own link and acts. The
490 /// same shape as `ask`, which the zoom chords use for the same reason.
491 ///
492 /// Under `paint_mu`, the lock the drag it came from is already under —
493 /// a second lock here would order nothing that one does not.
494 sel_req: ?select.Range = null,
495
483 fn viewRows(t: *const Tile) u16 { 496 fn viewRows(t: *const Tile) u16 {
484 return t.stripe.rows - 1; 497 return t.stripe.rows - 1;
485 } 498 }
@@ -499,6 +512,17 @@ fn stripeContentRow(s: Stripe, row: u16) ?u16 {
499 return if (off < s.rows -| 1) off else null; 512 return if (off < s.rows -| 1) off else null;
500 } 513 }
501 514
515 /// Take this tile's pending selection request, if the keyboard left one.
516 ///
517 /// A swap and not a read: one posted question is one question asked, the
518 /// discipline `ask` already keeps.
519 fn takeSelectionReq(t: *Tile) ?select.Range {
520 t.shared.paint_mu.lock();
521 defer t.shared.paint_mu.unlock();
522 defer t.sel_req = null;
523 return t.sel_req;
524 }
525
502 /// Which session line a zero-based terminal (row, col) points at. 526 /// Which session line a zero-based terminal (row, col) points at.
503 /// 527 ///
504 /// `select.Hit` and not a spelling of its own: it is the same triple the 528 /// `select.Hit` and not a spelling of its own: it is the same triple the
@@ -615,6 +639,18 @@ fn wallMouse(
615 .release => ended = shared.drag.release(), 639 .release => ended = shared.drag.release(),
616 } 640 }
617 moved = !std.meta.eql(was, shared.drag.range()); 641 moved = !std.meta.eql(was, shared.drag.range());
642 // The drag copies, so the button coming up is a question for the
643 // daemon — and the keyboard may not ask it. Posted here, under the
644 // lock the drag itself is under, for the pump whose link it goes
645 // out on. The tile is the drag's own and not the SELECTED one: a
646 // drag never moved the selection.
647 if (ended == .selection) {
648 const r = ended.selection;
649 // `x` may have taken the stripe away while the button was down
650 // — the same guard the click keeps, for the same reason.
651 if (r.from.tile < present.len and present[r.from.tile])
652 tiles[r.from.tile].sel_req = r;
653 }
618 } 654 }
619 655
620 // The highlight changed, so every stripe is now a frame out of date. 656 // The highlight changed, so every stripe is now a frame out of date.
@@ -635,6 +671,14 @@ fn wallMouse(
635 if (at.tile < present.len and present[at.tile]) 671 if (at.tile < present.len and present[at.tile])
636 moveSelection(tiles, shared, at.tile); 672 moveSelection(tiles, shared, at.tile);
637 } 673 }
674 // The question posted above needs a doorbell of its own: a release
675 // moves no highlight, so the bump and the round of rings above did not
676 // happen, and a pump over a quiet session would sit in poll(2) holding
677 // an unasked question until something else woke it.
678 if (ended == .selection) {
679 const at = ended.selection.from.tile;
680 if (at < present.len and present[at]) ring(&tiles[at]);
681 }
638 } 682 }
639 683
640 /// Everything the wall's keyboard loop is holding mid-stream. 684 /// Everything the wall's keyboard loop is holding mid-stream.
@@ -870,6 +914,64 @@ fn paintStripe(t: *Tile, alloc: std.mem.Allocator, eng: *Engine, history_rows: u
870 return true; 914 return true;
871 } 915 }
872 916
917 /// Say one sentence on the wall's own terminal, from a pump that may not
918 /// hold the zoom.
919 ///
920 /// `Core.banner` is the zoomed spelling and refuses when demoted — its sink
921 /// IS the zoom test — and a copy refused for its size has to be said
922 /// whether the tile is a stripe or the whole screen. Top-right, which at an
923 /// unzoomed wall is the first tile's label bar: it is written over by the
924 /// next repaint of that bar, which is the right lifetime for something the
925 /// user needs to read once.
926 fn wallBanner(shared: *Shared, text: []const u8) void {
927 if (!shared.is_tty) return;
928 shared.paint_mu.lock();
929 defer shared.paint_mu.unlock();
930 paint.paintBanner(shared.out_fd, shared.size, text);
931 }
932
933 /// Put a finished selection's text on the HOST terminal's clipboard.
934 ///
935 /// Which drag the answer has to still match is which screen this tile is
936 /// on: zoomed, the Core read the mouse and holds its own; unzoomed, the
937 /// wall's keyboard did and the drag is `Shared`'s. Nothing else separates
938 /// the two paths — the tile index is part of a `select.Range`, so a drag
939 /// begun over the neighbour cannot match a request made from here.
940 ///
941 /// Under `paint_mu` for `interact.semanticFrame`'s reason: an OSC 52
942 /// payload runs to 64 KiB and leaves as a write loop, and another thread's
943 /// screen clear spliced into the middle of it leaves the terminal hunting
944 /// for a string terminator and eating everything painted after.
945 fn copySelection(
946 t: *Tile,
947 alloc: std.mem.Allocator,
948 core: *interact.Core,
949 payload: []const u8,
950 ) void {
951 var answer: interact.Copy = .none;
952 {
953 t.shared.paint_mu.lock();
954 defer t.shared.paint_mu.unlock();
955 const held = if (t.shared.zoom.load(.acquire) == t.idx)
956 core.drag.range()
957 else
958 t.shared.drag.range();
959 answer = core.selectionCopy(payload, held);
960 switch (answer) {
961 // `is_tty` and not the tile's claim: a demoted stripe is the
962 // usual answerer here and the copy is still the user's. A piped
963 // `mux` asks its terminal for no mouse modes, so it can have no
964 // drag to copy in the first place.
965 .text => |text| if (t.shared.is_tty)
966 interact.writeSelectionCopy(alloc, t.shared.out_fd, text) catch {},
967 .none, .too_large => {},
968 }
969 }
970 // Outside the hold: `wallBanner` takes the same lock, which is not
971 // reentrant.
972 if (answer == .too_large) wallBanner(t.shared, "[selection too large to copy]");
973 }
974
873 /// The wall's answer to a Core asking whether it may paint: only while this 975 /// The wall's answer to a Core asking whether it may paint: only while this
874 /// tile IS the zoom, and with `paint_mu` held for as long as the paint takes 976 /// tile IS the zoom, and with `paint_mu` held for as long as the paint takes
875 /// (see `interact.Sink`). 977 /// (see `interact.Sink`).
@@ -1502,6 +1604,16 @@ fn pumpTile(t: *Tile) void {
1502 continue :outer; 1604 continue :outer;
1503 }; 1605 };
1504 } 1606 }
1607 // A wall drag that ended over this tile's stripe. Same reason the
1608 // chord above is asked from here: the keyboard reads the mouse but
1609 // this thread owns the link, and the answer arrives on it.
1610 if (takeSelectionReq(t)) |r| {
1611 core.requestSelection(&transport, r) catch {
1612 if (!redial(t, alloc, &core, &transport, target, &state, &agent_locals)) return;
1613 continue :outer;
1614 };
1615 }
1616
1505 // A chord that was never answered. Said with the banner rather than 1617 // A chord that was never answered. Said with the banner rather than
1506 // stderr for the client's reason: the terminal is in raw mode on 1618 // stderr for the client's reason: the terminal is in raw mode on
1507 // the alternate screen and a print there lands mid-grid. Reachable 1619 // the alternate screen and a print there lands mid-grid. Reachable
@@ -1699,6 +1811,7 @@ fn pumpTile(t: *Tile) void {
1699 }; 1811 };
1700 if (pick) |p| postAnswer(t, p); 1812 if (pick) |p| postAnswer(t, p);
1701 }, 1813 },
1814 .selection_reply => copySelection(t, alloc, &core, frame.payload),
1702 .agent_open => { 1815 .agent_open => {
1703 const id = proto.decodeAgentId(frame.payload) catch break :frames; 1816 const id = proto.decodeAgentId(frame.payload) catch break :frames;
1704 // Every refusal is the same answer on the wire — 1817 // Every refusal is the same answer on the wire —
@@ -3827,6 +3940,267 @@ test "a drag at the wall stays in the stripe it started in, and rings for a repa
3827 try std.testing.expect(shared.drag.span(1, 105, 80) == null); 3940 try std.testing.expect(shared.drag.span(1, 105, 80) == null);
3828 } 3941 }
3829 3942
3943 test "a wall drag posts its selection for the pump, because the keyboard owns no link" {
3944 const p = try std.posix.pipe2(.{ .NONBLOCK = true });
3945 defer std.posix.close(p[0]);
3946 defer std.posix.close(p[1]);
3947 var shared = Shared{ .out_fd = p[1], .size = .{ .cols = 80, .rows = 24 }, .is_tty = true };
3948 var tiles = [_]Tile{
3949 .{ .r = .{ .target = .{ .sock = "/s" }, .label = "a", .session = "a" }, .stripe = .{ .top = 0, .rows = 12 }, .shared = &shared, .idx = 0 },
3950 .{ .r = .{ .target = .{ .sock = "/s" }, .label = "b", .session = "b" }, .stripe = .{ .top = 12, .rows = 12 }, .shared = &shared, .idx = 1 },
3951 };
3952 var present = [_]bool{ true, true };
3953 for (&tiles) |*t| {
3954 const pipe = try std.posix.pipe2(.{ .NONBLOCK = true });
3955 t.wake_r = pipe[0];
3956 t.wake_w = pipe[1];
3957 }
3958 defer for (&tiles) |*t| {
3959 std.posix.close(t.wake_r);
3960 std.posix.close(t.wake_w);
3961 };
3962 tiles[0].win = .{ .history_rows = 100, .win_start = 0 };
3963 tiles[1].win = .{ .history_rows = 200, .win_start = 3 };
3964
3965 // A drag inside stripe b, released.
3966 wallMouse(&tiles, &present, &shared, .{ .kind = .press, .button = 0, .col = 4, .row = 15, .at = 0 });
3967 wallMouse(&tiles, &present, &shared, .{ .kind = .motion, .button = 32, .col = 9, .row = 17, .at = 0 });
3968 // Nothing is asked for until the button comes up: a request per cell
3969 // crossed is a round trip per cell crossed.
3970 try std.testing.expect(takeSelectionReq(&tiles[1]) == null);
3971 // Both doorbells emptied first: the MOTION rang them for the highlight
3972 // it moved, and a bell left in the pipe would answer for the release's
3973 // own ring below.
3974 var bell: [8]u8 = undefined;
3975 for (&tiles) |*t| _ = std.posix.read(t.wake_r, &bell) catch 0;
3976
3977 wallMouse(&tiles, &present, &shared, .{ .kind = .release, .button = 0, .col = 9, .row = 17, .at = 0 });
3978
3979 // Posted on the tile the drag was OVER, not the selected one — the
3980 // keyboard may not touch a Transport, so the ask belongs to the pump
3981 // whose link it will go out on.
3982 try std.testing.expect(takeSelectionReq(&tiles[0]) == null);
3983 const asked = takeSelectionReq(&tiles[1]) orelse return error.NothingAsked;
3984 try std.testing.expectEqual(@as(usize, 1), asked.from.tile);
3985 try std.testing.expectEqual(@as(u32, 205), asked.from.row);
3986 try std.testing.expectEqual(@as(u16, 4), asked.from.col);
3987 try std.testing.expectEqual(@as(u32, 207), asked.to.row);
3988 try std.testing.expectEqual(@as(u16, 9), asked.to.col);
3989 // Taken once. A pump that polled twice must not ask twice.
3990 try std.testing.expect(takeSelectionReq(&tiles[1]) == null);
3991
3992 // The doorbell is what gets it there: a stripe whose session is quiet
3993 // sends no frame of its own, so a pump waiting on its link would sit
3994 // in poll(2) holding an unasked question.
3995 try std.testing.expect(try std.posix.read(tiles[1].wake_r, &bell) > 0);
3996 // The neighbour is not woken: it has no question to ask.
3997 try std.testing.expectError(error.WouldBlock, std.posix.read(tiles[0].wake_r, &bell));
3998
3999 // A CLICK posts nothing. It highlights nothing, so there is no text
4000 // under it to ask about.
4001 wallMouse(&tiles, &present, &shared, .{ .kind = .press, .button = 0, .col = 4, .row = 15, .at = 0 });
4002 wallMouse(&tiles, &present, &shared, .{ .kind = .release, .button = 0, .col = 4, .row = 15, .at = 0 });
4003 try std.testing.expect(takeSelectionReq(&tiles[1]) == null);
4004 }
4005
4006 /// A transport that swallows the pump's frames. What the wall copy tests
4007 /// are about is what came back, not what went out.
4008 const SwallowTransport = struct {
4009 pub fn writeFrame(_: *SwallowTransport, _: proto.MsgType, _: []const u8) !void {}
4010 };
4011
4012 /// One `selection_reply` payload: id, status, watermark, text.
4013 fn replyBytes(
4014 buf: []u8,
4015 id: u32,
4016 status: proto.SelectionStatus,
4017 history_rows: u32,
4018 text: []const u8,
4019 ) []const u8 {
4020 std.mem.writeInt(u32, buf[0..4], id, .little);
4021 buf[4] = @intFromEnum(status);
4022 std.mem.writeInt(u32, buf[5..9], history_rows, .little);
4023 @memcpy(buf[proto.selection_reply_prefix_len..][0..text.len], text);
4024 return buf[0 .. proto.selection_reply_prefix_len + text.len];
4025 }
4026
4027 test "a wall drag copies on release, and a stale answer copies nothing" {
4028 const alloc = std.testing.allocator;
4029 const p = try std.posix.pipe2(.{ .NONBLOCK = true });
4030 defer std.posix.close(p[0]);
4031 defer std.posix.close(p[1]);
4032 var shared = Shared{ .out_fd = p[1], .size = .{ .cols = 80, .rows = 24 }, .is_tty = true };
4033 var tiles = [_]Tile{
4034 .{ .r = .{ .target = .{ .sock = "/s" }, .label = "a", .session = "a" }, .stripe = .{ .top = 0, .rows = 12 }, .shared = &shared, .idx = 0 },
4035 .{ .r = .{ .target = .{ .sock = "/s" }, .label = "b", .session = "b" }, .stripe = .{ .top = 12, .rows = 12 }, .shared = &shared, .idx = 1 },
4036 };
4037 var present = [_]bool{ true, true };
4038 for (&tiles) |*t| {
4039 const pipe = try std.posix.pipe2(.{ .NONBLOCK = true });
4040 t.wake_r = pipe[0];
4041 t.wake_w = pipe[1];
4042 }
4043 defer for (&tiles) |*t| {
4044 std.posix.close(t.wake_r);
4045 std.posix.close(t.wake_w);
4046 };
4047 tiles[0].win = .{ .history_rows = 100, .win_start = 0 };
4048 tiles[1].win = .{ .history_rows = 200, .win_start = 3 };
4049
4050 // The pump's half. A stripe's Core is DEMOTED — it holds no claim and
4051 // paints nothing — which is exactly why the copy cannot go out through
4052 // it and goes out through the wall instead.
4053 var core = try interact.Core.initSized(alloc, -1, p[1], shared.size);
4054 defer core.deinit();
4055 core.rep.history_rows = 200;
4056 var tr: SwallowTransport = .{};
4057 var buf: [8192]u8 = undefined;
4058 var rbuf: [64]u8 = undefined;
4059
4060 // Drag over stripe b, let go, and let its pump ask.
4061 wallMouse(&tiles, &present, &shared, .{ .kind = .press, .button = 0, .col = 4, .row = 15, .at = 0 });
4062 wallMouse(&tiles, &present, &shared, .{ .kind = .motion, .button = 32, .col = 9, .row = 17, .at = 0 });
4063 wallMouse(&tiles, &present, &shared, .{ .kind = .release, .button = 0, .col = 9, .row = 17, .at = 0 });
4064 try core.requestSelection(&tr, takeSelectionReq(&tiles[1]) orelse return error.NothingAsked);
4065 _ = drainWallPipe(p[0], &buf);
4066
4067 const id = core.semantic.pending_selection_id orelse return error.NoPending;
4068 copySelection(&tiles[1], alloc, &core, replyBytes(&rbuf, id, .ok, 200, "hello"));
4069 try std.testing.expectEqualStrings("\x1b]52;c;aGVsbG8=\x07", drainWallPipe(p[0], &buf));
4070
4071 // A reply that outlived its highlight. `relayout` is one of the three
4072 // clears — the stripes are re-cut under the anchor — and after one the
4073 // rows the answer names are on nobody's screen.
4074 wallMouse(&tiles, &present, &shared, .{ .kind = .press, .button = 0, .col = 4, .row = 15, .at = 0 });
4075 wallMouse(&tiles, &present, &shared, .{ .kind = .motion, .button = 32, .col = 9, .row = 17, .at = 0 });
4076 wallMouse(&tiles, &present, &shared, .{ .kind = .release, .button = 0, .col = 9, .row = 17, .at = 0 });
4077 try core.requestSelection(&tr, takeSelectionReq(&tiles[1]) orelse return error.NothingAsked);
4078 _ = drainWallPipe(p[0], &buf);
4079 shared.paint_mu.lock();
4080 shared.drag.clear();
4081 shared.paint_mu.unlock();
4082 const orphan = core.semantic.pending_selection_id orelse return error.NoPending;
4083 copySelection(&tiles[1], alloc, &core, replyBytes(&rbuf, orphan, .ok, 200, "hello"));
4084 try std.testing.expectEqualStrings("", drainWallPipe(p[0], &buf));
4085
4086 // With no terminal on the other end, nothing goes out. A piped `mux`
4087 // writes no terminal state at all — no modes asked for, so no report
4088 // to read and no drag to copy — and this is where that stays true if a
4089 // drag ever reaches here another way.
4090 shared.is_tty = false;
4091 wallMouse(&tiles, &present, &shared, .{ .kind = .press, .button = 0, .col = 4, .row = 15, .at = 0 });
4092 wallMouse(&tiles, &present, &shared, .{ .kind = .motion, .button = 32, .col = 9, .row = 17, .at = 0 });
4093 wallMouse(&tiles, &present, &shared, .{ .kind = .release, .button = 0, .col = 9, .row = 17, .at = 0 });
4094 try core.requestSelection(&tr, takeSelectionReq(&tiles[1]) orelse return error.NothingAsked);
4095 _ = drainWallPipe(p[0], &buf);
4096 const piped = core.semantic.pending_selection_id orelse return error.NoPending;
4097 copySelection(&tiles[1], alloc, &core, replyBytes(&rbuf, piped, .ok, 200, "hello"));
4098 try std.testing.expectEqualStrings("", drainWallPipe(p[0], &buf));
4099 }
4100
4101 test "a zoomed tile copies from its own drag, not the wall's" {
4102 const alloc = std.testing.allocator;
4103 const p = try std.posix.pipe2(.{ .NONBLOCK = true });
4104 defer std.posix.close(p[0]);
4105 defer std.posix.close(p[1]);
4106 var shared = Shared{ .out_fd = p[1], .size = .{ .cols = 80, .rows = 24 }, .is_tty = true };
4107 var tiles = [_]Tile{
4108 .{ .r = .{ .target = .{ .sock = "/s" }, .label = "a", .session = "a" }, .stripe = .{ .top = 0, .rows = 24 }, .shared = &shared, .idx = 0 },
4109 };
4110 const pipe = try std.posix.pipe2(.{ .NONBLOCK = true });
4111 tiles[0].wake_r = pipe[0];
4112 tiles[0].wake_w = pipe[1];
4113 defer std.posix.close(pipe[0]);
4114 defer std.posix.close(pipe[1]);
4115 tiles[0].win = .{ .history_rows = 0, .win_start = 0 };
4116 shared.zoom.store(0, .release);
4117
4118 var core = try interact.Core.initSized(alloc, -1, p[1], shared.size);
4119 defer core.deinit();
4120 core.is_tty = true;
4121 _ = core.claimTerminal();
4122 var tr: SwallowTransport = .{};
4123 var buf: [8192]u8 = undefined;
4124 var rbuf: [64]u8 = undefined;
4125
4126 // Zoomed, the Core reads the mouse itself and asks from where it
4127 // stands — it is already on the thread that owns the link, so there is
4128 // no relay and nothing lands in `sel_req`.
4129 _ = try core.forward(&tr, "\x1b[<0;3;2M");
4130 _ = try core.forward(&tr, "\x1b[<32;7;3M");
4131 _ = try core.forward(&tr, "\x1b[<0;7;3m");
4132 try std.testing.expect(takeSelectionReq(&tiles[0]) == null);
4133 const id = core.semantic.pending_selection_id orelse return error.NoPending;
4134 _ = drainWallPipe(p[0], &buf);
4135
4136 copySelection(&tiles[0], alloc, &core, replyBytes(&rbuf, id, .ok, 0, "hi"));
4137 try std.testing.expectEqualStrings("\x1b]52;c;aGk=\x07", drainWallPipe(p[0], &buf));
4138
4139 // And a highlight the WALL is holding does not answer for a zoomed
4140 // tile: the screen belongs to the Core, so its drag is the one a reply
4141 // has to still match.
4142 _ = try core.forward(&tr, "\x1b[<0;3;2M");
4143 _ = try core.forward(&tr, "\x1b[<32;7;3M");
4144 _ = try core.forward(&tr, "\x1b[<0;7;3m");
4145 const orphan = core.semantic.pending_selection_id orelse return error.NoPending;
4146 const wall_range = core.drag.range().?;
4147 core.drag.clear();
4148 shared.paint_mu.lock();
4149 shared.drag.press(.{ .row = 1, .col = 2 }, wall_range.from);
4150 shared.drag.motion(.{ .row = 2, .col = 6 }, wall_range.to);
4151 shared.paint_mu.unlock();
4152 _ = drainWallPipe(p[0], &buf);
4153 copySelection(&tiles[0], alloc, &core, replyBytes(&rbuf, orphan, .ok, 0, "hi"));
4154 try std.testing.expectEqualStrings("", drainWallPipe(p[0], &buf));
4155 }
4156
4157 test "a copy too big for OSC 52 is said out loud rather than dropped" {
4158 const alloc = std.testing.allocator;
4159 const p = try std.posix.pipe2(.{ .NONBLOCK = true });
4160 defer std.posix.close(p[0]);
4161 defer std.posix.close(p[1]);
4162 var shared = Shared{ .out_fd = p[1], .size = .{ .cols = 80, .rows = 24 }, .is_tty = true };
4163 var tiles = [_]Tile{
4164 .{ .r = .{ .target = .{ .sock = "/s" }, .label = "a", .session = "a" }, .stripe = .{ .top = 0, .rows = 24 }, .shared = &shared, .idx = 0 },
4165 };
4166 var present = [_]bool{true};
4167 const pipe = try std.posix.pipe2(.{ .NONBLOCK = true });
4168 tiles[0].wake_r = pipe[0];
4169 tiles[0].wake_w = pipe[1];
4170 defer std.posix.close(pipe[0]);
4171 defer std.posix.close(pipe[1]);
4172 tiles[0].win = .{ .history_rows = 0, .win_start = 0 };
4173
4174 var core = try interact.Core.initSized(alloc, -1, p[1], shared.size);
4175 defer core.deinit();
4176 var tr: SwallowTransport = .{};
4177 var buf: [8192]u8 = undefined;
4178
4179 wallMouse(&tiles, &present, &shared, .{ .kind = .press, .button = 0, .col = 4, .row = 3, .at = 0 });
4180 wallMouse(&tiles, &present, &shared, .{ .kind = .motion, .button = 32, .col = 9, .row = 5, .at = 0 });
4181 wallMouse(&tiles, &present, &shared, .{ .kind = .release, .button = 0, .col = 9, .row = 5, .at = 0 });
4182 try core.requestSelection(&tr, takeSelectionReq(&tiles[0]) orelse return error.NothingAsked);
4183 _ = drainWallPipe(p[0], &buf);
4184
4185 // Bigger than OSC 52 can carry and well inside what the daemon will
4186 // send. `validClipboard` would refuse this without a word, and a copy
4187 // that silently did nothing is the whole reason this message exists.
4188 const fits = proto.clipboard_base64_max / 4 * 3;
4189 const rbuf = try alloc.alloc(u8, proto.selection_reply_prefix_len + fits + 1);
4190 defer alloc.free(rbuf);
4191 const big = try alloc.alloc(u8, fits + 1);
4192 defer alloc.free(big);
4193 @memset(big, 'x');
4194
4195 const id = core.semantic.pending_selection_id orelse return error.NoPending;
4196 copySelection(&tiles[0], alloc, &core, replyBytes(rbuf, id, .ok, 0, big));
4197 const said = drainWallPipe(p[0], &buf);
4198 try std.testing.expect(std.mem.indexOf(u8, said, "too large") != null);
4199 // Refused, not trimmed: a half-copied selection is worse than none,
4200 // because the user finds out when they paste it.
4201 try std.testing.expect(std.mem.indexOf(u8, said, "\x1b]52;") == null);
4202 }
4203
3830 test "a stripe paints its own highlight and nobody else's" { 4204 test "a stripe paints its own highlight and nobody else's" {
3831 const alloc = std.testing.allocator; 4205 const alloc = std.testing.allocator;
3832 const p = try std.posix.pipe2(.{ .NONBLOCK = true }); 4206 const p = try std.posix.pipe2(.{ .NONBLOCK = true });
test/e2e.sh
Old New
@@ -1554,6 +1554,7 @@ cleanup() {
1554 "$OUT.wmcap2" "$OUT.wmcap2.err" "$OUT.wmpc2" "$OUT.wmfa" "$OUT.wmfb" \ 1554 "$OUT.wmcap2" "$OUT.wmcap2.err" "$OUT.wmpc2" "$OUT.wmfa" "$OUT.wmfb" \
1555 "$OUT.wmcap3" "$OUT.wmcap3.err" "$OUT.wmpc3" \ 1555 "$OUT.wmcap3" "$OUT.wmcap3.err" "$OUT.wmpc3" \
1556 "$OUT.wmcap4" "$OUT.wmcap4.err" "$OUT.wmpc4" \ 1556 "$OUT.wmcap4" "$OUT.wmcap4.err" "$OUT.wmpc4" \
1557 "$OUT.selcap" "$OUT.selcap.err" "$OUT.selpc" \
1557 "$OUT.wmstop" 1558 "$OUT.wmstop"
1558 # The convergence files a FAILING assert_converged leaves behind 1559 # The convergence files a FAILING assert_converged leaves behind
1559 # (.render/.dump/.rvt/.dvt/.diff for that capture) are deliberately not 1560 # (.render/.dump/.rvt/.dvt/.diff for that capture) are deliberately not
@@ -7713,9 +7714,58 @@ grep -q "wm-four" "$OUT.wmfb" || {
7713 grep -q "wm-four" "$OUT.wmfa" && { 7714 grep -q "wm-four" "$OUT.wmfa" && {
7714 echo "e2e FAIL: wall click: a label-bar click selected the tile it belongs to:" 7715 echo "e2e FAIL: wall click: a label-bar click selected the tile it belongs to:"
7715 cat "$OUT.wmfa"; exit 1; } 7716 cat "$OUT.wmfa"; exit 1; }
7717 ok "a click selects the stripe under it, and a click on a label bar selects nothing"
7718
7719
7720 # ---- a drag copies, and the copy leaves as OSC 52 ----------------------
7721 #
7722 # The first end-to-end proof that this feature does what it was asked for.
7723 # The highlight legs above could not have one: a highlight is a PAINT, and
7724 # these legs read the daemon rather than the screen. A copy is different —
7725 # it lands on the CLIENT's tty as OSC 52, which the fixture holding that
7726 # tty can read, so the assertion is the copied text itself.
7727 #
7728 # Zoomed, because that is where the geometry is knowable: `mux TARGET` is a
7729 # wall of one tile entered zoomed and `renderClipped` is top-anchored, so a
7730 # terminal row IS a grid row and the text can be put on one by name. The
7731 # session is given its content with an explicit cursor address rather than
7732 # by printing lines, so the row this drag crosses does not depend on where
7733 # a prompt happened to leave the cursor.
7734 #
7735 # The typed line is `ZZ-%s` and the printed line is `ZZ-COPYME`, so the
7736 # expect below cannot be satisfied by the shell ECHOING the command back.
7737 #
7738 # Columns 1 to 9 and no further: the prompt lands at column 10 on the same
7739 # row, and a selection that reached it would copy the prompt too.
7740 set +e
7741 timeout 60 "$PTYCLIENT" --cols 100 --rows 30 --out "$OUT.selcap" --err "$OUT.selcap.err" -- \
7742 "$MUX" --sock "$SOCK50" --session s > "$OUT.selpc" 2>&1 <<'EOF'
7743 settle 900 20000
7744 send printf '\\033[2J\\033[9;1HZZ-%s' COPYME\n
7745 expect ZZ-COPYME 15000
7746 settle 700 20000
7747 send \x1b[<0;3;9M\x1b[<0;3;9m
7748 settle 500 15000
7749 send \x1b[<0;1;9M\x1b[<32;9;9M\x1b[<0;9;9m
7750 expect \x1b]52;c;WlotQ09QWU1F 15000
7751 send \x1cd
7752 waitexit 10000
7753 EOF
7754 RC=$?
7755 set -e
7756 [ "$RC" -eq 0 ] || {
7757 echo "e2e FAIL: drag copy: ptyclient leg exited $RC (no OSC 52 with the dragged text?):"
7758 cat "$OUT.selpc"; exit 1; }
7759 # Exactly one copy on that tty, which is what makes the CLICK above a
7760 # negative rather than decoration: a click highlights nothing, so it has
7761 # nothing to copy, and a client that copied on every button release would
7762 # have written two.
7763 SELCOPIES=$(grep -ao "$(printf '\033]52;')" "$OUT.selcap" | wc -l)
7764 [ "$SELCOPIES" = "1" ] || {
7765 echo "e2e FAIL: drag copy: $SELCOPIES OSC 52 writes on the client's tty, expected 1"; exit 1; }
7716 assert_stopped "$SOCK50" "$D48PID" "wall mouse" "$OUT.wmstop" 7766 assert_stopped "$SOCK50" "$D48PID" "wall mouse" "$OUT.wmstop"
7717 D48PID="" 7767 D48PID=""
7718 ok "a click selects the stripe under it, and a click on a label bar selects nothing" 7768 ok "a drag copies on release, and a click copies nothing"
7719 7769
7720 7770
7721 # The long-lived daemon has served every scenario that wanted it; stop it 7771 # The long-lived daemon has served every scenario that wanted it; stop it
@@ -7830,12 +7880,15 @@ DPID=""
7830 # convergence point, because their subject is WHICH SESSION a keystroke 7880 # convergence point, because their subject is WHICH SESSION a keystroke
7831 # reached (read off two daemon captures, one of which must not contain it) 7881 # reached (read off two daemon captures, one of which must not contain it)
7832 # and a client grid compared against the daemon's says nothing about where 7882 # and a client grid compared against the daemon's says nothing about where
7833 # the wall decided to send input. They are last in the file rather than 7883 # the wall decided to send input. The 63rd is the drag that copies, and no
7834 # beside the other wall legs on purpose: the ordinals in this paragraph are 7884 # convergence point because its subject is a side channel and not a grid:
7835 # positions, so a scenario inserted in the middle renumbers every sentence 7885 # what it asserts on is OSC 52 arriving on the client's own tty, carrying
7836 # after it. 7886 # text the daemon extracted from rows the client never painted twice. They
7837 [ "$OK_COUNT" = "62" ] || { 7887 # are last in the file rather than beside the other wall legs on purpose:
7838 echo "e2e FAIL: $OK_COUNT scenario checkpoints ran, the pin says 62 —" 7888 # the ordinals in this paragraph are positions, so a scenario inserted in
7889 # the middle renumbers every sentence after it.
7890 [ "$OK_COUNT" = "63" ] || {
7891 echo "e2e FAIL: $OK_COUNT scenario checkpoints ran, the pin says 63 —"
7839 echo " a scenario was added (update the pin) or silently lost" 7892 echo " a scenario was added (update the pin) or silently lost"
7840 exit 1 7893 exit 1
7841 } 7894 }
@@ -7843,4 +7896,4 @@ DPID=""
7843 echo "e2e FAIL: $CONV_COUNT convergence points ran, the pin says 35" 7896 echo "e2e FAIL: $CONV_COUNT convergence points ran, the pin says 35"
7844 exit 1 7897 exit 1
7845 } 7898 }
7846 echo "e2e OK (62 scenarios, 35 convergence points)" 7899 echo "e2e OK (63 scenarios, 35 convergence points)"