diff options
Diffstat (limited to 'src/tty/tty.zig')
| -rw-r--r-- | src/tty/tty.zig | 85 |
1 files changed, 83 insertions, 2 deletions
diff --git a/src/tty/tty.zig b/src/tty/tty.zig index e8e3c1bd..eadc3a9d 100644 --- a/src/tty/tty.zig +++ b/src/tty/tty.zig @@ -37,6 +37,13 @@ pub const Command = struct { winsize: vaxis.Winsize, mouse: vaxis.Mouse, paste: []const u8, + /// The bracketed-paste brackets. vaxis posts them ONLY because this + /// union declares fields with these exact names — its Loop gates every + /// event on `@hasField` — and the pasted bytes themselves arrive + /// BETWEEN them as ordinary key presses, which the loop accumulates + /// into one `.paste` above instead of running as commands. + paste_start, + paste_end, /// a language query finished on a worker; rows are lsp-domain-owned lsp_done: struct { id: u32, rows: []u8 }, /// a selection-filter worker finished; every stdout is gpa-owned @@ -403,6 +410,15 @@ pub fn run(init: std.process.Init, opts: pardes.Options) !void { // gtk-enable-primary-paste=false — no mode we request can surface middle // clicks there (see test/snapshots/ghostty-mid.snap). try vx.setMouseMode(tty.writer(), true); + // Bracketed paste. Without it a paste into pardes-in-a-terminal is just a + // flood of key presses: plausible-looking in insert mode, and in normal + // mode every pasted character runs as a command. With it the terminal + // wraps the bytes in \x1b[200~ / \x1b[201~ and the loop coalesces them. + // No defer to switch it back off, for the same reason the mouse modes + // above have none: setBracketedPaste records state.bracketed_paste, and + // vaxis's resetState — reached from the `defer vx.deinit` above, while the + // tty is still open — sends the disable off that flag. + try vx.setBracketedPaste(tty.writer(), true); pardes.image.start(io, allocs.image); if (comptime pardes.pdf_enabled) pardes.pdf.start(allocs.pdf); @@ -569,6 +585,16 @@ pub fn run(init: std.process.Init, opts: pardes.Options) !void { var check_files = false; var pending: ?@TypeOf(Command.value) = .tick; + // Where a bracketed paste is assembled. It has to outlive one drain pass: + // the burst arrives over as many passes as the terminal takes to write it, + // and the markers are the only thing that says where it ends. + var paste_buf: std.Io.Writer.Allocating = .init(gpa); + defer paste_buf.deinit(); + var in_paste = false; + // 4 MiB ceiling, past which the tail is dropped rather than grown into. A + // paste that large is a mis-click on a file, not an edit, and the core + // would have to hold the whole of it as one undo entry. + const max_paste_bytes: usize = 4 << 20; while (!core.quit) { var event = if (pending) |ev| blk: { pending = null; @@ -610,7 +636,31 @@ pub fn run(init: std.process.Init, opts: pardes.Options) !void { } core.update(.{ .eof = .{ .pane = @intCast(e.id) } }); }, - .key_press => |key| core.update(.{ .key = .{ + .key_press => |key| if (in_paste) { + // Between the markers a key is DATA, never a command. Same + // two inputs as the dispatch below, so a pasted character + // is exactly the character the core would have been given. + const text = key.text orelse ""; + const cp = mapKey(effCp(key)); + const bytes: []const u8 = if (text.len > 0) + text + else if (cp == pardes.Key.tab) + "\t" + else if (cp == pardes.Key.enter or (key.mods.ctrl and cp == 'j')) + // vaxis gives control bytes no text at all: a line + // break inside a paste reaches the ground parser as a + // bare CR (-> Key.enter) or, from a terminal that does + // not translate them, a bare LF — which that parser + // reports as ctrl+j. Nothing in here is a real + // keypress, so both of them are just a newline. + "\n" + else + // arrows, F-keys, a stray escape: noise a paste has no + // business carrying, dropped rather than smuggled in. + ""; + const room = max_paste_bytes -| paste_buf.written().len; + paste_buf.writer.writeAll(bytes[0..@min(bytes.len, room)]) catch {}; + } else core.update(.{ .key = .{ .cp = mapKey(effCp(key)), .text = key.text orelse "", .ctrl = key.mods.ctrl, @@ -653,6 +703,18 @@ pub fn run(init: std.process.Init, opts: pardes.Options) !void { core.update(.{ .paste = bytes }); gpa.free(@constCast(bytes)); }, + .paste_start => { + in_paste = true; + paste_buf.clearRetainingCapacity(); + }, + .paste_end => { + in_paste = false; + // ONE event for the whole paste — the core borrows the + // bytes for the call, exactly like the OSC 52 arm above. + const pasted = paste_buf.written(); + if (pasted.len > 0) core.update(.{ .paste = pasted }); + paste_buf.clearRetainingCapacity(); + }, .command => |line| { core.update(.{ .command = line }); gpa.free(line); @@ -683,7 +745,12 @@ pub fn run(init: std.process.Init, opts: pardes.Options) !void { }, } batch += 1; - if (stop or output or native_pdf_page_changed or batch >= 64) break; + // A paste in flight keeps draining WITHOUT rendering: a hundred + // thousand pasted characters are one edit, not a hundred thousand + // render-worthy events. That cannot spin — the drain still ends + // the moment the queue runs dry (tryEvent below) — so a terminal + // which sends paste_start and never paste_end costs one frame. + if (stop or output or native_pdf_page_changed or (!in_paste and batch >= 64)) break; event = (try loop.tryEvent()) orelse break; } tz_event.end(); @@ -983,6 +1050,20 @@ fn drainEffects( // mirror the core's yank register out via OSC 52 if (core.yank) |y| vx.copyToSystemClipboard(tty.writer(), y, gpa) catch {}; }, + .read_clipboard => { + // ...and the other direction, OSC 52 read. The answer arrives on + // vaxis's reader thread as an ordinary `.paste` event and reaches + // the core through the same path an outer bracketed paste does — + // this request is the only wiring it needs. "The answer arrives" + // is the optimistic reading: a clipboard READ is an exfiltration + // primitive and terminals treat it as one (ghostty prompts by + // default, xterm ships it off, a multiplexer or ssh link may eat + // it), and a refusal looks exactly like silence. So the core's + // pending request is dropped by the next keystroke rather than + // pasting minutes late, and `SPC p` in a locked-down terminal + // honestly does nothing. + vx.requestSystemClipboard(tty.writer()) catch {}; + }, .lsp => |q| { if (!threads_ok) continue; // pre-loop drain: nothing to answer to yet const pane = core.panes[q.pane] orelse continue; |
