04b2c810
fix: handle native copy shortcut before terminal input
a73x 2026-09-06 08:32
Commit message
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)) |