03138bb4
feat: forward native application mouse and clipboard writes
a73x 2026-09-06 11:09
Commit message
README.md
| Old | New | ||
|---|---|---|---|
| @@ -79,6 +79,15 @@ sessions survive. Glyphs refresh automatically when display scale changes, | |||
| 79 | retaining the selected family. Installed Nerd Font Mono icons use that face; | 79 | retaining the selected family. Installed Nerd Font Mono icons use that face; |
| 80 | font fallback and cross-cell programming ligatures are not implemented. | 80 | font fallback and cross-cell programming ligatures are not implemented. |
| 81 | 81 | ||
| 82 | In the native GUI, a left drag selects and copies text unless the application | ||
| 83 | has requested mouse reporting. Applications such as tmux then receive ordinary | ||
| 84 | clicks and drags; hold Shift when starting a drag to select locally instead. | ||
| 85 | That choice remains fixed until release, even if Shift changes or the pointer | ||
| 86 | crosses another pane. Ctrl+Shift+C copies the current local selection. | ||
| 87 | Application OSC 52 writes update the desktop clipboard (`c`) or primary | ||
| 88 | selection (`p`/`s`); clipboard queries remain refused. GUI paste is not yet | ||
| 89 | implemented. | ||
| 90 | |||
| 82 | Choose a Ghostty theme file by name in `$XDG_CONFIG_HOME/mux/themes/` (or | 91 | Choose a Ghostty theme file by name in `$XDG_CONFIG_HOME/mux/themes/` (or |
| 83 | `~/.config/mux/themes/`), or by absolute path: | 92 | `~/.config/mux/themes/`), or by absolute path: |
| 84 | 93 | ||
RETRO.md
| Old | New | ||
|---|---|---|---|
| @@ -1419,3 +1419,77 @@ belongs to the selection-identity slice; normal application drag, shared tmux | |||
| 1419 | selection, independent buffer/clipboard checks and required Shift+drag override | 1419 | selection, independent buffer/clipboard checks and required Shift+drag override |
| 1420 | belong to the application mouse slice. Neither gap is claimed fixed by the | 1420 | belong to the application mouse slice. Neither gap is claimed fixed by the |
| 1421 | redraw-preservation commit. User acceptance of the overall feature is pending. | 1421 | redraw-preservation commit. User acceptance of the overall feature is pending. |
| 1422 | |||
| 1423 | |||
| 1424 | ### Application mouse and clipboard writes — 2026-09-06 | ||
| 1425 | |||
| 1426 | The Ghostty/foot comparison identified separate owners: a normal drag belongs | ||
| 1427 | to an application that requests mouse reporting; Shift at press chooses native | ||
| 1428 | selection for the complete gesture. Alternate screen alone does not decide. | ||
| 1429 | The new controller captures the originating attachment and mode token; the pump | ||
| 1430 | owns reports, original-format cancellation and stale-event rejection. The shared | ||
| 1431 | wheel encoder now supplies all pointer formats. Application clipboard writes | ||
| 1432 | reuse ClientCore validation and bounded decoding, with one shared text predicate | ||
| 1433 | and SDL writer. No daemon, wire, dependency or new module was needed. The functional change | ||
| 1434 | adds 291 production Zig lines beyond the opening refactor (excluding embedded | ||
| 1435 | unit tests and build wiring): 134 for pump lifecycle/delivery, 88 for pane input | ||
| 1436 | policy, 48 for SDL adaptation/hooks, and 21 for clipboard decoding/validation. | ||
| 1437 | The growth buys the missing behavior while retaining existing owners. | ||
| 1438 | |||
| 1439 | Luna implemented the opening encoder and initial controller/decoding/tests; | ||
| 1440 | Terra independently reviewed the boundaries and built acceptance fixtures; root | ||
| 1441 | integrated and independently validated them. Review and compilation caught | ||
| 1442 | incorrect expected bytes, runtime enum/character typing, double-counted pixel | ||
| 1443 | origins, partial-cell bounds, button ownership and outside-coordinate handling. | ||
| 1444 | The opening cleanup commit preceded the complete formatter gate because a | ||
| 1445 | concurrent GUI edit entered that check. Next sprint coordinator action: freeze | ||
| 1446 | all touched source until the opening check exits, then commit; never infer a | ||
| 1447 | successful gate from completed unit output. The later integrated check passed. | ||
| 1448 | |||
| 1449 | Real NVIDIA Wayland checks verify direct application reports and both clipboard | ||
| 1450 | targets with independent wl-paste reads. A separate foot/muxg two-client tmux | ||
| 1451 | fixture verifies ordinary selection and the shared tmux paste buffer in both | ||
| 1452 | directions, plus Shift-local copying without changing tmux state. An attempted | ||
| 1453 | raw-program-inside-tmux cancellation check assumed the inner application's | ||
| 1454 | mouse-off would disable the outer terminal's reporting. That assumption is | ||
| 1455 | false; removed that redundant path and kept direct mode-cancellation and shared | ||
| 1456 | tmux selection as separate oracles. Failure logs remain with the final evidence | ||
| 1457 | under `dist/application-mouse/`. | ||
| 1458 | |||
| 1459 | The user requested a hands-on binary without a new recording. No review server | ||
| 1460 | or publication was created; previous pending demos remain untouched. Hands-on | ||
| 1461 | acceptance is still pending. Final delivery gate results and fixture teardown | ||
| 1462 | are recorded below. | ||
| 1463 | |||
| 1464 | Retained debt and next owners/triggers: | ||
| 1465 | |||
| 1466 | - Selection-identity slice: track the same text through terminal scrolling, | ||
| 1467 | reflow and history eviction. This sprint fixes tmux-owned selection sharing; | ||
| 1468 | it does not give native local selection terminal line identity. | ||
| 1469 | - Input owner: GUI paste and composition remain separate. Additional physical | ||
| 1470 | mouse buttons during a held gesture are ignored; add chorded gestures only | ||
| 1471 | when needed, preserving each original attachment and cancellation contract. | ||
| 1472 | - Platform owner: Linux/Wayland evidence does not establish macOS. Native primary | ||
| 1473 | selection uses the available SDL adapter; numbered and secondary OSC52 targets | ||
| 1474 | remain unsupported, and clipboard reads remain refused. | ||
| 1475 | - Transport owner: the existing wheel wait behind a continuously readable stream | ||
| 1476 | remains a separate liveness follow-up; no scheduler rewrite was added here. | ||
| 1477 | |||
| 1478 | Final validation: full CI passed all 117 e2e scenarios, agent and throughput; | ||
| 1479 | final required check and ReleaseSafe native-core/native units passed. Full native | ||
| 1480 | integration passed, followed by separate final NVIDIA Wayland direct mouse, | ||
| 1481 | shared foot/tmux, ten wheel/scale and eleven selection checkpoints. The owned | ||
| 1482 | compositor PID 49292 was stopped and verified; test rigs cleaned up their own | ||
| 1483 | processes. Previous pending demos and the user's staged main-worktree file remain | ||
| 1484 | untouched. | ||
| 1485 | |||
| 1486 | The native raw-output frame gate remains red: initial p99 20.597 ms against a | ||
| 1487 | 20 ms budget. A controlled comparison reproduced it on the prior wheel release | ||
| 1488 | (23.134 ms) and new release (21.374 ms). All had ongoing output, successful | ||
| 1489 | reopen and input-to-painted upper bounds below 61 ms. Presentation dominated; | ||
| 1490 | this does not establish a stable performance pass or an improvement. Keep | ||
| 1491 | `stress.log`, `stress-baseline.log`, `stress-comparison.log` and their JSON | ||
| 1492 | artifacts. Renderer owner follow-up: resolve the existing NVIDIA presentation | ||
| 1493 | budget variability using this same isolated fixture; do not widen the budget | ||
| 1494 | or rerun until green. The new mouse feature is handed off for functional review | ||
| 1495 | with this limitation explicit. | ||
build.zig
| Old | New | ||
|---|---|---|---|
| @@ -1218,7 +1218,15 @@ pub fn build(b: *std.Build) void { | |||
| 1218 | native_wheel.addArtifactArg(muxg_exe); | 1218 | native_wheel.addArtifactArg(muxg_exe); |
| 1219 | native_wheel.step.dependOn(&native_selection.step); | 1219 | native_wheel.step.dependOn(&native_selection.step); |
| 1220 | const native_e2e_step = b.step("native-e2e", "Run the native client's end-to-end leg (opt-in)"); | 1220 | const native_e2e_step = b.step("native-e2e", "Run the native client's end-to-end leg (opt-in)"); |
| 1221 | native_e2e_step.dependOn(&native_wheel.step); | 1221 | const native_mouse = b.addSystemCommand(&.{ "python3", "-B", "test/native_mouse.py" }); |
| 1222 | native_mouse.addArtifactArg(mux_exe); | ||
| 1223 | native_mouse.addArtifactArg(muxg_exe); | ||
| 1224 | native_mouse.step.dependOn(&native_wheel.step); | ||
| 1225 | const native_tmux_mouse = b.addSystemCommand(&.{ "python3", "-B", "test/native_tmux_mouse.py" }); | ||
| 1226 | native_tmux_mouse.addArtifactArg(mux_exe); | ||
| 1227 | native_tmux_mouse.addArtifactArg(muxg_exe); | ||
| 1228 | native_tmux_mouse.step.dependOn(&native_mouse.step); | ||
| 1229 | native_e2e_step.dependOn(&native_tmux_mouse.step); | ||
| 1222 | 1230 | ||
| 1223 | // Both paths come from this build graph: a ReleaseSafe GUI beside a stale | 1231 | // Both paths come from this build graph: a ReleaseSafe GUI beside a stale |
| 1224 | // Debug daemon gives misleading latency numbers under raw terminal output. | 1232 | // Debug daemon gives misleading latency numbers under raw terminal output. |
docs/superpowers/plans/2026-09-06-native-application-mouse.md
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,120 @@ | |||
| 1 | # Native application mouse and clipboard writes | ||
| 2 | |||
| 3 | Status: implementation and independent review complete. CI and functional gates | ||
| 4 | pass; the existing frame-time budget miss is retained below. Hands-on acceptance | ||
| 5 | is pending. | ||
| 6 | |||
| 7 | Goal: normal mouse gestures reach applications requesting mouse reporting, | ||
| 8 | including tmux, while Shift+drag remains a native selection. Application OSC 52 | ||
| 9 | writes reach the desktop clipboard through the existing daemon protocol. | ||
| 10 | The user requested a hands-on binary rather than another demo recording. | ||
| 11 | |||
| 12 | ## Reuse and ownership map | ||
| 13 | |||
| 14 | | Behavior | Existing implementation | Owner | Change | | ||
| 15 | | --- | --- | --- | --- | | ||
| 16 | | Mouse wire formats | client/keymap.zig encodeWheel | shared client | Extract encodeMouse; retain wheel wrapper, delete duplicate encoding need | | ||
| 17 | | Negotiated modes, ordered transport | session_pump.zig routeWheel, ClientCore.terminal_modes | pump | Stamp gestures with connection/mode identity; encode at transport boundary | | ||
| 18 | | Pane hit testing and attachment lifetime | interaction.Controller, runtime.Runtime | native core | Latch gesture source and owner; clamp app coordinates to original pane | | ||
| 19 | | Local selection/extraction | client.selection.Drag, pump selection request | existing owners | Reuse unchanged for Shift override | | ||
| 20 | | Clipboard validation | client_core.validClipboard, term_event | shared client | Add bounded text decoding; retain TUI validation contract | | ||
| 21 | | Platform clipboard | frame.zig SDL_SetClipboardText | SDL adapter | One writer for local selection and decoded app effects; p/s use primary | | ||
| 22 | |||
| 23 | No new module, VT parser, daemon change, or wire format is needed. The GUI owns | ||
| 24 | pane geometry and gesture choice; the pump owns negotiated protocol and wire | ||
| 25 | lifetime. A press uses the latest received modes under the pump mutex. Pending | ||
| 26 | presses whose mode/connection identity changed before transmission are discarded; | ||
| 27 | an already transmitted press is released in its original format on cancellation. | ||
| 28 | Mouse ownership remains fixed through modifier changes and crossing another pane. | ||
| 29 | |||
| 30 | ## Acceptance | ||
| 31 | |||
| 32 | - Mouse off: existing local drag/copy remains. Alternate screen alone does not | ||
| 33 | grant applications mouse ownership. | ||
| 34 | - Modes 9/1000/1002/1003: press-only / press-release / held motion / hover; | ||
| 35 | negotiated X10, UTF8, SGR, URXVT and SGR pixels retain coordinate/modifier rules. | ||
| 36 | - Shift at press forces local selection for the entire gesture, with no app | ||
| 37 | press, motion or release. Changing Shift mid-gesture does not switch owner. | ||
| 38 | - Nonzero pane origin, high DPI, edge crossing and release all use original pane | ||
| 39 | coordinates. Modals/dividers retain priority; source replacement receives no | ||
| 40 | stale gesture. Focus loss, geometry or mode changes cancel held app buttons. | ||
| 41 | - Two clients attached to one tmux: ordinary drag in either client enters tmux | ||
| 42 | selection, visible in both. Inspect tmux buffer independently; Shift+drag in | ||
| 43 | muxg affects only the desktop selection, not tmux's buffer. | ||
| 44 | - Valid OSC52 c writes desktop clipboard; p/s write primary. Invalid base64, | ||
| 45 | NUL, invalid UTF8, empty/oversized and unsupported targets preserve clipboard. | ||
| 46 | OSC52 reads remain refused. Inspect clipboard independently of GUI state. | ||
| 47 | |||
| 48 | ## Validation and follow-ups | ||
| 49 | |||
| 50 | Use pinned Ghostty source and installed foot as behavioral references; no | ||
| 51 | Ghostty executable is installed. Keep exact protocol tests and actual PTY/GUI | ||
| 52 | checks, then repository CI and native gates. Preserve unrelated running demos. | ||
| 53 | |||
| 54 | Selection following text through terminal scroll/reflow/eviction remains the | ||
| 55 | next identity slice. GUI paste, IME, ligatures and OSC52 reads are outside this | ||
| 56 | slice. Existing continuous-output wheel scheduling debt remains separately | ||
| 57 | tracked; this change must not introduce unbounded input deferral. | ||
| 58 | |||
| 59 | ## Implementation and review evidence | ||
| 60 | |||
| 61 | Opening commit `93a94c8` extracts the encoder and updates its wheel caller. | ||
| 62 | The first check found a Zig runtime character type error and incorrect fixture | ||
| 63 | expectations; those were corrected. The subsequent opening check was interrupted | ||
| 64 | by concurrent interaction formatting edits, so it is not recorded as green. | ||
| 65 | The integrated required check subsequently passed inside CI. Keep opening work | ||
| 66 | frozen through the complete check before committing in future sprints. | ||
| 67 | |||
| 68 | The controller now retains a source attachment and pump token for an application | ||
| 69 | gesture; a local-held flag prevents a Shift gesture from turning into hover | ||
| 70 | reports when its highlight is invalidated. The pump owns cancellation generation, | ||
| 71 | last transmitted button and negotiated format. SDL supplies input and clipboard | ||
| 72 | IO. `mouseFormat` serves wheel and pointer encoding; `validClipboardText` is the | ||
| 73 | single text safety predicate for decoded app writes and local clipboard copies. | ||
| 74 | There are no new modules, dependencies, daemon changes or protocol fields. | ||
| 75 | |||
| 76 | Independent review corrected double-counted pixel origins, partial terminal | ||
| 77 | cells at pane edges, wrong-button release, attachment replacement, and local | ||
| 78 | outside-coordinate handling. Actual Wayland checks pass exact press/motion/ | ||
| 79 | release bytes, middle/right routing, mode-change cancellation, Shift ownership, | ||
| 80 | cross-pane clamping, and desktop/primary targets. The two-client foot/tmux check | ||
| 81 | passes shared selection and buffer changes in both directions, and local Shift | ||
| 82 | selection leaves tmux untouched. Evidence is under `dist/application-mouse/`. | ||
| 83 | |||
| 84 | Only one physical button gesture is captured at a time; additional button | ||
| 85 | presses are ignored until release. Chorded multi-button application input can be | ||
| 86 | a later input-owner extension when required. Numbered/secondary OSC52 targets | ||
| 87 | have no native platform adapter and are ignored. Clipboard writes from attached | ||
| 88 | panes are delivered regardless of focus, consistent with session-scoped effects; | ||
| 89 | focus never changes the originating selection request or its attachment. | ||
| 90 | |||
| 91 | The default offscreen native gate skips the foot test explicitly. Real Wayland | ||
| 92 | and NVIDIA evidence is separate; none of these checks establishes macOS behavior. | ||
| 93 | |||
| 94 | ## Final validation and handoff | ||
| 95 | |||
| 96 | - Full `make ci`: PASS, including required check, all 117 e2e scenarios, agent | ||
| 97 | and throughput. `ci.log` retains the complete output. | ||
| 98 | - Final `make check` after closing cleanup: PASS (`check-final.log`). | ||
| 99 | - ReleaseSafe native-core/native unit tests: PASS (`native-units-final.log`). | ||
| 100 | - Full `make native-e2e`: PASS (`native-e2e.log`); its offscreen foot skip is | ||
| 101 | explicit, rather than reported as real-client evidence. | ||
| 102 | - Final ReleaseSafe NVIDIA Wayland: direct mouse/clipboard, shared foot/tmux, | ||
| 103 | ten wheel/scale and eleven selection checkpoints PASS | ||
| 104 | (`final-wayland-{mouse,tmux_mouse,wheel,selection}.log`). | ||
| 105 | - `make native-stress`: frame budget FAIL. Initial p99 20.597 ms exceeded the | ||
| 106 | 20 ms limit. A controlled comparison also failed on the previous wheel | ||
| 107 | release (23.134 ms) and this release (21.374 ms); presentation dominated. | ||
| 108 | Input-to-painted observations stayed below 61 ms, including polling overhead, | ||
| 109 | while output continued and reopen/lifecycle checks succeeded. Retain all three | ||
| 110 | logs; no stable frame-budget pass or performance improvement is claimed. | ||
| 111 | |||
| 112 | The retained rendering-budget issue belongs to the renderer owner, with the | ||
| 113 | same isolated NVIDIA raw-output fixture as its next acceptance check. It is not | ||
| 114 | silently waived by this feature's passing functional tests. | ||
| 115 | |||
| 116 | Release binaries are in `dist/native-mouse-release/bin/`. Launch `muxg` there | ||
| 117 | with the usual connection arguments; no remote daemon update is required. | ||
| 118 | The owned compositor PID 49292 was stopped and verified (`cleanup.json`); | ||
| 119 | all test rigs closed their sessions/daemons. Existing pending demos remain. | ||
| 120 | No new recording, publication, or user acceptance is claimed. | ||
docs/superpowers/plans/2026-09-06-native-text-selection.md
| Old | New | ||
|---|---|---|---|
| @@ -209,7 +209,8 @@ skips `clipboard_set`. GUI paste stays deferred by the user's clarification. | |||
| 209 | 209 | ||
| 210 | Shift+drag is part of the overall mouse feature, as confirmed by the user; it is | 210 | Shift+drag is part of the overall mouse feature, as confirmed by the user; it is |
| 211 | required when adding application mouse forwarding, not an optional later polish. | 211 | required when adding application mouse forwarding, not an optional later polish. |
| 212 | The next planned slice is [wheel scrolling](2026-09-06-native-wheel-scrolling.md). | 212 | Wheel scrolling is implemented. The active follow-up is |
| 213 | [application mouse and clipboard writes](2026-09-06-native-application-mouse.md). | ||
| 213 | The following application mouse/clipboard slice must demonstrate: | 214 | The following application mouse/clipboard slice must demonstrate: |
| 214 | 215 | ||
| 215 | - Normal click/drag reaches applications that request mouse reporting, using | 216 | - Normal click/drag reaches applications that request mouse reporting, using |
src/client/client_core.zig
| Old | New | ||
|---|---|---|---|
| @@ -1,9 +1,9 @@ | |||
| 1 | //! What a client does with a daemon frame besides paint it: the terminal | 1 | //! What a client does with a daemon frame besides paint it: the terminal |
| 2 | //! modes a session set, a clipboard write, a bell, the answer to a selection | 2 | //! modes a session set, a clipboard write, a bell, the answer to a selection |
| 3 | //! request. One decoder with no transport and no terminal under it, so the | 3 | //! request. One decoder with no transport and no terminal under it, so the |
| 4 | //! CLI client and the browser core read the same frame the same way, and | 4 | //! CLI client and the browser core read the same frame the same way. Receive |
| 5 | //! `pending_selection_id` is the whole of its state. Every result BORROWS | 5 | //! results borrow the payload; the optional clipboard text adapter explicitly |
| 6 | //! the payload; nothing here allocates. | 6 | //! allocates an owned decoded copy. |
| 7 | const std = @import("std"); | 7 | const std = @import("std"); |
| 8 | const proto = @import("term").protocol; | 8 | const proto = @import("term").protocol; |
| 9 | 9 | ||
| @@ -12,6 +12,26 @@ pub const ClipboardSet = struct { | |||
| 12 | target: u8, | 12 | target: u8, |
| 13 | base64: []const u8, | 13 | base64: []const u8, |
| 14 | }; | 14 | }; |
| 15 | pub const ClipboardText = struct { primary: bool, text: []u8 }; | ||
| 16 | |||
| 17 | /// Text clipboard adapters must not silently truncate NUL or invalid UTF-8. | ||
| 18 | pub fn validClipboardText(text: []const u8) bool { | ||
| 19 | return text.len != 0 and std.unicode.utf8ValidateSlice(text) and std.mem.indexOfScalar(u8, text, 0) == null; | ||
| 20 | } | ||
| 21 | |||
| 22 | /// Decode a supported clipboard target into owned, validated text. Targets | ||
| 23 | /// addressed to numbered buffers or the secondary selection are ignored. | ||
| 24 | pub fn decodeClipboard(alloc: std.mem.Allocator, target: u8, base64: []const u8) !?ClipboardText { | ||
| 25 | if (target != 'c' and target != 'p' and target != 's') return null; | ||
| 26 | if (!validClipboard(target, base64)) return error.InvalidClipboard; | ||
| 27 | const size = std.base64.standard.Decoder.calcSizeForSlice(base64) catch return error.InvalidClipboard; | ||
| 28 | if (size == 0) return error.InvalidClipboard; | ||
| 29 | const text = try alloc.alloc(u8, size); | ||
| 30 | errdefer alloc.free(text); | ||
| 31 | std.base64.standard.Decoder.decode(text, base64) catch return error.InvalidClipboard; | ||
| 32 | if (!validClipboardText(text)) return error.InvalidClipboard; | ||
| 33 | return .{ .primary = target == 'p' or target == 's', .text = text }; | ||
| 34 | } | ||
| 15 | 35 | ||
| 16 | pub const State = union(enum) { | 36 | pub const State = union(enum) { |
| 17 | terminal_modes: proto.TermModes, | 37 | terminal_modes: proto.TermModes, |
| @@ -165,6 +185,21 @@ test "client core accepts clipboard and bell events" { | |||
| 165 | } | 185 | } |
| 166 | } | 186 | } |
| 167 | 187 | ||
| 188 | test "decodeClipboard decodes supported targets and rejects unsafe text" { | ||
| 189 | const a = std.testing.allocator; | ||
| 190 | const decoded = (try decodeClipboard(a, 'c', "aMOp")) orelse return error.ExpectedClipboard; | ||
| 191 | defer a.free(decoded.text); | ||
| 192 | try std.testing.expect(!decoded.primary); | ||
| 193 | try std.testing.expectEqualStrings("hé", decoded.text); | ||
| 194 | const primary = (try decodeClipboard(a, 's', "aGk=")) orelse return error.ExpectedClipboard; | ||
| 195 | defer a.free(primary.text); | ||
| 196 | try std.testing.expect(primary.primary); | ||
| 197 | try std.testing.expect((try decodeClipboard(a, 'q', "aGk=")) == null); | ||
| 198 | try std.testing.expectError(error.InvalidClipboard, decodeClipboard(a, 'c', "aGk")); | ||
| 199 | try std.testing.expectError(error.InvalidClipboard, decodeClipboard(a, 'c', "AA==")); | ||
| 200 | try std.testing.expectError(error.InvalidClipboard, decodeClipboard(a, 'c', "//8=")); | ||
| 201 | } | ||
| 202 | |||
| 168 | test "client core accepts every clipboard target boundary" { | 203 | test "client core accepts every clipboard target boundary" { |
| 169 | var core = ClientCore{}; | 204 | var core = ClientCore{}; |
| 170 | const targets = [_]u8{ 'c', 'p', 'q', 's', '0', '1', '2', '3', '4', '5', '6', '7' }; | 205 | const targets = [_]u8{ 'c', 'p', 'q', 's', '0', '1', '2', '3', '4', '5', '6', '7' }; |
src/client/session_pump.zig
| Old | New | ||
|---|---|---|---|
| @@ -23,6 +23,7 @@ pub const SelectionRequest = struct { | |||
| 23 | pub const Say = union(enum) { | 23 | pub const Say = union(enum) { |
| 24 | input: []const u8, | 24 | input: []const u8, |
| 25 | wheel: Wheel, | 25 | wheel: Wheel, |
| 26 | mouse: Mouse, | ||
| 26 | resize: proto.Size, | 27 | resize: proto.Size, |
| 27 | selection: SelectionRequest, | 28 | selection: SelectionRequest, |
| 28 | end: struct { request: u64, force: bool = false }, | 29 | end: struct { request: u64, force: bool = false }, |
| @@ -37,6 +38,21 @@ pub const Wheel = struct { | |||
| 37 | pixel_y: u32, | 38 | pixel_y: u32, |
| 38 | mods: client.keymap.Mods = .{}, | 39 | mods: client.keymap.Mods = .{}, |
| 39 | }; | 40 | }; |
| 41 | /// A GUI gesture belongs to the modes and wire admitted at its press. | ||
| 42 | pub const MouseToken = struct { generation: u64, modes: proto.TermModes }; | ||
| 43 | pub const Mouse = struct { | ||
| 44 | token: MouseToken, | ||
| 45 | kind: enum { press, motion, release }, | ||
| 46 | button: u8 = 0, | ||
| 47 | col: u16, | ||
| 48 | row: u16, | ||
| 49 | pixel_x: u32, | ||
| 50 | pixel_y: u32, | ||
| 51 | mods: client.keymap.Mods = .{}, | ||
| 52 | }; | ||
| 53 | fn mouseFormat(modes: proto.TermModes) client.keymap.MouseFormat { | ||
| 54 | return if (modes.mouse_sgr_pixels) .sgr_pixels else if (modes.mouse_sgr) .sgr else if (modes.mouse_urxvt) .urxvt else if (modes.mouse_utf8) .utf8 else .x10; | ||
| 55 | } | ||
| 40 | const HistoryRequest = struct { | 56 | const HistoryRequest = struct { |
| 41 | start: u32, | 57 | start: u32, |
| 42 | size: proto.Size, | 58 | size: proto.Size, |
| @@ -116,6 +132,9 @@ pub const Pump = struct { | |||
| 116 | // A cancelled request stays here until its reply is drained. The wire has | 132 | // A cancelled request stays here until its reply is drained. The wire has |
| 117 | // no request ID, so replacing it could accept an old same-origin reply. | 133 | // no request ID, so replacing it could accept an old same-origin reply. |
| 118 | history_pending: ?HistoryRequest = null, | 134 | history_pending: ?HistoryRequest = null, |
| 135 | mouse_generation: u64 = 0, // mu: wire, geometry and mode cancellation | ||
| 136 | mouse_active: ?Mouse = null, // transport thread only, last transmitted report | ||
| 137 | clipboard: [2]?[]u8 = .{ null, null }, // mu: latest write per desktop target | ||
| 119 | 138 | ||
| 120 | pub fn start(alloc: std.mem.Allocator, opts: Options) !*Pump { | 139 | pub fn start(alloc: std.mem.Allocator, opts: Options) !*Pump { |
| 121 | if (opts.session.len != 0 and !proto.validSessionName(opts.session)) return error.InvalidSession; | 140 | if (opts.session.len != 0 and !proto.validSessionName(opts.session)) return error.InvalidSession; |
| @@ -185,6 +204,7 @@ pub const Pump = struct { | |||
| 185 | for (self.mailbox.items) |msg| if (msg == .input) self.alloc.free(msg.input); | 204 | for (self.mailbox.items) |msg| if (msg == .input) self.alloc.free(msg.input); |
| 186 | if (self.selection_result) |result| self.alloc.free(result.text); | 205 | if (self.selection_result) |result| self.alloc.free(result.text); |
| 187 | if (self.history) |g| g.deinit(); | 206 | if (self.history) |g| g.deinit(); |
| 207 | for (self.clipboard) |text| if (text) |t| self.alloc.free(t); | ||
| 188 | self.mailbox.deinit(self.alloc); | 208 | self.mailbox.deinit(self.alloc); |
| 189 | closePipe(self.wake_pipe); | 209 | closePipe(self.wake_pipe); |
| 190 | closePipe(self.cancel_pipe); | 210 | closePipe(self.cancel_pipe); |
| @@ -192,6 +212,38 @@ pub const Pump = struct { | |||
| 192 | self.alloc.destroy(self); | 212 | self.alloc.destroy(self); |
| 193 | } | 213 | } |
| 194 | 214 | ||
| 215 | pub fn mouseToken(self: *Pump) ?MouseToken { | ||
| 216 | self.mu.lock(); | ||
| 217 | defer self.mu.unlock(); | ||
| 218 | if (!self.admitted or self.status.phase != .attached or self.closing.load(.acquire)) return null; | ||
| 219 | return .{ .generation = self.mouse_generation, .modes = self.core.terminal_modes }; | ||
| 220 | } | ||
| 221 | pub fn mouseFresh(self: *Pump, token: MouseToken) bool { | ||
| 222 | const current = self.mouseToken() orelse return false; | ||
| 223 | return current.generation == token.generation; | ||
| 224 | } | ||
| 225 | /// Cancellation cannot allocate or wait for a GUI-thread transport write. | ||
| 226 | pub fn cancelMouse(self: *Pump, token: MouseToken) void { | ||
| 227 | self.mu.lock(); | ||
| 228 | if (self.mouse_generation == token.generation) self.mouse_generation +%= 1; | ||
| 229 | self.mu.unlock(); | ||
| 230 | ring(self.wake_pipe[1], 1); | ||
| 231 | } | ||
| 232 | pub fn takeClipboard(self: *Pump, primary: bool) ?[]u8 { | ||
| 233 | self.mu.lock(); | ||
| 234 | defer self.mu.unlock(); | ||
| 235 | const index: usize = @intFromBool(primary); | ||
| 236 | const text = self.clipboard[index]; | ||
| 237 | self.clipboard[index] = null; | ||
| 238 | return text; | ||
| 239 | } | ||
| 240 | fn clearClipboardLocked(self: *Pump) void { | ||
| 241 | for (&self.clipboard) |*text| { | ||
| 242 | if (text.*) |t| self.alloc.free(t); | ||
| 243 | text.* = null; | ||
| 244 | } | ||
| 245 | } | ||
| 246 | |||
| 195 | /// Caller holds mu while copying both this version and the displayed grid. | 247 | /// Caller holds mu while copying both this version and the displayed grid. |
| 196 | pub fn selectionVersionLocked(self: *const Pump) SelectionVersion { | 248 | pub fn selectionVersionLocked(self: *const Pump) SelectionVersion { |
| 197 | if (self.history != null) return self.history_version; | 249 | if (self.history != null) return self.history_version; |
| @@ -310,6 +362,8 @@ pub const Pump = struct { | |||
| 310 | 362 | ||
| 311 | fn setState(self: *Pump, phase: Phase, code: u8, reason: []const u8) void { | 363 | fn setState(self: *Pump, phase: Phase, code: u8, reason: []const u8) void { |
| 312 | if (phase != .attached) { | 364 | if (phase != .attached) { |
| 365 | self.mouse_generation +%= 1; | ||
| 366 | self.clearClipboardLocked(); | ||
| 313 | self.invalidateSelectionLocked(); | 367 | self.invalidateSelectionLocked(); |
| 314 | self.returnLiveLocked(); | 368 | self.returnLiveLocked(); |
| 315 | } | 369 | } |
| @@ -375,7 +429,10 @@ pub const Pump = struct { | |||
| 375 | } | 429 | } |
| 376 | 430 | ||
| 377 | fn attach(self: *Pump, wire: *Wire, fresh: bool) !void { | 431 | fn attach(self: *Pump, wire: *Wire, fresh: bool) !void { |
| 432 | if (fresh) try self.releaseMouse(wire) else self.mouse_active = null; | ||
| 378 | self.mu.lock(); | 433 | self.mu.lock(); |
| 434 | self.mouse_generation +%= 1; | ||
| 435 | self.clearClipboardLocked(); | ||
| 379 | self.invalidateSelectionLocked(); | 436 | self.invalidateSelectionLocked(); |
| 380 | self.selection_revision +%= 1; | 437 | self.selection_revision +%= 1; |
| 381 | self.returnLiveLocked(); | 438 | self.returnLiveLocked(); |
| @@ -438,6 +495,7 @@ pub const Pump = struct { | |||
| 438 | try wire.send(.input, bytes); | 495 | try wire.send(.input, bytes); |
| 439 | }, | 496 | }, |
| 440 | .wheel => |wheel| try self.routeWheel(wire, wheel), | 497 | .wheel => |wheel| try self.routeWheel(wire, wheel), |
| 498 | .mouse => |mouse| try self.routeMouse(wire, mouse), | ||
| 441 | .selection => |req| { | 499 | .selection => |req| { |
| 442 | self.mu.lock(); | 500 | self.mu.lock(); |
| 443 | const payload = self.beginSelectionLocked(req); | 501 | const payload = self.beginSelectionLocked(req); |
| @@ -445,7 +503,9 @@ pub const Pump = struct { | |||
| 445 | if (payload) |bytes| try wire.send(.selection_req, &bytes); | 503 | if (payload) |bytes| try wire.send(.selection_req, &bytes); |
| 446 | }, | 504 | }, |
| 447 | .resize => |size| { | 505 | .resize => |size| { |
| 506 | try self.releaseMouse(wire); | ||
| 448 | self.mu.lock(); | 507 | self.mu.lock(); |
| 508 | self.mouse_generation +%= 1; | ||
| 449 | self.invalidateSelectionLocked(); | 509 | self.invalidateSelectionLocked(); |
| 450 | self.selection_revision +%= 1; | 510 | self.selection_revision +%= 1; |
| 451 | self.returnLiveLocked(); | 511 | self.returnLiveLocked(); |
| @@ -462,6 +522,63 @@ pub const Pump = struct { | |||
| 462 | } | 522 | } |
| 463 | } | 523 | } |
| 464 | 524 | ||
| 525 | fn writeMouse(wire: *Wire, event: Mouse) !bool { | ||
| 526 | const modes = event.token.modes; | ||
| 527 | if (event.kind == .release and modes.mouse_x10 and !modes.mouse_normal and !modes.mouse_button and !modes.mouse_any) return false; | ||
| 528 | var seq: [client.keymap.mouse_max_seq_len]u8 = undefined; | ||
| 529 | const bytes = client.keymap.encodeMouse(mouseFormat(modes), event.button + @as(u8, if (event.kind == .motion) 32 else 0), event.kind == .release, event.col, event.row, event.pixel_x, event.pixel_y, event.mods, &seq); | ||
| 530 | if (bytes.len == 0) return false; | ||
| 531 | try wire.send(.input, bytes); | ||
| 532 | return true; | ||
| 533 | } | ||
| 534 | fn releaseMouse(self: *Pump, wire: *Wire) !void { | ||
| 535 | var event = self.mouse_active orelse return; | ||
| 536 | self.mouse_active = null; | ||
| 537 | event.kind = .release; | ||
| 538 | _ = try writeMouse(wire, event); | ||
| 539 | } | ||
| 540 | fn reconcileMouse(self: *Pump, wire: *Wire) !void { | ||
| 541 | if (self.mouse_active) |active| { | ||
| 542 | if (!self.mouseFresh(active.token) or self.closing.load(.acquire)) try self.releaseMouse(wire); | ||
| 543 | } | ||
| 544 | } | ||
| 545 | fn routeMouse(self: *Pump, wire: *Wire, event: Mouse) !void { | ||
| 546 | try self.reconcileMouse(wire); | ||
| 547 | if (!self.mouseFresh(event.token) or !event.token.modes.appMouse() or event.button > 3) return; | ||
| 548 | switch (event.kind) { | ||
| 549 | .press => { | ||
| 550 | if (event.button == 3) return; | ||
| 551 | try self.releaseMouse(wire); | ||
| 552 | self.mu.lock(); | ||
| 553 | self.returnLiveLocked(); | ||
| 554 | self.invalidateSelectionLocked(); | ||
| 555 | self.selection_revision +%= 1; | ||
| 556 | self.mu.unlock(); | ||
| 557 | if (try writeMouse(wire, event)) self.mouse_active = event; | ||
| 558 | self.wake(); | ||
| 559 | }, | ||
| 560 | .motion => { | ||
| 561 | const modes = event.token.modes; | ||
| 562 | if (self.mouse_active) |active| { | ||
| 563 | if (active.button != event.button or (!modes.mouse_any and !modes.mouse_button)) return; | ||
| 564 | const same = if (modes.mouse_sgr_pixels) active.pixel_x == event.pixel_x and active.pixel_y == event.pixel_y else active.col == event.col and active.row == event.row; | ||
| 565 | if (same) return; | ||
| 566 | if (try writeMouse(wire, event)) self.mouse_active = event; | ||
| 567 | } else if (event.button == 3 and modes.mouse_any) { | ||
| 568 | _ = try writeMouse(wire, event); | ||
| 569 | } | ||
| 570 | }, | ||
| 571 | .release => { | ||
| 572 | const active = self.mouse_active orelse return; | ||
| 573 | if (event.button != active.button) return; | ||
| 574 | // If a legacy release is outside its encoding range, release | ||
| 575 | // at the last representable point instead of leaving it held. | ||
| 576 | if (!try writeMouse(wire, event)) try self.releaseMouse(wire); | ||
| 577 | self.mouse_active = null; | ||
| 578 | }, | ||
| 579 | } | ||
| 580 | } | ||
| 581 | |||
| 465 | fn routeWheel(self: *Pump, wire: *Wire, wheel: Wheel) !void { | 582 | fn routeWheel(self: *Pump, wire: *Wire, wheel: Wheel) !void { |
| 466 | self.mu.lock(); | 583 | self.mu.lock(); |
| 467 | if (!self.admitted or self.status.phase != .attached or wheel.notches == 0) { | 584 | if (!self.admitted or self.status.phase != .attached or wheel.notches == 0) { |
| @@ -491,7 +608,7 @@ pub const Pump = struct { | |||
| 491 | self.wake(); | 608 | self.wake(); |
| 492 | var seq: [client.keymap.mouse_max_seq_len]u8 = undefined; | 609 | var seq: [client.keymap.mouse_max_seq_len]u8 = undefined; |
| 493 | const bytes = if (modes.appMouse()) client.keymap.encodeWheel( | 610 | const bytes = if (modes.appMouse()) client.keymap.encodeWheel( |
| 494 | if (modes.mouse_sgr_pixels) .sgr_pixels else if (modes.mouse_sgr) .sgr else if (modes.mouse_urxvt) .urxvt else if (modes.mouse_utf8) .utf8 else .x10, | 611 | mouseFormat(modes), |
| 495 | wheel.notches > 0, | 612 | wheel.notches > 0, |
| 496 | wheel.col, | 613 | wheel.col, |
| 497 | wheel.row, | 614 | wheel.row, |
| @@ -565,6 +682,7 @@ pub const Pump = struct { | |||
| 565 | .busy => busy = true, | 682 | .busy => busy = true, |
| 566 | .idle => {}, | 683 | .idle => {}, |
| 567 | }; | 684 | }; |
| 685 | try self.reconcileMouse(wire); | ||
| 568 | try self.mail(wire, !busy); | 686 | try self.mail(wire, !busy); |
| 569 | try self.requestHistory(wire); | 687 | try self.requestHistory(wire); |
| 570 | if (self.closing.load(.acquire)) { | 688 | if (self.closing.load(.acquire)) { |
| @@ -624,6 +742,7 @@ pub const Pump = struct { | |||
| 624 | self.selection_revision +%= 1; | 742 | self.selection_revision +%= 1; |
| 625 | self.invalidateSelectionLocked(); | 743 | self.invalidateSelectionLocked(); |
| 626 | } | 744 | } |
| 745 | if (self.replica.session_epoch != old_epoch or self.grid.cols != old_cols or self.grid.rows != old_rows) self.mouse_generation +%= 1; | ||
| 627 | if (self.replica.session_epoch != old_epoch) self.returnLiveLocked(); | 746 | if (self.replica.session_epoch != old_epoch) self.returnLiveLocked(); |
| 628 | if (self.scroll_rows != 0) { | 747 | if (self.scroll_rows != 0) { |
| 629 | self.scroll_rows = @min(self.scroll_rows, self.replica.history_rows); | 748 | self.scroll_rows = @min(self.scroll_rows, self.replica.history_rows); |
| @@ -665,9 +784,22 @@ pub const Pump = struct { | |||
| 665 | else => switch (self.core.receive(kind, payload)) { | 784 | else => switch (self.core.receive(kind, payload)) { |
| 666 | .effect => |effect| switch (effect) { | 785 | .effect => |effect| switch (effect) { |
| 667 | .bell => self.status.bell = true, | 786 | .bell => self.status.bell = true, |
| 668 | .clipboard_set => return .skip, | 787 | .clipboard_set => |clip| { |
| 788 | const decoded = client.core.decodeClipboard(self.alloc, clip.target, clip.base64) catch |err| switch (err) { | ||
| 789 | error.InvalidClipboard => return .skip, | ||
| 790 | else => return err, | ||
| 791 | } orelse return .skip; | ||
| 792 | if (!self.admitted or self.status.phase != .attached) { | ||
| 793 | self.alloc.free(decoded.text); | ||
| 794 | return .skip; | ||
| 795 | } | ||
| 796 | const index: usize = @intFromBool(decoded.primary); | ||
| 797 | if (self.clipboard[index]) |old| self.alloc.free(old); | ||
| 798 | self.clipboard[index] = decoded.text; | ||
| 799 | }, | ||
| 669 | }, | 800 | }, |
| 670 | .state => { | 801 | .state => { |
| 802 | self.mouse_generation +%= 1; | ||
| 671 | self.invalidateSelectionLocked(); | 803 | self.invalidateSelectionLocked(); |
| 672 | self.selection_revision +%= 1; | 804 | self.selection_revision +%= 1; |
| 673 | if (self.scroll_rows != 0) { | 805 | if (self.scroll_rows != 0) { |
| @@ -1612,3 +1744,106 @@ test "ready terminal mode frames precede queued wheel input through the actual w | |||
| 1612 | defer pixels.deinit(std.testing.allocator); | 1744 | defer pixels.deinit(std.testing.allocator); |
| 1613 | try std.testing.expectEqualStrings("\x1b[<64;423;319M", pixels.payload); | 1745 | try std.testing.expectEqualStrings("\x1b[<64;423;319M", pixels.payload); |
| 1614 | } | 1746 | } |
| 1747 | |||
| 1748 | test "mouse press motion release uses negotiated SGR coordinates" { | ||
| 1749 | const p = try selectionTestPump(); | ||
| 1750 | defer p.stop(); | ||
| 1751 | p.admitted = true; | ||
| 1752 | p.status.phase = .attached; | ||
| 1753 | _ = try p.onFrame(.term_modes, &proto.encodeTermModes(.{ .bracketed_paste = false, .mouse_button = true, .mouse_sgr = true })); | ||
| 1754 | const incoming = try std.posix.pipe2(.{ .NONBLOCK = true, .CLOEXEC = true }); | ||
| 1755 | defer closePipe(incoming); | ||
| 1756 | const outgoing = try std.posix.pipe2(.{ .NONBLOCK = true, .CLOEXEC = true }); | ||
| 1757 | defer closePipe(outgoing); | ||
| 1758 | var tr: client.Transport = .{ .link = .{ .pipe = .{ .child = std.process.Child.init(&.{"unused"}, std.testing.allocator), .r = incoming[0], .w = outgoing[1] } } }; | ||
| 1759 | var wire = try Wire.init(std.testing.allocator, &tr); | ||
| 1760 | defer wire.deinit(); | ||
| 1761 | const token = p.mouseToken().?; | ||
| 1762 | try p.say(.{ .mouse = .{ .token = token, .kind = .press, .button = 0, .col = 2, .row = 3, .pixel_x = 20, .pixel_y = 30 } }); | ||
| 1763 | try p.mail(&wire, true); | ||
| 1764 | var frame = (try proto.readFrame(std.testing.allocator, outgoing[0])).?; | ||
| 1765 | defer frame.deinit(std.testing.allocator); | ||
| 1766 | try std.testing.expectEqualStrings("\x1b[<0;3;4M", frame.payload); | ||
| 1767 | try p.say(.{ .mouse = .{ .token = token, .kind = .motion, .button = 0, .col = 4, .row = 5, .pixel_x = 40, .pixel_y = 50 } }); | ||
| 1768 | try p.mail(&wire, true); | ||
| 1769 | var motion = (try proto.readFrame(std.testing.allocator, outgoing[0])).?; | ||
| 1770 | defer motion.deinit(std.testing.allocator); | ||
| 1771 | try std.testing.expectEqualStrings("\x1b[<32;5;6M", motion.payload); | ||
| 1772 | try p.say(.{ .mouse = .{ .token = token, .kind = .release, .button = 0, .col = 4, .row = 5, .pixel_x = 40, .pixel_y = 50 } }); | ||
| 1773 | try p.mail(&wire, true); | ||
| 1774 | var release = (try proto.readFrame(std.testing.allocator, outgoing[0])).?; | ||
| 1775 | defer release.deinit(std.testing.allocator); | ||
| 1776 | try std.testing.expectEqualStrings("\x1b[<0;5;6m", release.payload); | ||
| 1777 | // Normal tracking ignores held motion, then a format change cancels in | ||
| 1778 | // the format of the transmitted press, exactly once. | ||
| 1779 | _ = try p.onFrame(.term_modes, &proto.encodeTermModes(.{ .bracketed_paste = false, .mouse_normal = true, .mouse_sgr = true })); | ||
| 1780 | var event: Mouse = .{ .token = p.mouseToken().?, .kind = .press, .col = 2, .row = 3, .pixel_x = 20, .pixel_y = 30 }; | ||
| 1781 | try p.routeMouse(&wire, event); | ||
| 1782 | const normal = (try proto.readFrame(std.testing.allocator, outgoing[0])).?; | ||
| 1783 | defer normal.deinit(std.testing.allocator); | ||
| 1784 | event.kind = .motion; | ||
| 1785 | event.col = 5; | ||
| 1786 | try p.routeMouse(&wire, event); | ||
| 1787 | var probe: [1]u8 = undefined; | ||
| 1788 | try std.testing.expectError(error.WouldBlock, std.posix.read(outgoing[0], &probe)); | ||
| 1789 | _ = try p.onFrame(.term_modes, &proto.encodeTermModes(.{ .bracketed_paste = false, .mouse_any = true })); | ||
| 1790 | try p.reconcileMouse(&wire); | ||
| 1791 | const cancelled = (try proto.readFrame(std.testing.allocator, outgoing[0])).?; | ||
| 1792 | defer cancelled.deinit(std.testing.allocator); | ||
| 1793 | try std.testing.expectEqualStrings("\x1b[<0;3;4m", cancelled.payload); | ||
| 1794 | event.kind = .release; | ||
| 1795 | try p.routeMouse(&wire, event); | ||
| 1796 | try p.reconcileMouse(&wire); | ||
| 1797 | try std.testing.expectError(error.WouldBlock, std.posix.read(outgoing[0], &probe)); | ||
| 1798 | |||
| 1799 | // A cancelled queued press never starts a replacement gesture. | ||
| 1800 | event.token = p.mouseToken().?; | ||
| 1801 | event.kind = .press; | ||
| 1802 | try p.say(.{ .mouse = event }); | ||
| 1803 | p.cancelMouse(event.token); | ||
| 1804 | try p.mail(&wire, true); | ||
| 1805 | try std.testing.expectError(error.WouldBlock, std.posix.read(outgoing[0], &probe)); | ||
| 1806 | event.token = p.mouseToken().?; | ||
| 1807 | event.kind = .motion; | ||
| 1808 | event.button = 3; | ||
| 1809 | try p.routeMouse(&wire, event); | ||
| 1810 | const hover = (try proto.readFrame(std.testing.allocator, outgoing[0])).?; | ||
| 1811 | defer hover.deinit(std.testing.allocator); | ||
| 1812 | try std.testing.expectEqualSlices(u8, &.{ 27, '[', 'M', 67, 38, 36 }, hover.payload); | ||
| 1813 | |||
| 1814 | _ = try p.onFrame(.term_modes, &proto.encodeTermModes(.{ .bracketed_paste = false, .mouse_x10 = true })); | ||
| 1815 | event.token = p.mouseToken().?; | ||
| 1816 | event.kind = .press; | ||
| 1817 | event.button = 0; | ||
| 1818 | try p.routeMouse(&wire, event); | ||
| 1819 | const x10 = (try proto.readFrame(std.testing.allocator, outgoing[0])).?; | ||
| 1820 | defer x10.deinit(std.testing.allocator); | ||
| 1821 | try std.testing.expectEqualSlices(u8, &.{ 27, '[', 'M', 32, 38, 36 }, x10.payload); | ||
| 1822 | event.kind = .release; | ||
| 1823 | try p.routeMouse(&wire, event); | ||
| 1824 | try std.testing.expectError(error.WouldBlock, std.posix.read(outgoing[0], &probe)); | ||
| 1825 | } | ||
| 1826 | |||
| 1827 | test "clipboard targets are retained separately and cleared on state change" { | ||
| 1828 | const p = try selectionTestPump(); | ||
| 1829 | defer p.stop(); | ||
| 1830 | p.admitted = true; | ||
| 1831 | p.status.phase = .attached; | ||
| 1832 | _ = try p.onFrame(.term_event, "\x00cYQ=="); | ||
| 1833 | _ = try p.onFrame(.term_event, "\x00pYg=="); | ||
| 1834 | _ = try p.onFrame(.term_event, "\x00cYw=="); | ||
| 1835 | _ = try p.onFrame(.term_event, "\x00cAA=="); // NUL must preserve c. | ||
| 1836 | const ctext = p.takeClipboard(false).?; | ||
| 1837 | defer std.testing.allocator.free(ctext); | ||
| 1838 | const ptext = p.takeClipboard(true).?; | ||
| 1839 | defer std.testing.allocator.free(ptext); | ||
| 1840 | try std.testing.expectEqualStrings("c", ctext); | ||
| 1841 | try std.testing.expectEqualStrings("b", ptext); | ||
| 1842 | _ = try p.onFrame(.term_event, "\x00cYQ=="); | ||
| 1843 | _ = try p.onFrame(.term_event, "\x00sYg=="); | ||
| 1844 | p.mu.lock(); | ||
| 1845 | p.setState(.reconnecting, 0, "reconnecting"); | ||
| 1846 | p.mu.unlock(); | ||
| 1847 | try std.testing.expect(p.takeClipboard(false) == null); | ||
| 1848 | try std.testing.expect(p.takeClipboard(true) == null); | ||
| 1849 | } | ||
src/gui/frame.zig
| Old | New | ||
|---|---|---|---|
| @@ -87,19 +87,31 @@ pub const Hook = union(enum) { | |||
| 87 | text: []const u8, | 87 | text: []const u8, |
| 88 | key: struct { code: u32, mods: u16 = 0 }, | 88 | key: struct { code: u32, mods: u16 = 0 }, |
| 89 | click: struct { x: f32, y: f32 }, | 89 | click: struct { x: f32, y: f32 }, |
| 90 | pointer: struct { kind: enum { down, motion, up }, x: f32, y: f32 }, | 90 | pointer: struct { kind: enum { down, motion, up }, x: f32, y: f32, button: u8 = 0, mods: ?u8 = null }, |
| 91 | wheel: struct { x: f32, y: f32, delta: f32, flipped: bool = false }, | 91 | wheel: struct { x: f32, y: f32, delta: f32, flipped: bool = false }, |
| 92 | state: []const u8, | 92 | state: []const u8, |
| 93 | resize: struct { w: u32, h: u32 }, | 93 | resize: struct { w: u32, h: u32 }, |
| 94 | capture: []const u8, | 94 | capture: []const u8, |
| 95 | capture_last: []const u8, | 95 | capture_last: []const u8, |
| 96 | clipboard: []const u8, | 96 | clipboard: []const u8, |
| 97 | primary: []const u8, | ||
| 97 | quit, | 98 | quit, |
| 98 | }; | 99 | }; |
| 99 | 100 | ||
| 100 | pub fn parseHook(line: []const u8) ?Hook { | 101 | pub fn parseHook(line: []const u8) ?Hook { |
| 101 | if (std.mem.eql(u8, line, "quit")) return .quit; | 102 | if (std.mem.eql(u8, line, "quit")) return .quit; |
| 102 | if (std.mem.startsWith(u8, line, "state:")) return .{ .state = line[6..] }; | 103 | if (std.mem.startsWith(u8, line, "state:")) return .{ .state = line[6..] }; |
| 104 | if (std.mem.startsWith(u8, line, "mouse:")) { | ||
| 105 | var it = std.mem.splitScalar(u8, line[6..], ','); | ||
| 106 | const name = it.next() orelse return null; | ||
| 107 | const kind: @FieldType(@FieldType(Hook, "pointer"), "kind") = if (std.mem.eql(u8, name, "down")) .down else if (std.mem.eql(u8, name, "move")) .motion else if (std.mem.eql(u8, name, "up")) .up else return null; | ||
| 108 | const x = std.fmt.parseFloat(f32, it.next() orelse return null) catch return null; | ||
| 109 | const y = std.fmt.parseFloat(f32, it.next() orelse return null) catch return null; | ||
| 110 | const button = std.fmt.parseInt(u8, it.next() orelse return null, 10) catch return null; | ||
| 111 | const mods = std.fmt.parseInt(u8, it.next() orelse return null, 10) catch return null; | ||
| 112 | if (button > 2 or mods > 7 or it.next() != null) return null; | ||
| 113 | return .{ .pointer = .{ .kind = kind, .x = x, .y = y, .button = button, .mods = mods } }; | ||
| 114 | } | ||
| 103 | inline for (.{ .{ "mousedown:", .down }, .{ "mousemove:", .motion }, .{ "mouseup:", .up } }) |entry| { | 115 | inline for (.{ .{ "mousedown:", .down }, .{ "mousemove:", .motion }, .{ "mouseup:", .up } }) |entry| { |
| 104 | if (std.mem.startsWith(u8, line, entry[0])) { | 116 | if (std.mem.startsWith(u8, line, entry[0])) { |
| 105 | const comma = std.mem.indexOfScalar(u8, line, ',') orelse return null; | 117 | const comma = std.mem.indexOfScalar(u8, line, ',') orelse return null; |
| @@ -107,6 +119,7 @@ pub fn parseHook(line: []const u8) ?Hook { | |||
| 107 | } | 119 | } |
| 108 | } | 120 | } |
| 109 | if (std.mem.startsWith(u8, line, "capture-last:")) return .{ .capture_last = line[13..] }; | 121 | if (std.mem.startsWith(u8, line, "capture-last:")) return .{ .capture_last = line[13..] }; |
| 122 | if (std.mem.startsWith(u8, line, "primary:")) return .{ .primary = line[8..] }; | ||
| 110 | if (std.mem.startsWith(u8, line, "clipboard:")) return .{ .clipboard = line[10..] }; | 123 | if (std.mem.startsWith(u8, line, "clipboard:")) return .{ .clipboard = line[10..] }; |
| 111 | if (std.mem.startsWith(u8, line, "wheel:")) { | 124 | if (std.mem.startsWith(u8, line, "wheel:")) { |
| 112 | var it = std.mem.splitScalar(u8, line[6..], ','); | 125 | var it = std.mem.splitScalar(u8, line[6..], ','); |
| @@ -262,6 +275,15 @@ const HookReader = struct { | |||
| 262 | ev.button.type = c.SDL_EVENT_MOUSE_BUTTON_UP; | 275 | ev.button.type = c.SDL_EVENT_MOUSE_BUTTON_UP; |
| 263 | }, | 276 | }, |
| 264 | .pointer => |at| { | 277 | .pointer => |at| { |
| 278 | if (at.mods) |mods| { | ||
| 279 | // Queue modifier state with the ordinary SDL event, so | ||
| 280 | // multiple FIFO gestures retain their own event ordering. | ||
| 281 | ev.user.type = c.SDL_EVENT_USER; | ||
| 282 | ev.user.code = 0x4d4f4453; | ||
| 283 | ev.user.data1 = @ptrFromInt(@as(usize, mods)); | ||
| 284 | if (!c.SDL_PushEvent(&ev)) return error.EventInjectionFailed; | ||
| 285 | ev = std.mem.zeroes(c.SDL_Event); | ||
| 286 | } | ||
| 265 | if (at.kind == .motion) { | 287 | if (at.kind == .motion) { |
| 266 | ev.motion.type = c.SDL_EVENT_MOUSE_MOTION; | 288 | ev.motion.type = c.SDL_EVENT_MOUSE_MOTION; |
| 267 | ev.motion.state = c.SDL_BUTTON_LMASK; | 289 | ev.motion.state = c.SDL_BUTTON_LMASK; |
| @@ -269,7 +291,11 @@ const HookReader = struct { | |||
| 269 | ev.motion.y = at.y; | 291 | ev.motion.y = at.y; |
| 270 | } else { | 292 | } else { |
| 271 | ev.button.type = if (at.kind == .down) c.SDL_EVENT_MOUSE_BUTTON_DOWN else c.SDL_EVENT_MOUSE_BUTTON_UP; | 293 | ev.button.type = if (at.kind == .down) c.SDL_EVENT_MOUSE_BUTTON_DOWN else c.SDL_EVENT_MOUSE_BUTTON_UP; |
| 272 | ev.button.button = c.SDL_BUTTON_LEFT; | 294 | ev.button.button = switch (at.button) { |
| 295 | 1 => c.SDL_BUTTON_MIDDLE, | ||
| 296 | 2 => c.SDL_BUTTON_RIGHT, | ||
| 297 | else => c.SDL_BUTTON_LEFT, | ||
| 298 | }; | ||
| 273 | ev.button.x = at.x; | 299 | ev.button.x = at.x; |
| 274 | ev.button.y = at.y; | 300 | ev.button.y = at.y; |
| 275 | } | 301 | } |
| @@ -307,8 +333,8 @@ const HookReader = struct { | |||
| 307 | self.capture_last = copy; | 333 | self.capture_last = copy; |
| 308 | return; | 334 | return; |
| 309 | }, | 335 | }, |
| 310 | .clipboard => |path| { | 336 | .clipboard, .primary => |path| { |
| 311 | const text = c.SDL_GetClipboardText() orelse return error.ClipboardReadFailed; | 337 | const text = (if (hook == .primary) c.SDL_GetPrimarySelectionText() else c.SDL_GetClipboardText()) orelse return error.ClipboardReadFailed; |
| 312 | defer c.SDL_free(@ptrCast(text)); | 338 | defer c.SDL_free(@ptrCast(text)); |
| 313 | const file = try std.fs.cwd().createFile(path, .{ .truncate = true }); | 339 | const file = try std.fs.cwd().createFile(path, .{ .truncate = true }); |
| 314 | defer file.close(); | 340 | defer file.close(); |
| @@ -350,7 +376,7 @@ const Events = struct { | |||
| 350 | self.syncCapture(); | 376 | self.syncCapture(); |
| 351 | } | 377 | } |
| 352 | fn syncCapture(self: *Events) void { | 378 | fn syncCapture(self: *Events) void { |
| 353 | const capturing = self.ui.drag != null or self.ui.selection_drag.buttonHeld(); | 379 | const capturing = self.ui.hasPointerCapture(); |
| 354 | if (self.captured != capturing) { | 380 | if (self.captured != capturing) { |
| 355 | _ = c.SDL_CaptureMouse(capturing); | 381 | _ = c.SDL_CaptureMouse(capturing); |
| 356 | self.captured = capturing; | 382 | self.captured = capturing; |
| @@ -359,7 +385,7 @@ const Events = struct { | |||
| 359 | /// Commit events sample the actual drawable even if its resize notice | 385 | /// Commit events sample the actual drawable even if its resize notice |
| 360 | /// is still behind this event in SDL's bounded queue. | 386 | /// is still behind this event in SDL's bounded queue. |
| 361 | fn dispatch(self: *Events, ev: c.SDL_Event) !bool { | 387 | fn dispatch(self: *Events, ev: c.SDL_Event) !bool { |
| 362 | 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 ev.type == c.SDL_EVENT_MOUSE_WHEEL 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))) { | 388 | 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 ev.type == c.SDL_EVENT_MOUSE_WHEEL or ((self.ui.hasPointerCapture()) and (ev.type == c.SDL_EVENT_MOUSE_MOTION or ev.type == c.SDL_EVENT_MOUSE_BUTTON_UP))) { |
| 363 | self.geometry_dirty = true; | 389 | self.geometry_dirty = true; |
| 364 | try self.refreshGeometry(); | 390 | try self.refreshGeometry(); |
| 365 | } | 391 | } |
| @@ -377,12 +403,17 @@ const Events = struct { | |||
| 377 | c.SDL_EVENT_KEY_DOWN => try self.ui.keyDown(interactionKey(ev.key)), | 403 | c.SDL_EVENT_KEY_DOWN => try self.ui.keyDown(interactionKey(ev.key)), |
| 378 | c.SDL_EVENT_KEY_UP => self.ui.keyUp(ev.key.key), | 404 | c.SDL_EVENT_KEY_UP => self.ui.keyUp(ev.key.key), |
| 379 | c.SDL_EVENT_WINDOW_FOCUS_LOST => self.ui.focusLost(), | 405 | c.SDL_EVENT_WINDOW_FOCUS_LOST => self.ui.focusLost(), |
| 380 | c.SDL_EVENT_MOUSE_BUTTON_DOWN => if (ev.button.button == c.SDL_BUTTON_LEFT) { | 406 | c.SDL_EVENT_USER => if (ev.user.code == 0x4d4f4453) { |
| 407 | const mods = @intFromPtr(ev.user.data1); | ||
| 408 | c.SDL_SetModState(@as(c.SDL_Keymod, if (mods & 1 != 0) c.SDL_KMOD_SHIFT else 0) | @as(c.SDL_Keymod, if (mods & 2 != 0) c.SDL_KMOD_ALT else 0) | @as(c.SDL_Keymod, if (mods & 4 != 0) c.SDL_KMOD_CTRL else 0)); | ||
| 409 | }, | ||
| 410 | c.SDL_EVENT_MOUSE_BUTTON_DOWN => { | ||
| 411 | const button = mouseButton(ev.button.button) orelse return true; | ||
| 381 | var w: c_int = 0; | 412 | var w: c_int = 0; |
| 382 | var h: c_int = 0; | 413 | var h: c_int = 0; |
| 383 | if (c.SDL_GetWindowSize(self.win, &w, &h)) { | 414 | if (c.SDL_GetWindowSize(self.win, &w, &h)) { |
| 384 | const at = physicalPoint(ev.button.x, ev.button.y, w, h, self.ui.fb_w, self.ui.fb_h); | 415 | const at = physicalPoint(ev.button.x, ev.button.y, w, h, self.ui.fb_w, self.ui.fb_h); |
| 385 | try self.ui.pointerDown(at.x, at.y, grabPixels(w, self.ui.fb_w), grabPixels(h, self.ui.fb_h)); | 416 | try self.ui.mouseDown(at.x, at.y, grabPixels(w, self.ui.fb_w), grabPixels(h, self.ui.fb_h), button, mouseMods()); |
| 386 | } | 417 | } |
| 387 | }, | 418 | }, |
| 388 | c.SDL_EVENT_MOUSE_WHEEL => { | 419 | c.SDL_EVENT_MOUSE_WHEEL => { |
| @@ -390,34 +421,30 @@ const Events = struct { | |||
| 390 | var h: c_int = 0; | 421 | var h: c_int = 0; |
| 391 | if (c.SDL_GetWindowSize(self.win, &w, &h)) { | 422 | if (c.SDL_GetWindowSize(self.win, &w, &h)) { |
| 392 | const at = physicalPoint(ev.wheel.mouse_x, ev.wheel.mouse_y, w, h, self.ui.fb_w, self.ui.fb_h); | 423 | const at = physicalPoint(ev.wheel.mouse_x, ev.wheel.mouse_y, w, h, self.ui.fb_w, self.ui.fb_h); |
| 393 | const mods = c.SDL_GetModState(); | 424 | try self.ui.wheel(at.x, at.y, ev.wheel.y, ev.wheel.direction == c.SDL_MOUSEWHEEL_FLIPPED, mouseMods()); |
| 394 | try self.ui.wheel(at.x, at.y, ev.wheel.y, ev.wheel.direction == c.SDL_MOUSEWHEEL_FLIPPED, .{ | ||
| 395 | .shift = mods & c.SDL_KMOD_SHIFT != 0, | ||
| 396 | .ctrl = mods & c.SDL_KMOD_CTRL != 0, | ||
| 397 | .alt = mods & c.SDL_KMOD_ALT != 0, | ||
| 398 | }); | ||
| 399 | } | 425 | } |
| 400 | }, | 426 | }, |
| 401 | c.SDL_EVENT_MOUSE_MOTION, c.SDL_EVENT_MOUSE_BUTTON_UP => { | 427 | c.SDL_EVENT_MOUSE_MOTION, c.SDL_EVENT_MOUSE_BUTTON_UP => { |
| 402 | if (ev.type == c.SDL_EVENT_MOUSE_BUTTON_UP and ev.button.button != c.SDL_BUTTON_LEFT) return true; | 428 | const motion = ev.type == c.SDL_EVENT_MOUSE_MOTION; |
| 403 | if (self.ui.drag != null or self.ui.selection_drag.buttonHeld()) { | 429 | const button = if (motion) 0 else mouseButton(ev.button.button) orelse return true; |
| 404 | var w: c_int = 0; | 430 | var w: c_int = 0; |
| 405 | var h: c_int = 0; | 431 | var h: c_int = 0; |
| 406 | if (c.SDL_GetWindowSize(self.win, &w, &h)) { | 432 | if (c.SDL_GetWindowSize(self.win, &w, &h)) { |
| 407 | const motion = ev.type == c.SDL_EVENT_MOUSE_MOTION; | 433 | const x = physicalSignedAxis(if (motion) ev.motion.x else ev.button.x, w, self.ui.fb_w); |
| 408 | const x = physicalSignedAxis(if (motion) ev.motion.x else ev.button.x, w, self.ui.fb_w); | 434 | const y = physicalSignedAxis(if (motion) ev.motion.y else ev.button.y, h, self.ui.fb_h); |
| 409 | const y = physicalSignedAxis(if (motion) ev.motion.y else ev.button.y, h, self.ui.fb_h); | 435 | if (x != null and y != null) { |
| 410 | if (x != null and y != null) { | 436 | if (motion) try self.ui.mouseMove(x.?, y.?, mouseMods()) else { |
| 411 | if (ev.type == c.SDL_EVENT_MOUSE_BUTTON_UP) { | 437 | if (button == 0 and self.ui.drag != null) try self.ui.pointerMove(x.?, y.?); |
| 412 | if (self.ui.drag != null) try self.ui.pointerMove(x.?, y.?); | 438 | try self.ui.mouseUp(@intCast(@max(x.?, 0)), @intCast(@max(y.?, 0)), button, mouseMods()); |
| 413 | try self.ui.pointerUp(@intCast(@max(x.?, 0)), @intCast(@max(y.?, 0))); | ||
| 414 | } else try self.ui.pointerMove(x.?, y.?); | ||
| 415 | } else if (self.ui.selection_drag.on() != null) { | ||
| 416 | if (ev.type == c.SDL_EVENT_MOUSE_BUTTON_UP) try self.ui.pointerUp(0, 0) else try self.ui.pointerMove(-1, -1); | ||
| 417 | } | 439 | } |
| 440 | } else if (self.ui.app_drag != null) { | ||
| 441 | self.ui.cancelMouse(); | ||
| 442 | } else if (motion) { | ||
| 443 | try self.ui.pointerMove(-1, -1); | ||
| 444 | } else if (button == 0) { | ||
| 445 | try self.ui.mouseUp(0, 0, button, mouseMods()); | ||
| 418 | } | 446 | } |
| 419 | } | 447 | } |
| 420 | if (ev.type == c.SDL_EVENT_MOUSE_BUTTON_UP) self.ui.cancelDrag(); | ||
| 421 | }, | 448 | }, |
| 422 | c.SDL_EVENT_WINDOW_PIXEL_SIZE_CHANGED, c.SDL_EVENT_WINDOW_RESIZED, c.SDL_EVENT_WINDOW_DISPLAY_SCALE_CHANGED => self.geometry_dirty = true, | 449 | c.SDL_EVENT_WINDOW_PIXEL_SIZE_CHANGED, c.SDL_EVENT_WINDOW_RESIZED, c.SDL_EVENT_WINDOW_DISPLAY_SCALE_CHANGED => self.geometry_dirty = true, |
| 423 | c.SDL_EVENT_WINDOW_EXPOSED => self.ui.dirty = true, | 450 | c.SDL_EVENT_WINDOW_EXPOSED => self.ui.dirty = true, |
| @@ -612,16 +639,17 @@ pub fn run(alloc: std.mem.Allocator, opts: Options) !u8 { | |||
| 612 | if (usr1_seen.swap(false, .acq_rel)) report(&ring); | 639 | if (usr1_seen.swap(false, .acq_rel)) report(&ring); |
| 613 | const now = std.time.milliTimestamp(); | 640 | const now = std.time.milliTimestamp(); |
| 614 | events.ui.dirty = events.ui.poll(now) or events.ui.dirty; | 641 | events.ui.dirty = events.ui.poll(now) or events.ui.dirty; |
| 642 | // Every effect is drained from its source attachment; focus does not | ||
| 643 | // redirect another pane's write into a selection request. | ||
| 644 | for (events.ui.rt.lives) |slot| if (slot) |live| { | ||
| 645 | inline for (.{ false, true }) |primary| if (live.pump.takeClipboard(primary)) |text| { | ||
| 646 | defer alloc.free(text); | ||
| 647 | try setClipboard(&events.ui, text, primary); | ||
| 648 | }; | ||
| 649 | }; | ||
| 615 | if (events.ui.takeSelectionText()) |text| { | 650 | if (events.ui.takeSelectionText()) |text| { |
| 616 | defer alloc.free(text); | 651 | defer alloc.free(text); |
| 617 | // SDL's clipboard contract is NUL terminated; preserving the | 652 | try setClipboard(&events.ui, text, false); |
| 618 | // previous clipboard is safer than silently truncating a wire | ||
| 619 | // selection that contains NUL. | ||
| 620 | if (text.len != 0 and std.mem.indexOfScalar(u8, text, 0) == null) { | ||
| 621 | const z = try alloc.dupeZ(u8, text); | ||
| 622 | defer alloc.free(z); | ||
| 623 | if (!c.SDL_SetClipboardText(z.ptr)) events.ui.setNotice("Clipboard update failed"); | ||
| 624 | } | ||
| 625 | } | 653 | } |
| 626 | try events.ui.pollEnd(); | 654 | try events.ui.pollEnd(); |
| 627 | events.syncCapture(); | 655 | events.syncCapture(); |
| @@ -930,6 +958,26 @@ test "pending End header stays with its origin and rejects stale generations" { | |||
| 930 | events.ui.pending_end = null; | 958 | events.ui.pending_end = null; |
| 931 | } | 959 | } |
| 932 | 960 | ||
| 961 | fn mouseButton(button: u8) ?u8 { | ||
| 962 | return switch (button) { | ||
| 963 | c.SDL_BUTTON_LEFT => 0, | ||
| 964 | c.SDL_BUTTON_MIDDLE => 1, | ||
| 965 | c.SDL_BUTTON_RIGHT => 2, | ||
| 966 | else => null, | ||
| 967 | }; | ||
| 968 | } | ||
| 969 | fn mouseMods() keymap.Mods { | ||
| 970 | const mods = c.SDL_GetModState(); | ||
| 971 | return .{ .shift = mods & c.SDL_KMOD_SHIFT != 0, .ctrl = mods & c.SDL_KMOD_CTRL != 0, .alt = mods & c.SDL_KMOD_ALT != 0 }; | ||
| 972 | } | ||
| 973 | fn setClipboard(ui: *interaction.Controller, text: []const u8, primary: bool) !void { | ||
| 974 | if (!client.core.validClipboardText(text)) return; | ||
| 975 | const z = try ui.rt.alloc.dupeZ(u8, text); | ||
| 976 | defer ui.rt.alloc.free(z); | ||
| 977 | const ok = if (primary) c.SDL_SetPrimarySelectionText(z.ptr) else c.SDL_SetClipboardText(z.ptr); | ||
| 978 | if (!ok) ui.setNotice("Clipboard update failed"); | ||
| 979 | } | ||
| 980 | |||
| 933 | const Header = struct { | 981 | const Header = struct { |
| 934 | bytes: [512]u8 = undefined, | 982 | bytes: [512]u8 = undefined, |
| 935 | cells: [512]term.grid.Cell = undefined, | 983 | cells: [512]term.grid.Cell = undefined, |
src/gui/interaction.zig
| Old | New | ||
|---|---|---|---|
| @@ -93,6 +93,8 @@ pub const Controller = struct { | |||
| 93 | modal_held: std.AutoHashMapUnmanaged(u32, void) = .empty, | 93 | modal_held: std.AutoHashMapUnmanaged(u32, void) = .empty, |
| 94 | drag: ?struct { id: model.DividerId, tab: model.TabId, offset: i64, changed: bool = false } = null, | 94 | drag: ?struct { id: model.DividerId, tab: model.TabId, offset: i64, changed: bool = false } = null, |
| 95 | selection_drag: client.selection.Drag = .{}, | 95 | selection_drag: client.selection.Drag = .{}, |
| 96 | app_drag: ?struct { key: model.Attachment, token: client.session_pump.MouseToken, button: u8 } = null, | ||
| 97 | local_held: bool = false, | ||
| 96 | wheel_remainder: [model.max_panes]struct { key: ?model.Attachment = null, wheel: Wheel = .{} } = @splat(.{}), | 98 | wheel_remainder: [model.max_panes]struct { key: ?model.Attachment = null, wheel: Wheel = .{} } = @splat(.{}), |
| 97 | selection_key: ?model.Attachment = null, | 99 | selection_key: ?model.Attachment = null, |
| 98 | selection_request: u32 = 0, | 100 | selection_request: u32 = 0, |
| @@ -109,6 +111,16 @@ pub const Controller = struct { | |||
| 109 | self.modal_held.deinit(self.rt.alloc); | 111 | self.modal_held.deinit(self.rt.alloc); |
| 110 | if (self.picker) |picker| picker.deinit(); | 112 | if (self.picker) |picker| picker.deinit(); |
| 111 | } | 113 | } |
| 114 | pub fn hasPointerCapture(self: *const Controller) bool { | ||
| 115 | return self.drag != null or self.selection_drag.buttonHeld() or self.app_drag != null or self.local_held; | ||
| 116 | } | ||
| 117 | pub fn cancelMouse(self: *Controller) void { | ||
| 118 | if (self.app_drag) |app| { | ||
| 119 | if (self.rt.accepts(app.key)) self.rt.get(app.key.pane).?.pump.cancelMouse(app.token); | ||
| 120 | self.app_drag = null; | ||
| 121 | } | ||
| 122 | self.local_held = false; | ||
| 123 | } | ||
| 112 | pub fn clearSelection(self: *Controller) void { | 124 | pub fn clearSelection(self: *Controller) void { |
| 113 | if (self.selection_key) |key| if (self.rt.get(key.pane)) |live| live.pump.cancelSelection(); | 125 | if (self.selection_key) |key| if (self.rt.get(key.pane)) |live| live.pump.cancelSelection(); |
| 114 | self.selection_drag.clear(); | 126 | self.selection_drag.clear(); |
| @@ -140,6 +152,9 @@ pub const Controller = struct { | |||
| 140 | } | 152 | } |
| 141 | pub fn poll(self: *Controller, now: i64) bool { | 153 | pub fn poll(self: *Controller, now: i64) bool { |
| 142 | const changed = self.rt.poll(now); | 154 | const changed = self.rt.poll(now); |
| 155 | if (self.app_drag) |app| { | ||
| 156 | if (!self.rt.accepts(app.key) or !self.rt.get(app.key.pane).?.pump.mouseFresh(app.token)) self.cancelMouse(); | ||
| 157 | } | ||
| 143 | if (self.selection_key) |key| { | 158 | if (self.selection_key) |key| { |
| 144 | const live = self.rt.get(key.pane) orelse { | 159 | const live = self.rt.get(key.pane) orelse { |
| 145 | self.clearSelection(); | 160 | self.clearSelection(); |
| @@ -354,6 +369,78 @@ pub const Controller = struct { | |||
| 354 | self.dirty = true; | 369 | self.dirty = true; |
| 355 | } | 370 | } |
| 356 | 371 | ||
| 372 | pub fn mouseDown(self: *Controller, x: u32, y: u32, grab_x: u32, grab_y: u32, button: u8, mods: keymap.Mods) !void { | ||
| 373 | if (self.hasPointerCapture() or button > 2 or self.command_mode) return; | ||
| 374 | if (self.picker != null or self.recovery != null or self.resize_mode or self.layout.hitDivider(x, y, grab_x, grab_y) != null) { | ||
| 375 | if (button == 0) try self.pointerDown(x, y, grab_x, grab_y); | ||
| 376 | return; | ||
| 377 | } | ||
| 378 | if (self.layout.hit(x, y)) |id| { | ||
| 379 | const p = self.layout.get(id).?; | ||
| 380 | if (p.content.contains(x, y)) if (self.rt.get(id)) |live| { | ||
| 381 | if (!mods.shift) if (live.pump.mouseToken()) |token| if (token.modes.appMouse()) { | ||
| 382 | const event = self.mouseAt(live.key, token, .press, button, x, y, mods) orelse return; | ||
| 383 | try live.pump.say(.{ .mouse = event }); | ||
| 384 | self.clearSelection(); | ||
| 385 | self.intent_dirty = self.intent_dirty or self.rt.workspace.tab().focus != id; | ||
| 386 | _ = self.rt.workspace.focus(id); | ||
| 387 | self.app_drag = .{ .key = live.key, .token = token, .button = button }; | ||
| 388 | self.dirty = true; | ||
| 389 | return; | ||
| 390 | }; | ||
| 391 | }; | ||
| 392 | } | ||
| 393 | if (button != 0) return; | ||
| 394 | try self.pointerDown(x, y, grab_x, grab_y); | ||
| 395 | self.local_held = self.selection_drag.buttonHeld(); | ||
| 396 | } | ||
| 397 | |||
| 398 | fn mouseAt(self: *Controller, key: model.Attachment, token: client.session_pump.MouseToken, kind: anytype, button: u8, x: u32, y: u32, mods: keymap.Mods) ?client.session_pump.Mouse { | ||
| 399 | const p = self.layout.get(key.pane) orelse return null; | ||
| 400 | if (self.metrics.cell_w == 0 or self.metrics.cell_h == 0 or p.cols == 0 or p.rows == 0 or p.content.w == 0 or p.content.h == 0) return null; | ||
| 401 | const px = std.math.clamp(x, p.content.x, p.content.x +| p.content.w -| 1); | ||
| 402 | const py = std.math.clamp(y, p.content.y, p.content.y +| p.content.h -| 1); | ||
| 403 | return .{ .token = token, .kind = kind, .button = button, .col = @intCast(@min(@as(u32, p.cols - 1), (px - p.content.x) / self.metrics.cell_w)), .row = @intCast(@min(@as(u32, p.rows - 1), (py - p.content.y) / self.metrics.cell_h)), .pixel_x = px - p.content.x, .pixel_y = py - p.content.y, .mods = mods }; | ||
| 404 | } | ||
| 405 | |||
| 406 | pub fn mouseMove(self: *Controller, x: i64, y: i64, mods: keymap.Mods) !void { | ||
| 407 | if (self.app_drag) |app| { | ||
| 408 | if (!self.rt.accepts(app.key)) return self.cancelMouse(); | ||
| 409 | const live = self.rt.get(app.key.pane) orelse return self.cancelMouse(); | ||
| 410 | if (!live.pump.mouseFresh(app.token)) return self.cancelMouse(); | ||
| 411 | const event = self.mouseAt(app.key, app.token, .motion, app.button, @intCast(@max(x, 0)), @intCast(@max(y, 0)), mods) orelse return; | ||
| 412 | try live.pump.say(.{ .mouse = event }); | ||
| 413 | return; | ||
| 414 | } | ||
| 415 | if (self.local_held or self.drag != null or self.selection_drag.buttonHeld()) return self.pointerMove(x, y); | ||
| 416 | if (x < 0 or y < 0 or mods.shift or self.picker != null or self.recovery != null or self.resize_mode or self.command_mode) return; | ||
| 417 | const id = self.layout.hit(@intCast(x), @intCast(y)) orelse return; | ||
| 418 | if (self.layout.hitDivider(@intCast(x), @intCast(y), 0, 0) != null) return; | ||
| 419 | const p = self.layout.get(id) orelse return; | ||
| 420 | if (!p.content.contains(@intCast(x), @intCast(y))) return; | ||
| 421 | const live = self.rt.get(id) orelse return; | ||
| 422 | const token = live.pump.mouseToken() orelse return; | ||
| 423 | if (!token.modes.mouse_any) return; | ||
| 424 | if (self.mouseAt(live.key, token, .motion, 3, @intCast(x), @intCast(y), mods)) |event| try live.pump.say(.{ .mouse = event }); | ||
| 425 | } | ||
| 426 | |||
| 427 | pub fn mouseUp(self: *Controller, x: u32, y: u32, button: u8, mods: keymap.Mods) !void { | ||
| 428 | if (self.app_drag) |app| { | ||
| 429 | if (button != app.button) return; | ||
| 430 | if (!self.rt.accepts(app.key)) return self.cancelMouse(); | ||
| 431 | const live = self.rt.get(app.key.pane) orelse return self.cancelMouse(); | ||
| 432 | if (live.pump.mouseFresh(app.token)) { | ||
| 433 | if (self.mouseAt(app.key, app.token, .release, app.button, x, y, mods)) |event| try live.pump.say(.{ .mouse = event }); | ||
| 434 | } | ||
| 435 | self.app_drag = null; | ||
| 436 | return; | ||
| 437 | } | ||
| 438 | if ((self.local_held or self.drag != null or self.selection_drag.buttonHeld()) and button == 0) { | ||
| 439 | self.local_held = false; | ||
| 440 | return self.pointerUp(x, y); | ||
| 441 | } | ||
| 442 | } | ||
| 443 | |||
| 357 | pub fn pointerUp(self: *Controller, x: u32, y: u32) !void { | 444 | pub fn pointerUp(self: *Controller, x: u32, y: u32) !void { |
| 358 | if (self.drag != null) { | 445 | if (self.drag != null) { |
| 359 | self.cancelDrag(); | 446 | self.cancelDrag(); |
| @@ -441,6 +528,7 @@ pub const Controller = struct { | |||
| 441 | }); | 528 | }); |
| 442 | } | 529 | } |
| 443 | pub fn cancelDrag(self: *Controller) void { | 530 | pub fn cancelDrag(self: *Controller) void { |
| 531 | self.cancelMouse(); | ||
| 444 | if (self.drag == null) return; | 532 | if (self.drag == null) return; |
| 445 | self.intent_dirty = self.intent_dirty or self.drag.?.changed; | 533 | self.intent_dirty = self.intent_dirty or self.drag.?.changed; |
| 446 | self.drag = null; | 534 | self.drag = null; |
test/native_mouse.py
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,208 @@ | |||
| 1 | #!/usr/bin/env python3 | ||
| 2 | """Application mouse reports and OSC 52 through native SDL input and real PTYs. | ||
| 3 | |||
| 4 | The FIFO's generic `mouse:KIND,X,Y,BUTTON,MODS` form is deliberately used | ||
| 5 | here: it reaches the same SDL dispatch path as a user event, while the | ||
| 6 | fixture can pin all button and modifier combinations without a compositor. | ||
| 7 | """ | ||
| 8 | import os | ||
| 9 | import shlex | ||
| 10 | import subprocess | ||
| 11 | import sys | ||
| 12 | import time | ||
| 13 | |||
| 14 | sys.dont_write_bytecode = True | ||
| 15 | from native_lifecycle import start_persistent | ||
| 16 | from native_resize import by_id | ||
| 17 | from native_selection import SelectionRig | ||
| 18 | from native_tiling import eventually, require | ||
| 19 | |||
| 20 | |||
| 21 | SHIFT = 1 | ||
| 22 | |||
| 23 | |||
| 24 | class MouseRig(SelectionRig): | ||
| 25 | def mouse(self, kind, point, button=0, mods=0): | ||
| 26 | """Inject a normalized SDL mouse event through the generic hook.""" | ||
| 27 | x, y = point.split(',') | ||
| 28 | self.send(f'mouse:{kind},{x},{y},{button},{mods}') | ||
| 29 | |||
| 30 | def mouse_cell(self, state, pane, col, row, kind, button=0, mods=0): | ||
| 31 | self.mouse(kind, self.cell_point(state, pane, col, row), button, mods) | ||
| 32 | |||
| 33 | def desktop(self): | ||
| 34 | return self.clipboard() | ||
| 35 | |||
| 36 | def primary(self): | ||
| 37 | value = self.artifact('primary', '.txt').read_text() | ||
| 38 | if self.env['SDL_VIDEO_DRIVER'] == 'wayland': | ||
| 39 | external = subprocess.run(['wl-paste', '--primary', '--no-newline'], env=self.env, | ||
| 40 | capture_output=True, timeout=3) | ||
| 41 | if external.returncode or external.stdout.decode() != value: | ||
| 42 | return None | ||
| 43 | return value | ||
| 44 | |||
| 45 | def wait_desktop(self, text): | ||
| 46 | eventually(lambda: self.desktop() == text, 'desktop clipboard did not become ' + repr(text)) | ||
| 47 | |||
| 48 | def wait_primary(self, text): | ||
| 49 | eventually(lambda: self.primary() == text, 'primary selection did not become ' + repr(text)) | ||
| 50 | |||
| 51 | |||
| 52 | def reader_program(rig, tag): | ||
| 53 | """A raw foreground terminal app with independent input and output oracles.""" | ||
| 54 | source = rig.root / ('mouse-reader-' + tag + '.py') | ||
| 55 | received = rig.root / ('mouse-received-' + tag + '.bin') | ||
| 56 | control = rig.root / ('mouse-control-' + tag) | ||
| 57 | source.write_text( | ||
| 58 | 'import os, select, sys, termios, tty, time\n' | ||
| 59 | 'from pathlib import Path\n' | ||
| 60 | f'received=Path({str(received)!r}); control=Path({str(control)!r})\n' | ||
| 61 | 'fd=sys.stdin.fileno(); old=termios.tcgetattr(fd); tty.setraw(fd)\n' | ||
| 62 | 'try:\n' | ||
| 63 | ' sys.stdout.write("\\033[?25l\\033[2J\\033[HAPP-MOUSE-READY\\033[2;1Halpha café omega\\033[?1002h\\033[?1006h"); sys.stdout.flush()\n' | ||
| 64 | ' seen=0\n' | ||
| 65 | ' while True:\n' | ||
| 66 | ' if control.exists():\n' | ||
| 67 | ' commands=control.read_text()[seen:]; seen += len(commands)\n' | ||
| 68 | ' for command in commands.splitlines():\n' | ||
| 69 | ' if command == "off":\n' | ||
| 70 | ' sys.stdout.write("\\033[?1002l\\033[4;1HMODE-OFF"); sys.stdout.flush(); continue\n' | ||
| 71 | ' if command == "on":\n' | ||
| 72 | ' sys.stdout.write("\\033[?1002h\\033[4;1HMODE-ON "); sys.stdout.flush(); continue\n' | ||
| 73 | ' target={"c":"c","p":"p","s":"s","bad":"c","q":"q"}.get(command)\n' | ||
| 74 | ' if target:\n' | ||
| 75 | ' value={"c":"DESKTOP-C","p":"PRIMARY-P","s":"PRIMARY-S","bad":"\\x00","q":"IGNORED-Q"}[command]\n' | ||
| 76 | ' import base64\n' | ||
| 77 | ' sys.stdout.write("\\033]52;"+target+";"+base64.b64encode(value.encode()).decode()+"\\a"); sys.stdout.flush()\n' | ||
| 78 | ' ready, _, _ = select.select([fd], [], [], .02)\n' | ||
| 79 | ' if ready:\n' | ||
| 80 | ' data=os.read(fd, 4096)\n' | ||
| 81 | ' if not data: break\n' | ||
| 82 | ' with received.open("ab") as out: out.write(data)\n' | ||
| 83 | 'finally:\n' | ||
| 84 | ' termios.tcsetattr(fd, termios.TCSADRAIN, old)\n') | ||
| 85 | return source, received, control | ||
| 86 | |||
| 87 | |||
| 88 | def start_reader(rig, pane, tag): | ||
| 89 | source, received, control = reader_program(rig, tag) | ||
| 90 | rig.focus(pane) # A real Wayland pointer serial precedes any OSC 52 write. | ||
| 91 | rig.shell('python3 ' + shlex.quote(str(source))) | ||
| 92 | rig.wait_state(lambda s: 'APP-MOUSE-READY' in by_id(s)[pane]['painted_text']) | ||
| 93 | return received, control | ||
| 94 | |||
| 95 | |||
| 96 | def bytes_after(path, offset, expected, message): | ||
| 97 | eventually(lambda: path.exists() and path.stat().st_size >= offset + len(expected), message) | ||
| 98 | actual = path.read_bytes()[offset:] | ||
| 99 | require(actual == expected, f'{message}: got {actual!r}, expected {expected!r}') | ||
| 100 | |||
| 101 | |||
| 102 | def application_drag(rig, pane, other): | ||
| 103 | received, control = start_reader(rig, pane, 'direct') | ||
| 104 | state = rig.state() | ||
| 105 | # 1002 + 1006: left press, held motion, then SGR release. The cells | ||
| 106 | # are one-based on the wire and relative to the selected source pane. | ||
| 107 | rig.mouse_cell(state, pane, 0, 1, 'down') | ||
| 108 | rig.mouse_cell(state, pane, 4, 1, 'move') | ||
| 109 | rig.mouse_cell(state, pane, 4, 1, 'up') | ||
| 110 | expected = b'\033[<0;1;2M\033[<32;5;2M\033[<0;5;2m' | ||
| 111 | bytes_after(received, 0, expected, 'application press/drag/release did not reach its PTY') | ||
| 112 | |||
| 113 | # Middle/right belong to the application too. Adding Shift after | ||
| 114 | # press changes report modifiers without turning this into local copy. | ||
| 115 | for button in (1, 2): | ||
| 116 | offset = received.stat().st_size | ||
| 117 | rig.mouse_cell(state, pane, 0, 1, 'down', button=button) | ||
| 118 | rig.mouse_cell(state, pane, 4, 1, 'move', button=button, mods=SHIFT) | ||
| 119 | rig.mouse_cell(state, pane, 4, 1, 'up', button=button, mods=SHIFT) | ||
| 120 | expected = (f'\033[<{button};1;2M\033[<{button + 36};5;2M' | ||
| 121 | f'\033[<{button + 4};5;2m').encode() | ||
| 122 | bytes_after(received, offset, expected, 'application modified button drag did not reach its PTY') | ||
| 123 | |||
| 124 | # A terminal mode change cancels a held app gesture but sends one | ||
| 125 | # release in the original SGR format, so the application cannot keep | ||
| 126 | # its button state stuck. Subsequent pointer events stay local until | ||
| 127 | # the application enables reporting again. | ||
| 128 | offset = received.stat().st_size | ||
| 129 | rig.mouse_cell(state, pane, 0, 1, 'down') | ||
| 130 | bytes_after(received, offset, b'\033[<0;1;2M', 'application press before mode change missing') | ||
| 131 | with control.open('a') as out: | ||
| 132 | out.write('off\n') | ||
| 133 | rig.wait_state(lambda s: 'MODE-OFF' in by_id(s)[pane]['painted_text']) | ||
| 134 | eventually(lambda: received.read_bytes()[offset:] == b'\033[<0;1;2M\033[<0;1;2m', | ||
| 135 | 'mode change did not release the held application button') | ||
| 136 | rig.mouse_cell(state, pane, 4, 1, 'up') | ||
| 137 | time.sleep(.08) | ||
| 138 | require(received.read_bytes()[offset:] == b'\033[<0;1;2M\033[<0;1;2m', | ||
| 139 | 'stale release after mode cancellation reached the application') | ||
| 140 | with control.open('a') as out: | ||
| 141 | out.write('on\n') | ||
| 142 | rig.wait_state(lambda s: 'MODE-ON' in by_id(s)[pane]['painted_text']) | ||
| 143 | |||
| 144 | # Shift latches a local selection even while the application has mouse | ||
| 145 | # reporting. It must not append any input to the terminal application. | ||
| 146 | offset = received.stat().st_size | ||
| 147 | rig.mouse_cell(state, pane, 0, 1, 'down', mods=SHIFT) | ||
| 148 | rig.mouse_cell(state, pane, 4, 1, 'move') | ||
| 149 | rig.mouse_cell(state, pane, 4, 1, 'up') | ||
| 150 | rig.wait_desktop('alpha') | ||
| 151 | time.sleep(.12) | ||
| 152 | require(received.stat().st_size == offset, 'Shift local drag leaked application mouse bytes') | ||
| 153 | |||
| 154 | # Capture stays with the original pane. A move over another tile must | ||
| 155 | # report the source pane's clamped edge, never a neighbour coordinate. | ||
| 156 | state = rig.state() | ||
| 157 | source = by_id(state)[pane] | ||
| 158 | other_content = by_id(state)[other]['content'] | ||
| 159 | endpoint = rig.cell_point(state, other, 4, 1) | ||
| 160 | end_x = other_content['x'] + 4.5 * state['cell_w'] | ||
| 161 | end_y = other_content['y'] + 1.5 * state['cell_h'] | ||
| 162 | offset = received.stat().st_size | ||
| 163 | rig.mouse_cell(state, pane, 0, 1, 'down') | ||
| 164 | rig.mouse('move', endpoint) | ||
| 165 | rig.mouse('up', endpoint) | ||
| 166 | edge_x = max(0, min(source['cols'] - 1, int((end_x - source['content']['x']) / state['cell_w']))) + 1 | ||
| 167 | edge_y = max(0, min(source['rows'] - 1, int((end_y - source['content']['y']) / state['cell_h']))) + 1 | ||
| 168 | expected = (f'\033[<0;1;2M\033[<32;{edge_x};{edge_y}M\033[<0;{edge_x};{edge_y}m').encode() | ||
| 169 | bytes_after(received, offset, expected, 'cross-pane capture changed application source or missed release') | ||
| 170 | |||
| 171 | # OSC 52 C owns desktop clipboard; P and S update primary only. An | ||
| 172 | # unsafe decoded NUL and an unsupported target leave prior ownership. | ||
| 173 | for command, primary in (('c', None), ('p', 'PRIMARY-P'), ('s', 'PRIMARY-S')): | ||
| 174 | with control.open('a') as out: | ||
| 175 | out.write(command + '\n') | ||
| 176 | if command == 'c': | ||
| 177 | rig.wait_desktop('DESKTOP-C') | ||
| 178 | else: | ||
| 179 | rig.wait_primary(primary) | ||
| 180 | require(rig.desktop() == 'DESKTOP-C', f'OSC 52 {command} overwrote desktop clipboard') | ||
| 181 | with control.open('a') as out: | ||
| 182 | out.write('bad\nq\n') | ||
| 183 | time.sleep(.15) | ||
| 184 | require(rig.desktop() == 'DESKTOP-C' and rig.primary() == 'PRIMARY-S', | ||
| 185 | 'invalid or unsupported OSC 52 write changed clipboard ownership') | ||
| 186 | rig.ok('application mouse bytes, Shift local drag, source capture, and OSC 52 targets') | ||
| 187 | |||
| 188 | |||
| 189 | def main(): | ||
| 190 | require(len(sys.argv) == 3, 'usage: native_mouse.py MUX MUXG') | ||
| 191 | rig = MouseRig(*sys.argv[1:]) | ||
| 192 | try: | ||
| 193 | refs = start_persistent(rig) | ||
| 194 | panes = list(refs) | ||
| 195 | application_drag(rig, panes[1], panes[2]) | ||
| 196 | rig.kernel_sizes() | ||
| 197 | rig.assert_cli_untouched() | ||
| 198 | rig.quit() | ||
| 199 | print('Native mouse acceptance passed; artifacts:', rig.root, flush=True) | ||
| 200 | except BaseException: | ||
| 201 | rig.failure_artifacts() | ||
| 202 | raise | ||
| 203 | finally: | ||
| 204 | rig.close() | ||
| 205 | |||
| 206 | |||
| 207 | if __name__ == '__main__': | ||
| 208 | main() | ||
test/native_tmux_mouse.py
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,180 @@ | |||
| 1 | #!/usr/bin/env python3 | ||
| 2 | """Wayland-only mouse selection shared by muxg and a real foot tmux client.""" | ||
| 3 | import os | ||
| 4 | import shlex | ||
| 5 | import shutil | ||
| 6 | import subprocess | ||
| 7 | import sys | ||
| 8 | import time | ||
| 9 | |||
| 10 | sys.dont_write_bytecode = True | ||
| 11 | from native_resize import by_id | ||
| 12 | from native_selection import SelectionRig, cell_background | ||
| 13 | from native_tiling import eventually, require | ||
| 14 | from wayland_pointer import Pointer | ||
| 15 | |||
| 16 | |||
| 17 | def tmux(rig, socket, *args, check=True): | ||
| 18 | return subprocess.run(['tmux', '-S', str(socket), *args], env=rig.env, | ||
| 19 | capture_output=True, text=True, timeout=3, check=check) | ||
| 20 | |||
| 21 | |||
| 22 | def find_pid(tree, pid): | ||
| 23 | if tree.get('pid') == pid: | ||
| 24 | return tree | ||
| 25 | for node in tree.get('nodes', []) + tree.get('floating_nodes', []): | ||
| 26 | found = find_pid(node, pid) | ||
| 27 | if found: | ||
| 28 | return found | ||
| 29 | return None | ||
| 30 | |||
| 31 | |||
| 32 | def foot_point(pointer, pid, cols, rows, col, row): | ||
| 33 | node = find_pid(pointer.query('get_tree'), pid) | ||
| 34 | require(node is not None, 'foot window is not mapped') | ||
| 35 | rect = node['rect'] | ||
| 36 | # The fixture removes Sway decorations and foot padding, so the terminal | ||
| 37 | # grid starts at the client origin. Keep well away from either edge. | ||
| 38 | return ((col + .5) * rect['width'] / cols, | ||
| 39 | (row + .5) * rect['height'] / rows) | ||
| 40 | |||
| 41 | |||
| 42 | def pane_mode(rig, socket, target): | ||
| 43 | return tmux(rig, socket, 'display-message', '-p', '-t', target, | ||
| 44 | '#{pane_in_mode}').stdout.strip() | ||
| 45 | |||
| 46 | |||
| 47 | def buffer(rig, socket): | ||
| 48 | return tmux(rig, socket, 'show-buffer', check=False).stdout | ||
| 49 | |||
| 50 | |||
| 51 | def leave_copy_mode(rig, socket, target): | ||
| 52 | tmux(rig, socket, 'send-keys', '-t', target, '-X', 'cancel', check=False) | ||
| 53 | eventually(lambda: pane_mode(rig, socket, target) == '0', 'tmux did not leave copy mode') | ||
| 54 | |||
| 55 | |||
| 56 | def write_tmux_config(root): | ||
| 57 | config = root / 'tmux.conf' | ||
| 58 | config.write_text( | ||
| 59 | 'set -g mouse on\n' | ||
| 60 | 'set -g status off\n' | ||
| 61 | 'set -g set-clipboard on\n' | ||
| 62 | 'set -g mode-keys vi\n' | ||
| 63 | 'bind -T copy-mode MouseDragEnd1Pane send -X copy-selection-no-clear\n' | ||
| 64 | 'bind -T copy-mode-vi MouseDragEnd1Pane send -X copy-selection-no-clear\n') | ||
| 65 | return config | ||
| 66 | |||
| 67 | |||
| 68 | def run(mux, muxg): | ||
| 69 | if os.environ.get('MUXG_VIDEODRIVER') != 'wayland': | ||
| 70 | print('Native tmux mouse skipped: requires MUXG_VIDEODRIVER=wayland', flush=True) | ||
| 71 | return | ||
| 72 | require(shutil.which('foot') and shutil.which('tmux'), 'native tmux mouse requires foot and tmux') | ||
| 73 | pointer_binary = os.environ.get('MUXG_TEST_POINTER') | ||
| 74 | require(pointer_binary, 'native tmux mouse requires MUXG_TEST_POINTER') | ||
| 75 | |||
| 76 | rig = SelectionRig(mux, muxg) | ||
| 77 | foot = None | ||
| 78 | foot_pointer = None | ||
| 79 | socket = rig.root / 'shared.tmux.sock' | ||
| 80 | target = 'shared:0.0' | ||
| 81 | try: | ||
| 82 | sock, _ = rig.daemon('tmux') | ||
| 83 | state = rig.launch_gui(['--sock', sock, '--session', 'tmux'], 'gui') | ||
| 84 | pane = state['panes'][0]['id'] | ||
| 85 | config = write_tmux_config(rig.root) | ||
| 86 | paint = ('printf "\\033[2J\\033[HROW-000 alpha alpha\\nROW-001 bravo bravo\\n' | ||
| 87 | 'ROW-002 charlie charlie\\n"; exec sh') | ||
| 88 | command = ('exec tmux -S ' + shlex.quote(str(socket)) + ' -f ' + shlex.quote(str(config)) + | ||
| 89 | ' new-session -A -s shared ' + shlex.quote(paint)) | ||
| 90 | rig.shell(command) | ||
| 91 | state = rig.wait_state(lambda s: 'ROW-002 charlie charlie' in by_id(s)[pane]['painted_text']) | ||
| 92 | eventually(lambda: 'ROW-001 bravo bravo' in tmux(rig, socket, 'capture-pane', '-p', '-e', '-t', target).stdout, | ||
| 93 | 'tmux pane never painted the specimen') | ||
| 94 | |||
| 95 | # This is a second real terminal client of the exact same tmux server. | ||
| 96 | foot_config = rig.root / 'foot.ini' | ||
| 97 | foot_config.write_text('[main]\npad=0x0\nfont=monospace:size=12\n') | ||
| 98 | foot = rig.spawn(['foot', '-c', str(foot_config), '-a', 'muxg-tmux-mouse', | ||
| 99 | '-T', 'muxg-tmux-mouse', '-w', '960x600', | ||
| 100 | 'tmux', '-S', str(socket), 'attach', '-t', 'shared'], 'foot') | ||
| 101 | foot_pointer = Pointer(pointer_binary, rig.env, foot.pid) | ||
| 102 | def foot_ready(): | ||
| 103 | node = find_pid(foot_pointer.query('get_tree'), foot.pid) | ||
| 104 | clients = tmux(rig, socket, 'list-clients', '-F', '#{client_tty}').stdout.splitlines() | ||
| 105 | return node is not None and len(clients) >= 2 | ||
| 106 | eventually(foot_ready, 'foot did not attach as a second tmux client', seconds=10) | ||
| 107 | subprocess.run(['swaymsg', f'[pid={foot.pid}] floating enable, border none'], env=rig.env, | ||
| 108 | capture_output=True, check=True, timeout=3) | ||
| 109 | |||
| 110 | # A normal muxg drag reaches tmux's mouse binding. Its copy-mode | ||
| 111 | # selection remains visible in muxg and becomes the actual tmux buffer. | ||
| 112 | subprocess.run(['swaymsg', f'[pid={rig.gui.pid}] focus'], env=rig.env, capture_output=True, check=True) | ||
| 113 | state = rig.state() | ||
| 114 | before = cell_background(rig, state, pane, 10, 0) | ||
| 115 | rig.select(pane, (8, 0), (12, 0)) | ||
| 116 | eventually(lambda: pane_mode(rig, socket, target) == '1', 'muxg drag did not enter tmux copy mode') | ||
| 117 | eventually(lambda: 'alpha' in buffer(rig, socket), 'muxg drag did not populate tmux buffer') | ||
| 118 | eventually(lambda: cell_background(rig, state, pane, 10, 0) != before, | ||
| 119 | 'tmux selection was not visibly painted by muxg') | ||
| 120 | rig.ok('ordinary muxg drag enters tmux copy mode, paints selection and copies its buffer') | ||
| 121 | |||
| 122 | leave_copy_mode(rig, socket, target) | ||
| 123 | state = rig.state() | ||
| 124 | before = cell_background(rig, state, pane, 10, 1) | ||
| 125 | subprocess.run(['swaymsg', f'[pid={foot.pid}] focus'], env=rig.env, capture_output=True, check=True) | ||
| 126 | clients = tmux(rig, socket, 'list-clients', '-F', '#{client_termname} #{client_width} #{client_height}').stdout.splitlines() | ||
| 127 | cols, rows = next(tuple(map(int, line.split()[1:])) for line in clients if line.startswith('foot ')) | ||
| 128 | a = foot_point(foot_pointer, foot.pid, cols, rows, 8, 1) | ||
| 129 | b = foot_point(foot_pointer, foot.pid, cols, rows, 12, 1) | ||
| 130 | foot_pointer.event('mousedown', *a) | ||
| 131 | foot_pointer.event('mousemove', *b) | ||
| 132 | foot_pointer.event('mouseup', *b) | ||
| 133 | eventually(lambda: pane_mode(rig, socket, target) == '1', 'foot drag did not enter tmux copy mode') | ||
| 134 | eventually(lambda: 'bravo' in buffer(rig, socket), 'foot drag did not populate shared tmux buffer') | ||
| 135 | eventually(lambda: cell_background(rig, state, pane, 10, 1) != before, | ||
| 136 | 'foot tmux selection did not repaint muxg') | ||
| 137 | rig.ok('ordinary foot drag shares tmux selection state and muxg repaint') | ||
| 138 | |||
| 139 | leave_copy_mode(rig, socket, target) | ||
| 140 | prior = buffer(rig, socket) | ||
| 141 | subprocess.run(['swaymsg', f'[pid={rig.gui.pid}] focus'], env=rig.env, capture_output=True, check=True) | ||
| 142 | state = rig.state() | ||
| 143 | before = cell_background(rig, state, pane, 10, 0) | ||
| 144 | a = rig.cell_point(state, pane, 8, 0) | ||
| 145 | b = rig.cell_point(state, pane, 12, 0) | ||
| 146 | rig.send('mouse:down,' + a + ',0,1', 'mouse:move,' + b + ',0,0', | ||
| 147 | 'mouse:up,' + b + ',0,0') | ||
| 148 | eventually(lambda: cell_background(rig, state, pane, 10, 0) != before, | ||
| 149 | 'Shift muxg drag did not paint its local selection') | ||
| 150 | rig.copied('alpha') | ||
| 151 | time.sleep(.15) | ||
| 152 | require(pane_mode(rig, socket, target) == '0' and buffer(rig, socket) == prior, | ||
| 153 | 'Shift muxg drag changed tmux selection or buffer') | ||
| 154 | rig.ok('Shift muxg drag remains local and leaves shared tmux buffer unchanged') | ||
| 155 | rig.quit() | ||
| 156 | print('Native tmux mouse acceptance passed; artifacts:', rig.root, flush=True) | ||
| 157 | except BaseException: | ||
| 158 | rig.failure_artifacts() | ||
| 159 | raise | ||
| 160 | finally: | ||
| 161 | if foot_pointer is not None: | ||
| 162 | foot_pointer.close() | ||
| 163 | if foot is not None and foot.poll() is None: | ||
| 164 | foot.terminate() | ||
| 165 | try: | ||
| 166 | foot.wait(timeout=3) | ||
| 167 | except subprocess.TimeoutExpired: | ||
| 168 | foot.kill() | ||
| 169 | foot.wait(timeout=3) | ||
| 170 | tmux(rig, socket, 'kill-server', check=False) | ||
| 171 | rig.close() | ||
| 172 | |||
| 173 | |||
| 174 | def main(): | ||
| 175 | require(len(sys.argv) == 3, 'usage: native_tmux_mouse.py MUX MUXG') | ||
| 176 | run(*sys.argv[1:]) | ||
| 177 | |||
| 178 | |||
| 179 | if __name__ == '__main__': | ||
| 180 | main() | ||