e439be23
feat: a drag copies the text it crossed
a73x 2026-08-22 13:13
Commit message
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)" |