a73x

04b2c810

fix: handle native copy shortcut before terminal input

a73x   2026-09-06 08:32

Commit message
fix: handle native copy shortcut before terminal input

RETRO.md
Old New
@@ -1186,3 +1186,46 @@ it without overwriting the clipboard. Framebuffer and clipboard observations
1186 passed; all probe processes were closed. This does not add wheel scrolling or 1186 passed; all probe processes were closed. This does not add wheel scrolling or
1187 application mouse forwarding. Evidence and the rerunnable probe remain under 1187 application mouse forwarding. Evidence and the rerunnable probe remain under
1188 `dist/text-selection/app-scroll*` and `check-app-selection.py`. 1188 `dist/text-selection/app-scroll*` and `check-app-selection.py`.
1189
1190 ### Hands-on copy shortcut — 2026-09-06
1191
1192 The hands-on review found Ctrl+Shift+C reached the PTY as Ctrl+C. The SDL adapter
1193 now recognises the copy chord and the controller consumes it before terminal
1194 input, even with no selected range. Copy on release and explicit copying share
1195 one versioned request queue. Highlight and stale-attachment rules remain in the
1196 controller; extraction and clipboard writing keep their existing owners. The
1197 small cleanup removed an unnecessary forwarding helper. No new module,
1198 dependency or wire message was introduced.
1199
1200 Luna implemented and Terra reviewed. Root caught a modifier-mask mistake that
1201 required both left and right modifiers simultaneously; the final adapter tests
1202 cover all four physical Ctrl/Shift combinations and Alt/GUI/Mode exclusions.
1203 The real-PTY regression independently records SIGINT: shortcut copy during a
1204 held drag, after release and without a selection leaves the process alone;
1205 ordinary Ctrl+C still interrupts it. Root also corrected test ordering races by
1206 waiting for actual highlighted pixels before releasing output or injecting the
1207 shortcut after Wayland pointer input. Earlier failures remain in the logs.
1208
1209 Full CI, native integration, native/core units, full offscreen and independent
1210 Wayland clipboard/scale checks passed. The user confirmed copying works in the restarted review GUI. The new
1211 26.4-second continuous recording demonstrates copying before mouse release and
1212 independent `wl-paste` output. Its pointer events come through Wayland; the copy
1213 chord uses SDL event injection. Original footage is retained separately.
1214
1215 The initial hands-on launch used default colors because the user's mux config
1216 does not exist; prior demonstrations used isolated configs. Relaunched with the
1217 recording's explicit appearance flags. Next hands-on launch: reuse the recorded
1218 appearance arguments in `dist/text-selection/hands-on-gui.json`, and distinguish
1219 demo config from persistent user config when describing the launch.
1220
1221 The user reached for paste but agreed it can remain deferred. They clarified
1222 their mouse-selection example was Claude Code, whose fullscreen mode requests
1223 mouse reporting and displays its own copy toast. Alternate-screen state alone
1224 does not confer mouse ownership. The TUI's `Core.forward` branches on `appMouse()`;
1225 the GUI currently owns drags locally. `session_pump.onFrame` also skips validated
1226 application `clipboard_set` effects. The next mouse/clipboard parity slice must
1227 assess both application mouse forwarding and application clipboard requests,
1228 alongside GUI paste and copy feedback. See the upstream explanation at
1229 https://code.claude.com/docs/en/fullscreen#keep-native-text-selection .
1230 Confirmation of copying is hands-on feedback,
1231 not acceptance of the whole sprint or authorization to end daemon sessions.
docs/demos/native-text-selection.html
Old New
@@ -17,14 +17,15 @@ video{width:100%;display:block;background:#101114;border-radius:10px;margin:22px
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> 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> 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> 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> 20 <p class="muted">26.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> 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> 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> 23 <ul><li>Drag across terminal text in either direction. Release to copy; the highlight remains until cleared. Ctrl+Shift+C also copies the current selection.</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. Ctrl+Shift+C with no selection does nothing; plain Ctrl+C still reaches the terminal.</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> 24 <p>The recording copies during a held drag with Ctrl+Shift+C, then reads the desktop clipboard using <code>wl-paste --no-newline</code> in another pane. Pointer input comes through Wayland; the recorded shortcut uses SDL event injection. GUI paste is a later slice.</p>
25 <h2>Validation</h2> 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> 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> 27 <p>The copy shortcut is checked against a real foreground PTY process: Ctrl+Shift+C copies without SIGINT, including with no selection; plain Ctrl+C still delivers SIGINT. Adapter tests cover both sides of Ctrl and Shift.</p>
28 <p>Separately measured NVIDIA responsiveness before the shortcut follow-up: 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 <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>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>Verified with real Vim and less in the offscreen GUI: keyboard scrolling clears the GUI highlight but keeps text already copied. Scrolling before mouse release cancels that drag. Vim's own visual selection is separate.</p> 31 <p>Verified with real Vim and less in the offscreen GUI: keyboard scrolling clears the GUI highlight but keeps text already copied. Scrolling before mouse release cancels that drag. Vim's own visual selection is separate.</p>
docs/superpowers/plans/2026-09-06-native-text-selection.md
Old New
@@ -8,12 +8,12 @@ Worktree branch: `gui-text-selection` (existing worktree directory retained).
8 ## Goal and trial behavior 8 ## Goal and trial behavior
9 9
10 Drag across visible terminal text, see the selected cells, and copy the daemon's 10 Drag across visible terminal text, see the selected cells, and copy the daemon's
11 text to the desktop clipboard on release. Reuse the TUI's single-click versus 11 text to the desktop clipboard on release or with Ctrl+Shift+C. Reuse the TUI's single-click versus
12 drag semantics and daemon extraction. A plain click continues to focus a pane. 12 drag semantics and daemon extraction. A plain click continues to focus a pane.
13 The selection belongs to its starting pane; crossing another pane must never 13 The selection belongs to its starting pane; crossing another pane must never
14 select or copy that pane's text. A new press clears the previous highlight. 14 select or copy that pane's text. A new press clears the previous highlight.
15 15
16 This slice covers visible text and copy on release. Wheel/history navigation, 16 This slice covers visible text, copy on release and the copy shortcut. Wheel/history navigation,
17 edge autoscroll, double/triple click, rectangular selection, clipboard paste, 17 edge autoscroll, double/triple click, rectangular selection, clipboard paste,
18 application mouse forwarding and ligatures remain separate work. The existing 18 application mouse forwarding and ligatures remain separate work. The existing
19 GUI does not forward application mouse clicks; this slice preserves that scope. 19 GUI does not forward application mouse clicks; this slice preserves that scope.
@@ -29,6 +29,7 @@ GUI does not forward application mouse clicks; this slice preserves that scope.
29 | Pointer hit testing, modal routing and cancellation | `interaction.Controller.pointerDown`/`pointerMove`, `focusLost`, `updateGeometry` | Window-free native controller | Use shared drag state with native geometry; add release/copy intent and cancellation. Keep divider drag distinct from text drag. | 29 | Pointer hit testing, modal routing and cancellation | `interaction.Controller.pointerDown`/`pointerMove`, `focusLost`, `updateGeometry` | Window-free native controller | Use shared drag state with native geometry; add release/copy intent and cancellation. Keep divider drag distinct from text drag. |
30 | Logical-to-physical input, mouse capture, clipboard IO | `frame.Events`, `physicalPoint`, `physicalSignedAxis`, existing pointer hooks | SDL frame adapter | Extend ordinary mouse routing and use SDL clipboard writing. SDL remains confined to frame. Test hooks observe actual clipboard state and inject existing SDL events. | 30 | Logical-to-physical input, mouse capture, clipboard IO | `frame.Events`, `physicalPoint`, `physicalSignedAxis`, existing pointer hooks | SDL frame adapter | Extend ordinary mouse routing and use SDL clipboard writing. SDL remains confined to frame. Test hooks observe actual clipboard state and inject existing SDL events. |
31 | Selection highlight | `quads.rowInstances`, existing themed cell colors | Native painter | Consume a supplied row span; select complete wide glyph cells and retain readable colors. Never mutate the authoritative grid for highlighting. | 31 | Selection highlight | `quads.rowInstances`, existing themed cell colors | Native painter | Consume a supplied row span; select complete wide glyph cells and retain readable colors. Never mutate the authoritative grid for highlighting. |
32 | Copy shortcut from hands-on feedback | `frame.interactionKey`, `Controller.keyDown`, selection request in `pointerUp`, `Runtime.requestSelection` | SDL maps the chord; native controller owns copy intent | Extract the existing request action for both release and shortcut. Consume Ctrl+Shift+C even without selection; preserve plain Ctrl+C. No second text cache or clipboard implementation. |
32 33
33 The existing pure selection module already supplies the needed domain rule; a 34 The existing pure selection module already supplies the needed domain rule; a
34 new selection framework is unnecessary. Moving it to the shared client owner 35 new selection framework is unnecessary. Moving it to the shared client owner
@@ -45,6 +46,10 @@ the desktop clipboard's cap; the existing wire's 1 MiB bound still applies.
45 Copy on release uses exact daemon text for soft wraps, hard newlines, trailing 46 Copy on release uses exact daemon text for soft wraps, hard newlines, trailing
46 spaces, non-ASCII text and wide-cell continuations. A selected wide glyph is 47 spaces, non-ASCII text and wide-cell continuations. A selected wide glyph is
47 visibly complete. Other panes retain their text, PTYs and input behavior. 48 visibly complete. Other panes retain their text, PTYs and input behavior.
49 - Ctrl+Shift+C copies the current fresh range without clearing the highlight or
50 sending Ctrl+C to the application. With no selection it leaves the clipboard
51 and application untouched. Plain Ctrl+C still interrupts an actual foreground
52 PTY process. Repeat/release and modal routing retain their existing ownership.
48 - A new selection, cancellation, resize/DPI transition, focus loss, attachment 53 - A new selection, cancellation, resize/DPI transition, focus loss, attachment
49 replacement/reconnect or detach cannot allow a delayed reply to overwrite the 54 replacement/reconnect or detach cannot allow a delayed reply to overwrite the
50 clipboard. Empty/invalid/unavailable/refused/oversized results leave its prior 55 clipboard. Empty/invalid/unavailable/refused/oversized results leave its prior
@@ -78,7 +83,8 @@ benefit rather than an arbitrary file split. Reuse the existing Rig and recorder
78 assets with isolated XDG state and compositor addresses. 83 assets with isolated XDG state and compositor addresses.
79 84
80 Deliver a private Tailscale review page with achievements, actual GUI recording, 85 Deliver a private Tailscale review page with achievements, actual GUI recording,
81 controls, checks and limitations. Preserve previous pages. Record demo approval 86 controls, checks and limitations. Preserve demos awaiting review; tear down accepted
87 demo routes and owned processes while retaining their artifacts. Record demo approval
82 only after the user gives it. 88 only after the user gives it.
83 89
84 90
@@ -153,7 +159,8 @@ fixture cleanup before the successful recording.
153 Review page source: `docs/demos/native-text-selection.html`. The 24.4-second 159 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` 160 continuous NVIDIA 200% recording shows real drags and independent `wl-paste`
155 output, with no cuts or audio. Private handoff route: 161 output, with no cuts or audio. Private handoff route:
156 https://charizard.folk-amberjack.ts.net/text-selection/ . Previous routes remain. 162 https://charizard.folk-amberjack.ts.net/text-selection/ . The accepted appearance
163 routes were subsequently removed; their artifacts remain.
157 Route-specific teardown: `tailscale serve --https=443 --set-path /text-selection off`, 164 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`. 165 then stop only the page server identified by `dist/text-selection/server.json`.
159 166
@@ -164,3 +171,35 @@ Only the allowlisted page server remains (PID 3384909, port 18774); owned
164 browser/compositor fixtures are stopped. The final offscreen path was rechecked 171 browser/compositor fixtures are stopped. The final offscreen path was rechecked
165 after sharing the Wayland pointer adapter. The retained recording script now 172 after sharing the Wayland pointer adapter. The retained recording script now
166 explicitly focuses its owned window before its first pane click. 173 explicitly focuses its owned window before its first pane click.
174
175 ## Hands-on copy-shortcut feedback
176
177 The user found Ctrl+Shift+C was forwarded as terminal Ctrl+C. The SDL adapter now
178 maps that chord to copy intent, and the native controller consumes it before
179 terminal input. Both mouse release and keyboard copy share the existing request
180 queue, ID and freshness handling; clipboard writing and daemon extraction are
181 unchanged. No new module, dependency or protocol was needed. Pure modifiers keep
182 the selection; plain Ctrl+C still goes to the PTY. Existing modal/prefix handling
183 retains precedence. Luna implemented, Terra reviewed, root added the real-PTY
184 oracle and removed a redundant forwarding helper.
185
186 The regression uses a real foreground Python process recording SIGINT to a file.
187 It verifies copying during a held drag, retaining the released highlight, copying
188 with no selection, and ordinary Ctrl+C delivery. Adapter tests cover left/right
189 modifier combinations and excluded Alt/GUI/Mode modifiers. Fixture output and
190 keyboard events wait for actual highlighted pixels where compositor input and
191 the passive hook arrive on separate connections. Early race failures are retained
192 beside final results in `dist/text-selection/copy-*`.
193
194 Full CI and native integration passed after the shortcut change, alongside
195 native/core units and offscreen/Wayland selection checks. The review GUI was
196 restarted with the same explicit appearance flags; the user confirmed copying.
197 Updated footage is 26.4 seconds and includes copy before release (SDL-injected
198 shortcut, real Wayland pointer, independent desktop clipboard read). Previous
199 performance measurements predate this keyboard follow-up.
200
201 The user's application-selection example was Claude Code, not Neovim. Follow-up
202 parity must distinguish alternate-screen mode from requested mouse reporting:
203 TUI `Core.forward` already uses `TermModes.appMouse()`. Native application mouse
204 forwarding and application clipboard effects are both missing; the pump currently
205 skips `clipboard_set`. GUI paste stays deferred by the user's clarification.
src/gui/frame.zig
Old New
@@ -116,6 +116,8 @@ pub fn parseHook(line: []const u8) ?Hook {
116 if (std.mem.startsWith(u8, line, "key:")) { 116 if (std.mem.startsWith(u8, line, "key:")) {
117 const name = line["key:".len..]; 117 const name = line["key:".len..];
118 if (std.mem.eql(u8, name, "prefix")) return .{ .key = .{ .code = c.SDLK_BACKSLASH, .mods = c.SDL_KMOD_CTRL } }; 118 if (std.mem.eql(u8, name, "prefix")) return .{ .key = .{ .code = c.SDLK_BACKSLASH, .mods = c.SDL_KMOD_CTRL } };
119 if (std.mem.eql(u8, name, "copy")) return .{ .key = .{ .code = c.SDLK_C, .mods = c.SDL_KMOD_CTRL | c.SDL_KMOD_SHIFT } };
120 if (std.mem.eql(u8, name, "interrupt")) return .{ .key = .{ .code = c.SDLK_C, .mods = c.SDL_KMOD_CTRL } };
119 if (name.len == 1 and std.mem.indexOfScalar(u8, "hjklvbrdxp", name[0]) != null) return .{ .key = .{ .code = name[0] } }; 121 if (name.len == 1 and std.mem.indexOfScalar(u8, "hjklvbrdxp", name[0]) != null) return .{ .key = .{ .code = name[0] } };
120 inline for (.{ .{ "enter", c.SDLK_RETURN }, .{ "tab", c.SDLK_TAB }, .{ "escape", c.SDLK_ESCAPE }, .{ "backspace", c.SDLK_BACKSPACE }, .{ "up", c.SDLK_UP }, .{ "down", c.SDLK_DOWN }, .{ "left", c.SDLK_LEFT }, .{ "right", c.SDLK_RIGHT } }) |pair| { 122 inline for (.{ .{ "enter", c.SDLK_RETURN }, .{ "tab", c.SDLK_TAB }, .{ "escape", c.SDLK_ESCAPE }, .{ "backspace", c.SDLK_BACKSPACE }, .{ "up", c.SDLK_UP }, .{ "down", c.SDLK_DOWN }, .{ "left", c.SDLK_LEFT }, .{ "right", c.SDLK_RIGHT } }) |pair| {
121 if (std.mem.eql(u8, name, pair[0])) return .{ .key = .{ .code = pair[1] } }; 123 if (std.mem.eql(u8, name, pair[0])) return .{ .key = .{ .code = pair[1] } };
@@ -438,6 +440,7 @@ fn grabPixels(logical: c_int, pixels: c_int) u32 {
438 fn interactionKey(ev: c.SDL_KeyboardEvent) interaction.KeyDown { 440 fn interactionKey(ev: c.SDL_KeyboardEvent) interaction.KeyDown {
439 const translated = if (ev.scancode != c.SDL_SCANCODE_UNKNOWN) c.SDL_GetKeyFromScancode(ev.scancode, ev.mod, false) else ev.key; 441 const translated = if (ev.scancode != c.SDL_SCANCODE_UNKNOWN) c.SDL_GetKeyFromScancode(ev.scancode, ev.mod, false) else ev.key;
440 const prefix = ev.key == c.SDLK_BACKSLASH and ev.mod & c.SDL_KMOD_CTRL != 0 and ev.mod & (c.SDL_KMOD_ALT | c.SDL_KMOD_GUI | c.SDL_KMOD_MODE) == 0; 442 const prefix = ev.key == c.SDLK_BACKSLASH and ev.mod & c.SDL_KMOD_CTRL != 0 and ev.mod & (c.SDL_KMOD_ALT | c.SDL_KMOD_GUI | c.SDL_KMOD_MODE) == 0;
443 const copy = ev.key == c.SDLK_C and ev.mod & c.SDL_KMOD_CTRL != 0 and ev.mod & c.SDL_KMOD_SHIFT != 0 and ev.mod & (c.SDL_KMOD_ALT | c.SDL_KMOD_GUI | c.SDL_KMOD_MODE) == 0;
441 return .{ 444 return .{
442 .code = ev.key, 445 .code = ev.key,
443 .kind = switch (ev.key) { 446 .kind = switch (ev.key) {
@@ -464,6 +467,7 @@ fn interactionKey(ev: c.SDL_KeyboardEvent) interaction.KeyDown {
464 .prefix = prefix, 467 .prefix = prefix,
465 .modified = ev.mod & (c.SDL_KMOD_CTRL | c.SDL_KMOD_ALT | c.SDL_KMOD_GUI) != 0, 468 .modified = ev.mod & (c.SDL_KMOD_CTRL | c.SDL_KMOD_ALT | c.SDL_KMOD_GUI) != 0,
466 .repeat = ev.repeat, 469 .repeat = ev.repeat,
470 .copy = copy,
467 .terminal = keyEvent(if (prefix) ev.key else translated, ev.mod), 471 .terminal = keyEvent(if (prefix) ev.key else translated, ev.mod),
468 }; 472 };
469 } 473 }
@@ -1258,6 +1262,28 @@ test "command key text is consumed and subsequent ordinary text is not suppresse
1258 try std.testing.expectEqualSlices(u8, &.{0x1c}, keymap.encode(keyEvent(c.SDLK_BACKSLASH, event.key.mod).?, &bytes)); 1262 try std.testing.expectEqualSlices(u8, &.{0x1c}, keymap.encode(keyEvent(c.SDLK_BACKSLASH, event.key.mod).?, &bytes));
1259 } 1263 }
1260 1264
1265 test "copy chord is exact while plain interrupt remains terminal input" {
1266 var copy_event = std.mem.zeroes(c.SDL_KeyboardEvent);
1267 copy_event.key = c.SDLK_C;
1268 copy_event.mod = c.SDL_KMOD_CTRL | c.SDL_KMOD_SHIFT;
1269 const copy = interactionKey(copy_event);
1270 try std.testing.expect(copy.copy);
1271 var interrupt_event = copy_event;
1272 interrupt_event.mod = c.SDL_KMOD_CTRL;
1273 const interrupt = interactionKey(interrupt_event);
1274 try std.testing.expect(!interrupt.copy);
1275 try std.testing.expect(interrupt.terminal != null);
1276 for ([_]u16{ c.SDL_KMOD_LCTRL | c.SDL_KMOD_LSHIFT, c.SDL_KMOD_RCTRL | c.SDL_KMOD_RSHIFT, c.SDL_KMOD_LCTRL | c.SDL_KMOD_RSHIFT, c.SDL_KMOD_RCTRL | c.SDL_KMOD_LSHIFT }) |mods| {
1277 copy_event.mod = mods;
1278 try std.testing.expect(interactionKey(copy_event).copy);
1279 }
1280 for ([_]u16{ c.SDL_KMOD_ALT, c.SDL_KMOD_GUI, c.SDL_KMOD_MODE, c.SDL_KMOD_RALT }) |extra| {
1281 var excluded = copy_event;
1282 excluded.mod |= extra;
1283 try std.testing.expect(!interactionKey(excluded).copy);
1284 }
1285 }
1286
1261 test "later pane atlas growth precedes earlier pane UV generation" { 1287 test "later pane atlas growth precedes earlier pane UV generation" {
1262 const a = std.testing.allocator; 1288 const a = std.testing.allocator;
1263 var face = try font.Face.open(16); 1289 var face = try font.Face.open(16);
src/gui/interaction.zig
Old New
@@ -19,6 +19,7 @@ pub const KeyDown = struct {
19 prefix: bool = false, 19 prefix: bool = false,
20 modified: bool = false, 20 modified: bool = false,
21 repeat: bool = false, 21 repeat: bool = false,
22 copy: bool = false,
22 terminal: ?keymap.Event = null, 23 terminal: ?keymap.Event = null,
23 }; 24 };
24 pub const PendingEnd = struct { key: model.Attachment, request: u64 }; 25 pub const PendingEnd = struct { key: model.Attachment, request: u64 };
@@ -355,13 +356,24 @@ pub const Controller = struct {
355 switch (self.selection_drag.release()) { 356 switch (self.selection_drag.release()) {
356 .click => {}, 357 .click => {},
357 .selection => |range| { 358 .selection => |range| {
358 self.selection_request +%= 1; 359 try self.queueSelection(live, range);
359 try self.rt.requestSelection(live.key, self.selection_request, range, self.selection_version);
360 }, 360 },
361 .nothing => self.clearSelection(), 361 .nothing => self.clearSelection(),
362 } 362 }
363 self.dirty = true; 363 self.dirty = true;
364 } 364 }
365 fn queueSelection(self: *Controller, live: *runtime.Live, range: client.selection.Range) !void {
366 self.selection_request +%= 1;
367 try self.rt.requestSelection(live.key, self.selection_request, range, self.selection_version);
368 }
369 pub fn copySelection(self: *Controller) !void {
370 const key = self.selection_key orelse return;
371 if (!self.rt.accepts(key)) return self.clearSelection();
372 const live = self.rt.get(key.pane) orelse return;
373 const range = self.selection_drag.range() orelse return;
374 if (!live.pump.selectionFresh(self.selection_version)) return self.clearSelection();
375 try self.queueSelection(live, range);
376 }
365 pub fn pointerMove(self: *Controller, x: i64, y: i64) !void { 377 pub fn pointerMove(self: *Controller, x: i64, y: i64) !void {
366 if (self.drag == null and self.selection_drag.on() != null) { 378 if (self.drag == null and self.selection_drag.on() != null) {
367 if (x < 0 or y < 0) { 379 if (x < 0 or y < 0) {
@@ -456,6 +468,12 @@ pub const Controller = struct {
456 self.suppress_text = true; 468 self.suppress_text = true;
457 return; 469 return;
458 } 470 }
471 if (input.copy and !input.prefix and self.picker == null and self.recovery == null and !self.resize_mode and !self.command_mode) {
472 self.consumed_key = key;
473 self.suppress_text = true;
474 try self.copySelection();
475 return;
476 }
459 if (self.recovery) |*menu| { 477 if (self.recovery) |*menu| {
460 try self.modal_held.put(self.rt.alloc, key, {}); 478 try self.modal_held.put(self.rt.alloc, key, {});
461 self.suppress_text = true; 479 self.suppress_text = true;
test/native_selection.py
Old New
@@ -117,6 +117,55 @@ def arm_output(rig, pane_id, label):
117 return trigger 117 return trigger
118 118
119 119
120 def copy_shortcut(rig, pane):
121 """Observe copy and SIGINT independently in a real foreground PTY process."""
122 rig.focus(pane)
123 interrupted, stop = (rig.root / name for name in ('copy-sigint', 'copy-stop'))
124 program = rig.root / 'copy-foreground.py'
125 program.write_text(
126 'import signal, time\nfrom pathlib import Path\n'
127 f'interrupted = Path({str(interrupted)!r})\nstop = Path({str(stop)!r})\n'
128 'def on_interrupt(signum, frame):\n'
129 ' with interrupted.open("a") as out: out.write("INT\\n")\n'
130 'signal.signal(signal.SIGINT, on_interrupt)\n'
131 'print("\\033[0m\\033[2J\\033[HCOPY-READY\\033[2;1Halpha café omega\\033[4;1H", end="", flush=True)\n'
132 'while not stop.exists(): time.sleep(.02)\n')
133 rig.shell('python3 ' + shlex.quote(str(program)))
134 state = rig.wait_state(lambda s: by_id(s)[pane]['painted_text'].startswith('COPY-READY'))
135 background = cell_background(rig, state, pane, 1, 1)
136 try:
137 rig.select(pane, (6, 1), (9, 1))
138 rig.copied('café')
139 # Copy before release proves the shortcut invoked the copy path itself.
140 rig.select(pane, (0, 1), (4, 1), release=False)
141 eventually(lambda: cell_background(rig, state, pane, 1, 1) != background,
142 'held selection was not painted before copy shortcut')
143 rig.key('copy')
144 rig.copied('alpha')
145 eventually(lambda: cell_background(rig, state, pane, 1, 1) != background,
146 'copy shortcut cleared held selection')
147 rig.send('mouseup:' + rig.cell_point(state, pane, 4, 1))
148 rig.key('copy')
149 rig.unchanged('alpha')
150 require(not interrupted.exists(), 'copy shortcut sent SIGINT to foreground process')
151 eventually(lambda: cell_background(rig, state, pane, 1, 1) != background,
152 'copy shortcut cleared released selection')
153 rig.focus(pane) # Header press clears the range.
154 eventually(lambda: cell_background(rig, state, pane, 1, 1) == background,
155 'header press did not clear selection before copy shortcut')
156 rig.key('copy')
157 rig.unchanged('alpha')
158 require(not interrupted.exists(), 'copy without selection sent SIGINT')
159 rig.key('interrupt')
160 eventually(lambda: interrupted.exists() and interrupted.read_text() == 'INT\n',
161 'ordinary Ctrl+C did not interrupt the foreground PTY process')
162 rig.unchanged('alpha')
163 rig.ok('Ctrl+Shift+C copies and retains selection without SIGINT; plain Ctrl+C still interrupts')
164 finally:
165 stop.touch()
166 specimen(rig, pane, 'PANE-' + str(pane))
167
168
120 def scale_selection(rig, pane): 169 def scale_selection(rig, pane):
121 output = os.environ.get('MUXG_TEST_SCALE_OUTPUT') 170 output = os.environ.get('MUXG_TEST_SCALE_OUTPUT')
122 if not output: 171 if not output:
@@ -154,6 +203,7 @@ def exercise(rig):
154 panes = list(refs) 203 panes = list(refs)
155 wraps = {pane: specimen(rig, pane, 'PANE-' + str(pane)) for pane in panes} 204 wraps = {pane: specimen(rig, pane, 'PANE-' + str(pane)) for pane in panes}
156 target, neighbour = panes[1], panes[2] 205 target, neighbour = panes[1], panes[2]
206 copy_shortcut(rig, target)
157 rig.focus(panes[0]) 207 rig.focus(panes[0])
158 state = rig.state() 208 state = rig.state()
159 before = {p: cell_background(rig, state, p, 1, 1) for p in panes} 209 before = {p: cell_background(rig, state, p, 1, 1) for p in panes}
@@ -206,14 +256,20 @@ def exercise(rig):
206 256
207 trigger = arm_output(rig, neighbour, 'NEIGHBOUR-OUTPUT') 257 trigger = arm_output(rig, neighbour, 'NEIGHBOUR-OUTPUT')
208 specimen(rig, neighbour, 'PANE-' + str(neighbour)) 258 specimen(rig, neighbour, 'PANE-' + str(neighbour))
259 background = cell_background(rig, rig.state(), target, 7, 1)
209 state = rig.select(target, (6, 1), (9, 1), release=False) 260 state = rig.select(target, (6, 1), (9, 1), release=False)
261 eventually(lambda: cell_background(rig, state, target, 7, 1) != background,
262 'selection was not painted before neighbouring output')
210 trigger.touch() 263 trigger.touch()
211 rig.wait_state(lambda s: 'NEIGHBOUR-OUTPUT' in by_id(s)[neighbour]['painted_text']) 264 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)) 265 rig.send('mouseup:' + rig.cell_point(state, target, 9, 1))
213 rig.copied('café') 266 rig.copied('café')
214 trigger = arm_output(rig, target, 'SELECTED-OUTPUT') 267 trigger = arm_output(rig, target, 'SELECTED-OUTPUT')
215 specimen(rig, target, 'PANE-' + str(target)) 268 specimen(rig, target, 'PANE-' + str(target))
269 background = cell_background(rig, rig.state(), target, 1, 1)
216 state = rig.select(target, (0, 1), (4, 1), release=False) 270 state = rig.select(target, (0, 1), (4, 1), release=False)
271 eventually(lambda: cell_background(rig, state, target, 1, 1) != background,
272 'selection was not painted before selected-pane output')
217 trigger.touch() 273 trigger.touch()
218 rig.wait_state(lambda s: 'SELECTED-OUTPUT' in by_id(s)[target]['painted_text']) 274 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)) 275 rig.send('mouseup:' + rig.cell_point(state, target, 4, 1))