a73x

73034a05

feat: add native clipboard paste

a73x   2026-09-07 09:00

Commit message
feat: add native clipboard paste

README.md
Old New
@@ -86,8 +86,10 @@ clicks and drags; hold Shift when starting a drag to select locally instead.
86 That choice remains fixed until release, even if Shift changes or the pointer 86 That choice remains fixed until release, even if Shift changes or the pointer
87 crosses another pane. Ctrl+Shift+C copies the current local selection. 87 crosses another pane. Ctrl+Shift+C copies the current local selection.
88 Application OSC 52 writes update the desktop clipboard (`c`) or primary 88 Application OSC 52 writes update the desktop clipboard (`c`) or primary
89 selection (`p`/`s`); clipboard queries remain refused. GUI paste is not yet 89 selection (`p`/`s`); clipboard queries remain refused. Ctrl+Shift+V pastes
90 implemented. 90 into the focused pane or an editable picker field; Cmd+V does the same on
91 macOS. A terminal that enables bracketed paste receives one bracket pair
92 around the complete clipboard text, including when the payload is chunked.
91 93
92 Choose a Ghostty theme file by name in `$XDG_CONFIG_HOME/mux/themes/` (or 94 Choose a Ghostty theme file by name in `$XDG_CONFIG_HOME/mux/themes/` (or
93 `~/.config/mux/themes/`), or by absolute path: 95 `~/.config/mux/themes/`), or by absolute path:
src/client/session_pump.zig
Old New
@@ -24,6 +24,7 @@ pub const SelectionRequest = struct {
24 }; 24 };
25 pub const Say = union(enum) { 25 pub const Say = union(enum) {
26 input: []const u8, 26 input: []const u8,
27 paste: []const u8,
27 wheel: Wheel, 28 wheel: Wheel,
28 mouse: Mouse, 29 mouse: Mouse,
29 resize: proto.Size, 30 resize: proto.Size,
@@ -32,6 +33,7 @@ pub const Say = union(enum) {
32 detach, 33 detach,
33 quit, 34 quit,
34 }; 35 };
36 const paste_chunk_len = 32 * 1024;
35 pub const Wheel = struct { 37 pub const Wheel = struct {
36 notches: i32, 38 notches: i32,
37 col: u16, 39 col: u16,
@@ -170,7 +172,8 @@ pub const Pump = struct {
170 return self; 172 return self;
171 } 173 }
172 174
173 /// Input is copied before returning. Quit and detach never allocate. 175 /// Input and paste payloads are copied before returning. Quit and detach
176 /// never allocate.
174 pub fn say(self: *Pump, msg: Say) !void { 177 pub fn say(self: *Pump, msg: Say) !void {
175 self.mailbox_mu.lock(); 178 self.mailbox_mu.lock();
176 defer self.mailbox_mu.unlock(); 179 defer self.mailbox_mu.unlock();
@@ -203,8 +206,12 @@ pub const Pump = struct {
203 }, 206 },
204 else => { 207 else => {
205 var owned = msg; 208 var owned = msg;
206 if (msg == .input) owned = .{ .input = try self.alloc.dupe(u8, msg.input) }; 209 switch (msg) {
207 errdefer if (owned == .input) self.alloc.free(owned.input); 210 .input => |bytes| owned = .{ .input = try self.alloc.dupe(u8, bytes) },
211 .paste => |bytes| owned = .{ .paste = try self.alloc.dupe(u8, bytes) },
212 else => {},
213 }
214 errdefer self.freeOwnedSay(owned);
208 try self.mailbox.append(self.alloc, owned); 215 try self.mailbox.append(self.alloc, owned);
209 }, 216 },
210 } 217 }
@@ -222,7 +229,7 @@ pub const Pump = struct {
222 pub fn stop(self: *Pump) void { 229 pub fn stop(self: *Pump) void {
223 self.say(.quit) catch unreachable; 230 self.say(.quit) catch unreachable;
224 if (self.thread) |thread| thread.join(); 231 if (self.thread) |thread| thread.join();
225 for (self.mailbox.items) |msg| if (msg == .input) self.alloc.free(msg.input); 232 for (self.mailbox.items) |msg| self.freeOwnedSay(msg);
226 if (self.selection_result) |result| self.alloc.free(result.text); 233 if (self.selection_result) |result| self.alloc.free(result.text);
227 if (self.history) |g| g.deinit(); 234 if (self.history) |g| g.deinit();
228 for (self.clipboard) |text| if (text) |t| self.alloc.free(t); 235 for (self.clipboard) |text| if (text) |t| self.alloc.free(t);
@@ -233,6 +240,14 @@ pub const Pump = struct {
233 self.alloc.destroy(self); 240 self.alloc.destroy(self);
234 } 241 }
235 242
243 fn freeOwnedSay(self: *Pump, msg: Say) void {
244 switch (msg) {
245 .input => |bytes| self.alloc.free(bytes),
246 .paste => |bytes| self.alloc.free(bytes),
247 else => {},
248 }
249 }
250
236 pub fn mouseToken(self: *Pump) ?MouseToken { 251 pub fn mouseToken(self: *Pump) ?MouseToken {
237 self.mu.lock(); 252 self.mu.lock();
238 defer self.mu.unlock(); 253 defer self.mu.unlock();
@@ -546,7 +561,7 @@ pub const Pump = struct {
546 self.mailbox_mu.unlock(); 561 self.mailbox_mu.unlock();
547 var consumed = messages.items.len; 562 var consumed = messages.items.len;
548 defer { 563 defer {
549 for (messages.items[0..consumed]) |msg| if (msg == .input) self.alloc.free(msg.input); 564 for (messages.items[0..consumed]) |msg| self.freeOwnedSay(msg);
550 messages.deinit(self.alloc); 565 messages.deinit(self.alloc);
551 } 566 }
552 try self.flushSelectionClear(wire); 567 try self.flushSelectionClear(wire);
@@ -588,6 +603,24 @@ pub const Pump = struct {
588 if (scrolled) self.wake(); 603 if (scrolled) self.wake();
589 try wire.send(.input, bytes); 604 try wire.send(.input, bytes);
590 }, 605 },
606 .paste => |bytes| {
607 self.mu.lock();
608 const scrolled = self.scroll_rows != 0;
609 const bracketed = self.core.terminal_modes.bracketed_paste;
610 self.selection_revision +%= 1;
611 self.invalidateSelectionLocked();
612 self.returnLiveLocked();
613 self.mu.unlock();
614 if (scrolled) self.wake();
615 if (bracketed) try wire.send(.input, app_input.paste_begin);
616 var offset: usize = 0;
617 while (offset < bytes.len) {
618 const end = @min(offset + paste_chunk_len, bytes.len);
619 try wire.send(.input, bytes[offset..end]);
620 offset = end;
621 }
622 if (bracketed) try wire.send(.input, app_input.paste_end);
623 },
591 .wheel => |wheel| try self.routeWheel(wire, wheel), 624 .wheel => |wheel| try self.routeWheel(wire, wheel),
592 .mouse => |mouse| try self.routeMouse(wire, mouse), 625 .mouse => |mouse| try self.routeMouse(wire, mouse),
593 .selection => |req| { 626 .selection => |req| {
@@ -2019,6 +2052,64 @@ test "ready terminal mode frames precede queued wheel input through the actual w
2019 try std.testing.expectEqualStrings("\x1b[<64;423;319M", pixels.payload); 2052 try std.testing.expectEqualStrings("\x1b[<64;423;319M", pixels.payload);
2020 } 2053 }
2021 2054
2055 test "paste samples modes at delivery and keeps one chunked envelope in mailbox order" {
2056 const a = std.testing.allocator;
2057 const p = try selectionTestPump();
2058 defer p.stop();
2059 p.admitted = true;
2060 var pair: [2]std.posix.fd_t = undefined;
2061 try std.testing.expectEqual(@as(c_int, 0), std.c.socketpair(std.posix.AF.UNIX, std.posix.SOCK.STREAM, 0, &pair));
2062 defer std.posix.close(pair[0]);
2063 defer std.posix.close(pair[1]);
2064 var tr: client.Transport = .{ .link = .{ .fd = pair[0] } };
2065 var wire = try Wire.init(a, &tr);
2066 defer wire.deinit();
2067
2068 _ = try p.onFrame(.term_modes, &proto.encodeTermModes(.{ .bracketed_paste = true }));
2069 const payload = try a.alloc(u8, paste_chunk_len + 7);
2070 defer a.free(payload);
2071 @memset(payload, 'p');
2072 @memcpy(payload[paste_chunk_len..], "tail\nxy");
2073 try p.say(.{ .input = "before" });
2074 try p.say(.{ .paste = payload });
2075 @memset(payload, 'x');
2076 try p.say(.{ .input = "after" });
2077 try p.mail(&wire, true);
2078
2079 const before = (try proto.readFrame(a, pair[1])).?;
2080 defer before.deinit(a);
2081 try std.testing.expectEqualStrings("before", before.payload);
2082 const begin = (try proto.readFrame(a, pair[1])).?;
2083 defer begin.deinit(a);
2084 try std.testing.expectEqualStrings(app_input.paste_begin, begin.payload);
2085 const first = (try proto.readFrame(a, pair[1])).?;
2086 defer first.deinit(a);
2087 try std.testing.expectEqual(paste_chunk_len, first.payload.len);
2088 for (first.payload) |byte| try std.testing.expectEqual(@as(u8, 'p'), byte);
2089 const tail = (try proto.readFrame(a, pair[1])).?;
2090 defer tail.deinit(a);
2091 try std.testing.expectEqualStrings("tail\nxy", tail.payload);
2092 const end = (try proto.readFrame(a, pair[1])).?;
2093 defer end.deinit(a);
2094 try std.testing.expectEqualStrings(app_input.paste_end, end.payload);
2095 const after = (try proto.readFrame(a, pair[1])).?;
2096 defer after.deinit(a);
2097 try std.testing.expectEqualStrings("after", after.payload);
2098
2099 try p.say(.{ .paste = "mode sampled when delivered" });
2100 _ = try p.onFrame(.term_modes, &proto.encodeTermModes(.{ .bracketed_paste = false }));
2101 try p.mail(&wire, true);
2102 const raw = (try proto.readFrame(a, pair[1])).?;
2103 defer raw.deinit(a);
2104 try std.testing.expectEqualStrings("mode sampled when delivered", raw.payload);
2105 }
2106
2107 test "queued paste is released when a pump stops" {
2108 const p = try selectionTestPump();
2109 try p.say(.{ .paste = "owned until shutdown" });
2110 p.stop();
2111 }
2112
2022 test "mouse press motion release uses negotiated SGR coordinates" { 2113 test "mouse press motion release uses negotiated SGR coordinates" {
2023 const p = try selectionTestPump(); 2114 const p = try selectionTestPump();
2024 defer p.stop(); 2115 defer p.stop();
src/gui/frame.zig
Old New
@@ -1,5 +1,6 @@
1 //! SDL window, workspace input, and whole-frame painting of owned pane snapshots. 1 //! SDL window, workspace input, and whole-frame painting of owned pane snapshots.
2 const std = @import("std"); 2 const std = @import("std");
3 const builtin = @import("builtin");
3 const client = @import("client"); 4 const client = @import("client");
4 const term = @import("term"); 5 const term = @import("term");
5 const input = @import("input"); 6 const input = @import("input");
@@ -93,6 +94,7 @@ pub const Hook = union(enum) {
93 resize: struct { w: u32, h: u32 }, 94 resize: struct { w: u32, h: u32 },
94 capture: []const u8, 95 capture: []const u8,
95 capture_last: []const u8, 96 capture_last: []const u8,
97 clipboard_set: []const u8,
96 clipboard: []const u8, 98 clipboard: []const u8,
97 primary: []const u8, 99 primary: []const u8,
98 quit, 100 quit,
@@ -119,6 +121,7 @@ pub fn parseHook(line: []const u8) ?Hook {
119 } 121 }
120 } 122 }
121 if (std.mem.startsWith(u8, line, "capture-last:")) return .{ .capture_last = line[13..] }; 123 if (std.mem.startsWith(u8, line, "capture-last:")) return .{ .capture_last = line[13..] };
124 if (std.mem.startsWith(u8, line, "clipboard-set:")) return .{ .clipboard_set = line[14..] };
122 if (std.mem.startsWith(u8, line, "primary:")) return .{ .primary = line[8..] }; 125 if (std.mem.startsWith(u8, line, "primary:")) return .{ .primary = line[8..] };
123 if (std.mem.startsWith(u8, line, "clipboard:")) return .{ .clipboard = line[10..] }; 126 if (std.mem.startsWith(u8, line, "clipboard:")) return .{ .clipboard = line[10..] };
124 if (std.mem.startsWith(u8, line, "wheel:")) { 127 if (std.mem.startsWith(u8, line, "wheel:")) {
@@ -139,6 +142,7 @@ pub fn parseHook(line: []const u8) ?Hook {
139 const name = line["key:".len..]; 142 const name = line["key:".len..];
140 if (std.mem.eql(u8, name, "prefix")) return .{ .key = .{ .code = c.SDLK_BACKSLASH, .mods = c.SDL_KMOD_CTRL } }; 143 if (std.mem.eql(u8, name, "prefix")) return .{ .key = .{ .code = c.SDLK_BACKSLASH, .mods = c.SDL_KMOD_CTRL } };
141 if (std.mem.eql(u8, name, "copy")) return .{ .key = .{ .code = c.SDLK_C, .mods = c.SDL_KMOD_CTRL | c.SDL_KMOD_SHIFT } }; 144 if (std.mem.eql(u8, name, "copy")) return .{ .key = .{ .code = c.SDLK_C, .mods = c.SDL_KMOD_CTRL | c.SDL_KMOD_SHIFT } };
145 if (std.mem.eql(u8, name, "paste")) return .{ .key = .{ .code = c.SDLK_V, .mods = c.SDL_KMOD_CTRL | c.SDL_KMOD_SHIFT } };
142 if (std.mem.eql(u8, name, "interrupt")) return .{ .key = .{ .code = c.SDLK_C, .mods = c.SDL_KMOD_CTRL } }; 146 if (std.mem.eql(u8, name, "interrupt")) return .{ .key = .{ .code = c.SDLK_C, .mods = c.SDL_KMOD_CTRL } };
143 if (name.len == 1 and std.mem.indexOfScalar(u8, "hjklvbrdxp", name[0]) != null) return .{ .key = .{ .code = name[0] } }; 147 if (name.len == 1 and std.mem.indexOfScalar(u8, "hjklvbrdxp", name[0]) != null) return .{ .key = .{ .code = name[0] } };
144 inline for (.{ .{ "enter", c.SDLK_RETURN }, .{ "tab", c.SDLK_TAB }, .{ "escape", c.SDLK_ESCAPE }, .{ "backspace", c.SDLK_BACKSPACE }, .{ "up", c.SDLK_UP }, .{ "down", c.SDLK_DOWN }, .{ "left", c.SDLK_LEFT }, .{ "right", c.SDLK_RIGHT } }) |pair| { 148 inline for (.{ .{ "enter", c.SDLK_RETURN }, .{ "tab", c.SDLK_TAB }, .{ "escape", c.SDLK_ESCAPE }, .{ "backspace", c.SDLK_BACKSPACE }, .{ "up", c.SDLK_UP }, .{ "down", c.SDLK_DOWN }, .{ "left", c.SDLK_LEFT }, .{ "right", c.SDLK_RIGHT } }) |pair| {
@@ -333,6 +337,15 @@ const HookReader = struct {
333 self.capture_last = copy; 337 self.capture_last = copy;
334 return; 338 return;
335 }, 339 },
340 .clipboard_set => |path| {
341 const text = try std.fs.cwd().readFileAlloc(self.alloc, path, term.protocol.max_payload);
342 defer self.alloc.free(text);
343 if (!client.core.validClipboardText(text)) return error.InvalidClipboardText;
344 const z = try self.alloc.dupeZ(u8, text);
345 defer self.alloc.free(z);
346 if (!c.SDL_SetClipboardText(z.ptr)) return error.ClipboardWriteFailed;
347 return;
348 },
336 .clipboard, .primary => |path| { 349 .clipboard, .primary => |path| {
337 const text = (if (hook == .primary) c.SDL_GetPrimarySelectionText() else c.SDL_GetClipboardText()) orelse return error.ClipboardReadFailed; 350 const text = (if (hook == .primary) c.SDL_GetPrimarySelectionText() else c.SDL_GetClipboardText()) orelse return error.ClipboardReadFailed;
338 defer c.SDL_free(@ptrCast(text)); 351 defer c.SDL_free(@ptrCast(text));
@@ -400,8 +413,17 @@ const Events = struct {
400 defer if (self.hook) |h| h.releaseText(ev.text.text); 413 defer if (self.hook) |h| h.releaseText(ev.text.text);
401 try self.ui.textInput(std.mem.span(ev.text.text)); 414 try self.ui.textInput(std.mem.span(ev.text.text));
402 }, 415 },
403 c.SDL_EVENT_KEY_DOWN => if (nativeShortcut(ev.key)) |shortcut| switch (shortcut) { 416 c.SDL_EVENT_KEY_DOWN => if (nativeShortcut(ev.key, builtin.os.tag == .macos)) |shortcut| switch (shortcut) {
404 .copy => try self.ui.copyShortcut(ev.key.key), 417 .copy => try self.ui.copyShortcut(ev.key.key),
418 .paste => {
419 const text = c.SDL_GetClipboardText() orelse {
420 self.ui.consumeShortcut(ev.key.key);
421 self.ui.setNotice("Clipboard read failed");
422 return true;
423 };
424 defer c.SDL_free(@ptrCast(text));
425 try self.ui.pasteShortcut(ev.key.key, std.mem.span(text));
426 },
405 } else try self.ui.keyDown(interactionKey(ev.key)), 427 } else try self.ui.keyDown(interactionKey(ev.key)),
406 c.SDL_EVENT_KEY_UP => self.ui.keyUp(ev.key.key), 428 c.SDL_EVENT_KEY_UP => self.ui.keyUp(ev.key.key),
407 c.SDL_EVENT_WINDOW_FOCUS_LOST => self.ui.focusLost(), 429 c.SDL_EVENT_WINDOW_FOCUS_LOST => self.ui.focusLost(),
@@ -498,10 +520,12 @@ fn grabPixels(logical: c_int, pixels: c_int) u32 {
498 if (logical <= 0 or pixels <= 0) return 0; 520 if (logical <= 0 or pixels <= 0) return 0;
499 return @intCast((@as(u64, @intCast(pixels)) * 6 + @as(u32, @intCast(logical)) - 1) / @as(u32, @intCast(logical))); 521 return @intCast((@as(u64, @intCast(pixels)) * 6 + @as(u32, @intCast(logical)) - 1) / @as(u32, @intCast(logical)));
500 } 522 }
501 const NativeShortcut = enum { copy }; 523 const NativeShortcut = enum { copy, paste };
502 fn nativeShortcut(ev: c.SDL_KeyboardEvent) ?NativeShortcut { 524 fn nativeShortcut(ev: c.SDL_KeyboardEvent, macos: bool) ?NativeShortcut {
503 if (ev.repeat) return null; 525 if (ev.repeat) return null;
504 if (ev.key == c.SDLK_C and ev.mod & c.SDL_KMOD_CTRL != 0 and ev.mod & c.SDL_KMOD_SHIFT != 0 and ev.mod & (c.SDL_KMOD_ALT | c.SDL_KMOD_GUI | c.SDL_KMOD_MODE) == 0) return .copy; 526 if (ev.key == c.SDLK_C and ev.mod & c.SDL_KMOD_CTRL != 0 and ev.mod & c.SDL_KMOD_SHIFT != 0 and ev.mod & (c.SDL_KMOD_ALT | c.SDL_KMOD_GUI | c.SDL_KMOD_MODE) == 0) return .copy;
527 if (ev.key == c.SDLK_V and ev.mod & c.SDL_KMOD_CTRL != 0 and ev.mod & c.SDL_KMOD_SHIFT != 0 and ev.mod & (c.SDL_KMOD_ALT | c.SDL_KMOD_GUI | c.SDL_KMOD_MODE) == 0) return .paste;
528 if (macos and ev.key == c.SDLK_V and ev.mod & c.SDL_KMOD_GUI != 0 and ev.mod & (c.SDL_KMOD_CTRL | c.SDL_KMOD_ALT | c.SDL_KMOD_SHIFT | c.SDL_KMOD_MODE) == 0) return .paste;
505 return null; 529 return null;
506 } 530 }
507 fn interactionKey(ev: c.SDL_KeyboardEvent) interaction.KeyDown { 531 fn interactionKey(ev: c.SDL_KeyboardEvent) interaction.KeyDown {
@@ -807,9 +831,13 @@ test "key mapping sends modifier punctuation and space through the shared encode
807 try std.testing.expect(keyEvent(c.SDLK_Q, c.SDL_KMOD_LCTRL | c.SDL_KMOD_RALT) == null); 831 try std.testing.expect(keyEvent(c.SDLK_Q, c.SDL_KMOD_LCTRL | c.SDL_KMOD_RALT) == null);
808 } 832 }
809 833
810 test "test hook rejects zero resize and retains text exactly" { 834 test "test hook retains text and clipboard paths and maps paste" {
811 try std.testing.expect(parseHook("resize:0x400") == null); 835 try std.testing.expect(parseHook("resize:0x400") == null);
812 try std.testing.expectEqualStrings("hi", parseHook("text:hi").?.text); 836 try std.testing.expectEqualStrings("hi", parseHook("text:hi").?.text);
837 try std.testing.expectEqualStrings("/tmp/source", parseHook("clipboard-set:/tmp/source").?.clipboard_set);
838 const paste = parseHook("key:paste").?.key;
839 try std.testing.expectEqual(c.SDLK_V, paste.code);
840 try std.testing.expect(paste.mods & c.SDL_KMOD_CTRL != 0 and paste.mods & c.SDL_KMOD_SHIFT != 0);
813 } 841 }
814 842
815 test "delayed End refusal cannot install a hidden force menu over an active picker" { 843 test "delayed End refusal cannot install a hidden force menu over an active picker" {
@@ -1352,23 +1380,45 @@ test "copy chord is exact while plain interrupt remains terminal input" {
1352 var copy_event = std.mem.zeroes(c.SDL_KeyboardEvent); 1380 var copy_event = std.mem.zeroes(c.SDL_KeyboardEvent);
1353 copy_event.key = c.SDLK_C; 1381 copy_event.key = c.SDLK_C;
1354 copy_event.mod = c.SDL_KMOD_CTRL | c.SDL_KMOD_SHIFT; 1382 copy_event.mod = c.SDL_KMOD_CTRL | c.SDL_KMOD_SHIFT;
1355 try std.testing.expectEqual(NativeShortcut.copy, nativeShortcut(copy_event).?); 1383 try std.testing.expectEqual(NativeShortcut.copy, nativeShortcut(copy_event, false).?);
1356 var interrupt_event = copy_event; 1384 var interrupt_event = copy_event;
1357 interrupt_event.mod = c.SDL_KMOD_CTRL; 1385 interrupt_event.mod = c.SDL_KMOD_CTRL;
1358 const interrupt = interactionKey(interrupt_event); 1386 const interrupt = interactionKey(interrupt_event);
1359 try std.testing.expect(nativeShortcut(interrupt_event) == null); 1387 try std.testing.expect(nativeShortcut(interrupt_event, false) == null);
1360 try std.testing.expect(interrupt.terminal != null); 1388 try std.testing.expect(interrupt.terminal != null);
1361 for ([_]u16{ c.SDL_KMOD_LCTRL | c.SDL_KMOD_LSHIFT, c.SDL_KMOD_RCTRL | c.SDL_KMOD_RSHIFT, c.SDL_KMOD_LCTRL | c.SDL_KMOD_RSHIFT, c.SDL_KMOD_RCTRL | c.SDL_KMOD_LSHIFT }) |mods| { 1389 for ([_]u16{ c.SDL_KMOD_LCTRL | c.SDL_KMOD_LSHIFT, c.SDL_KMOD_RCTRL | c.SDL_KMOD_RSHIFT, c.SDL_KMOD_LCTRL | c.SDL_KMOD_RSHIFT, c.SDL_KMOD_RCTRL | c.SDL_KMOD_LSHIFT }) |mods| {
1362 copy_event.mod = mods; 1390 copy_event.mod = mods;
1363 try std.testing.expectEqual(NativeShortcut.copy, nativeShortcut(copy_event).?); 1391 try std.testing.expectEqual(NativeShortcut.copy, nativeShortcut(copy_event, false).?);
1364 } 1392 }
1365 for ([_]u16{ c.SDL_KMOD_ALT, c.SDL_KMOD_GUI, c.SDL_KMOD_MODE, c.SDL_KMOD_RALT }) |extra| { 1393 for ([_]u16{ c.SDL_KMOD_ALT, c.SDL_KMOD_GUI, c.SDL_KMOD_MODE, c.SDL_KMOD_RALT }) |extra| {
1366 var excluded = copy_event; 1394 var excluded = copy_event;
1367 excluded.mod |= extra; 1395 excluded.mod |= extra;
1368 try std.testing.expect(nativeShortcut(excluded) == null); 1396 try std.testing.expect(nativeShortcut(excluded, false) == null);
1369 } 1397 }
1370 copy_event.repeat = true; 1398 copy_event.repeat = true;
1371 try std.testing.expect(nativeShortcut(copy_event) == null); 1399 try std.testing.expect(nativeShortcut(copy_event, false) == null);
1400 }
1401
1402 test "paste shortcuts are platform-specific and exact" {
1403 var event = std.mem.zeroes(c.SDL_KeyboardEvent);
1404 event.key = c.SDLK_V;
1405 const ctrl_shift: u16 = @intCast(c.SDL_KMOD_CTRL | c.SDL_KMOD_SHIFT);
1406 event.mod = ctrl_shift;
1407 try std.testing.expectEqual(NativeShortcut.paste, nativeShortcut(event, false).?);
1408 try std.testing.expectEqual(NativeShortcut.paste, nativeShortcut(event, true).?);
1409 event.mod = c.SDL_KMOD_CTRL;
1410 try std.testing.expect(nativeShortcut(event, false) == null);
1411 try std.testing.expect(interactionKey(event).terminal != null);
1412 event.mod = c.SDL_KMOD_GUI;
1413 try std.testing.expect(nativeShortcut(event, false) == null);
1414 try std.testing.expectEqual(NativeShortcut.paste, nativeShortcut(event, true).?);
1415 for ([_]u16{ c.SDL_KMOD_ALT, c.SDL_KMOD_GUI, c.SDL_KMOD_MODE, c.SDL_KMOD_RALT }) |extra| {
1416 event.mod = ctrl_shift | extra;
1417 try std.testing.expect(nativeShortcut(event, false) == null);
1418 }
1419 event.mod = ctrl_shift;
1420 event.repeat = true;
1421 try std.testing.expect(nativeShortcut(event, true) == null);
1372 } 1422 }
1373 1423
1374 test "later pane atlas growth precedes earlier pane UV generation" { 1424 test "later pane atlas growth precedes earlier pane UV generation" {
src/gui/interaction.zig
Old New
@@ -501,9 +501,12 @@ pub const Controller = struct {
501 try self.queueSelection(live, range); 501 try self.queueSelection(live, range);
502 } 502 }
503 pub fn copyShortcut(self: *Controller, key: u32) !void { 503 pub fn copyShortcut(self: *Controller, key: u32) !void {
504 self.consumeShortcut(key);
505 try self.copySelection();
506 }
507 pub fn consumeShortcut(self: *Controller, key: u32) void {
504 self.consumed_key = key; 508 self.consumed_key = key;
505 self.suppress_text = true; 509 self.suppress_text = true;
506 try self.copySelection();
507 } 510 }
508 pub fn pointerMove(self: *Controller, x: i64, y: i64) !void { 511 pub fn pointerMove(self: *Controller, x: i64, y: i64) !void {
509 if (self.drag == null and self.selection_drag.on() != null) { 512 if (self.drag == null and self.selection_drag.on() != null) {
@@ -588,6 +591,19 @@ pub const Controller = struct {
588 } 591 }
589 self.suppress_text = false; 592 self.suppress_text = false;
590 } 593 }
594 /// The frame owns clipboard access; the controller owns the current text
595 /// destination and keeps the physical shortcut from leaking into it.
596 pub fn pasteShortcut(self: *Controller, key: u32, text: []const u8) !void {
597 self.consumeShortcut(key);
598 if (text.len == 0) return;
599 if (self.picker) |picker| {
600 try picker.text(text);
601 self.dirty = true;
602 } else if (!self.command_mode and !self.resize_mode and self.recovery == null) {
603 self.clearSelection();
604 try self.rt.paste(text);
605 }
606 }
591 pub fn keyUp(self: *Controller, code: u32) void { 607 pub fn keyUp(self: *Controller, code: u32) void {
592 _ = self.modal_held.remove(code); 608 _ = self.modal_held.remove(code);
593 if (self.consumed_key == code) self.consumed_key = null; 609 if (self.consumed_key == code) self.consumed_key = null;
@@ -749,6 +765,29 @@ test "copy shortcut owns its physical key until release" {
749 try std.testing.expect(ui.consumed_key == null and !ui.suppress_text); 765 try std.testing.expect(ui.consumed_key == null and !ui.suppress_text);
750 } 766 }
751 767
768 test "paste shortcut follows the active text destination" {
769 const a = std.testing.allocator;
770 var rt = runtime.Runtime.init(a, .{});
771 defer rt.deinit();
772 var ui: Controller = .{ .rt = &rt, .metrics = .{ .cell_w = 8, .cell_h = 16 }, .fb_w = 800, .fb_h = 600 };
773 defer ui.deinit();
774 var next: u64 = 2;
775 var picker: picker_mod.Picker = .{ .alloc = a, .arena = std.heap.ArenaAllocator.init(a), .rt = &rt, .origin = null, .origin_tab = rt.workspace.active_tab_id, .pending = null, .ticket = .{ .generation = 1, .owner = 0, .attachment_generation = 0 }, .next_generation = &next, .key_path = null, .width = 800, .height = 600, .metrics = ui.metrics, .wake = null, .wake_ctx = null };
776 defer picker.arena.deinit();
777 defer picker.input.deinit(a);
778 picker.level = .session_name;
779 ui.picker = &picker;
780 defer ui.picker = null;
781 try ui.pasteShortcut(86, "new-shell");
782 try std.testing.expectEqualStrings("new-shell", picker.input.items);
783 try std.testing.expectEqual(@as(?u32, 86), ui.consumed_key);
784 ui.keyUp(86);
785 ui.picker = null;
786 ui.command_mode = true;
787 try ui.pasteShortcut(86, "not terminal input");
788 try std.testing.expect(ui.command_mode and ui.consumed_key == 86);
789 }
790
752 test "controller preserves exact held keys across modal dismissal and text edges" { 791 test "controller preserves exact held keys across modal dismissal and text edges" {
753 var rt = runtime.Runtime.init(std.testing.allocator, .{}); 792 var rt = runtime.Runtime.init(std.testing.allocator, .{});
754 defer rt.deinit(); 793 defer rt.deinit();
src/gui/runtime.zig
Old New
@@ -179,6 +179,14 @@ pub const Runtime = struct {
179 } 179 }
180 } 180 }
181 181
182 pub fn paste(self: *Runtime, text: []const u8) !void {
183 const live = self.get(self.workspace.tab().focus orelse return) orelse return;
184 switch (live.status.phase) {
185 .dialing, .attached, .reconnecting => try live.pump.say(.{ .paste = text }),
186 else => {},
187 }
188 }
189
182 pub fn wheel(self: *Runtime, key: model.Attachment, event: client.session_pump.Wheel) !void { 190 pub fn wheel(self: *Runtime, key: model.Attachment, event: client.session_pump.Wheel) !void {
183 const live = self.get(key.pane) orelse return; 191 const live = self.get(key.pane) orelse return;
184 if (!self.accepts(key) or live.status.phase != .attached) return; 192 if (!self.accepts(key) or live.status.phase != .attached) return;
test/native_selection.py
Old New
@@ -58,6 +58,16 @@ class SelectionRig(LifecycleRig):
58 return None 58 return None
59 return value 59 return value
60 60
61 def set_clipboard(self, text):
62 self.serial += 1
63 source = self.root / (str(self.serial) + '-clipboard-source.txt')
64 source.write_text(text)
65 self.send('clipboard-set:' + str(source))
66 # The state artifact is ordered after the setter in the same FIFO.
67 self.state()
68 eventually(lambda: self.clipboard() == text,
69 'SDL clipboard setter did not publish ' + repr(text[:80]))
70
61 def cell_point(self, state, pane_id, col, row): 71 def cell_point(self, state, pane_id, col, row):
62 content = by_id(state)[pane_id]['content'] 72 content = by_id(state)[pane_id]['content']
63 return self.point(state, content['x'] + (col + .5) * state['cell_w'], 73 return self.point(state, content['x'] + (col + .5) * state['cell_w'],
@@ -166,6 +176,85 @@ def copy_shortcut(rig, pane):
166 specimen(rig, pane, 'PANE-' + str(pane)) 176 specimen(rig, pane, 'PANE-' + str(pane))
167 177
168 178
179 def start_paste_reader(rig, pane):
180 """Run a raw foreground application which records exact pasted bytes."""
181 source = rig.root / 'paste-foreground.py'
182 received = rig.root / 'paste-received.bin'
183 control = rig.root / 'paste-control'
184 source.write_text(
185 'import os, select, sys, termios, tty\n'
186 'from pathlib import Path\n'
187 f'received=Path({str(received)!r}); control=Path({str(control)!r})\n'
188 'fd=sys.stdin.fileno(); old=termios.tcgetattr(fd); tty.setraw(fd)\n'
189 'try:\n'
190 ' sys.stdout.write("\\033[?2004l\\033[2J\\033[HPASTE-READY"); sys.stdout.flush()\n'
191 ' seen=0; running=True\n'
192 ' while running:\n'
193 ' if control.exists():\n'
194 ' commands=control.read_text()[seen:]; seen += len(commands)\n'
195 ' for command in commands.splitlines():\n'
196 ' if command == "on":\n'
197 ' sys.stdout.write("\\033[?2004h\\033[2;1HPASTE-MODE-ON "); sys.stdout.flush()\n'
198 ' elif command == "off":\n'
199 ' sys.stdout.write("\\033[?2004l\\033[2;1HPASTE-MODE-OFF"); sys.stdout.flush()\n'
200 ' elif command == "stop": running=False\n'
201 ' ready, _, _ = select.select([fd], [], [], .02)\n'
202 ' if ready:\n'
203 ' data=os.read(fd, 4096)\n'
204 ' if not data: break\n'
205 ' with received.open("ab") as out: out.write(data)\n'
206 'finally:\n'
207 ' termios.tcsetattr(fd, termios.TCSADRAIN, old)\n'
208 ' print("\\r\\nPASTE-DONE", flush=True)\n')
209 rig.focus(pane)
210 rig.shell('python3 ' + shlex.quote(str(source)))
211 rig.wait_state(lambda state: 'PASTE-READY' in by_id(state)[pane]['painted_text'])
212 return received, control
213
214
215 def append_control(path, command):
216 with path.open('a') as out:
217 out.write(command + '\n')
218
219
220 def pasted_bytes(received, offset, expected, message):
221 eventually(lambda: received.exists() and received.stat().st_size >= offset + len(expected),
222 message)
223 actual = received.read_bytes()[offset:]
224 require(actual == expected, f'{message}: got {actual!r}, expected {expected!r}')
225
226
227 def paste_shortcut(rig, pane):
228 received, control = start_paste_reader(rig, pane)
229 try:
230 raw = 'raw first line\nsecond café 界'
231 rig.set_clipboard(raw)
232 rig.key('paste')
233 pasted_bytes(received, 0, raw.encode(), 'plain clipboard bytes did not reach the focused PTY')
234
235 append_control(control, 'on')
236 rig.wait_state(lambda state: 'PASTE-MODE-ON' in by_id(state)[pane]['painted_text'])
237 bracketed = 'bracketed\ntext café 界'
238 rig.set_clipboard(bracketed)
239 offset = received.stat().st_size
240 rig.key('paste')
241 pasted_bytes(received, offset, b'\033[200~' + bracketed.encode() + b'\033[201~',
242 'bracketed clipboard bytes were not wrapped exactly once')
243
244 large = ('0123456789abcdef' * 2300) + '\nlarge café 界'
245 require(len(large.encode()) > 32 * 1024, 'large paste fixture did not cross a pump chunk')
246 rig.set_clipboard(large)
247 offset = received.stat().st_size
248 rig.key('paste')
249 pasted_bytes(received, offset, b'\033[200~' + large.encode() + b'\033[201~',
250 'chunked bracketed paste lost, duplicated or rewrapped bytes')
251 rig.ok('native paste sends raw, Unicode, multiline and one chunked bracket envelope')
252 finally:
253 append_control(control, 'stop')
254 rig.wait_state(lambda state: 'PASTE-DONE' in by_id(state)[pane]['painted_text'])
255 specimen(rig, pane, 'PANE-' + str(pane))
256
257
169 def scale_selection(rig, pane): 258 def scale_selection(rig, pane):
170 output = os.environ.get('MUXG_TEST_SCALE_OUTPUT') 259 output = os.environ.get('MUXG_TEST_SCALE_OUTPUT')
171 if not output: 260 if not output:
@@ -289,6 +378,7 @@ def exercise(rig):
289 wraps = {pane: specimen(rig, pane, 'PANE-' + str(pane)) for pane in panes} 378 wraps = {pane: specimen(rig, pane, 'PANE-' + str(pane)) for pane in panes}
290 target, neighbour = panes[1], panes[2] 379 target, neighbour = panes[1], panes[2]
291 copy_shortcut(rig, target) 380 copy_shortcut(rig, target)
381 paste_shortcut(rig, target)
292 rig.focus(panes[0]) 382 rig.focus(panes[0])
293 state = rig.state() 383 state = rig.state()
294 before = {p: cell_background(rig, state, p, 1, 1) for p in panes} 384 before = {p: cell_background(rig, state, p, 1, 1) for p in panes}