a73x

03138bb4

feat: forward native application mouse and clipboard writes

a73x   2026-09-06 11:09

Commit message
feat: forward native application mouse and clipboard writes

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