73034a05
feat: add native clipboard paste
a73x 2026-09-07 09:00
Commit message
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} |