3754dae9
feat: select native terminal text and copy on release
a73x 2026-09-06 07:51
Commit message
RETRO.md
| Old | New | ||
|---|---|---|---|
| @@ -974,7 +974,7 @@ backlog. Duplicate appearance checkboxes now point to their completed slices. | |||
| 974 | 974 | ||
| 975 | | Issue | Current gap and evidence | | 975 | | Issue | Current gap and evidence | |
| 976 | | --- | --- | | 976 | | --- | --- | |
| 977 | | `8b16e26b` mouse/wheel/selection | Missing pane wheel scrollback, terminal app mouse forwarding, drag selection and copy. `src/gui/frame.zig` handles left-button focus/rail drag, with no wheel event or clipboard path. Follow the issue's wheel-first slice, then shared selection and daemon-owned extraction. Paste is a subsequent slice. | | 977 | | `8b16e26b` mouse/wheel/selection | Visible-text drag selection and desktop copy are now delivered on the text-selection branch (demo acceptance pending). Wheel/history navigation, application mouse forwarding, extended selection gestures and GUI paste remain. See the text-selection delivery entry below; the user selected this slice before wheel scrolling. | |
| 978 | | `f3cf5785` SSH askpass | No native prompt UI for passphrases or first-use host-key confirmation. TUI owns the askpass listener/prompt flow; shared askpass support exists. Earlier SSH failure classification did not implement prompts. | | 978 | | `f3cf5785` SSH askpass | No native prompt UI for passphrases or first-use host-key confirmation. TUI owns the askpass listener/prompt flow; shared askpass support exists. Earlier SSH failure classification did not implement prompts. | |
| 979 | | `50ca9ba5` agent forwarding | No GUI `-A` policy or agent relay. Picker targets disable forwarding; functional relay handling remains in the TUI pump. | | 979 | | `50ca9ba5` agent forwarding | No GUI `-A` policy or agent relay. Picker targets disable forwarding; functional relay handling remains in the TUI pump. | |
| 980 | | `fb4a0ee4` prediction | No native local typing prediction overlay. The predictor remains TUI-local; sharing must preserve the authoritative replica. | | 980 | | `fb4a0ee4` prediction | No native local typing prediction overlay. The predictor remains TUI-local; sharing must preserve the authoritative replica. | |
| @@ -1102,3 +1102,69 @@ receiving. The existing protocol does not carry a server sequence precondition; | |||
| 1102 | observed output changes can be rejected, but atomic copying of a historical | 1102 | observed output changes can be rejected, but atomic copying of a historical |
| 1103 | frame is not promised. A protocol-level version guard is retained for the next | 1103 | frame is not promised. A protocol-level version guard is retained for the next |
| 1104 | selection-consistency change if that stronger guarantee is required. | 1104 | selection-consistency change if that stronger guarantee is required. |
| 1105 | |||
| 1106 | |||
| 1107 | ## Native text selection delivery — 2026-09-06 | ||
| 1108 | |||
| 1109 | Implemented visible text drag selection and desktop copy on release. The user | ||
| 1110 | selected this before wheel scrolling; wheel remains deferred and unimplemented. | ||
| 1111 | The shared selection model was moved once in opening commit `b0a68b1`; both | ||
| 1112 | frontends consume it. Daemon extraction and the existing wire remain the owners | ||
| 1113 | of Unicode, soft wraps, trimming and wide-cell copy semantics. | ||
| 1114 | |||
| 1115 | The reuse map guided implementation. Closing cleanup replaced repeated source | ||
| 1116 | version fields/rules with `SelectionVersion`, centralized pump invalidation, | ||
| 1117 | removed redundant held-range storage and the painter's duplicate span type. | ||
| 1118 | Queued request tickets cancel work before transmission; one owned result and a | ||
| 1119 | bounded deadline cross into the SDL clipboard adapter. Core rules remain free | ||
| 1120 | of window/font libraries. No new runtime dependency or protocol message was | ||
| 1121 | introduced. Root completed shared pump cleanup/tests after Luna's initial | ||
| 1122 | integration; Terra independently reviewed the final production diff. | ||
| 1123 | |||
| 1124 | Review and real checks caught coordinate/history overflow, cross-pane subtraction, | ||
| 1125 | mouse capture remaining after release, lost divider release coordinates, stale | ||
| 1126 | press-version replacement, mode/reconnect invalidation, timeout locking, | ||
| 1127 | allocation-failure ownership and empty-copy behavior. All actionable production | ||
| 1128 | findings were resolved. Initial focused test claims covered existing tests only; | ||
| 1129 | closing work added explicit transport and GUI high-history cases before delivery. | ||
| 1130 | |||
| 1131 | Full CI, client/native units/build, native integration, core-without-GUI metadata, | ||
| 1132 | real NVIDIA Wayland pointer/clipboard checks and both selection-specific and | ||
| 1133 | retained DPI gates passed. Separate NVIDIA stress passed with frame p99 19,267 us | ||
| 1134 | and sampled input upper bound 65.3 ms; historical frame-budget misses remain | ||
| 1135 | unexplained. Logs and earlier failed fixture attempts remain in | ||
| 1136 | `dist/text-selection/`. No macOS or performance improvement is inferred. | ||
| 1137 | |||
| 1138 | The 24.4-second continuous recording uses real Wayland pointer motion and reads | ||
| 1139 | clipboard contents with a separate client. SDL injection alone lacked a valid | ||
| 1140 | Wayland input serial; the retained pointer adapter now makes that boundary | ||
| 1141 | explicit. Clipboard publication is asynchronous, so the independent reader waits | ||
| 1142 | for offers to arrive. An early recorder failure skipped cleanup; the recorder | ||
| 1143 | now guarantees fixture teardown and root stopped only that failed fixture's | ||
| 1144 | owned processes. Private page: https://charizard.folk-amberjack.ts.net/text-selection/ . | ||
| 1145 | Demo approval remains pending; automated success is not user acceptance. | ||
| 1146 | |||
| 1147 | Carry-forward actions and ownership: | ||
| 1148 | |||
| 1149 | - Next selection-consistency sprint: the existing wire has no source-version | ||
| 1150 | precondition at the daemon. Observed changes cancel copying, but an atomic | ||
| 1151 | historical-frame copy would require coordinated wire/frontend work. | ||
| 1152 | - Next mouse-parity sprint: implement wheel/history, then assess edge autoscroll, | ||
| 1153 | word/line/rectangular selection and GUI paste as separately scoped behavior. | ||
| 1154 | Application mouse forwarding remains open under issue `8b16e26b`. | ||
| 1155 | - Next renderer follow-up: investigate earlier NVIDIA budget misses and offscreen | ||
| 1156 | growth artifacts. Current passes do not settle their cause. | ||
| 1157 | - Next recording/tooling change: consider promoting the retained C virtual-pointer | ||
| 1158 | helper and recorder assets; the Python input adapter is now shared by tests and | ||
| 1159 | this demo, while source/protocol/binary provenance remains with the artifacts. | ||
| 1160 | - Next frame/test-protocol change: assess broader extraction of passive hooks; | ||
| 1161 | this slice added one read-only clipboard observation, not a second input path. | ||
| 1162 | |||
| 1163 | |||
| 1164 | The final private page passed HTTP range, desktop/mobile layout, playback and | ||
| 1165 | seeking checks; all three appearance pages still return success. The final | ||
| 1166 | retained recorder also passed after explicitly focusing the owned window before | ||
| 1167 | its first pane click. Offscreen selection was rechecked after the Wayland driver | ||
| 1168 | addition. Browser and compositor fixtures are stopped; only the allowlisted | ||
| 1169 | loopback page server (PID 3384909, port 18774) remains. The main checkout's staged | ||
| 1170 | `RETRO.pre-native-merge.md` remains byte-for-byte unchanged. | ||
build.zig
| Old | New | ||
|---|---|---|---|
| @@ -1209,8 +1209,12 @@ pub fn build(b: *std.Build) void { | |||
| 1209 | native_theme_config.addArtifactArg(mux_exe); | 1209 | native_theme_config.addArtifactArg(mux_exe); |
| 1210 | native_theme_config.addArtifactArg(muxg_exe); | 1210 | native_theme_config.addArtifactArg(muxg_exe); |
| 1211 | native_theme_config.step.dependOn(&native_fonts.step); | 1211 | native_theme_config.step.dependOn(&native_fonts.step); |
| 1212 | const native_selection = b.addSystemCommand(&.{ "python3", "-B", "test/native_selection.py" }); | ||
| 1213 | native_selection.addArtifactArg(mux_exe); | ||
| 1214 | native_selection.addArtifactArg(muxg_exe); | ||
| 1215 | native_selection.step.dependOn(&native_theme_config.step); | ||
| 1212 | const native_e2e_step = b.step("native-e2e", "Run the native client's end-to-end leg (opt-in)"); | 1216 | const native_e2e_step = b.step("native-e2e", "Run the native client's end-to-end leg (opt-in)"); |
| 1213 | native_e2e_step.dependOn(&native_theme_config.step); | 1217 | native_e2e_step.dependOn(&native_selection.step); |
| 1214 | 1218 | ||
| 1215 | // Both paths come from this build graph: a ReleaseSafe GUI beside a stale | 1219 | // Both paths come from this build graph: a ReleaseSafe GUI beside a stale |
| 1216 | // Debug daemon gives misleading latency numbers under raw terminal output. | 1220 | // Debug daemon gives misleading latency numbers under raw terminal output. |
docs/demos/native-text-selection.html
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,32 @@ | |||
| 1 | <!doctype html> | ||
| 2 | <html lang="en"> | ||
| 3 | <meta charset="utf-8"> | ||
| 4 | <meta name="viewport" content="width=device-width, initial-scale=1"> | ||
| 5 | <title>mux · Text selection</title> | ||
| 6 | <style> | ||
| 7 | :root{color-scheme:dark;font-family:system-ui,sans-serif;background:#17191d;color:#e8e9ed} | ||
| 8 | *{box-sizing:border-box}body{max-width:960px;margin:auto;padding:36px 22px 64px;line-height:1.65} | ||
| 9 | h1{font-size:clamp(2rem,6vw,3.8rem);line-height:1.1;margin:12px 0 20px;letter-spacing:-.04em}h2{font-size:1.25rem;margin-top:32px} | ||
| 10 | a{color:#99ded6}p{max-width:75ch}.eyebrow{font-size:.8rem;letter-spacing:.13em;text-transform:uppercase;color:#a1b5b3} | ||
| 11 | video{width:100%;display:block;background:#101114;border-radius:10px;margin:22px 0 8px;border:1px solid #393f46} | ||
| 12 | .cards{display:grid;grid-template-columns:repeat(3,1fr);gap:14px;margin:28px 0}.card{padding:18px;border:1px solid #393f46;border-radius:10px}.card strong{display:block}.muted{color:#adb5bf}code{font-size:.9em;background:#272c32;padding:2px 5px;border-radius:4px}li{margin:8px 0}.status{color:#aadbd0} | ||
| 13 | @media(max-width:650px){.cards{grid-template-columns:1fr}body{padding-top:24px}} | ||
| 14 | </style> | ||
| 15 | <p class="eyebrow">mux native · GUI / TUI parity</p> | ||
| 16 | <h1>Select text.<br>Copy on release.</h1> | ||
| 17 | <p>Drag across visible terminal text and copy it to the desktop clipboard. The GUI uses the existing shared gesture model; the daemon extracts the text, preserving Unicode and the difference between wrapped lines and real newlines.</p> | ||
| 18 | <p class="status">Implemented · Automated checks passed · Demo acceptance pending</p> | ||
| 19 | <video controls playsinline preload="metadata" poster="preview.png"><source src="demo.mp4" type="video/mp4">Your browser can <a href="demo.mp4">download the recording</a>.</video> | ||
| 20 | <p class="muted">24.4 seconds, continuous recording · NVIDIA RTX 3080 · Wayland at 200% scale · <a href="demo.mp4">Open video</a></p> | ||
| 21 | <div class="cards"><div class="card"><strong>One gesture model</strong>The terminal UI and native GUI share click, drag, direction and pane-confinement rules.</div><div class="card"><strong>Correct text ownership</strong>Copy requests use existing daemon extraction, including soft wraps and wide glyphs.</div><div class="card"><strong>Explicit cancellation</strong>Selection is cleared when observed output or geometry changes invalidate its coordinates.</div></div> | ||
| 22 | <h2>What to try</h2> | ||
| 23 | <ul><li>Drag across terminal text in either direction. Release to copy; the highlight remains until cleared.</li><li>Select in another pane. A press focuses that pane, and the selection stays with its starting session.</li><li>A simple click clears the highlight without replacing the clipboard. Resizing, leaving the window or typing also clears selection.</li></ul> | ||
| 24 | <p>The recording reads the desktop clipboard using <code>wl-paste --no-newline</code> in another pane. GUI paste is a later slice.</p> | ||
| 25 | <h2>Validation</h2> | ||
| 26 | <p id="validation">Full CI, native units and integration passed. Real Wayland pointer tests verify desktop clipboard text, delayed replies, cancellation, timeouts and surviving shells. Held selection and fresh copying pass at 100%, 150% and 200%; the retained DPI/resize gate also passed. Core policy tests run without GUI package metadata.</p> | ||
| 27 | <p>Separately measured NVIDIA responsiveness: frame p99 <strong>19.267 ms</strong> (20 ms limit); sampled input-to-painted upper bound <strong>65.3 ms</strong> (250 ms limit, up to 5 ms polling overhead). Earlier unexplained frame-budget misses remain a renderer follow-up; this pass is not a speedup claim.</p> | ||
| 28 | <h2>Scope and limits</h2> | ||
| 29 | <p>This slice selects visible text. Wheel scrolling, edge autoscroll, word and line selection, rectangular selection, GUI paste and application mouse forwarding remain pending. Ligatures are deferred.</p> | ||
| 30 | <p>Observed changes in the selected pane cancel copying. Copying a frozen historical screen while fresh output arrives would need a later protocol change. These checks cover Linux; macOS validation remains open.</p> | ||
| 31 | <p class="muted">Passing automated checks is separate from your demo approval. Feedback requested: selection feel, highlight readability and copy-on-release behavior.</p> | ||
| 32 | </html> | ||
docs/superpowers/plans/2026-09-06-native-text-selection.md
| Old | New | ||
|---|---|---|---|
| @@ -1,6 +1,7 @@ | |||
| 1 | # Native parity — text selection | 1 | # Native parity — text selection |
| 2 | 2 | ||
| 3 | Status: planning and opening cleanup. User selected text selection as the next | 3 | Status: implemented, independently reviewed and validated; actual GUI demo |
| 4 | recorded. User demo acceptance is pending. User selected text selection as the next | ||
| 4 | sprint on 2026-09-06; wheel scrolling remains unimplemented and deferred. | 5 | sprint on 2026-09-06; wheel scrolling remains unimplemented and deferred. |
| 5 | Worktree branch: `gui-text-selection` (existing worktree directory retained). | 6 | Worktree branch: `gui-text-selection` (existing worktree directory retained). |
| 6 | 7 | ||
| @@ -79,3 +80,87 @@ assets with isolated XDG state and compositor addresses. | |||
| 79 | Deliver a private Tailscale review page with achievements, actual GUI recording, | 80 | Deliver a private Tailscale review page with achievements, actual GUI recording, |
| 80 | controls, checks and limitations. Preserve previous pages. Record demo approval | 81 | controls, checks and limitations. Preserve previous pages. Record demo approval |
| 81 | only after the user gives it. | 82 | only after the user gives it. |
| 83 | |||
| 84 | |||
| 85 | ## Delivered structure and closing review | ||
| 86 | |||
| 87 | `client.selection` is the single gesture model, consumed by both frontends. | ||
| 88 | `Drag.buttonHeld()` distinguishes capture lifetime from a retained highlight; | ||
| 89 | the native painter consumes the shared `Span` directly. `SelectionVersion` in | ||
| 90 | `session_pump` carries sequence, history, session epoch and a revision for | ||
| 91 | connection, geometry and mode changes. The runtime captures that version under | ||
| 92 | the same lock as the independent displayed grid. The controller retains the | ||
| 93 | press version and attachment; it never refreshes an old drag into a new frame. | ||
| 94 | |||
| 95 | The pump owns queued-request tickets, cancellation, the deadline and at most one | ||
| 96 | owned result. It validates the version before sending, while decoding a reply | ||
| 97 | and when transferring the result. Its single invalidation path clears semantic | ||
| 98 | correlation and owned text, including requests cancelled before mailbox service. | ||
| 99 | The SDL adapter writes clipboard text and reports failures; it preserves the | ||
| 100 | previous clipboard for empty or unrepresentable NUL-containing replies. | ||
| 101 | |||
| 102 | Review resolved history-coordinate overflow, neighbour-history subtraction, | ||
| 103 | held mouse capture, release-position resize regression, stale press versions, | ||
| 104 | mode/connection invalidation, unlocked timeout access, allocation-failure | ||
| 105 | ownership, logical-only geometry changes and empty-copy behavior. Root completed | ||
| 106 | the pump ownership cleanup and focused tests after the first integration pass; | ||
| 107 | Terra independently reviewed the final version. No new library or wire message | ||
| 108 | was added. Production growth connects the existing policy and wire to native | ||
| 109 | input, rendering and clipboard adapters; test code supplies independent failure | ||
| 110 | and ownership evidence. | ||
| 111 | |||
| 112 | The existing protocol has no source-sequence precondition at the daemon. | ||
| 113 | Observed client-side changes cancel selection conservatively; copying an atomic | ||
| 114 | historical frame is not claimed. A wire-level version precondition belongs to | ||
| 115 | any future sprint that strengthens this contract, across all frontend callers. | ||
| 116 | |||
| 117 | |||
| 118 | ## Validation and demo evidence | ||
| 119 | |||
| 120 | Final source passed full `make ci`, client/native units and native build, | ||
| 121 | `make native-e2e`, and native core tests with GUI package metadata hidden. | ||
| 122 | Real offscreen and NVIDIA Wayland selection fixtures cover three panes on two | ||
| 123 | daemons, daemon-extracted Unicode/wraps, actual framebuffer highlights, | ||
| 124 | clipboard reads, other-pane/selected-pane output, delayed requests, blank copies, | ||
| 125 | timeouts and detach without ending the shell. Wayland tests additionally use | ||
| 126 | real virtual-pointer input and held selection across 100/150/200% transitions. | ||
| 127 | The retained `native_scale.py` gate also passed. No Mac evidence is claimed. | ||
| 128 | |||
| 129 | `make native-stress` passed separately from recording and other GUI fixtures: | ||
| 130 | frame p99 19,267 us (20,000 limit), sampled input-to-painted upper bound 65.3 ms | ||
| 131 | (250 limit; up to 5 ms polling overhead). Earlier unexplained NVIDIA misses stay | ||
| 132 | open; this is one acceptance run, not a speedup claim. | ||
| 133 | |||
| 134 | Logs, fixture paths, paired release build evidence, helper source/protocol, | ||
| 135 | verified agent models/cumulative counters and recording scripts are retained in | ||
| 136 | `dist/text-selection/`. The maintained `test/wayland_pointer.py` adapts the | ||
| 137 | retained helper's line protocol; it requires one isolated headless Sway output. | ||
| 138 | Set `MUXG_TEST_POINTER` to the absolute `dist/text-selection/pointer` binary when | ||
| 139 | running `test/native_selection.py` on Wayland, and optionally set | ||
| 140 | `MUXG_TEST_SCALE_OUTPUT=HEADLESS-1` for its held-selection scale scenarios. | ||
| 141 | The helper sources, generated Wayland protocol and binary live with these | ||
| 142 | artifacts; compile `pointer.c` and `pointer-protocol.c` with wayland-client if | ||
| 143 | rebuilding the fixture. SDL-injected events remain the offscreen input path. | ||
| 144 | |||
| 145 | The first Wayland test exposed a harness limitation: injected SDL mouse events | ||
| 146 | cannot provide the input serial needed for compositor clipboard ownership. | ||
| 147 | The final Wayland gate uses real pointer events and waits for clipboard offers | ||
| 148 | to reach a separate `wl-paste` client. Initial failed logs are retained. The | ||
| 149 | first demo's cleanup was interrupted by the recorder's minimum-length check; | ||
| 150 | root stopped only its three owned GUI/daemon processes and fixed unconditional | ||
| 151 | fixture cleanup before the successful recording. | ||
| 152 | |||
| 153 | Review page source: `docs/demos/native-text-selection.html`. The 24.4-second | ||
| 154 | continuous NVIDIA 200% recording shows real drags and independent `wl-paste` | ||
| 155 | output, with no cuts or audio. Private handoff route: | ||
| 156 | https://charizard.folk-amberjack.ts.net/text-selection/ . Previous routes remain. | ||
| 157 | Route-specific teardown: `tailscale serve --https=443 --set-path /text-selection off`, | ||
| 158 | then stop only the page server identified by `dist/text-selection/server.json`. | ||
| 159 | |||
| 160 | |||
| 161 | Private page verification passed HTTPS/byte ranges, desktop/mobile layout, | ||
| 162 | playback and seeking; all three earlier appearance pages remain reachable. | ||
| 163 | Only the allowlisted page server remains (PID 3384909, port 18774); owned | ||
| 164 | browser/compositor fixtures are stopped. The final offscreen path was rechecked | ||
| 165 | after sharing the Wayland pointer adapter. The retained recording script now | ||
| 166 | explicitly focuses its owned window before its first pane click. | ||
docs/superpowers/plans/2026-09-06-native-wheel-scrolling.md
| Old | New | ||
|---|---|---|---|
| @@ -34,8 +34,10 @@ behavior while keeping each pane independent. | |||
| 34 | - Demonstrate real NVIDIA Wayland wheel input at 200% and across the retained | 34 | - Demonstrate real NVIDIA Wayland wheel input at 200% and across the retained |
| 35 | scale transitions; verify other panes, focus, PTY sizes and shell identities. | 35 | scale transitions; verify other panes, focus, PTY sizes and shell identities. |
| 36 | 36 | ||
| 37 | Selection, clipboard copy/paste, general app click/drag forwarding, ligatures, | 37 | Visible-text selection and clipboard copy are delivered by the separate |
| 38 | font fallback and terminal-wall retirement are later scope. Horizontal wheel | 38 | text-selection sprint; wheel work must preserve them. Clipboard paste, general |
| 39 | app click/drag forwarding, ligatures, font fallback and terminal-wall retirement | ||
| 40 | remain outside this wheel slice. Horizontal wheel | ||
| 39 | behavior is outside this initial vertical scrolling slice. | 41 | behavior is outside this initial vertical scrolling slice. |
| 40 | 42 | ||
| 41 | ## Opening architecture findings and ownership | 43 | ## Opening architecture findings and ownership |
src/client/selection.zig
| Old | New | ||
|---|---|---|---|
| @@ -134,6 +134,11 @@ pub const Drag = struct { | |||
| 134 | return if (self.phase == .idle) null else self.anchor.tile; | 134 | return if (self.phase == .idle) null else self.anchor.tile; |
| 135 | } | 135 | } |
| 136 | 136 | ||
| 137 | /// Whether the pointer button remains down and motion should be captured. | ||
| 138 | pub fn buttonHeld(self: *const Drag) bool { | ||
| 139 | return self.phase == .down or self.phase == .dragging; | ||
| 140 | } | ||
| 141 | |||
| 137 | /// The selection as an ordered pair, or null while there is none. | 142 | /// The selection as an ordered pair, or null while there is none. |
| 138 | pub fn range(self: *const Drag) ?Range { | 143 | pub fn range(self: *const Drag) ?Range { |
| 139 | return switch (self.phase) { | 144 | return switch (self.phase) { |
| @@ -172,6 +177,18 @@ test "select: a press alone is a click, and highlights nothing on its way" { | |||
| 172 | try std.testing.expect(d.range() == null); | 177 | try std.testing.expect(d.range() == null); |
| 173 | } | 178 | } |
| 174 | 179 | ||
| 180 | test "select: absolute history rows do not narrow the pointer cell" { | ||
| 181 | var d: Drag = .{}; | ||
| 182 | const origin: Hit = .{ .tile = 7, .row = 70_000, .col = 3 }; | ||
| 183 | d.press(.{ .row = 2, .col = 3 }, origin); | ||
| 184 | try std.testing.expect(d.buttonHeld()); | ||
| 185 | d.motion(.{ .row = 3, .col = 3 }, .{ .tile = 7, .row = 70_001, .col = 3 }); | ||
| 186 | const release = d.release(); | ||
| 187 | try std.testing.expectEqual(@as(u32, 70_001), release.selection.to.row); | ||
| 188 | try std.testing.expect(!d.buttonHeld()); | ||
| 189 | try std.testing.expectEqual(@as(u16, 3), (d.range() orelse unreachable).from.col); | ||
| 190 | } | ||
| 191 | |||
| 175 | test "select: a drag names its tile from the press, before it is a selection" { | 192 | test "select: a drag names its tile from the press, before it is a selection" { |
| 176 | var d: Drag = .{}; | 193 | var d: Drag = .{}; |
| 177 | try std.testing.expect(d.on() == null); | 194 | try std.testing.expect(d.on() == null); |
src/client/session_pump.zig
| Old | New | ||
|---|---|---|---|
| @@ -8,7 +8,25 @@ const term = @import("term"); | |||
| 8 | const proto = term.protocol; | 8 | const proto = term.protocol; |
| 9 | const Wire = @import("buffered_wire.zig").Wire; | 9 | const Wire = @import("buffered_wire.zig").Wire; |
| 10 | 10 | ||
| 11 | pub const Say = union(enum) { input: []const u8, resize: proto.Size, end: struct { request: u64, force: bool = false }, detach, quit }; | 11 | /// Identity of the displayed terminal state, copied with its grid under mu. |
| 12 | /// Revision invalidates coordinates across reconnects, resizes and mode frames. | ||
| 13 | pub const SelectionVersion = struct { seq: u64, history_rows: u32, epoch: u64, revision: u64 }; | ||
| 14 | pub const SelectionRequest = struct { | ||
| 15 | id: u32, | ||
| 16 | anchor: proto.SelectionPoint, | ||
| 17 | active: proto.SelectionPoint, | ||
| 18 | version: SelectionVersion, | ||
| 19 | ticket: u64 = 0, // stamped by say; cancellation also invalidates queued work | ||
| 20 | }; | ||
| 21 | pub const Say = union(enum) { | ||
| 22 | input: []const u8, | ||
| 23 | resize: proto.Size, | ||
| 24 | selection: SelectionRequest, | ||
| 25 | end: struct { request: u64, force: bool = false }, | ||
| 26 | detach, | ||
| 27 | quit, | ||
| 28 | }; | ||
| 29 | pub const SelectionResult = struct { id: u32, status: proto.SelectionStatus, text: []u8, version: SelectionVersion }; | ||
| 12 | pub const Phase = enum { dialing, attached, reconnecting, exited, refused, taken, failed, dial_failed }; | 30 | pub const Phase = enum { dialing, attached, reconnecting, exited, refused, taken, failed, dial_failed }; |
| 13 | pub const EndPhase = enum { idle, pending, accepted, refused, unknown }; | 31 | pub const EndPhase = enum { idle, pending, accepted, refused, unknown }; |
| 14 | pub const EndState = struct { | 32 | pub const EndState = struct { |
| @@ -44,6 +62,7 @@ pub const Options = struct { | |||
| 44 | retry_initial: bool = false, | 62 | retry_initial: bool = false, |
| 45 | open_timeout_ms: u32 = 15000, | 63 | open_timeout_ms: u32 = 15000, |
| 46 | end_timeout_ms: u32 = 2000, | 64 | end_timeout_ms: u32 = 2000, |
| 65 | selection_timeout_ms: u32 = 2000, | ||
| 47 | wake: ?*const fn (?*anyopaque) void = null, | 66 | wake: ?*const fn (?*anyopaque) void = null, |
| 48 | wake_ctx: ?*anyopaque = null, | 67 | wake_ctx: ?*anyopaque = null, |
| 49 | }; | 68 | }; |
| @@ -66,6 +85,11 @@ pub const Pump = struct { | |||
| 66 | thread: ?std.Thread = null, | 85 | thread: ?std.Thread = null, |
| 67 | admitted: bool = false, | 86 | admitted: bool = false, |
| 68 | end_until: i64 = 0, // guarded by mu with status.ending | 87 | end_until: i64 = 0, // guarded by mu with status.ending |
| 88 | selection_pending: ?SelectionRequest = null, | ||
| 89 | selection_revision: u64 = 0, // mu: connection, geometry and terminal modes | ||
| 90 | selection_ticket: u64 = 0, // mu: queued and sent request cancellation | ||
| 91 | selection_until: i64 = 0, | ||
| 92 | selection_result: ?SelectionResult = null, | ||
| 69 | 93 | ||
| 70 | pub fn start(alloc: std.mem.Allocator, opts: Options) !*Pump { | 94 | pub fn start(alloc: std.mem.Allocator, opts: Options) !*Pump { |
| 71 | if (opts.session.len != 0 and !proto.validSessionName(opts.session)) return error.InvalidSession; | 95 | if (opts.session.len != 0 and !proto.validSessionName(opts.session)) return error.InvalidSession; |
| @@ -89,6 +113,14 @@ pub const Pump = struct { | |||
| 89 | defer self.mailbox_mu.unlock(); | 113 | defer self.mailbox_mu.unlock(); |
| 90 | if (self.closing.load(.acquire)) return; | 114 | if (self.closing.load(.acquire)) return; |
| 91 | switch (msg) { | 115 | switch (msg) { |
| 116 | .selection => |req| { | ||
| 117 | self.mu.lock(); | ||
| 118 | defer self.mu.unlock(); | ||
| 119 | self.invalidateSelectionLocked(); | ||
| 120 | var owned = req; | ||
| 121 | owned.ticket = self.selection_ticket; | ||
| 122 | try self.mailbox.append(self.alloc, .{ .selection = owned }); | ||
| 123 | }, | ||
| 92 | .end => |req| { | 124 | .end => |req| { |
| 93 | self.mu.lock(); | 125 | self.mu.lock(); |
| 94 | defer self.mu.unlock(); | 126 | defer self.mu.unlock(); |
| @@ -125,6 +157,7 @@ pub const Pump = struct { | |||
| 125 | self.say(.quit) catch unreachable; | 157 | self.say(.quit) catch unreachable; |
| 126 | if (self.thread) |thread| thread.join(); | 158 | if (self.thread) |thread| thread.join(); |
| 127 | for (self.mailbox.items) |msg| if (msg == .input) self.alloc.free(msg.input); | 159 | for (self.mailbox.items) |msg| if (msg == .input) self.alloc.free(msg.input); |
| 160 | if (self.selection_result) |result| self.alloc.free(result.text); | ||
| 128 | self.mailbox.deinit(self.alloc); | 161 | self.mailbox.deinit(self.alloc); |
| 129 | closePipe(self.wake_pipe); | 162 | closePipe(self.wake_pipe); |
| 130 | closePipe(self.cancel_pipe); | 163 | closePipe(self.cancel_pipe); |
| @@ -132,6 +165,52 @@ pub const Pump = struct { | |||
| 132 | self.alloc.destroy(self); | 165 | self.alloc.destroy(self); |
| 133 | } | 166 | } |
| 134 | 167 | ||
| 168 | /// Caller holds mu while copying both this version and the displayed grid. | ||
| 169 | pub fn selectionVersionLocked(self: *const Pump) SelectionVersion { | ||
| 170 | return .{ .seq = self.replica.last_seq, .history_rows = self.replica.history_rows, .epoch = self.replica.session_epoch, .revision = self.selection_revision }; | ||
| 171 | } | ||
| 172 | fn selectionFreshLocked(self: *const Pump, version: SelectionVersion) bool { | ||
| 173 | return !self.closing.load(.acquire) and self.status.phase == .attached and | ||
| 174 | self.replica.state_since_attach and std.meta.eql(version, self.selectionVersionLocked()); | ||
| 175 | } | ||
| 176 | pub fn selectionFresh(self: *Pump, version: SelectionVersion) bool { | ||
| 177 | self.mu.lock(); | ||
| 178 | defer self.mu.unlock(); | ||
| 179 | return self.selectionFreshLocked(version); | ||
| 180 | } | ||
| 181 | /// Validate again under the same lock that transfers ownership. A newer | ||
| 182 | /// frame after reply decoding must not leave an old copy ready for the UI. | ||
| 183 | pub fn takeSelection(self: *Pump) ?SelectionResult { | ||
| 184 | self.mu.lock(); | ||
| 185 | defer self.mu.unlock(); | ||
| 186 | const result = self.selection_result orelse return null; | ||
| 187 | self.selection_result = null; | ||
| 188 | if (!self.selectionFreshLocked(result.version)) { | ||
| 189 | self.alloc.free(result.text); | ||
| 190 | return null; | ||
| 191 | } | ||
| 192 | return result; | ||
| 193 | } | ||
| 194 | fn invalidateSelectionLocked(self: *Pump) void { | ||
| 195 | self.selection_ticket +%= 1; | ||
| 196 | self.selection_pending = null; | ||
| 197 | self.selection_until = 0; | ||
| 198 | self.core.pending_selection_id = null; | ||
| 199 | if (self.selection_result) |result| self.alloc.free(result.text); | ||
| 200 | self.selection_result = null; | ||
| 201 | } | ||
| 202 | pub fn cancelSelection(self: *Pump) void { | ||
| 203 | self.mu.lock(); | ||
| 204 | defer self.mu.unlock(); | ||
| 205 | self.invalidateSelectionLocked(); | ||
| 206 | } | ||
| 207 | fn beginSelectionLocked(self: *Pump, req: SelectionRequest) ?[proto.selection_req_len]u8 { | ||
| 208 | if (req.ticket != self.selection_ticket or !self.selectionFreshLocked(req.version)) return null; | ||
| 209 | self.selection_pending = req; | ||
| 210 | self.selection_until = std.time.milliTimestamp() + self.opts.selection_timeout_ms; | ||
| 211 | return self.core.beginSelection(.{ .id = req.id, .anchor = req.anchor, .active = req.active }); | ||
| 212 | } | ||
| 213 | |||
| 135 | fn wake(self: *Pump) void { | 214 | fn wake(self: *Pump) void { |
| 136 | if (self.opts.wake) |f| f(self.opts.wake_ctx); | 215 | if (self.opts.wake) |f| f(self.opts.wake_ctx); |
| 137 | } | 216 | } |
| @@ -144,6 +223,7 @@ pub const Pump = struct { | |||
| 144 | } | 223 | } |
| 145 | 224 | ||
| 146 | fn setState(self: *Pump, phase: Phase, code: u8, reason: []const u8) void { | 225 | fn setState(self: *Pump, phase: Phase, code: u8, reason: []const u8) void { |
| 226 | if (phase != .attached) self.invalidateSelectionLocked(); | ||
| 147 | self.status.phase = phase; | 227 | self.status.phase = phase; |
| 148 | self.status.exit_code = code; | 228 | self.status.exit_code = code; |
| 149 | self.status.reason_len = @min(reason.len, self.status.reason.len); | 229 | self.status.reason_len = @min(reason.len, self.status.reason.len); |
| @@ -171,6 +251,7 @@ pub const Pump = struct { | |||
| 171 | while (!self.closing.load(.acquire)) { | 251 | while (!self.closing.load(.acquire)) { |
| 172 | var dial: client.handoff.Dial = .{}; | 252 | var dial: client.handoff.Dial = .{}; |
| 173 | self.expireEnd(); | 253 | self.expireEnd(); |
| 254 | self.expireSelection(); | ||
| 174 | var tr = client.Transport.openUntil(self.alloc, self.opts.target, null, self.cancel_pipe[0], &dial, std.time.milliTimestamp() + self.opts.open_timeout_ms) catch |err| { | 255 | var tr = client.Transport.openUntil(self.alloc, self.opts.target, null, self.cancel_pipe[0], &dial, std.time.milliTimestamp() + self.opts.open_timeout_ms) catch |err| { |
| 175 | if (self.closing.load(.acquire)) return; | 256 | if (self.closing.load(.acquire)) return; |
| 176 | var buf: [1024]u8 = undefined; | 257 | var buf: [1024]u8 = undefined; |
| @@ -206,10 +287,12 @@ pub const Pump = struct { | |||
| 206 | 287 | ||
| 207 | fn attach(self: *Pump, wire: *Wire, fresh: bool) !void { | 288 | fn attach(self: *Pump, wire: *Wire, fresh: bool) !void { |
| 208 | self.mu.lock(); | 289 | self.mu.lock(); |
| 290 | self.invalidateSelectionLocked(); | ||
| 291 | self.selection_revision +%= 1; | ||
| 209 | const args = self.replica.attachArgs(); | 292 | const args = self.replica.attachArgs(); |
| 210 | self.replica.state_since_attach = false; | 293 | self.replica.state_since_attach = false; |
| 211 | self.mu.unlock(); | ||
| 212 | self.admitted = false; | 294 | self.admitted = false; |
| 295 | self.mu.unlock(); | ||
| 213 | var buf: [proto.attach_max_len]u8 = undefined; | 296 | var buf: [proto.attach_max_len]u8 = undefined; |
| 214 | try wire.send(.attach, proto.encodeAttachNamed(&buf, if (self.opts.existing_only) 0 else self.opts.cols, if (self.opts.existing_only) 0 else self.opts.rows, if (fresh) 0 else args.have_seq, if (fresh) 0 else args.have_epoch, proto.wireName(self.opts.session))); | 297 | try wire.send(.attach, proto.encodeAttachNamed(&buf, if (self.opts.existing_only) 0 else self.opts.cols, if (self.opts.existing_only) 0 else self.opts.rows, if (fresh) 0 else args.have_seq, if (fresh) 0 else args.have_epoch, proto.wireName(self.opts.session))); |
| 215 | } | 298 | } |
| @@ -231,7 +314,10 @@ pub const Pump = struct { | |||
| 231 | const pending = self.status.ending.request == req.request and self.status.ending.phase == .pending; | 314 | const pending = self.status.ending.request == req.request and self.status.ending.phase == .pending; |
| 232 | self.mu.unlock(); | 315 | self.mu.unlock(); |
| 233 | if (!pending) continue; | 316 | if (!pending) continue; |
| 234 | if (!self.admitted) { | 317 | self.mu.lock(); |
| 318 | const admitted = self.admitted; | ||
| 319 | self.mu.unlock(); | ||
| 320 | if (!admitted) { | ||
| 235 | self.finishPendingEnd("Attachment changed before End; retry to check the session"); | 321 | self.finishPendingEnd("Attachment changed before End; retry to check the session"); |
| 236 | continue; | 322 | continue; |
| 237 | } | 323 | } |
| @@ -239,7 +325,17 @@ pub const Pump = struct { | |||
| 239 | try wire.send(.end_req, proto.encodeEndReq(&buf, req.force, self.opts.session)); | 325 | try wire.send(.end_req, proto.encodeEndReq(&buf, req.force, self.opts.session)); |
| 240 | }, | 326 | }, |
| 241 | .input => |bytes| try wire.send(.input, bytes), | 327 | .input => |bytes| try wire.send(.input, bytes), |
| 328 | .selection => |req| { | ||
| 329 | self.mu.lock(); | ||
| 330 | const payload = self.beginSelectionLocked(req); | ||
| 331 | self.mu.unlock(); | ||
| 332 | if (payload) |bytes| try wire.send(.selection_req, &bytes); | ||
| 333 | }, | ||
| 242 | .resize => |size| { | 334 | .resize => |size| { |
| 335 | self.mu.lock(); | ||
| 336 | self.invalidateSelectionLocked(); | ||
| 337 | self.selection_revision +%= 1; | ||
| 338 | self.mu.unlock(); | ||
| 243 | self.opts.cols = size.cols; | 339 | self.opts.cols = size.cols; |
| 244 | self.opts.rows = size.rows; | 340 | self.opts.rows = size.rows; |
| 245 | if (!self.opts.existing_only or self.admitted) { | 341 | if (!self.opts.existing_only or self.admitted) { |
| @@ -256,6 +352,7 @@ pub const Pump = struct { | |||
| 256 | var eager = wire.tr.link == .quic; | 352 | var eager = wire.tr.link == .quic; |
| 257 | while (true) { | 353 | while (true) { |
| 258 | self.expireEnd(); | 354 | self.expireEnd(); |
| 355 | self.expireSelection(); | ||
| 259 | try self.mail(wire); | 356 | try self.mail(wire); |
| 260 | if (self.closing.load(.acquire)) { | 357 | if (self.closing.load(.acquire)) { |
| 261 | try wire.send(.detach, ""); | 358 | try wire.send(.detach, ""); |
| @@ -333,6 +430,7 @@ pub const Pump = struct { | |||
| 333 | else => return err, | 430 | else => return err, |
| 334 | }; | 431 | }; |
| 335 | self.last_apply_us = @intCast(@min(std.math.maxInt(u32), @max(0, @divTrunc(std.time.nanoTimestamp() - begin, 1000)))); | 432 | self.last_apply_us = @intCast(@min(std.math.maxInt(u32), @max(0, @divTrunc(std.time.nanoTimestamp() - begin, 1000)))); |
| 433 | self.invalidateSelectionLocked(); | ||
| 336 | if (applied == .resync) return .resync; | 434 | if (applied == .resync) return .resync; |
| 337 | if (kind == .snapshot) self.snapshot_ready = true; | 435 | if (kind == .snapshot) self.snapshot_ready = true; |
| 338 | self.setState(.attached, 0, ""); | 436 | self.setState(.attached, 0, ""); |
| @@ -347,12 +445,33 @@ pub const Pump = struct { | |||
| 347 | self.setState(.taken, 0, "session taken over"); | 445 | self.setState(.taken, 0, "session taken over"); |
| 348 | return .end; | 446 | return .end; |
| 349 | }, | 447 | }, |
| 448 | .selection_reply => { | ||
| 449 | const pending = self.selection_pending orelse return .skip; | ||
| 450 | const result = self.core.receive(kind, payload); | ||
| 451 | const reply = switch (result) { | ||
| 452 | .reply => |r| r.selection, | ||
| 453 | else => return .skip, | ||
| 454 | }; | ||
| 455 | if (!self.selectionFreshLocked(pending.version) or reply.history_rows != pending.version.history_rows) { | ||
| 456 | self.selection_pending = null; | ||
| 457 | return .skip; | ||
| 458 | } | ||
| 459 | self.selection_pending = null; | ||
| 460 | const text = try self.alloc.dupe(u8, reply.text); | ||
| 461 | if (self.selection_result) |old| self.alloc.free(old.text); | ||
| 462 | self.selection_result = .{ .id = reply.id, .status = reply.status, .text = text, .version = pending.version }; | ||
| 463 | return .changed; | ||
| 464 | }, | ||
| 350 | else => switch (self.core.receive(kind, payload)) { | 465 | else => switch (self.core.receive(kind, payload)) { |
| 351 | .effect => |effect| switch (effect) { | 466 | .effect => |effect| switch (effect) { |
| 352 | .bell => self.status.bell = true, | 467 | .bell => self.status.bell = true, |
| 353 | .clipboard_set => return .skip, | 468 | .clipboard_set => return .skip, |
| 354 | }, | 469 | }, |
| 355 | .state => return .changed, | 470 | .state => { |
| 471 | self.invalidateSelectionLocked(); | ||
| 472 | self.selection_revision +%= 1; | ||
| 473 | return .changed; | ||
| 474 | }, | ||
| 356 | else => return .skip, | 475 | else => return .skip, |
| 357 | }, | 476 | }, |
| 358 | } | 477 | } |
| @@ -371,11 +490,26 @@ pub const Pump = struct { | |||
| 371 | self.mu.unlock(); | 490 | self.mu.unlock(); |
| 372 | if (expired) self.wake(); | 491 | if (expired) self.wake(); |
| 373 | } | 492 | } |
| 493 | |||
| 494 | fn expireSelection(self: *Pump) void { | ||
| 495 | self.mu.lock(); | ||
| 496 | const expired = self.selection_pending != null and std.time.milliTimestamp() >= self.selection_until; | ||
| 497 | if (expired) { | ||
| 498 | const pending = self.selection_pending.?; | ||
| 499 | self.invalidateSelectionLocked(); | ||
| 500 | self.selection_result = .{ .id = pending.id, .status = .unavailable, .text = &.{}, .version = pending.version }; | ||
| 501 | } | ||
| 502 | self.mu.unlock(); | ||
| 503 | if (expired) self.wake(); | ||
| 504 | } | ||
| 374 | fn endWaitMs(self: *Pump, cap: i32) i32 { | 505 | fn endWaitMs(self: *Pump, cap: i32) i32 { |
| 375 | self.mu.lock(); | 506 | self.mu.lock(); |
| 376 | defer self.mu.unlock(); | 507 | defer self.mu.unlock(); |
| 377 | if (self.status.ending.phase != .pending) return cap; | 508 | const now = std.time.milliTimestamp(); |
| 378 | return @intCast(@min(cap, @max(0, self.end_until - std.time.milliTimestamp()))); | 509 | var wait: i64 = cap; |
| 510 | if (self.status.ending.phase == .pending) wait = @min(wait, @max(0, self.end_until - now)); | ||
| 511 | if (self.selection_pending != null) wait = @min(wait, @max(0, self.selection_until - now)); | ||
| 512 | return @intCast(wait); | ||
| 379 | } | 513 | } |
| 380 | fn finishPendingEnd(self: *Pump, reason: []const u8) void { | 514 | fn finishPendingEnd(self: *Pump, reason: []const u8) void { |
| 381 | self.mu.lock(); | 515 | self.mu.lock(); |
| @@ -988,3 +1122,102 @@ test "reconnecting SSH authentication refusal is terminal after an established p | |||
| 988 | defer a.free(runs); | 1122 | defer a.free(runs); |
| 989 | try std.testing.expectEqual(@as(usize, 2), runs.len); | 1123 | try std.testing.expectEqual(@as(usize, 2), runs.len); |
| 990 | } | 1124 | } |
| 1125 | |||
| 1126 | // Exercise the production mailbox admission and frame decoder synchronously; | ||
| 1127 | // these tests own actual pipes/grid memory but need no transport peer or GUI. | ||
| 1128 | fn selectionTestPump() !*Pump { | ||
| 1129 | const a = std.testing.allocator; | ||
| 1130 | const g = try term.grid.Grid.init(a, 11, 3); | ||
| 1131 | errdefer g.deinit(); | ||
| 1132 | const wp = try std.posix.pipe2(.{ .NONBLOCK = true, .CLOEXEC = true }); | ||
| 1133 | errdefer closePipe(wp); | ||
| 1134 | const cp = try std.posix.pipe2(.{ .NONBLOCK = true, .CLOEXEC = true }); | ||
| 1135 | errdefer closePipe(cp); | ||
| 1136 | const p = try a.create(Pump); | ||
| 1137 | p.* = .{ .alloc = a, .opts = .{ .target = .{ .sock = "unused" }, .cols = 11, .rows = 3 }, .grid = g, .replica = .init(a, g), .wake_pipe = wp, .cancel_pipe = cp }; | ||
| 1138 | _ = try p.onFrame(.snapshot, &testSnapshot()); | ||
| 1139 | return p; | ||
| 1140 | } | ||
| 1141 | fn selectionTestQueue(p: *Pump, id: u32) !SelectionRequest { | ||
| 1142 | p.mu.lock(); | ||
| 1143 | const version = p.selectionVersionLocked(); | ||
| 1144 | p.mu.unlock(); | ||
| 1145 | try p.say(.{ .selection = .{ .id = id, .anchor = .{ .row = 0, .col = 0 }, .active = .{ .row = 1, .col = 3 }, .version = version } }); | ||
| 1146 | return p.mailbox.items[p.mailbox.items.len - 1].selection; | ||
| 1147 | } | ||
| 1148 | fn selectionTestBegin(p: *Pump, req: SelectionRequest) ?[proto.selection_req_len]u8 { | ||
| 1149 | p.mu.lock(); | ||
| 1150 | defer p.mu.unlock(); | ||
| 1151 | return p.beginSelectionLocked(req); | ||
| 1152 | } | ||
| 1153 | fn selectionTestReply(p: *Pump, id: u32, text: []const u8) !void { | ||
| 1154 | var payload: std.ArrayList(u8) = .empty; | ||
| 1155 | defer payload.deinit(std.testing.allocator); | ||
| 1156 | try proto.encodeSelectionReply(&payload, std.testing.allocator, id, .ok, 0, text); | ||
| 1157 | _ = try p.onFrame(.selection_reply, payload.items); | ||
| 1158 | @memset(payload.items, 0xaa); // The published result must own its text. | ||
| 1159 | } | ||
| 1160 | |||
| 1161 | test "selection cancels queued work and rejects stale displayed versions before sending" { | ||
| 1162 | const p = try selectionTestPump(); | ||
| 1163 | defer p.stop(); | ||
| 1164 | const cancelled = try selectionTestQueue(p, 1); | ||
| 1165 | p.cancelSelection(); | ||
| 1166 | try std.testing.expect(selectionTestBegin(p, cancelled) == null); | ||
| 1167 | const stale = try selectionTestQueue(p, 2); | ||
| 1168 | var newer = testSnapshot(); | ||
| 1169 | std.mem.writeInt(u64, newer[0..8], 38, .little); | ||
| 1170 | _ = try p.onFrame(.snapshot, &newer); | ||
| 1171 | try std.testing.expect(selectionTestBegin(p, stale) == null); | ||
| 1172 | const current = try selectionTestQueue(p, 3); | ||
| 1173 | const bytes = selectionTestBegin(p, current).?; | ||
| 1174 | const wire = try proto.decodeSelectionReq(&bytes); | ||
| 1175 | try std.testing.expectEqual(@as(u32, 3), wire.id); | ||
| 1176 | try std.testing.expectEqualDeep(current.active, wire.active); | ||
| 1177 | } | ||
| 1178 | |||
| 1179 | test "selection results own UTF-8, newest request wins, and output cancels a decoded result" { | ||
| 1180 | const p = try selectionTestPump(); | ||
| 1181 | defer p.stop(); | ||
| 1182 | _ = selectionTestBegin(p, try selectionTestQueue(p, 1)).?; | ||
| 1183 | _ = selectionTestBegin(p, try selectionTestQueue(p, 2)).?; | ||
| 1184 | try selectionTestReply(p, 1, "old"); | ||
| 1185 | try std.testing.expect(p.takeSelection() == null); | ||
| 1186 | const text = try std.testing.allocator.alloc(u8, 70 * 1024); | ||
| 1187 | defer std.testing.allocator.free(text); | ||
| 1188 | @memset(text, 'x'); // Native copy does not inherit the OSC 52 base64 cap. | ||
| 1189 | try selectionTestReply(p, 2, text); | ||
| 1190 | const copied = p.takeSelection().?; | ||
| 1191 | defer std.testing.allocator.free(copied.text); | ||
| 1192 | try std.testing.expectEqualStrings(text, copied.text); | ||
| 1193 | _ = selectionTestBegin(p, try selectionTestQueue(p, 3)).?; | ||
| 1194 | try selectionTestReply(p, 3, "café"); | ||
| 1195 | _ = try p.onFrame(.snapshot, &testSnapshot()); | ||
| 1196 | try std.testing.expect(p.takeSelection() == null); | ||
| 1197 | } | ||
| 1198 | |||
| 1199 | test "selection mode changes, cancellation and timeout discard later replies" { | ||
| 1200 | const p = try selectionTestPump(); | ||
| 1201 | defer p.stop(); | ||
| 1202 | const req = try selectionTestQueue(p, 1); | ||
| 1203 | _ = selectionTestBegin(p, req).?; | ||
| 1204 | _ = try p.onFrame(.term_modes, &proto.encodeTermModes(.{ .bracketed_paste = false, .alt_screen = true })); | ||
| 1205 | try std.testing.expect(!p.selectionFresh(req.version)); | ||
| 1206 | try selectionTestReply(p, 1, "wrong screen"); | ||
| 1207 | try std.testing.expect(p.takeSelection() == null); | ||
| 1208 | _ = selectionTestBegin(p, try selectionTestQueue(p, 2)).?; | ||
| 1209 | p.cancelSelection(); | ||
| 1210 | try selectionTestReply(p, 2, "cleared"); | ||
| 1211 | try std.testing.expect(p.takeSelection() == null); | ||
| 1212 | _ = selectionTestBegin(p, try selectionTestQueue(p, 3)).?; | ||
| 1213 | p.mu.lock(); | ||
| 1214 | p.selection_until = 0; | ||
| 1215 | p.mu.unlock(); | ||
| 1216 | p.expireSelection(); | ||
| 1217 | const expired = p.takeSelection().?; | ||
| 1218 | defer std.testing.allocator.free(expired.text); | ||
| 1219 | try std.testing.expectEqual(proto.SelectionStatus.unavailable, expired.status); | ||
| 1220 | try selectionTestReply(p, 3, "too late"); | ||
| 1221 | try std.testing.expect(p.takeSelection() == null); | ||
| 1222 | try std.testing.expect(p.core.pending_selection_id == null); | ||
| 1223 | } | ||
src/gui/frame.zig
| Old | New | ||
|---|---|---|---|
| @@ -92,6 +92,7 @@ pub const Hook = union(enum) { | |||
| 92 | resize: struct { w: u32, h: u32 }, | 92 | resize: struct { w: u32, h: u32 }, |
| 93 | capture: []const u8, | 93 | capture: []const u8, |
| 94 | capture_last: []const u8, | 94 | capture_last: []const u8, |
| 95 | clipboard: []const u8, | ||
| 95 | quit, | 96 | quit, |
| 96 | }; | 97 | }; |
| 97 | 98 | ||
| @@ -105,6 +106,7 @@ pub fn parseHook(line: []const u8) ?Hook { | |||
| 105 | } | 106 | } |
| 106 | } | 107 | } |
| 107 | if (std.mem.startsWith(u8, line, "capture-last:")) return .{ .capture_last = line[13..] }; | 108 | if (std.mem.startsWith(u8, line, "capture-last:")) return .{ .capture_last = line[13..] }; |
| 109 | if (std.mem.startsWith(u8, line, "clipboard:")) return .{ .clipboard = line[10..] }; | ||
| 108 | if (std.mem.startsWith(u8, line, "click:")) { | 110 | if (std.mem.startsWith(u8, line, "click:")) { |
| 109 | const comma = std.mem.indexOfScalar(u8, line, ',') orelse return null; | 111 | const comma = std.mem.indexOfScalar(u8, line, ',') orelse return null; |
| 110 | return .{ .click = .{ .x = std.fmt.parseFloat(f32, line[6..comma]) catch return null, .y = std.fmt.parseFloat(f32, line[comma + 1 ..]) catch return null } }; | 112 | return .{ .click = .{ .x = std.fmt.parseFloat(f32, line[6..comma]) catch return null, .y = std.fmt.parseFloat(f32, line[comma + 1 ..]) catch return null } }; |
| @@ -284,6 +286,14 @@ const HookReader = struct { | |||
| 284 | self.capture_last = copy; | 286 | self.capture_last = copy; |
| 285 | return; | 287 | return; |
| 286 | }, | 288 | }, |
| 289 | .clipboard => |path| { | ||
| 290 | const text = c.SDL_GetClipboardText() orelse return error.ClipboardReadFailed; | ||
| 291 | defer c.SDL_free(@ptrCast(text)); | ||
| 292 | const file = try std.fs.cwd().createFile(path, .{ .truncate = true }); | ||
| 293 | defer file.close(); | ||
| 294 | try file.writeAll(std.mem.span(text)); | ||
| 295 | return; | ||
| 296 | }, | ||
| 287 | .quit => ev.type = c.SDL_EVENT_QUIT, | 297 | .quit => ev.type = c.SDL_EVENT_QUIT, |
| 288 | } | 298 | } |
| 289 | if (!c.SDL_PushEvent(&ev)) { | 299 | if (!c.SDL_PushEvent(&ev)) { |
| @@ -319,7 +329,7 @@ const Events = struct { | |||
| 319 | self.syncCapture(); | 329 | self.syncCapture(); |
| 320 | } | 330 | } |
| 321 | fn syncCapture(self: *Events) void { | 331 | fn syncCapture(self: *Events) void { |
| 322 | const capturing = self.ui.drag != null; | 332 | const capturing = self.ui.drag != null or self.ui.selection_drag.buttonHeld(); |
| 323 | if (self.captured != capturing) { | 333 | if (self.captured != capturing) { |
| 324 | _ = c.SDL_CaptureMouse(capturing); | 334 | _ = c.SDL_CaptureMouse(capturing); |
| 325 | self.captured = capturing; | 335 | self.captured = capturing; |
| @@ -328,7 +338,7 @@ const Events = struct { | |||
| 328 | /// Commit events sample the actual drawable even if its resize notice | 338 | /// Commit events sample the actual drawable even if its resize notice |
| 329 | /// is still behind this event in SDL's bounded queue. | 339 | /// is still behind this event in SDL's bounded queue. |
| 330 | fn dispatch(self: *Events, ev: c.SDL_Event) !bool { | 340 | fn dispatch(self: *Events, ev: c.SDL_Event) !bool { |
| 331 | if ((ev.type == c.SDL_EVENT_KEY_DOWN and (self.ui.resize_mode or ev.key.key == c.SDLK_RETURN or ev.key.key == c.SDLK_KP_ENTER)) or ev.type == c.SDL_EVENT_MOUSE_BUTTON_DOWN or (self.ui.drag != null and (ev.type == c.SDL_EVENT_MOUSE_MOTION or ev.type == c.SDL_EVENT_MOUSE_BUTTON_UP))) { | 341 | if ((ev.type == c.SDL_EVENT_KEY_DOWN and (self.ui.resize_mode or ev.key.key == c.SDLK_RETURN or ev.key.key == c.SDLK_KP_ENTER)) or ev.type == c.SDL_EVENT_MOUSE_BUTTON_DOWN or ((self.ui.drag != null or self.ui.selection_drag.buttonHeld()) and (ev.type == c.SDL_EVENT_MOUSE_MOTION or ev.type == c.SDL_EVENT_MOUSE_BUTTON_UP))) { |
| 332 | self.geometry_dirty = true; | 342 | self.geometry_dirty = true; |
| 333 | try self.refreshGeometry(); | 343 | try self.refreshGeometry(); |
| 334 | } | 344 | } |
| @@ -356,14 +366,21 @@ const Events = struct { | |||
| 356 | }, | 366 | }, |
| 357 | c.SDL_EVENT_MOUSE_MOTION, c.SDL_EVENT_MOUSE_BUTTON_UP => { | 367 | c.SDL_EVENT_MOUSE_MOTION, c.SDL_EVENT_MOUSE_BUTTON_UP => { |
| 358 | if (ev.type == c.SDL_EVENT_MOUSE_BUTTON_UP and ev.button.button != c.SDL_BUTTON_LEFT) return true; | 368 | if (ev.type == c.SDL_EVENT_MOUSE_BUTTON_UP and ev.button.button != c.SDL_BUTTON_LEFT) return true; |
| 359 | if (self.ui.drag != null) { | 369 | if (self.ui.drag != null or self.ui.selection_drag.buttonHeld()) { |
| 360 | var w: c_int = 0; | 370 | var w: c_int = 0; |
| 361 | var h: c_int = 0; | 371 | var h: c_int = 0; |
| 362 | if (c.SDL_GetWindowSize(self.win, &w, &h)) { | 372 | if (c.SDL_GetWindowSize(self.win, &w, &h)) { |
| 363 | const motion = ev.type == c.SDL_EVENT_MOUSE_MOTION; | 373 | const motion = ev.type == c.SDL_EVENT_MOUSE_MOTION; |
| 364 | const x = physicalSignedAxis(if (motion) ev.motion.x else ev.button.x, w, self.ui.fb_w); | 374 | const x = physicalSignedAxis(if (motion) ev.motion.x else ev.button.x, w, self.ui.fb_w); |
| 365 | const y = physicalSignedAxis(if (motion) ev.motion.y else ev.button.y, h, self.ui.fb_h); | 375 | const y = physicalSignedAxis(if (motion) ev.motion.y else ev.button.y, h, self.ui.fb_h); |
| 366 | if (x != null and y != null) try self.ui.pointerMove(x.?, y.?); | 376 | if (x != null and y != null) { |
| 377 | if (ev.type == c.SDL_EVENT_MOUSE_BUTTON_UP) { | ||
| 378 | if (self.ui.drag != null) try self.ui.pointerMove(x.?, y.?); | ||
| 379 | try self.ui.pointerUp(@intCast(@max(x.?, 0)), @intCast(@max(y.?, 0))); | ||
| 380 | } else try self.ui.pointerMove(x.?, y.?); | ||
| 381 | } else if (self.ui.selection_drag.on() != null) { | ||
| 382 | if (ev.type == c.SDL_EVENT_MOUSE_BUTTON_UP) try self.ui.pointerUp(0, 0) else try self.ui.pointerMove(-1, -1); | ||
| 383 | } | ||
| 367 | } | 384 | } |
| 368 | } | 385 | } |
| 369 | if (ev.type == c.SDL_EVENT_MOUSE_BUTTON_UP) self.ui.cancelDrag(); | 386 | if (ev.type == c.SDL_EVENT_MOUSE_BUTTON_UP) self.ui.cancelDrag(); |
| @@ -384,7 +401,10 @@ const Events = struct { | |||
| 384 | var logical_w: c_int = 0; | 401 | var logical_w: c_int = 0; |
| 385 | var logical_h: c_int = 0; | 402 | var logical_h: c_int = 0; |
| 386 | if (!c.SDL_GetWindowSize(self.win, &logical_w, &logical_h)) return error.WindowSizeFailed; | 403 | if (!c.SDL_GetWindowSize(self.win, &logical_w, &logical_h)) return error.WindowSizeFailed; |
| 387 | if (logical_w != self.logical_w or logical_h != self.logical_h) self.ui.cancelDrag(); | 404 | if (logical_w != self.logical_w or logical_h != self.logical_h) { |
| 405 | self.ui.cancelDrag(); | ||
| 406 | self.ui.clearSelection(); | ||
| 407 | } | ||
| 388 | self.logical_w = logical_w; | 408 | self.logical_w = logical_w; |
| 389 | self.logical_h = logical_h; | 409 | self.logical_h = logical_h; |
| 390 | try self.updateGeometry(w, h, c.SDL_GetWindowDisplayScale(self.win)); | 410 | try self.updateGeometry(w, h, c.SDL_GetWindowDisplayScale(self.win)); |
| @@ -555,7 +575,18 @@ pub fn run(alloc: std.mem.Allocator, opts: Options) !u8 { | |||
| 555 | try events.refreshGeometry(); | 575 | try events.refreshGeometry(); |
| 556 | if (usr1_seen.swap(false, .acq_rel)) report(&ring); | 576 | if (usr1_seen.swap(false, .acq_rel)) report(&ring); |
| 557 | const now = std.time.milliTimestamp(); | 577 | const now = std.time.milliTimestamp(); |
| 558 | events.ui.dirty = rt.poll(now) or events.ui.dirty; | 578 | events.ui.dirty = events.ui.poll(now) or events.ui.dirty; |
| 579 | if (events.ui.takeSelectionText()) |text| { | ||
| 580 | defer alloc.free(text); | ||
| 581 | // SDL's clipboard contract is NUL terminated; preserving the | ||
| 582 | // previous clipboard is safer than silently truncating a wire | ||
| 583 | // selection that contains NUL. | ||
| 584 | if (text.len != 0 and std.mem.indexOfScalar(u8, text, 0) == null) { | ||
| 585 | const z = try alloc.dupeZ(u8, text); | ||
| 586 | defer alloc.free(z); | ||
| 587 | if (!c.SDL_SetClipboardText(z.ptr)) events.ui.setNotice("Clipboard update failed"); | ||
| 588 | } | ||
| 589 | } | ||
| 559 | try events.ui.pollEnd(); | 590 | try events.ui.pollEnd(); |
| 560 | events.syncCapture(); | 591 | events.syncCapture(); |
| 561 | if (events.ui.picker) |picker| if (picker.job) |job| if (job.done.load(.acquire)) { | 592 | if (events.ui.picker) |picker| if (picker.job) |job| if (job.done.load(.acquire)) { |
| @@ -627,7 +658,10 @@ pub fn run(alloc: std.mem.Allocator, opts: Options) !u8 { | |||
| 627 | ctx.y0 = @floatFromInt(p.content.y); | 658 | ctx.y0 = @floatFromInt(p.content.y); |
| 628 | const bg_start = lists.backgrounds.items.len; | 659 | const bg_start = lists.backgrounds.items.len; |
| 629 | const fg_start = lists.foregrounds.items.len; | 660 | const fg_start = lists.foregrounds.items.len; |
| 630 | for (0..grid.rows) |y| visible_blink = (try quads.rowInstances(&lists, alloc, grid.row(@intCast(y)), grid.cols, 0, @intCast(y), ctx)) or visible_blink; | 661 | for (0..grid.rows) |y| { |
| 662 | ctx.selection = if (events.ui.selectedSpan(p.id, live.snapshot_version.history_rows + @as(u32, @intCast(y)), grid.cols)) |s| .{ .from = s.from, .to = s.to } else null; | ||
| 663 | visible_blink = (try quads.rowInstances(&lists, alloc, grid.row(@intCast(y)), grid.cols, 0, @intCast(y), ctx)) or visible_blink; | ||
| 664 | } | ||
| 631 | if (rt.workspace.tab().focus == p.id and grid.cursor.x < grid.cols and grid.cursor.y < grid.rows) try lists.foregrounds.append(alloc, quads.cursorInstance(grid.cursor.x, grid.cursor.y, ctx)); | 665 | if (rt.workspace.tab().focus == p.id and grid.cursor.x < grid.cols and grid.cursor.y < grid.rows) try lists.foregrounds.append(alloc, quads.cursorInstance(grid.cursor.x, grid.cursor.y, ctx)); |
| 632 | clipPane(&lists, bg_start, fg_start, model.Rect.intersect(p.content, p.visible)); | 666 | clipPane(&lists, bg_start, fg_start, model.Rect.intersect(p.content, p.visible)); |
| 633 | ctx.x0 = @floatFromInt(p.header.x); | 667 | ctx.x0 = @floatFromInt(p.header.x); |
src/gui/interaction.zig
| Old | New | ||
|---|---|---|---|
| @@ -80,6 +80,10 @@ pub const Controller = struct { | |||
| 80 | resize_mode: bool = false, | 80 | resize_mode: bool = false, |
| 81 | modal_held: std.AutoHashMapUnmanaged(u32, void) = .empty, | 81 | modal_held: std.AutoHashMapUnmanaged(u32, void) = .empty, |
| 82 | drag: ?struct { id: model.DividerId, tab: model.TabId, offset: i64, changed: bool = false } = null, | 82 | drag: ?struct { id: model.DividerId, tab: model.TabId, offset: i64, changed: bool = false } = null, |
| 83 | selection_drag: client.selection.Drag = .{}, | ||
| 84 | selection_key: ?model.Attachment = null, | ||
| 85 | selection_request: u32 = 0, | ||
| 86 | selection_version: client.session_pump.SelectionVersion = .{ .seq = 0, .history_rows = 0, .epoch = 0, .revision = 0 }, | ||
| 83 | consumed_key: ?u32 = null, | 87 | consumed_key: ?u32 = null, |
| 84 | notice: []const u8 = "", | 88 | notice: []const u8 = "", |
| 85 | 89 | ||
| @@ -88,11 +92,68 @@ pub const Controller = struct { | |||
| 88 | pub fn deinit(self: *Controller) void { | 92 | pub fn deinit(self: *Controller) void { |
| 89 | if (self.pending_end != null) std.debug.print("muxg: End was still pending; its remote outcome is unknown\n", .{}); | 93 | if (self.pending_end != null) std.debug.print("muxg: End was still pending; its remote outcome is unknown\n", .{}); |
| 90 | self.cancelDrag(); | 94 | self.cancelDrag(); |
| 95 | self.clearSelection(); | ||
| 91 | self.modal_held.deinit(self.rt.alloc); | 96 | self.modal_held.deinit(self.rt.alloc); |
| 92 | if (self.picker) |picker| picker.deinit(); | 97 | if (self.picker) |picker| picker.deinit(); |
| 93 | } | 98 | } |
| 99 | pub fn clearSelection(self: *Controller) void { | ||
| 100 | if (self.selection_key) |key| if (self.rt.get(key.pane)) |live| live.pump.cancelSelection(); | ||
| 101 | self.selection_drag.clear(); | ||
| 102 | self.selection_key = null; | ||
| 103 | self.dirty = true; | ||
| 104 | } | ||
| 105 | fn hit(self: *Controller, x: u32, y: u32) ?client.selection.Hit { | ||
| 106 | if (self.metrics.cell_w == 0 or self.metrics.cell_h == 0) return null; | ||
| 107 | const id = self.layout.hit(x, y) orelse return null; | ||
| 108 | const p = self.layout.get(id) orelse return null; | ||
| 109 | if (x < p.content.x or y < p.content.y or x >= p.content.x + p.content.w or y >= p.content.y + p.content.h) return null; | ||
| 110 | const live = self.rt.get(id) orelse return null; | ||
| 111 | const local_row: u32 = (y - p.content.y) / self.metrics.cell_h; | ||
| 112 | const col: u16 = @intCast(@min(@as(u32, std.math.maxInt(u16)), (x - p.content.x) / self.metrics.cell_w)); | ||
| 113 | if (local_row >= live.snapshot.rows or col >= live.snapshot.cols) return null; | ||
| 114 | const actual_col = if (live.snapshot.row(@intCast(local_row)).cells[col].wide == .spacer_tail and col > 0) col - 1 else col; | ||
| 115 | return .{ .tile = @intCast(id), .row = live.snapshot_version.history_rows + local_row, .col = actual_col }; | ||
| 116 | } | ||
| 117 | fn cellFor(self: *Controller, x: u32, y: u32) client.selection.Cell { | ||
| 118 | const h = self.hit(x, y) orelse return self.selection_drag.at; | ||
| 119 | const p = self.layout.get(@intCast(h.tile)) orelse return self.selection_drag.at; | ||
| 120 | return .{ .row = @intCast((y -| p.content.y) / self.metrics.cell_h), .col = @intCast(@min(@as(u32, std.math.maxInt(u16)), (x -| p.content.x) / self.metrics.cell_w)) }; | ||
| 121 | } | ||
| 122 | pub fn selectedSpan(self: *const Controller, tile: usize, row: u32, cols: u16) ?client.selection.Span { | ||
| 123 | if (self.selection_key) |key| if (@as(usize, @intCast(key.pane)) == tile and self.rt.accepts(key)) { | ||
| 124 | return (self.selection_drag.range() orelse return null).span(tile, row, cols); | ||
| 125 | }; | ||
| 126 | return null; | ||
| 127 | } | ||
| 128 | pub fn poll(self: *Controller, now: i64) bool { | ||
| 129 | const changed = self.rt.poll(now); | ||
| 130 | if (self.selection_key) |key| { | ||
| 131 | const live = self.rt.get(key.pane) orelse { | ||
| 132 | self.clearSelection(); | ||
| 133 | return true; | ||
| 134 | }; | ||
| 135 | if (!self.rt.accepts(key) or !live.pump.selectionFresh(self.selection_version)) { | ||
| 136 | self.clearSelection(); | ||
| 137 | return true; | ||
| 138 | } | ||
| 139 | } | ||
| 140 | return changed; | ||
| 141 | } | ||
| 142 | pub fn takeSelectionText(self: *Controller) ?[]u8 { | ||
| 143 | const key = self.selection_key orelse return null; | ||
| 144 | const live = self.rt.get(key.pane) orelse return null; | ||
| 145 | if (!self.rt.accepts(key)) return null; | ||
| 146 | const result = live.pump.takeSelection() orelse return null; | ||
| 147 | if (result.id != self.selection_request or !std.meta.eql(result.version, self.selection_version) or result.status != .ok) { | ||
| 148 | self.rt.alloc.free(result.text); | ||
| 149 | if (result.status != .ok) self.setNotice(if (result.status == .too_large) "Selection too large" else "Selection unavailable"); | ||
| 150 | return null; | ||
| 151 | } | ||
| 152 | return result.text; | ||
| 153 | } | ||
| 94 | pub fn command(self: *Controller, key: Key) !void { | 154 | pub fn command(self: *Controller, key: Key) !void { |
| 95 | self.cancelDrag(); | 155 | self.cancelDrag(); |
| 156 | self.clearSelection(); | ||
| 96 | self.command_mode = false; | 157 | self.command_mode = false; |
| 97 | self.notice = ""; | 158 | self.notice = ""; |
| 98 | const ws = &self.rt.workspace; | 159 | const ws = &self.rt.workspace; |
| @@ -136,6 +197,7 @@ pub const Controller = struct { | |||
| 136 | } | 197 | } |
| 137 | pub fn openPicker(self: *Controller, mode: picker_mod.Picker.Mode) !void { | 198 | pub fn openPicker(self: *Controller, mode: picker_mod.Picker.Mode) !void { |
| 138 | self.cancelDrag(); | 199 | self.cancelDrag(); |
| 200 | self.clearSelection(); | ||
| 139 | self.recovery = null; | 201 | self.recovery = null; |
| 140 | self.notice = ""; | 202 | self.notice = ""; |
| 141 | const picker = try picker_mod.Picker.initMode(self.rt.alloc, self.rt, &self.next_request, self.key_path, @intCast(@max(self.fb_w, 0)), @intCast(@max(self.fb_h, 0)), self.metrics, self.wake_ctx, self.wake, mode); | 203 | const picker = try picker_mod.Picker.initMode(self.rt.alloc, self.rt, &self.next_request, self.key_path, @intCast(@max(self.fb_w, 0)), @intCast(@max(self.fb_h, 0)), self.metrics, self.wake_ctx, self.wake, mode); |
| @@ -248,6 +310,7 @@ pub const Controller = struct { | |||
| 248 | } | 310 | } |
| 249 | pub fn pointerDown(self: *Controller, x: u32, y: u32, grab_x: u32, grab_y: u32) !void { | 311 | pub fn pointerDown(self: *Controller, x: u32, y: u32, grab_x: u32, grab_y: u32) !void { |
| 250 | self.cancelDrag(); | 312 | self.cancelDrag(); |
| 313 | self.clearSelection(); | ||
| 251 | if (self.recovery) |menu| { | 314 | if (self.recovery) |menu| { |
| 252 | const view = menu.view(@intCast(@max(self.fb_w, 0)), @intCast(@max(self.fb_h, 0)), self.metrics); | 315 | const view = menu.view(@intCast(@max(self.fb_w, 0)), @intCast(@max(self.fb_h, 0)), self.metrics); |
| 253 | for (0..menu.count()) |i| if (view.rowRect(i).contains(x, y)) { | 316 | for (0..menu.count()) |i| if (view.rowRect(i).contains(x, y)) { |
| @@ -266,11 +329,53 @@ pub const Controller = struct { | |||
| 266 | if (self.layout.hit(x, y)) |id| { | 329 | if (self.layout.hit(x, y)) |id| { |
| 267 | self.intent_dirty = self.intent_dirty or self.rt.workspace.tab().focus != id; | 330 | self.intent_dirty = self.intent_dirty or self.rt.workspace.tab().focus != id; |
| 268 | _ = self.rt.workspace.focus(id); | 331 | _ = self.rt.workspace.focus(id); |
| 332 | const point = self.hit(x, y); | ||
| 333 | self.selection_drag.press(.{ .row = if (point) |h| @intCast(h.row - self.rt.get(id).?.snapshot_version.history_rows) else 0, .col = if (point) |h| h.col else 0 }, point); | ||
| 334 | if (point) |h| { | ||
| 335 | const live = self.rt.get(h.tile).?; | ||
| 336 | self.selection_key = live.key; | ||
| 337 | self.selection_version = live.snapshot_version; | ||
| 338 | } | ||
| 269 | } else if (self.layout.len == 0) try self.openPicker(.insert); | 339 | } else if (self.layout.len == 0) try self.openPicker(.insert); |
| 270 | } | 340 | } |
| 271 | self.dirty = true; | 341 | self.dirty = true; |
| 272 | } | 342 | } |
| 343 | |||
| 344 | pub fn pointerUp(self: *Controller, x: u32, y: u32) !void { | ||
| 345 | if (self.drag != null) { | ||
| 346 | self.cancelDrag(); | ||
| 347 | return; | ||
| 348 | } | ||
| 349 | const id = self.selection_key orelse return; | ||
| 350 | const live = self.rt.get(id.pane) orelse return self.clearSelection(); | ||
| 351 | if (!live.pump.selectionFresh(self.selection_version)) return self.clearSelection(); | ||
| 352 | const point = self.hit(x, y); | ||
| 353 | const cell = self.cellFor(x, y); | ||
| 354 | self.selection_drag.motion(cell, point); | ||
| 355 | switch (self.selection_drag.release()) { | ||
| 356 | .click => {}, | ||
| 357 | .selection => |range| { | ||
| 358 | self.selection_request +%= 1; | ||
| 359 | try self.rt.requestSelection(live.key, self.selection_request, range, self.selection_version); | ||
| 360 | }, | ||
| 361 | .nothing => self.clearSelection(), | ||
| 362 | } | ||
| 363 | self.dirty = true; | ||
| 364 | } | ||
| 273 | pub fn pointerMove(self: *Controller, x: i64, y: i64) !void { | 365 | pub fn pointerMove(self: *Controller, x: i64, y: i64) !void { |
| 366 | if (self.drag == null and self.selection_drag.on() != null) { | ||
| 367 | if (x < 0 or y < 0) { | ||
| 368 | self.selection_drag.motion(self.selection_drag.at, null); | ||
| 369 | self.dirty = true; | ||
| 370 | return; | ||
| 371 | } | ||
| 372 | const px: u32 = @intCast(@max(x, 0)); | ||
| 373 | const py: u32 = @intCast(@max(y, 0)); | ||
| 374 | const point = self.hit(px, py); | ||
| 375 | self.selection_drag.motion(self.cellFor(px, py), point); | ||
| 376 | self.dirty = true; | ||
| 377 | return; | ||
| 378 | } | ||
| 274 | const drag = self.drag orelse return; | 379 | const drag = self.drag orelse return; |
| 275 | if (drag.tab != self.rt.workspace.active_tab_id) return self.cancelDrag(); | 380 | if (drag.tab != self.rt.workspace.active_tab_id) return self.cancelDrag(); |
| 276 | const d = self.layout.divider(drag.id) orelse return self.cancelDrag(); | 381 | const d = self.layout.divider(drag.id) orelse return self.cancelDrag(); |
| @@ -286,6 +391,7 @@ pub const Controller = struct { | |||
| 286 | self.drag = null; | 391 | self.drag = null; |
| 287 | } | 392 | } |
| 288 | pub fn sendKey(self: *Controller, key: keymap.Event) !void { | 393 | pub fn sendKey(self: *Controller, key: keymap.Event) !void { |
| 394 | self.clearSelection(); | ||
| 289 | var buf: [keymap.max_seq_len]u8 = undefined; | 395 | var buf: [keymap.max_seq_len]u8 = undefined; |
| 290 | const bytes = keymap.encode(key, &buf); | 396 | const bytes = keymap.encode(key, &buf); |
| 291 | if (bytes.len != 0) try self.rt.input(bytes); | 397 | if (bytes.len != 0) try self.rt.input(bytes); |
| @@ -300,7 +406,10 @@ pub const Controller = struct { | |||
| 300 | if (self.picker) |picker| { | 406 | if (self.picker) |picker| { |
| 301 | try picker.text(text); | 407 | try picker.text(text); |
| 302 | self.dirty = true; | 408 | self.dirty = true; |
| 303 | } else if (!self.command_mode and !self.resize_mode and self.recovery == null) try self.rt.input(text); | 409 | } else if (!self.command_mode and !self.resize_mode and self.recovery == null) { |
| 410 | self.clearSelection(); | ||
| 411 | try self.rt.input(text); | ||
| 412 | } | ||
| 304 | } | 413 | } |
| 305 | self.suppress_text = false; | 414 | self.suppress_text = false; |
| 306 | } | 415 | } |
| @@ -315,11 +424,15 @@ pub const Controller = struct { | |||
| 315 | self.resize_mode = false; | 424 | self.resize_mode = false; |
| 316 | self.modal_held.clearRetainingCapacity(); | 425 | self.modal_held.clearRetainingCapacity(); |
| 317 | self.cancelDrag(); | 426 | self.cancelDrag(); |
| 427 | self.clearSelection(); | ||
| 318 | self.consumed_key = null; | 428 | self.consumed_key = null; |
| 319 | self.dirty = true; | 429 | self.dirty = true; |
| 320 | } | 430 | } |
| 321 | pub fn updateGeometry(self: *Controller, w: i32, h: i32, metrics: model.Metrics) !void { | 431 | pub fn updateGeometry(self: *Controller, w: i32, h: i32, metrics: model.Metrics) !void { |
| 322 | if (w != self.fb_w or h != self.fb_h or !std.meta.eql(metrics, self.metrics)) self.cancelDrag(); | 432 | if (w != self.fb_w or h != self.fb_h or !std.meta.eql(metrics, self.metrics)) { |
| 433 | self.cancelDrag(); | ||
| 434 | self.clearSelection(); | ||
| 435 | } | ||
| 323 | self.fb_w = w; | 436 | self.fb_w = w; |
| 324 | self.fb_h = h; | 437 | self.fb_h = h; |
| 325 | self.metrics = metrics; | 438 | self.metrics = metrics; |
| @@ -531,3 +644,36 @@ test "beginEnd records pending request without opening an ending modal" { | |||
| 531 | try std.testing.expectEqual(@as(u64, 1), ui.pending_end.?.request); | 644 | try std.testing.expectEqual(@as(u64, 1), ui.pending_end.?.request); |
| 532 | ui.pending_end = null; | 645 | ui.pending_end = null; |
| 533 | } | 646 | } |
| 647 | |||
| 648 | test "selection hit keeps absolute history and pane-local pointer cells" { | ||
| 649 | var rt = runtime.Runtime.init(std.testing.allocator, .{}); | ||
| 650 | defer rt.deinit(); | ||
| 651 | const metrics: model.Metrics = .{ .cell_w = 8, .cell_h = 16 }; | ||
| 652 | const first = try rt.add(.{ .via = "cat" }, "history-a", 800, 600, metrics); | ||
| 653 | const second = try rt.add(.{ .via = "cat" }, "history-b", 800, 600, metrics); | ||
| 654 | var ui: Controller = .{ .rt = &rt, .metrics = metrics, .fb_w = 800, .fb_h = 600 }; | ||
| 655 | defer ui.deinit(); | ||
| 656 | ui.layout = rt.workspace.layout(800, 600, metrics); | ||
| 657 | for (ui.layout.items()) |placement| { | ||
| 658 | const live = rt.get(placement.id).?; | ||
| 659 | try live.snapshot.resize(22, 9); | ||
| 660 | live.snapshot_version.history_rows = if (placement.id == first) 70_000 else 3; | ||
| 661 | } | ||
| 662 | const a = ui.layout.get(first).?; | ||
| 663 | const b = ui.layout.get(second).?; | ||
| 664 | const ha = ui.hit(a.content.x + 4, a.content.y + 16).?; | ||
| 665 | const hb = ui.hit(b.content.x + 4, b.content.y + 16).?; | ||
| 666 | try std.testing.expectEqual(@as(u32, 70_001), ha.row); | ||
| 667 | try std.testing.expectEqual(@as(u32, 4), hb.row); | ||
| 668 | try std.testing.expectEqual(@as(u16, 1), ui.cellFor(a.content.x + 4, a.content.y + 16).row); | ||
| 669 | try std.testing.expectEqual(@as(u16, 1), ui.cellFor(b.content.x + 4, b.content.y + 16).row); | ||
| 670 | try ui.pointerDown(a.content.x + 4, a.content.y + 16, 0, 0); | ||
| 671 | try ui.pointerMove(b.content.x + 4, b.content.y + 32); | ||
| 672 | try std.testing.expect(ui.selection_drag.buttonHeld()); | ||
| 673 | try std.testing.expectEqual(@as(u32, 70_001), ui.selection_drag.range().?.to.row); | ||
| 674 | try ui.pointerMove(a.content.x + 12, a.content.y + 32); | ||
| 675 | try std.testing.expectEqual(@as(u32, 70_002), ui.selection_drag.range().?.to.row); | ||
| 676 | ui.focusLost(); | ||
| 677 | try std.testing.expect(!ui.selection_drag.buttonHeld()); | ||
| 678 | try std.testing.expect(ui.selection_drag.range() == null); | ||
| 679 | } | ||
src/gui/quads.zig
| Old | New | ||
|---|---|---|---|
| @@ -1,5 +1,6 @@ | |||
| 1 | //! Grid cells to ordered background and foreground instance streams. | 1 | //! Grid cells to ordered background and foreground instance streams. |
| 2 | const std = @import("std"); | 2 | const std = @import("std"); |
| 3 | const client = @import("client"); | ||
| 3 | const term = @import("term"); | 4 | const term = @import("term"); |
| 4 | const grid = term.grid; | 5 | const grid = term.grid; |
| 5 | const proto = term.protocol; | 6 | const proto = term.protocol; |
| @@ -33,7 +34,7 @@ pub const Lists = struct { | |||
| 33 | try out.appendSlice(a, s.foregrounds.items); | 34 | try out.appendSlice(a, s.foregrounds.items); |
| 34 | } | 35 | } |
| 35 | }; | 36 | }; |
| 36 | pub const Ctx = struct { cell_w: u16, cell_h: u16, ascent: u16, x0: f32 = 0, y0: f32 = 0, atlas_w: f32, atlas_h: f32, glyphs: Glyphs, blink_visible: bool = true, theme: *const theme_mod.Theme = &theme_mod.legacy, fg: ?u32 = null, bg: ?u32 = null }; | 37 | pub const Ctx = struct { cell_w: u16, cell_h: u16, ascent: u16, x0: f32 = 0, y0: f32 = 0, atlas_w: f32, atlas_h: f32, glyphs: Glyphs, blink_visible: bool = true, theme: *const theme_mod.Theme = &theme_mod.legacy, fg: ?u32 = null, bg: ?u32 = null, selection: ?client.selection.Span = null }; |
| 37 | pub fn rgbaOf(c: u32, d: u32, appearance: *const theme_mod.Theme) u32 { | 38 | pub fn rgbaOf(c: u32, d: u32, appearance: *const theme_mod.Theme) u32 { |
| 38 | return switch (c >> 24) { | 39 | return switch (c >> 24) { |
| 39 | 1 => appearance.palette[@intCast(c & 0xff)], | 40 | 1 => appearance.palette[@intCast(c & 0xff)], |
| @@ -84,9 +85,10 @@ pub fn rowInstances(out: *Lists, a: std.mem.Allocator, row: *const grid.Row, col | |||
| 84 | for (row.cells[0..n], 0..) |cell, x| { | 85 | for (row.cells[0..n], 0..) |cell, x| { |
| 85 | if (cell.wide == .spacer_tail) continue; | 86 | if (cell.wide == .spacer_tail) continue; |
| 86 | const span_cols: usize = @min(if (cell.wide == .wide) @as(usize, 2) else 1, n - x); | 87 | const span_cols: usize = @min(if (cell.wide == .wide) @as(usize, 2) else 1, n - x); |
| 88 | const selected = if (ctx.selection) |s| x <= s.to and x + span_cols - 1 >= s.from else false; | ||
| 87 | const inv = cell.style.flags & (1 << 4) != 0; | 89 | const inv = cell.style.flags & (1 << 4) != 0; |
| 88 | const bg = if (inv) rgbaOf(cell.style.fg, ctx.fg orelse ctx.theme.terminal_fg, ctx.theme) else rgbaOf(cell.style.bg, ctx.bg orelse ctx.theme.terminal_bg, ctx.theme); | 90 | const bg = if (selected) ctx.theme.chrome_focus_bg else if (inv) rgbaOf(cell.style.fg, ctx.fg orelse ctx.theme.terminal_fg, ctx.theme) else rgbaOf(cell.style.bg, ctx.bg orelse ctx.theme.terminal_bg, ctx.theme); |
| 89 | if (inv or cell.style.bg != proto.color_none) try out.backgrounds.append(a, solid(ctx.x0 + @as(f32, @floatFromInt(col_off + x)) * cw, top, cw * @as(f32, @floatFromInt(span_cols)), ch, bg)); | 91 | if (selected or inv or cell.style.bg != proto.color_none) try out.backgrounds.append(a, solid(ctx.x0 + @as(f32, @floatFromInt(col_off + x)) * cw, top, cw * @as(f32, @floatFromInt(span_cols)), ch, bg)); |
| 90 | } | 92 | } |
| 91 | for (row.cells[0..n], 0..) |cell, x| { | 93 | for (row.cells[0..n], 0..) |cell, x| { |
| 92 | if (cell.wide == .spacer_tail) continue; | 94 | if (cell.wide == .spacer_tail) continue; |
| @@ -100,6 +102,8 @@ pub fn rowInstances(out: *Lists, a: std.mem.Allocator, row: *const grid.Row, col | |||
| 100 | const left = ctx.x0 + @as(f32, @floatFromInt(col_off + x)) * cw; | 102 | const left = ctx.x0 + @as(f32, @floatFromInt(col_off + x)) * cw; |
| 101 | const span_cols: usize = @min(if (cell.wide == .wide) @as(usize, 2) else 1, n - x); | 103 | const span_cols: usize = @min(if (cell.wide == .wide) @as(usize, 2) else 1, n - x); |
| 102 | const span = cw * @as(f32, @floatFromInt(span_cols)); | 104 | const span = cw * @as(f32, @floatFromInt(span_cols)); |
| 105 | const selected = if (ctx.selection) |s| x <= s.to and x + span_cols - 1 >= s.from else false; | ||
| 106 | if (selected) fg = ctx.theme.chrome_focus_fg; | ||
| 103 | if (cell.text_len > 0) { | 107 | if (cell.text_len > 0) { |
| 104 | const run = try ctx.glyphs.resolve(ctx.glyphs.ctx, row.textOf(cell), variantOf(flags)); | 108 | const run = try ctx.glyphs.resolve(ctx.glyphs.ctx, row.textOf(cell), variantOf(flags)); |
| 105 | var pen_x: i32 = 0; | 109 | var pen_x: i32 = 0; |
| @@ -251,6 +255,24 @@ test "glyphs and a clipped wide edge stay inside the authoritative span" { | |||
| 251 | try std.testing.expect(glyph.u1 < @as(f32, 22) / ctx.atlas_w); | 255 | try std.testing.expect(glyph.u1 < @as(f32, 22) / ctx.atlas_w); |
| 252 | } | 256 | } |
| 253 | 257 | ||
| 258 | test "selection highlight covers wide cells without changing grid styles" { | ||
| 259 | var cells = [_]grid.Cell{ | ||
| 260 | .{ .wide = .wide, .text_len = 1, .style = .{ .fg = proto.colorRgb(1, 2, 3), .bg = proto.colorRgb(4, 5, 6) } }, | ||
| 261 | .{ .wide = .spacer_tail }, | ||
| 262 | }; | ||
| 263 | var row: grid.Row = .{ .cells = &cells, .text = .{ .items = @constCast("界"), .capacity = 3 } }; | ||
| 264 | var lists: Lists = .{}; | ||
| 265 | defer lists.deinit(std.testing.allocator); | ||
| 266 | var ctx = testCtx(true); | ||
| 267 | ctx.selection = .{ .from = 1, .to = 1 }; | ||
| 268 | _ = try rowInstances(&lists, std.testing.allocator, &row, 2, 0, 0, ctx); | ||
| 269 | // Selecting the continuation still paints the owning wide cell as one | ||
| 270 | // complete two-column rectangle, while the source style remains intact. | ||
| 271 | try std.testing.expectEqual(@as(f32, 16), lists.backgrounds.items[0].w); | ||
| 272 | try std.testing.expectEqual(theme_mod.legacy.chrome_focus_bg, lists.backgrounds.items[0].rgba); | ||
| 273 | try std.testing.expectEqual(proto.colorRgb(1, 2, 3), cells[0].style.fg); | ||
| 274 | } | ||
| 275 | |||
| 254 | /// Clip only the newly emitted range, adjusting glyph UVs proportionally. | 276 | /// Clip only the newly emitted range, adjusting glyph UVs proportionally. |
| 255 | /// Removed instances are compacted without changing background/foreground order. | 277 | /// Removed instances are compacted without changing background/foreground order. |
| 256 | pub fn clip(list: *std.ArrayListUnmanaged(Instance), start: usize, x: f32, y: f32, w: f32, h: f32) void { | 278 | pub fn clip(list: *std.ArrayListUnmanaged(Instance), start: usize, x: f32, y: f32, w: f32, h: f32) void { |
src/gui/runtime.zig
| Old | New | ||
|---|---|---|---|
| @@ -11,6 +11,7 @@ pub const Live = struct { | |||
| 11 | pump: *Pump, | 11 | pump: *Pump, |
| 12 | snapshot: *term.grid.Grid, | 12 | snapshot: *term.grid.Grid, |
| 13 | snapshot_seq: u64 = 0, | 13 | snapshot_seq: u64 = 0, |
| 14 | snapshot_version: client.session_pump.SelectionVersion = .{ .seq = 0, .history_rows = 0, .epoch = 0, .revision = 0 }, | ||
| 14 | painted_seq: u64 = 0, | 15 | painted_seq: u64 = 0, |
| 15 | status: client.session_pump.State = .{}, | 16 | status: client.session_pump.State = .{}, |
| 16 | size: term.protocol.Size, | 17 | size: term.protocol.Size, |
| @@ -48,6 +49,7 @@ pub const Live = struct { | |||
| 48 | const src = self.pump.grid; | 49 | const src = self.pump.grid; |
| 49 | try copyGrid(self.snapshot, src, @min(cols, src.cols), @min(rows, src.rows)); | 50 | try copyGrid(self.snapshot, src, @min(cols, src.cols), @min(rows, src.rows)); |
| 50 | self.snapshot_seq = self.pump.replica.last_seq; | 51 | self.snapshot_seq = self.pump.replica.last_seq; |
| 52 | self.snapshot_version = self.pump.selectionVersionLocked(); | ||
| 51 | return self.pump.last_apply_us; | 53 | return self.pump.last_apply_us; |
| 52 | } | 54 | } |
| 53 | }; | 55 | }; |
| @@ -129,6 +131,7 @@ pub const Runtime = struct { | |||
| 129 | if (keep_snapshot) { | 131 | if (keep_snapshot) { |
| 130 | try copyGrid(live.snapshot, old.snapshot, old.snapshot.cols, old.snapshot.rows); | 132 | try copyGrid(live.snapshot, old.snapshot, old.snapshot.cols, old.snapshot.rows); |
| 131 | live.snapshot_seq = old.snapshot_seq; | 133 | live.snapshot_seq = old.snapshot_seq; |
| 134 | live.snapshot_version = old.snapshot_version; | ||
| 132 | live.preserve_snapshot = true; | 135 | live.preserve_snapshot = true; |
| 133 | } | 136 | } |
| 134 | for (&self.lives) |*slot| if (slot.* == old) { | 137 | for (&self.lives) |*slot| if (slot.* == old) { |
| @@ -170,6 +173,12 @@ pub const Runtime = struct { | |||
| 170 | else => {}, | 173 | else => {}, |
| 171 | } | 174 | } |
| 172 | } | 175 | } |
| 176 | |||
| 177 | pub fn requestSelection(self: *Runtime, key: model.Attachment, id: u32, range: client.selection.Range, version: client.session_pump.SelectionVersion) !void { | ||
| 178 | const live = self.get(key.pane) orelse return error.MissingPane; | ||
| 179 | if (!self.accepts(key)) return error.StaleAttachment; | ||
| 180 | try live.pump.say(.{ .selection = .{ .id = id, .anchor = .{ .row = range.from.row, .col = range.from.col }, .active = .{ .row = range.to.row, .col = range.to.col }, .version = version } }); | ||
| 181 | } | ||
| 173 | pub fn poll(self: *Runtime, now: i64) bool { | 182 | pub fn poll(self: *Runtime, now: i64) bool { |
| 174 | var changed = false; | 183 | var changed = false; |
| 175 | for (self.lives) |p| if (p) |live| { | 184 | for (self.lives) |p| if (p) |live| { |
test/native_selection.py
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,305 @@ | |||
| 1 | #!/usr/bin/env python3 | ||
| 2 | """Real drag events, daemon extraction, framebuffer and desktop clipboard oracles.""" | ||
| 3 | import os | ||
| 4 | import shlex | ||
| 5 | import signal | ||
| 6 | import subprocess | ||
| 7 | import sys | ||
| 8 | import time | ||
| 9 | |||
| 10 | sys.dont_write_bytecode = True | ||
| 11 | from native_lifecycle import LifecycleRig, start_persistent | ||
| 12 | from native_resize import by_id | ||
| 13 | from native_theme import pixel | ||
| 14 | from native_tiling import eventually, require | ||
| 15 | |||
| 16 | |||
| 17 | class SelectionRig(LifecycleRig): | ||
| 18 | pointer = None | ||
| 19 | |||
| 20 | def send(self, *lines): | ||
| 21 | if self.env['SDL_VIDEO_DRIVER'] != 'wayland': | ||
| 22 | return super().send(*lines) | ||
| 23 | from wayland_pointer import Pointer | ||
| 24 | for line in lines: | ||
| 25 | kind, _, coords = line.partition(':') | ||
| 26 | if kind in ('mousedown', 'mousemove', 'mouseup', 'click'): | ||
| 27 | if self.pointer is None: | ||
| 28 | binary = os.environ.get('MUXG_TEST_POINTER') | ||
| 29 | require(binary, 'Wayland clipboard acceptance requires MUXG_TEST_POINTER') | ||
| 30 | self.pointer = Pointer(binary, self.env, self.gui.pid) | ||
| 31 | subprocess.run(['swaymsg', f'[pid={self.gui.pid}] focus'], env=self.env, | ||
| 32 | capture_output=True, check=True, timeout=3) | ||
| 33 | self.pointer.move(*map(float, coords.split(','))) | ||
| 34 | self.state() # Deliver initial window focus before testing a pane click. | ||
| 35 | self.pointer.event(kind, *map(float, coords.split(','))) | ||
| 36 | else: | ||
| 37 | super().send(line) | ||
| 38 | |||
| 39 | def close(self): | ||
| 40 | try: | ||
| 41 | if self.pointer is not None: | ||
| 42 | self.pointer.close() | ||
| 43 | self.pointer = None | ||
| 44 | finally: | ||
| 45 | super().close() | ||
| 46 | |||
| 47 | def clipboard(self): | ||
| 48 | value = self.artifact('clipboard', '.txt').read_text() | ||
| 49 | if self.env['SDL_VIDEO_DRIVER'] == 'wayland': | ||
| 50 | # A separate client asks the compositor, not the application hook. | ||
| 51 | external = subprocess.run(['wl-paste', '--no-newline'], env=self.env, | ||
| 52 | capture_output=True, timeout=3) | ||
| 53 | self.clipboard_observed = {'sdl': value, 'desktop': external.stdout.decode(), | ||
| 54 | 'error': external.stderr.decode()} | ||
| 55 | # SDL publishes asynchronously to the compositor. Poll for both | ||
| 56 | # clients to agree instead of treating offer propagation as failure. | ||
| 57 | if external.returncode or external.stdout.decode() != value: | ||
| 58 | return None | ||
| 59 | return value | ||
| 60 | |||
| 61 | def cell_point(self, state, pane_id, col, row): | ||
| 62 | content = by_id(state)[pane_id]['content'] | ||
| 63 | return self.point(state, content['x'] + (col + .5) * state['cell_w'], | ||
| 64 | content['y'] + (row + .5) * state['cell_h']) | ||
| 65 | |||
| 66 | def select(self, pane_id, start, finish, release=True): | ||
| 67 | state = self.state() | ||
| 68 | a, b = (self.cell_point(state, pane_id, *cell) for cell in (start, finish)) | ||
| 69 | self.send('mousedown:' + a, 'mousemove:' + b) | ||
| 70 | if release: | ||
| 71 | self.send('mouseup:' + b) | ||
| 72 | return state | ||
| 73 | |||
| 74 | def copied(self, text): | ||
| 75 | try: | ||
| 76 | eventually(lambda: self.clipboard() == text, 'clipboard did not become ' + repr(text)) | ||
| 77 | except AssertionError as error: | ||
| 78 | raise AssertionError(f'{error}; observed {getattr(self, "clipboard_observed", None)}') from error | ||
| 79 | |||
| 80 | def unchanged(self, text): | ||
| 81 | # Observe over several event-loop turns so a queued reply has a chance | ||
| 82 | # to arrive; a single immediate equality would miss a late overwrite. | ||
| 83 | until = time.monotonic() + .35 | ||
| 84 | while time.monotonic() < until: | ||
| 85 | require(self.clipboard() == text, 'cancelled selection changed clipboard') | ||
| 86 | time.sleep(.025) | ||
| 87 | |||
| 88 | |||
| 89 | def specimen(rig, pane_id, tag): | ||
| 90 | rig.focus(pane_id) | ||
| 91 | pane = by_id(rig.state())[pane_id] | ||
| 92 | require(pane['cols'] >= 22 and pane['rows'] >= 9, 'selection specimen needs 22x9 cells') | ||
| 93 | wrap = 'W' * pane['cols'] + 'RAP' | ||
| 94 | marker = 'READY-' + tag + '-' + str(rig.serial) | ||
| 95 | output = ('\033[0m\033[2J\033[H' + tag + '\033[2;1Halpha café 界 omega' | ||
| 96 | '\033[3;1Hhard one\r\nhard two ' | ||
| 97 | '\033[5;1H' + wrap + '\033[8;1H' + marker + '\033[9;1H') | ||
| 98 | escaped = output.replace('\033', '\\033').replace('\r', '\\r').replace('\n', '\\n') | ||
| 99 | rig.shell("export PS1=''; printf '%b' " + shlex.quote(escaped)) | ||
| 100 | rig.wait_state(lambda s: marker in by_id(s)[pane_id]['painted_text']) | ||
| 101 | return wrap | ||
| 102 | |||
| 103 | |||
| 104 | def cell_background(rig, state, pane_id, col, row): | ||
| 105 | rect = by_id(state)[pane_id]['content'] | ||
| 106 | # Cell corner avoids glyph ink and samples actual completed painted state. | ||
| 107 | return pixel(rig.last_pixels(), rect['x'] + col * state['cell_w'] + 1, | ||
| 108 | rect['y'] + row * state['cell_h'] + 1) | ||
| 109 | |||
| 110 | |||
| 111 | def arm_output(rig, pane_id, label): | ||
| 112 | """Shell-owned output released by a file, with no input during the drag.""" | ||
| 113 | trigger = rig.root / label | ||
| 114 | rig.focus(pane_id) | ||
| 115 | rig.shell('(while ! test -e ' + shlex.quote(str(trigger)) + | ||
| 116 | "; do sleep .02; done; printf '\\033[7;1H" + label + "') &") | ||
| 117 | return trigger | ||
| 118 | |||
| 119 | |||
| 120 | def scale_selection(rig, pane): | ||
| 121 | output = os.environ.get('MUXG_TEST_SCALE_OUTPUT') | ||
| 122 | if not output: | ||
| 123 | return | ||
| 124 | require(rig.pointer is not None, 'scale acceptance requires real Wayland input') | ||
| 125 | original = next(o['scale'] for o in rig.pointer.query('get_outputs') if o['name'] == output) | ||
| 126 | |||
| 127 | def scale(value): | ||
| 128 | response = subprocess.run(['swaymsg', '-r', 'output', output, 'scale', str(value)], | ||
| 129 | env=rig.env, capture_output=True, text=True, check=True, timeout=3) | ||
| 130 | require('"success": true' in response.stdout, 'compositor refused fixture scale') | ||
| 131 | return rig.wait_state(lambda s: abs(s['width'] / s['logical_width'] - value) < .01) | ||
| 132 | |||
| 133 | try: | ||
| 134 | for value in (1, 1.5, 2): | ||
| 135 | specimen(rig, pane, 'SCALE-' + str(value)) | ||
| 136 | rig.select(pane, (0, 1), (4, 1)) | ||
| 137 | rig.copied('alpha') | ||
| 138 | rig.select(pane, (6, 1), (9, 1), release=False) | ||
| 139 | state = scale(value) | ||
| 140 | rig.send('mouseup:' + rig.cell_point(state, pane, 9, 1)) | ||
| 141 | rig.unchanged('alpha') | ||
| 142 | specimen(rig, pane, 'SCALED-' + str(value)) | ||
| 143 | rig.select(pane, (6, 1), (9, 1)) | ||
| 144 | rig.copied('café') | ||
| 145 | rig.kernel_sizes() | ||
| 146 | finally: | ||
| 147 | scale(original) | ||
| 148 | rig.ok('real held drags cancel at 100/150/200% transitions; new drags copy and PTYs agree') | ||
| 149 | |||
| 150 | |||
| 151 | def exercise(rig): | ||
| 152 | refs = start_persistent(rig) | ||
| 153 | rig.drag('stacked', dy=rig.state()['cell_h'] * 3) | ||
| 154 | panes = list(refs) | ||
| 155 | wraps = {pane: specimen(rig, pane, 'PANE-' + str(pane)) for pane in panes} | ||
| 156 | target, neighbour = panes[1], panes[2] | ||
| 157 | rig.focus(panes[0]) | ||
| 158 | state = rig.state() | ||
| 159 | before = {p: cell_background(rig, state, p, 1, 1) for p in panes} | ||
| 160 | rig.select(target, (0, 1), (4, 1), release=False) | ||
| 161 | eventually(lambda: cell_background(rig, state, target, 1, 1) != before[target], | ||
| 162 | 'drag did not visibly highlight the off-origin pane') | ||
| 163 | for p in (panes[0], neighbour): | ||
| 164 | require(cell_background(rig, state, p, 1, 1) == before[p], 'highlight leaked into neighbour') | ||
| 165 | require(rig.state()['focus'] == target, 'press did not focus selected pane') | ||
| 166 | rig.send('mouseup:' + rig.cell_point(state, target, 4, 1)) | ||
| 167 | rig.copied('alpha') | ||
| 168 | rig.ok('off-origin held drag paints only its pane and release copies daemon text') | ||
| 169 | |||
| 170 | for pane in panes: | ||
| 171 | rig.select(pane, (4, 1), (0, 1)) | ||
| 172 | rig.copied('alpha') | ||
| 173 | rig.select(pane, (6, 1), (10, 1)) | ||
| 174 | rig.copied('café') | ||
| 175 | # Endpoint on the wide glyph continuation must include the whole glyph. | ||
| 176 | rig.select(pane, (10, 1), (12, 1)) | ||
| 177 | rig.copied(' 界') | ||
| 178 | rig.select(pane, (0, 2), (10, 3)) | ||
| 179 | rig.copied('hard one\nhard two') | ||
| 180 | rig.select(pane, (0, 4), (2, 5)) | ||
| 181 | rig.copied(wraps[pane]) | ||
| 182 | rig.ok('both directions, Unicode, wide continuation, trimming, hard newline and soft wrap') | ||
| 183 | |||
| 184 | baseline = wraps[panes[-1]] | ||
| 185 | state = rig.state() | ||
| 186 | same = rig.cell_point(state, target, 0, 1) | ||
| 187 | rig.send('mousedown:' + same, 'mousemove:' + same, 'mouseup:' + same) | ||
| 188 | rig.unchanged(baseline) | ||
| 189 | rig.focus(neighbour) # Header press is deliberately not terminal text. | ||
| 190 | rig.unchanged(baseline) | ||
| 191 | rig.chord('enter') | ||
| 192 | rig.picker('hosts') | ||
| 193 | rig.select(target, (0, 1), (4, 1)) | ||
| 194 | rig.unchanged(baseline) | ||
| 195 | rig.key('escape') | ||
| 196 | rig.key('escape') | ||
| 197 | rig.ok('same-cell tremor, header press and modal pointer input preserve clipboard') | ||
| 198 | |||
| 199 | # Start in one pane, enter its neighbour, then release back over the origin. | ||
| 200 | # The neighbour must not supply either the endpoint or copied text. | ||
| 201 | state = rig.select(target, (0, 1), (4, 1), release=False) | ||
| 202 | rig.send('mousemove:' + rig.cell_point(state, neighbour, 16, 1), | ||
| 203 | 'mouseup:' + rig.cell_point(state, target, 4, 1)) | ||
| 204 | rig.copied('alpha') | ||
| 205 | rig.ok('cross-pane motion keeps the original session selection') | ||
| 206 | |||
| 207 | trigger = arm_output(rig, neighbour, 'NEIGHBOUR-OUTPUT') | ||
| 208 | specimen(rig, neighbour, 'PANE-' + str(neighbour)) | ||
| 209 | state = rig.select(target, (6, 1), (9, 1), release=False) | ||
| 210 | trigger.touch() | ||
| 211 | rig.wait_state(lambda s: 'NEIGHBOUR-OUTPUT' in by_id(s)[neighbour]['painted_text']) | ||
| 212 | rig.send('mouseup:' + rig.cell_point(state, target, 9, 1)) | ||
| 213 | rig.copied('café') | ||
| 214 | trigger = arm_output(rig, target, 'SELECTED-OUTPUT') | ||
| 215 | specimen(rig, target, 'PANE-' + str(target)) | ||
| 216 | state = rig.select(target, (0, 1), (4, 1), release=False) | ||
| 217 | trigger.touch() | ||
| 218 | rig.wait_state(lambda s: 'SELECTED-OUTPUT' in by_id(s)[target]['painted_text']) | ||
| 219 | rig.send('mouseup:' + rig.cell_point(state, target, 4, 1)) | ||
| 220 | rig.unchanged('café') | ||
| 221 | rig.ok('independent pane output preserves selection; selected-pane changes cancel it') | ||
| 222 | rig.select(target, (0, 1), (4, 1)) | ||
| 223 | rig.copied('alpha') | ||
| 224 | |||
| 225 | # Pause only this fixture's real daemon. The queued selection is still | ||
| 226 | # decoded/extracted by that daemon when resumed, not by a protocol mock. | ||
| 227 | sock = refs[target][0] | ||
| 228 | daemon = next(proc for path, proc in rig.daemons if path == sock) | ||
| 229 | daemon.send_signal(signal.SIGSTOP) | ||
| 230 | try: | ||
| 231 | rig.select(target, (6, 1), (9, 1)) | ||
| 232 | rig.state() # Event queue barrier, no repaint or data repair. | ||
| 233 | rig.focus(neighbour) | ||
| 234 | finally: | ||
| 235 | daemon.send_signal(signal.SIGCONT) | ||
| 236 | rig.unchanged('alpha') | ||
| 237 | rig.ok('a delayed real-daemon reply cannot copy after a new press clears selection') | ||
| 238 | |||
| 239 | # Resize while a reply is waiting; then validate the real PTY sizes. | ||
| 240 | daemon.send_signal(signal.SIGSTOP) | ||
| 241 | try: | ||
| 242 | rig.select(target, (6, 1), (9, 1)) | ||
| 243 | rig.state() | ||
| 244 | rig.drag('beside', dx=rig.state()['cell_w'] * -2) | ||
| 245 | rig.state() # Process the resize events before allowing the reply. | ||
| 246 | finally: | ||
| 247 | daemon.send_signal(signal.SIGCONT) | ||
| 248 | rig.unchanged('alpha') | ||
| 249 | rig.kernel_sizes() | ||
| 250 | rig.ok('geometry change cancels pending copy and all PTYs match the resized panes') | ||
| 251 | specimen(rig, target, 'PANE-' + str(target)) | ||
| 252 | state = rig.state() | ||
| 253 | blank = cell_background(rig, state, target, 16, 8) | ||
| 254 | rig.select(target, (15, 8), (18, 8), release=False) | ||
| 255 | eventually(lambda: cell_background(rig, state, target, 16, 8) != blank, | ||
| 256 | 'empty-copy fixture did not select its in-bounds blank cells') | ||
| 257 | rig.send('mouseup:' + rig.cell_point(state, target, 18, 8)) | ||
| 258 | rig.state() | ||
| 259 | rig.unchanged('alpha') | ||
| 260 | daemon.send_signal(signal.SIGSTOP) | ||
| 261 | try: | ||
| 262 | rig.select(target, (6, 1), (9, 1)) | ||
| 263 | rig.wait_state(lambda s: 'Selection unavailable' in s['notice']) | ||
| 264 | finally: | ||
| 265 | daemon.send_signal(signal.SIGCONT) | ||
| 266 | rig.unchanged('alpha') | ||
| 267 | rig.ok('empty copy and a real unanswered request preserve clipboard; timeout is visible') | ||
| 268 | scale_selection(rig, target) | ||
| 269 | specimen(rig, target, 'DETACH') | ||
| 270 | rig.select(target, (0, 1), (4, 1)) | ||
| 271 | rig.copied('alpha') | ||
| 272 | daemon.send_signal(signal.SIGSTOP) | ||
| 273 | try: | ||
| 274 | rig.select(target, (6, 1), (9, 1)) | ||
| 275 | rig.state() | ||
| 276 | rig.chord('d') | ||
| 277 | rig.wait_state(lambda s: target not in by_id(s)) | ||
| 278 | finally: | ||
| 279 | daemon.send_signal(signal.SIGCONT) | ||
| 280 | rig.unchanged('alpha') | ||
| 281 | require(rig.status(*refs[target])['cols'] > 0, 'detach ended the selected shell') | ||
| 282 | rig.ok('detach cancels copying with a stalled daemon and leaves its shell alive') | ||
| 283 | rig.assert_cli_untouched() | ||
| 284 | return refs | ||
| 285 | |||
| 286 | |||
| 287 | def main(): | ||
| 288 | require(len(sys.argv) == 3, 'usage: native_selection.py MUX MUXG') | ||
| 289 | rig = SelectionRig(*sys.argv[1:]) | ||
| 290 | try: | ||
| 291 | exercise(rig) | ||
| 292 | rig.quit() | ||
| 293 | print('Native selection acceptance passed; artifacts:', rig.root, flush=True) | ||
| 294 | except BaseException: | ||
| 295 | rig.failure_artifacts() | ||
| 296 | raise | ||
| 297 | finally: | ||
| 298 | for _, proc in rig.daemons: | ||
| 299 | if proc.poll() is None: | ||
| 300 | proc.send_signal(signal.SIGCONT) | ||
| 301 | rig.close() | ||
| 302 | |||
| 303 | |||
| 304 | if __name__ == '__main__': | ||
| 305 | main() | ||
test/wayland_pointer.py
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,65 @@ | |||
| 1 | """Drive the retained virtual-pointer helper on an isolated Sway output. | ||
| 2 | |||
| 3 | MUXG_TEST_POINTER names the helper binary (line protocol: move x y w h, | ||
| 4 | button 0/1; each command returns ok). A real input serial is required for | ||
| 5 | Wayland clipboard ownership; SDL-injected events cannot establish it. | ||
| 6 | """ | ||
| 7 | import json | ||
| 8 | import select | ||
| 9 | import subprocess | ||
| 10 | |||
| 11 | |||
| 12 | class Pointer: | ||
| 13 | def __init__(self, binary, env, pid): | ||
| 14 | self.env, self.pid = env, pid | ||
| 15 | self.proc = subprocess.Popen([binary], env=env, stdin=subprocess.PIPE, | ||
| 16 | stdout=subprocess.PIPE, text=True) | ||
| 17 | |||
| 18 | def command(self, line): | ||
| 19 | self.proc.stdin.write(line + '\n') | ||
| 20 | self.proc.stdin.flush() | ||
| 21 | if not select.select([self.proc.stdout], [], [], 3)[0]: | ||
| 22 | raise RuntimeError('virtual pointer command timed out') | ||
| 23 | if self.proc.stdout.readline().strip() != 'ok': | ||
| 24 | raise RuntimeError('virtual pointer command failed') | ||
| 25 | |||
| 26 | def query(self, kind): | ||
| 27 | return json.loads(subprocess.check_output(['swaymsg', '-r', '-t', kind], | ||
| 28 | env=self.env, timeout=3)) | ||
| 29 | |||
| 30 | def move(self, x, y): | ||
| 31 | def find(node): | ||
| 32 | if node.get('pid') == self.pid: | ||
| 33 | return node | ||
| 34 | for child in node.get('nodes', []) + node.get('floating_nodes', []): | ||
| 35 | found = find(child) | ||
| 36 | if found: | ||
| 37 | return found | ||
| 38 | node = find(self.query('get_tree')) | ||
| 39 | if node is None: | ||
| 40 | raise RuntimeError('owned native window is not mapped') | ||
| 41 | outputs = self.query('get_outputs') | ||
| 42 | if len(outputs) != 1 or not outputs[0]['name'].startswith('HEADLESS-'): | ||
| 43 | raise RuntimeError('pointer fixture requires one isolated headless output') | ||
| 44 | rect, screen = node['rect'], outputs[0]['rect'] | ||
| 45 | self.command(f"move {int(rect['x'] + x - screen['x'])} " | ||
| 46 | f"{int(rect['y'] + y - screen['y'])} {screen['width']} {screen['height']}") | ||
| 47 | |||
| 48 | def event(self, kind, x, y): | ||
| 49 | self.move(x, y) | ||
| 50 | if kind in ('mousedown', 'click'): | ||
| 51 | self.command('button 1') | ||
| 52 | if kind in ('mouseup', 'click'): | ||
| 53 | self.command('button 0') | ||
| 54 | |||
| 55 | def close(self): | ||
| 56 | self.proc.stdin.close() | ||
| 57 | try: | ||
| 58 | if self.proc.wait(timeout=3): | ||
| 59 | raise RuntimeError('virtual pointer failed on shutdown') | ||
| 60 | except subprocess.TimeoutExpired: | ||
| 61 | self.proc.kill() | ||
| 62 | self.proc.wait(timeout=3) | ||
| 63 | raise | ||
| 64 | finally: | ||
| 65 | self.proc.stdout.close() | ||