summaryrefslogtreecommitdiff
path: root/src/tty
diff options
context:
space:
mode:
Diffstat (limited to 'src/tty')
-rw-r--r--src/tty/tty.zig85
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;