diff options
Diffstat (limited to 'src/tty.zig')
| -rw-r--r-- | src/tty.zig | 225 |
1 files changed, 129 insertions, 96 deletions
diff --git a/src/tty.zig b/src/tty.zig index f2b36ed4..97f10a7a 100644 --- a/src/tty.zig +++ b/src/tty.zig @@ -3,16 +3,25 @@ //! them, resize them), and hands the core's Surface to vaxis cell-for-cell — //! the canonical interface rendered with no interpretation. const std = @import("std"); +const builtin = @import("builtin"); const posix = std.posix; -const linux = std.os.linux; +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; extern "c" fn execv(path: [*:0]const u8, argv: [*:null]const ?[*:0]const u8) c_int; extern "c" fn chdir(path: [*:0]const u8) c_int; extern "c" fn _exit(status: c_int) noreturn; +// absolute shell path per OS: execv must not search PATH (no allocation +// between fork and exec) +const bash_path: [*:0]const u8 = if (builtin.os.tag == .linux) "/usr/bin/bash" else "/bin/bash"; +// TIOCSWINSZ: absent from std.c.T on darwin — _IOW('t', 103, winsize) +const TIOCSWINSZ: c_int = @bitCast(@as(u32, if (@hasDecl(posix.T, "IOCSWINSZ")) posix.T.IOCSWINSZ else 0x80087467)); + pub const Command = struct { pub var value: union(enum) { nop, @@ -20,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, @@ -45,6 +54,11 @@ pub fn run(init: std.process.Init, opts: pardes.Options) !void { 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); @@ -55,7 +69,7 @@ pub fn run(init: std.process.Init, opts: pardes.Options) !void { defer pardes.image.stop(); const core = if (opts.load_path) |lp| blk: { - const bytes = try @import("look.zig").readFile(gpa, lp); + const bytes = try look.readFile(gpa, lp); defer gpa.free(bytes); break :blk try pardes.Pardes.initFromDump(gpa, opts, bytes); } else try pardes.Pardes.init(gpa, opts); @@ -66,6 +80,9 @@ 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); + // 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 @@ -74,7 +91,7 @@ pub fn run(init: std.process.Init, opts: pardes.Options) !void { // queue: leftover events own gpa bytes and would dump as leaks. for (&ptys) |*slot| if (slot.*) |*pt| { pt.reader.cancel(io) catch {}; - _ = linux.close(pt.file.handle); + _ = libc.close(pt.file.handle); slot.* = null; }; while (loop.tryEvent() catch null) |ev| switch (ev) { @@ -86,7 +103,7 @@ pub fn run(init: std.process.Init, opts: pardes.Options) !void { // Perform the initial spawns BEFORE any worker thread exists: forkpty from // a multithreaded process can wedge the child before exec. - 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(); @@ -95,84 +112,99 @@ 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| { - pt.reader.await(io) catch {}; // reader just finished; join it or its future leaks - _ = linux.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 /proc readlink per pane, per frame + // live cwd for tags/look: cheap per-pane lookup, per frame for (&ptys, 0..) |*slot, id| if (slot.*) |pt| { var lbuf: [1024]u8 = undefined; - var pbuf: [64]u8 = undefined; - const path = std.fmt.bufPrintSentinel(&pbuf, "/proc/{d}/cwd", .{pt.pid}, 0) catch continue; - const rc = linux.readlinkat(linux.AT.FDCWD, path, &lbuf, lbuf.len); - const n: isize = @bitCast(rc); - if (n > 0) core.setCwd(id, lbuf[0..@intCast(n)]); + if (look.shellCwd(pt.pid, &lbuf)) |cwd| core.setCwd(id, cwd); }; // ---- 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; @@ -187,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| { @@ -205,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, @@ -221,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; @@ -234,15 +279,10 @@ fn drainEffects( ptys[sp.pane] = .{ .file = child.file, .pid = child.pid, .reader = .{ .any_future = null, .result = {} } }; // report the pane's starting directory back to the core (tags) var lbuf: [1024]u8 = undefined; - var pbuf: [64]u8 = undefined; - if (std.fmt.bufPrintSentinel(&pbuf, "/proc/{d}/cwd", .{child.pid}, 0) catch null) |path| { - const rc = linux.readlinkat(linux.AT.FDCWD, path, &lbuf, lbuf.len); - const n: isize = @bitCast(rc); - if (n > 0) core.setCwd(sp.pane, lbuf[0..@intCast(n)]); - } + 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; } } }, @@ -252,7 +292,7 @@ fn drainEffects( .resize_pty => |rs| { if (ptys[rs.pane]) |pt| { const ws: posix.winsize = .{ .row = rs.rows, .col = rs.cols, .xpixel = 0, .ypixel = 0 }; - _ = posix.system.ioctl(pt.file.handle, posix.T.IOCSWINSZ, @intFromPtr(&ws)); + _ = posix.system.ioctl(pt.file.handle, TIOCSWINSZ, @intFromPtr(&ws)); } }, .open_link => {}, // tty: no link handling (look opens panes instead) @@ -263,21 +303,17 @@ fn drainEffects( if (f.path.len >= pathbuf.len) continue; @memcpy(pathbuf[0..f.path.len], f.path); pathbuf[f.path.len] = 0; - const rc = linux.open(pathbuf[0..f.path.len :0], .{ .ACCMODE = .WRONLY, .CREAT = true, .TRUNC = true }, 0o644); - const sfd: isize = @bitCast(rc); - if (sfd < 0) continue; - const fd: c_int = @intCast(sfd); + const fd = libc.open(pathbuf[0..f.path.len :0], .{ .ACCMODE = .WRONLY, .CREAT = true, .TRUNC = true }, @as(libc.mode_t, 0o644)); + if (fd < 0) continue; writeFd(fd, f.content); - _ = linux.close(fd); + _ = libc.close(fd); }, .write_dump => { const out = core.dump_out orelse continue; - const rc = linux.open(pardes.dump.default_path, .{ .ACCMODE = .WRONLY, .CREAT = true, .TRUNC = true }, 0o644); - const sfd: isize = @bitCast(rc); - if (sfd < 0) continue; - const fd: c_int = @intCast(sfd); + const fd = libc.open(pardes.dump.default_path, .{ .ACCMODE = .WRONLY, .CREAT = true, .TRUNC = true }, @as(libc.mode_t, 0o644)); + if (fd < 0) continue; writeFd(fd, out); - _ = linux.close(fd); + _ = libc.close(fd); }, .set_clipboard => { // mirror the core's yank register out via OSC 52 @@ -294,14 +330,14 @@ fn forkShell(cwd: ?[*:0]const u8, rows: u16, cols: u16) struct { file: std.Io.Fi const pid = forkpty(&master, null, null, &ws); if (pid == 0) { if (cwd) |c| _ = chdir(c); - const argv: [4:null]?[*:0]const u8 = .{ "/usr/bin/bash", "--rcfile", "/tmp/pardes-osc133.bash", null }; - _ = execv("/usr/bin/bash", &argv); + const argv: [4:null]?[*:0]const u8 = .{ bash_path, "--rcfile", "/tmp/pardes-osc133.bash", null }; + _ = execv(bash_path, &argv); _exit(127); } 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) { @@ -317,7 +353,7 @@ fn readPty(io: std.Io, gpa: std.mem.Allocator, pty: std.Io.File, id: usize, loop } // 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 }) catch {}; + _ = loop.tryPostEvent(.{ .pty_eof = .{ .id = id, .gen = gen } }) catch {}; } /// The effective codepoint the way vaxis Key.matches sees it: a single-char @@ -380,21 +416,18 @@ fn vaxisColor(c: pardes.Color) vaxis.Color { } fn writeFile(data: []const u8) void { - const rc = linux.open("/tmp/pardes-osc133.bash", .{ .ACCMODE = .WRONLY, .CREAT = true, .TRUNC = true }, 0o644); - const sfd: isize = @bitCast(rc); - if (sfd < 0) return; - const fd: c_int = @intCast(sfd); - defer _ = linux.close(fd); + const fd = libc.open("/tmp/pardes-osc133.bash", .{ .ACCMODE = .WRONLY, .CREAT = true, .TRUNC = true }, @as(libc.mode_t, 0o644)); + if (fd < 0) return; + defer _ = libc.close(fd); writeFd(fd, data); } fn writeFd(fd: c_int, data: []const u8) void { var off: usize = 0; while (off < data.len) { - const rc = linux.write(fd, data[off..].ptr, data.len - off); - const n: isize = @bitCast(rc); + const n = libc.write(fd, data[off..].ptr, data.len - off); if (n < 0) { - if (n == -@as(isize, @intFromEnum(linux.E.INTR))) continue; + if (libc.errno(n) == .INTR) continue; return; } off += @intCast(n); |
