summaryrefslogtreecommitdiff
path: root/src/tty.zig
diff options
context:
space:
mode:
Diffstat (limited to 'src/tty.zig')
-rw-r--r--src/tty.zig185
1 files changed, 121 insertions, 64 deletions
diff --git a/src/tty.zig b/src/tty.zig
index 9e2da26b..97f10a7a 100644
--- a/src/tty.zig
+++ b/src/tty.zig
@@ -8,6 +8,7 @@ const posix = std.posix;
const libc = std.c;
const vaxis = @import("vaxis");
const pardes = @import("pardes.zig");
+const tracy = @import("tracy.zig");
const look = @import("look.zig");
extern "c" fn forkpty(amaster: *c_int, name: ?[*:0]u8, termp: ?*const anyopaque, winp: ?*const posix.winsize) c_int;
@@ -28,7 +29,7 @@ pub const Command = struct {
tick,
key_press: vaxis.Key,
pty_read: struct { id: usize, bytes: []u8 },
- pty_eof: usize,
+ pty_eof: struct { id: usize, gen: u32 },
winsize: vaxis.Winsize,
mouse: vaxis.Mouse,
paste: []const u8,
@@ -48,10 +49,16 @@ pub fn run(init: std.process.Init, opts: pardes.Options) !void {
var tty_buf: [0x10000]u8 = undefined;
var tty = try vaxis.Tty.init(io, &tty_buf);
+ defer tty.deinit(); // restore cooked termios LAST, after vx flushed its resets
var vx = try vaxis.init(io, gpa, init.environ_map, .{ .system_clipboard_allocator = gpa });
defer vx.deinit(gpa, tty.writer());
try vx.enterAltScreen(tty.writer());
defer vx.exitAltScreen(tty.writer()) catch {};
+ // requests 1002;1003;1004;1006 (cell-coordinate SGR; called pre-query, so
+ // vaxis never upgrades to 1016 pixel mode). Note: ghostty's GTK apprt drops
+ // middle press+release BEFORE mouse reporting when the desktop sets
+ // 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);
var frame_arena: std.heap.ArenaAllocator = .init(gpa);
@@ -73,15 +80,30 @@ pub fn run(init: std.process.Init, opts: pardes.Options) !void {
var kitty_handles: [pardes.MAX_PANES]?vaxis.Image = @splat(null);
var ptys: [pardes.MAX_PANES]?Pty = @splat(null);
- defer for (&ptys) |*slot| if (slot.*) |*pt| {
- _ = libc.close(pt.file.handle);
- slot.* = null;
- };
+ // per-slot spawn generation: a reused pane id ignores the old shell's
+ // late pty_eof (which would otherwise close the NEW pty on that slot)
+ var gens: [pardes.MAX_PANES]u32 = @splat(0);
+ var loop: Loop = .init(io, &tty, &vx);
+ defer {
+ // reap the reader tasks (cancel interrupts a blocked read) before
+ // closing the masters — the runtime joins those threads on exit and a
+ // reader stuck in read(2) would hang the process — then drain the
+ // queue: leftover events own gpa bytes and would dump as leaks.
+ for (&ptys) |*slot| if (slot.*) |*pt| {
+ pt.reader.cancel(io) catch {};
+ _ = libc.close(pt.file.handle);
+ slot.* = null;
+ };
+ while (loop.tryEvent() catch null) |ev| switch (ev) {
+ .pty_read => |pr| gpa.free(pr.bytes),
+ .paste => |b| gpa.free(@constCast(b)),
+ else => {},
+ };
+ }
// Perform the initial spawns BEFORE any worker thread exists: forkpty from
// a multithreaded process can wedge the child before exec.
- var loop: Loop = .init(io, &tty, &vx);
- drainEffects(core, &ptys, io, gpa, &loop, &vx, &tty, false);
+ drainEffects(core, &ptys, &gens, io, gpa, &loop, &vx, &tty, false);
try loop.start();
defer loop.stop();
@@ -90,68 +112,85 @@ pub fn run(init: std.process.Init, opts: pardes.Options) !void {
// now threads are fine: start a reader task per pty
for (&ptys, 0..) |*slot, id| if (slot.*) |*pt| {
- pt.reader = try io.concurrent(readPty, .{ io, gpa, pt.file, id, &loop });
+ pt.reader = try io.concurrent(readPty, .{ io, gpa, pt.file, id, gens[id], &loop });
};
var pending: ?@TypeOf(Command.value) = .tick;
while (!core.quit) {
- const event = if (pending) |ev| blk: {
+ var event = if (pending) |ev| blk: {
pending = null;
break :blk ev;
} else try loop.nextEvent();
- switch (event) {
- .nop, .tick => {},
- .quit => break,
- .winsize => |ws| {
- try vx.resize(gpa, tty.writer(), ws);
- core.update(.{ .resize = .{ .cols = @intCast(ws.cols), .rows = @intCast(ws.rows) } });
- },
- .pty_read => |pr| {
- core.update(.{ .output = .{ .pane = @intCast(pr.id), .bytes = pr.bytes } });
- gpa.free(pr.bytes);
- },
- .pty_eof => |id| {
- if (ptys[id]) |*pt| {
- _ = libc.close(pt.file.handle);
- ptys[id] = null;
- }
- core.update(.{ .eof = .{ .pane = @intCast(id) } });
- },
- .key_press => |key| core.update(.{ .key = .{
- .cp = mapKey(effCp(key)),
- .text = key.text orelse "",
- .ctrl = key.mods.ctrl,
- .alt = key.mods.alt,
- } }),
- .mouse => |m| {
- const button: ?pardes.Mouse.Button = switch (m.button) {
- .left => .left,
- .middle => .middle,
- .right => .right,
- .wheel_up => .wheel_up,
- .wheel_down => .wheel_down,
- .none => .none, // button-less motion: hover tracking
- else => null,
- };
- if (button) |b| core.update(.{ .mouse = .{
- .button = b,
- .kind = switch (m.type) {
- .press => .press,
- .release => .release,
- .motion => .motion,
- .drag => .drag,
- },
- .col = @intCast(m.col),
- .row = @intCast(m.row),
- } });
- },
- .paste => |bytes| {
- core.update(.{ .paste = bytes });
- gpa.free(@constCast(bytes));
- },
+ const tz_event = tracy.zone(@src(), "event");
+ // Apply every queued INPUT event, then render ONCE — the gui shell
+ // drains SDL's queue the same way. Without this a wheel flick is fifty
+ // full render+repaint (and re-highlight) cycles instead of one.
+ // pty output keeps a frame per chunk: progress paints as it arrives.
+ var stop = false;
+ var batch: usize = 0;
+ while (true) {
+ var output = false;
+ switch (event) {
+ .nop, .tick => {},
+ .quit => stop = true,
+ .winsize => |ws| {
+ try vx.resize(gpa, tty.writer(), ws);
+ core.update(.{ .resize = .{ .cols = @intCast(ws.cols), .rows = @intCast(ws.rows) } });
+ },
+ .pty_read => |pr| {
+ core.update(.{ .output = .{ .pane = @intCast(pr.id), .bytes = pr.bytes } });
+ gpa.free(pr.bytes);
+ output = true;
+ },
+ .pty_eof => |e| if (gens[e.id] == e.gen) {
+ if (ptys[e.id]) |*pt| {
+ pt.reader.await(io) catch {}; // reader just finished; join it or its future leaks
+ _ = libc.close(pt.file.handle);
+ ptys[e.id] = null;
+ }
+ core.update(.{ .eof = .{ .pane = @intCast(e.id) } });
+ },
+ .key_press => |key| core.update(.{ .key = .{
+ .cp = mapKey(effCp(key)),
+ .text = key.text orelse "",
+ .ctrl = key.mods.ctrl,
+ .alt = key.mods.alt,
+ } }),
+ .mouse => |m| {
+ const button: ?pardes.Mouse.Button = switch (m.button) {
+ .left => .left,
+ .middle => .middle,
+ .right => .right,
+ .wheel_up => .wheel_up,
+ .wheel_down => .wheel_down,
+ .none => .none, // button-less motion: hover tracking
+ else => null,
+ };
+ if (button) |b| core.update(.{ .mouse = .{
+ .button = b,
+ .kind = switch (m.type) {
+ .press => .press,
+ .release => .release,
+ .motion => .motion,
+ .drag => .drag,
+ },
+ .col = @intCast(m.col),
+ .row = @intCast(m.row),
+ } });
+ },
+ .paste => |bytes| {
+ core.update(.{ .paste = bytes });
+ gpa.free(@constCast(bytes));
+ },
+ }
+ batch += 1;
+ if (stop or output or batch >= 64) break;
+ event = (try loop.tryEvent()) orelse break;
}
+ tz_event.end();
+ if (stop) break;
- drainEffects(core, &ptys, io, gpa, &loop, &vx, &tty, true);
+ drainEffects(core, &ptys, &gens, io, gpa, &loop, &vx, &tty, true);
if (core.quit) break;
// live cwd for tags/look: cheap per-pane lookup, per frame
@@ -162,7 +201,10 @@ pub fn run(init: std.process.Init, opts: pardes.Options) !void {
// ---- render: surface -> vaxis, cell for cell ----
_ = frame_arena.reset(.retain_capacity);
+ const tz_core = tracy.zone(@src(), "core.render");
const surface = try core.render(frame_arena.allocator());
+ tz_core.end();
+ const tz_cells = tracy.zone(@src(), "surface->vaxis");
const win = vx.window();
win.clear();
var y: u16 = 0;
@@ -177,6 +219,7 @@ pub fn run(init: std.process.Init, opts: pardes.Options) !void {
});
}
}
+ tz_cells.end();
// pixel attachments (kitty graphics): transmit once per pane, then
// re-place every frame (placements aren't persistent)
for (surface.images[0..surface.nimages]) |maybe| {
@@ -195,13 +238,17 @@ pub fn run(init: std.process.Init, opts: pardes.Options) !void {
}
}
if (surface.cursor) |cur| win.showCursor(cur.x, cur.y);
+ const tz_render = tracy.zone(@src(), "vx.render");
try vx.render(tty.writer());
+ tz_render.end();
+ tracy.frameMark();
}
}
fn drainEffects(
core: *pardes.Pardes,
ptys: *[pardes.MAX_PANES]?Pty,
+ gens: *[pardes.MAX_PANES]u32,
io: std.Io,
gpa: std.mem.Allocator,
loop: *Loop,
@@ -211,7 +258,15 @@ fn drainEffects(
) void {
while (core.nextEffect()) |effect| switch (effect) {
.spawn => |sp| {
- std.debug.assert(ptys[sp.pane] == null);
+ // the core reuses pane ids and there is no close effect: a deleted
+ // pane's shell lives in its slot until a respawn lands here — reap
+ // it (cancel joins the reader; its late eof is ignored by gen)
+ if (ptys[sp.pane]) |*old| {
+ old.reader.cancel(io) catch {};
+ _ = libc.close(old.file.handle);
+ ptys[sp.pane] = null;
+ }
+ gens[sp.pane] +%= 1;
const cwd = sp.cwd.slice();
var cwd_buf: [256:0]u8 = undefined;
var cwd_z: ?[*:0]const u8 = null;
@@ -227,7 +282,7 @@ fn drainEffects(
if (look.shellCwd(child.pid, &lbuf)) |wd| core.setCwd(sp.pane, wd);
if (threads_ok) {
if (ptys[sp.pane]) |*pt| {
- pt.reader = io.concurrent(readPty, .{ io, gpa, pt.file, @as(usize, sp.pane), loop }) catch pt.reader;
+ pt.reader = io.concurrent(readPty, .{ io, gpa, pt.file, @as(usize, sp.pane), gens[sp.pane], loop }) catch pt.reader;
}
}
},
@@ -282,7 +337,7 @@ fn forkShell(cwd: ?[*:0]const u8, rows: u16, cols: u16) struct { file: std.Io.Fi
return .{ .file = .{ .handle = master, .flags = .{ .nonblocking = false } }, .pid = pid };
}
-fn readPty(io: std.Io, gpa: std.mem.Allocator, pty: std.Io.File, id: usize, loop: *Loop) anyerror!void {
+fn readPty(io: std.Io, gpa: std.mem.Allocator, pty: std.Io.File, id: usize, gen: u32, loop: *Loop) anyerror!void {
var read_buf: [0x10000]u8 = undefined;
var reader = pty.readerStreaming(io, &read_buf);
while (true) {
@@ -296,7 +351,9 @@ fn readPty(io: std.Io, gpa: std.mem.Allocator, pty: std.Io.File, id: usize, loop
break;
};
}
- loop.postEvent(.{ .pty_eof = id }) catch {};
+ // non-blocking: a teardown cancel only unblocks one wait, so a blocking
+ // post into a full queue here could hang the exit
+ _ = loop.tryPostEvent(.{ .pty_eof = .{ .id = id, .gen = gen } }) catch {};
}
/// The effective codepoint the way vaxis Key.matches sees it: a single-char