a73x

3754dae9

feat: select native terminal text and copy on release

a73x   2026-09-06 07:51

Commit message
feat: select native terminal text and copy on release

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()