diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/gui.zig | 154 | ||||
| -rw-r--r-- | src/look.zig | 64 | ||||
| -rw-r--r-- | src/pardes.zig | 286 | ||||
| -rw-r--r-- | src/syntax.zig | 3 | ||||
| -rw-r--r-- | src/tracy.zig | 50 | ||||
| -rw-r--r-- | src/tty.zig | 225 | ||||
| -rw-r--r-- | src/tutor.txt | 14 |
7 files changed, 568 insertions, 228 deletions
diff --git a/src/gui.zig b/src/gui.zig index 516a6b79..2653a056 100644 --- a/src/gui.zig +++ b/src/gui.zig @@ -1,6 +1,6 @@ //! The SDL3 GPU shell: owns an SDL window + event loop, translates SDL input //! into core events, performs the core's effects (fork ptys, write them, -//! resize them — same duties as tty.zig, this is also native linux), and +//! resize them — same duties as tty.zig, this is also native), and //! rasterizes the core's Surface: one instanced quad per cell, glyphs from an //! stb_truetype R8 atlas. Test modes: PARDES_TEST_GRID=1 is headless (no SDL, //! stdin escape sequences in, text grid frames out); PARDES_TEST=1 keeps the @@ -13,10 +13,11 @@ 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"); // test modes only: the stdin escape-seq parser const ghostty_vt = @import("ghostty-vt"); // 256-color palette for .index cells const pardes = @import("pardes.zig"); +const look = @import("look.zig"); const is_emscripten = builtin.os.tag == .emscripten; @@ -34,6 +35,12 @@ extern "c" fn _exit(status: c_int) noreturn; extern "c" fn emscripten_get_element_css_size(target: [*:0]const u8, width: *f64, height: *f64) c_int; const EMSCRIPTEN_RESULT_SUCCESS: c_int = 0; +// 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)); + const log = std.log.scoped(.gui); const font_ttf = @embedFile("AdwaitaMono-Regular.ttf"); @@ -271,9 +278,9 @@ fn normCell(norm: f32, win: f32, cell: f32) u16 { // ---- pty plumbing: reader threads feed a mutex-protected queue ---- -const Pty = struct { fd: c_int, pid: linux.pid_t }; +const Pty = struct { fd: c_int, pid: libc.pid_t }; -const Msg = union(enum) { output: struct { pane: u8, bytes: []u8 }, eof: u8 }; +const Msg = union(enum) { output: struct { pane: u8, gen: u32, bytes: []u8 }, eof: struct { pane: u8, gen: u32, fd: c_int } }; const Queue = struct { gpa: std.mem.Allocator, @@ -335,24 +342,23 @@ const Queue = struct { } }; -fn readPtyThread(gpa: std.mem.Allocator, fd: c_int, pane: u8, q: *Queue) void { +fn readPtyThread(gpa: std.mem.Allocator, fd: c_int, pane: u8, gen: u32, q: *Queue) void { var buf: [0x10000]u8 = undefined; while (true) { - const rc = linux.read(fd, &buf, buf.len); - const n: isize = @bitCast(rc); + const n = libc.read(fd, &buf, buf.len); if (n < 0) { - if (n == -@as(isize, @intFromEnum(linux.E.INTR))) continue; + if (libc.errno(n) == .INTR) continue; break; // EIO when the child exits: treat as EOF } if (n == 0) break; const bytes = gpa.dupe(u8, buf[0..@intCast(n)]) catch break; - q.push(.{ .output = .{ .pane = pane, .bytes = bytes } }); + q.push(.{ .output = .{ .pane = pane, .gen = gen, .bytes = bytes } }); } - q.push(.{ .eof = pane }); + q.push(.{ .eof = .{ .pane = pane, .gen = gen, .fd = fd } }); } -fn spawnReader(gpa: std.mem.Allocator, pt: Pty, pane: u8, q: *Queue) void { - const th = std.Thread.spawn(.{}, readPtyThread, .{ gpa, pt.fd, pane, q }) catch return; +fn spawnReader(gpa: std.mem.Allocator, pt: Pty, pane: u8, gen: u32, q: *Queue) void { + const th = std.Thread.spawn(.{}, readPtyThread, .{ gpa, pt.fd, pane, gen, q }) catch return; th.detach(); } @@ -621,8 +627,11 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options) !void { writeFile(pardes.bash_rc); var ptys: [pardes.MAX_PANES]?Pty = @splat(null); + // per-slot spawn generation: drops a dead shell's late output/eof when its + // pane id has been respawned (see drainEffects .spawn) + var gens: [pardes.MAX_PANES]u32 = @splat(0); defer for (&ptys) |*slot| if (slot.*) |pt| { - _ = linux.close(pt.fd); + _ = libc.close(pt.fd); slot.* = null; }; var queue: Queue = .{ .gpa = gpa, .sdl_wake = true }; @@ -630,8 +639,8 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options) !void { // initial spawns BEFORE any worker thread exists: forkpty from a // multithreaded process can wedge the child before exec (see tty.zig). - drainEffects(core, &ptys, gpa, &queue, &g, false); - for (&ptys, 0..) |*slot, id| if (slot.*) |pt| spawnReader(gpa, pt, @intCast(id), &queue); + drainEffects(core, &ptys, &gens, gpa, &queue, &g, false); + for (&ptys, 0..) |*slot, id| if (slot.*) |pt| spawnReader(gpa, pt, @intCast(id), gens[id], &queue); _ = c.SDL_StartTextInput(window); @@ -657,22 +666,23 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options) !void { var msgs = queue.take(); for (msgs.items) |m| switch (m) { .output => |o| { - core.update(.{ .output = .{ .pane = o.pane, .bytes = o.bytes } }); + if (gens[o.pane] == o.gen) + core.update(.{ .output = .{ .pane = o.pane, .bytes = o.bytes } }); gpa.free(o.bytes); }, - .eof => |id| { - if (ptys[id]) |pt| { - _ = linux.close(pt.fd); - ptys[id] = null; + .eof => |e| { + _ = libc.close(e.fd); // the dead reader's master — stale or current + if (gens[e.pane] == e.gen) { + ptys[e.pane] = null; + core.update(.{ .eof = .{ .pane = e.pane } }); } - core.update(.{ .eof = .{ .pane = id } }); }, }; msgs.deinit(gpa); // 3. steamdeck: poll gamepad axes into virtual cursor / wheel events pollGamepad(&g, core); // 4. effects - drainEffects(core, &ptys, gpa, &queue, &g, true); + drainEffects(core, &ptys, &gens, gpa, &queue, &g, true); if (core.quit) break; // 5. live cwd for tags/look: cheap /proc readlink per pane, per frame pollCwds(core, &ptys); @@ -894,14 +904,17 @@ fn runGrid(init: std.process.Init, opts_in: pardes.Options) !void { writeFile(pardes.bash_rc); var ptys: [pardes.MAX_PANES]?Pty = @splat(null); + // per-slot spawn generation: drops a dead shell's late output/eof when its + // pane id has been respawned (see drainEffects .spawn) + var gens: [pardes.MAX_PANES]u32 = @splat(0); defer for (&ptys) |*slot| if (slot.*) |pt| { - _ = linux.close(pt.fd); + _ = libc.close(pt.fd); slot.* = null; }; var queue: Queue = .{ .gpa = gpa, .sdl_wake = false }; defer queue.close(); - drainEffects(core, &ptys, gpa, &queue, null, false); - for (&ptys, 0..) |*slot, id| if (slot.*) |pt| spawnReader(gpa, pt, @intCast(id), &queue); + drainEffects(core, &ptys, &gens, gpa, &queue, null, false); + for (&ptys, 0..) |*slot, id| if (slot.*) |pt| spawnReader(gpa, pt, @intCast(id), gens[id], &queue); setStdinRaw() catch {}; // stdin may be a pipe, not a pty — best effort @@ -921,21 +934,22 @@ fn runGrid(init: std.process.Init, opts_in: pardes.Options) !void { var msgs = queue.take(); for (msgs.items) |m| switch (m) { .output => |o| { - core.update(.{ .output = .{ .pane = o.pane, .bytes = o.bytes } }); + if (gens[o.pane] == o.gen) + core.update(.{ .output = .{ .pane = o.pane, .bytes = o.bytes } }); gpa.free(o.bytes); n_events += 1; }, - .eof => |id| { - if (ptys[id]) |pt| { - _ = linux.close(pt.fd); - ptys[id] = null; + .eof => |e| { + _ = libc.close(e.fd); // the dead reader's master — stale or current + if (gens[e.pane] == e.gen) { + ptys[e.pane] = null; + core.update(.{ .eof = .{ .pane = e.pane } }); } - core.update(.{ .eof = .{ .pane = id } }); n_events += 1; }, }; msgs.deinit(gpa); - drainEffects(core, &ptys, gpa, &queue, null, true); + drainEffects(core, &ptys, &gens, gpa, &queue, null, true); pollCwds(core, &ptys); if (n_events == 0) continue; // idle tick: nothing changed, no frame _ = frame_arena.reset(.retain_capacity); @@ -981,8 +995,7 @@ const StdinFeed = struct { if ((fds[0].revents & (posix.POLL.HUP | posix.POLL.ERR)) != 0) out.eof = true; return out; } - const rc = linux.read(0, f.buf[f.fill..].ptr, f.buf.len - f.fill); - const rn: isize = @bitCast(rc); + const rn = libc.read(0, f.buf[f.fill..].ptr, f.buf.len - f.fill); if (rn < 0) return out; if (rn == 0) { out.eof = true; @@ -1413,6 +1426,7 @@ fn pollGamepad(g: *Gui, core: *pardes.Pardes) void { fn drainEffects( core: *pardes.Pardes, ptys: *[pardes.MAX_PANES]?Pty, + gens: *[pardes.MAX_PANES]u32, gpa: std.mem.Allocator, queue: *Queue, g: ?*Gui, // null in grid test mode (no SDL: clipboard effects are no-ops) @@ -1420,7 +1434,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. Kill + // it; its detached reader wakes on child death and the gen-guarded + // eof closes the old fd (not here — the reader still reads it). + if (ptys[sp.pane]) |old| { + _ = libc.kill(old.pid, libc.SIG.KILL); + 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; @@ -1433,13 +1455,8 @@ fn drainEffects( ptys[sp.pane] = pt; // 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", .{pt.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 (threads_ok) spawnReader(gpa, pt, sp.pane, queue); + if (look.shellCwd(pt.pid, &lbuf)) |wd| core.setCwd(sp.pane, wd); + if (threads_ok) spawnReader(gpa, pt, sp.pane, gens[sp.pane], queue); }, .write => |w| { if (ptys[w.pane]) |pt| writeFd(pt.fd, w.bytes.slice()); @@ -1447,7 +1464,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.fd, posix.T.IOCSWINSZ, @intFromPtr(&ws)); + _ = posix.system.ioctl(pt.fd, TIOCSWINSZ, @intFromPtr(&ws)); } }, .open_link => {}, // native: look opens panes instead; nothing to launch @@ -1458,21 +1475,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 => { if (g == null) continue; @@ -1500,8 +1513,8 @@ fn forkShell(cwd: ?[*:0]const u8, rows: u16, cols: u16) Pty { const pid = forkpty(&master, null, null, &ws); if (pid == 0) { if (cwd) |cd| _ = chdir(cd); - 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 .{ .fd = master, .pid = pid }; @@ -1510,11 +1523,7 @@ fn forkShell(cwd: ?[*:0]const u8, rows: u16, cols: u16) Pty { fn pollCwds(core: *pardes.Pardes, ptys: *[pardes.MAX_PANES]?Pty) void { 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); }; } @@ -2073,11 +2082,9 @@ fn writeCapturePpm(g: *Gui, gpa: std.mem.Allocator, pixels: []const u8, width: u const tmp_path = std.fmt.bufPrintSentinel(&tmp_buf, "{s}/latest.ppm.tmp", .{g.capture_dir}, 0) catch return error.CapturePathTooLong; const final_path = std.fmt.bufPrintSentinel(&final_buf, "{s}/latest.ppm", .{g.capture_dir}, 0) catch return error.CapturePathTooLong; - const orc = linux.open(tmp_path, .{ .ACCMODE = .WRONLY, .CREAT = true, .TRUNC = true }, 0o644); - const sfd: isize = @bitCast(orc); - if (sfd < 0) return error.CaptureWriteFailed; - const fd: c_int = @intCast(sfd); - defer _ = linux.close(fd); + const fd = libc.open(tmp_path, .{ .ACCMODE = .WRONLY, .CREAT = true, .TRUNC = true }, @as(libc.mode_t, 0o644)); + if (fd < 0) return error.CaptureWriteFailed; + defer _ = libc.close(fd); var header: [64]u8 = undefined; const hdr = std.fmt.bufPrint(&header, "P6\n{d} {d}\n255\n", .{ width, height }) catch return error.CaptureWriteFailed; @@ -2096,9 +2103,7 @@ fn writeCapturePpm(g: *Gui, gpa: std.mem.Allocator, pixels: []const u8, width: u } writeFd(fd, row_rgb); } - const rrc = linux.rename(tmp_path, final_path); - const rs: isize = @bitCast(rrc); - if (rs < 0) return error.CaptureWriteFailed; + if (libc.rename(tmp_path, final_path) != 0) return error.CaptureWriteFailed; } // ===================================================================== @@ -2377,21 +2382,18 @@ fn envU16(env: *std.process.Environ.Map, name: []const u8) ?u16 { } 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); diff --git a/src/look.zig b/src/look.zig index 67fbf8c5..8744da24 100644 --- a/src/look.zig +++ b/src/look.zig @@ -8,7 +8,8 @@ //! web — there is no filesystem; only URLs mean anything, and a URL //! opens in a new tab (the open_link effect). const std = @import("std"); -const linux = std.os.linux; +const builtin = @import("builtin"); +const libc = std.c; const pardes = @import("pardes.zig"); extern "c" fn realpath(path: [*:0]const u8, resolved: [*]u8) ?[*:0]u8; @@ -88,10 +89,9 @@ const platform_has_fs = switch (pardes.platform) { /// true if `path` exists and is a directory (open(O_DIRECTORY), no stat needed) fn isDir(path: [*:0]const u8) bool { - const rc = linux.open(path, .{ .ACCMODE = .RDONLY, .DIRECTORY = true, .CLOEXEC = true }, 0); - const sfd: isize = @bitCast(rc); - if (sfd < 0) return false; - _ = linux.close(@intCast(sfd)); + const fd = libc.open(path, .{ .ACCMODE = .RDONLY, .DIRECTORY = true, .CLOEXEC = true }); + if (fd < 0) return false; + _ = libc.close(fd); return true; } @@ -101,19 +101,16 @@ pub fn readFile(gpa: std.mem.Allocator, path: []const u8) ![]u8 { if (!platform_has_fs) return error.NoFilesystem; var pathbuf: [4096]u8 = undefined; const path_z = std.fmt.bufPrintSentinel(&pathbuf, "{s}", .{path}, 0) catch return error.PathTooLong; - const rc = linux.open(path_z, .{ .ACCMODE = .RDONLY }, 0); - const sfd: isize = @bitCast(rc); - if (sfd < 0) return error.OpenFailed; - const fd: i32 = @intCast(sfd); - defer _ = linux.close(fd); + const fd = libc.open(path_z, .{ .ACCMODE = .RDONLY }); + if (fd < 0) return error.OpenFailed; + defer _ = libc.close(fd); var buf: std.ArrayList(u8) = .empty; errdefer buf.deinit(gpa); var chunk: [16384]u8 = undefined; while (true) { - const r = linux.read(fd, &chunk, chunk.len); - const n: isize = @bitCast(r); + const n = libc.read(fd, &chunk, chunk.len); if (n < 0) { - if (n == -@as(isize, @intFromEnum(linux.E.INTR))) continue; + if (libc.errno(n) == .INTR) continue; return error.ReadFailed; } if (n == 0) break; @@ -121,3 +118,44 @@ pub fn readFile(gpa: std.mem.Allocator, path: []const u8) ![]u8 { } return buf.toOwnedSlice(gpa); } + +// ---- shell cwd: what directory a pane's looks resolve against ---- + +// macOS has no /proc; libproc's proc_pidinfo(PROC_PIDVNODEPATHINFO) yields the +// cwd vnode path. Not in std.c — layout from xnu's sys/proc_info.h. +const vnode_info_path = extern struct { + vi: [152]u8 align(8), // struct vnode_info: vinfo_stat + type + pad + fsid + path: [1024]u8, // MAXPATHLEN +}; +const proc_vnodepathinfo = extern struct { + cdir: vnode_info_path, + rdir: vnode_info_path, +}; +const PROC_PIDVNODEPATHINFO: c_int = 9; +extern "c" fn proc_pidinfo(pid: c_int, flavor: c_int, arg: u64, buffer: *anyopaque, buffersize: c_int) c_int; + +/// Live cwd of a shell process (pane tags, look resolution). linux reads +/// /proc/<pid>/cwd, darwin asks libproc; other POSIX systems have no cheap +/// answer — return null and panes keep their spawn-time cwd (callers already +/// tolerate failure: dead shells have no cwd either). +pub fn shellCwd(pid: libc.pid_t, buf: *[1024]u8) ?[]const u8 { + switch (builtin.os.tag) { + .linux => { + var pbuf: [64]u8 = undefined; + const path = std.fmt.bufPrintSentinel(&pbuf, "/proc/{d}/cwd", .{pid}, 0) catch return null; + const n = libc.readlink(path, buf, buf.len); + if (n <= 0) return null; + return buf[0..@intCast(n)]; + }, + .macos, .ios, .tvos, .watchos, .visionos => { + var info: proc_vnodepathinfo = undefined; + const n = proc_pidinfo(pid, PROC_PIDVNODEPATHINFO, 0, &info, @sizeOf(proc_vnodepathinfo)); + if (n < @as(c_int, @sizeOf(proc_vnodepathinfo))) return null; + const path = std.mem.sliceTo(&info.cdir.path, 0); + if (path.len == 0) return null; + @memcpy(buf[0..path.len], path); + return buf[0..path.len]; + }, + else => return null, + } +} diff --git a/src/pardes.zig b/src/pardes.zig index c4893b25..7fe4d95f 100644 --- a/src/pardes.zig +++ b/src/pardes.zig @@ -14,6 +14,7 @@ const uucode = @import("uucode"); const modal = @import("modal.zig"); const look = @import("look.zig"); const syntax = @import("syntax.zig"); +const tracy = @import("tracy.zig"); pub const image = @import("image.zig"); pub const dump = @import("dump.zig"); @@ -31,6 +32,9 @@ pub const BOX_H: u16 = 1; pub const MINW: u16 = 10; pub const MINH: u16 = 3; pub const PREFIX_W: u16 = 5; +/// vim 'scrolloff': keyboard cursor moves keep this many context rows visible +/// above/below the cursor (clamped at file boundaries and short panes) +const scroll_off = 3; pub const topbar_str = "Kill Newcol Tutor Debug Colors NextColor Dump"; const pane_builtins_str = "Del"; @@ -462,6 +466,14 @@ pub const Pane = struct { vsel: CharSel = .{}, /// one pending prefix codepoint (0 = none): `g` and `z` pending: u21 = 0, + /// `/` file search: the pattern is typed into the tag tail (no popup); + /// search_at is the tail length before " /" was appended (submit/cancel + /// restore it). n/N walk the grep results in pane search_pane, starting + /// just below search_row (the results shell's prompt row at submit). + search_edit: bool = false, + search_at: u16 = 0, + search_pane: ?usize = null, + search_row: i32 = 0, /// the editable tag tail: seeded with the default builtins on first touch; /// edited in place by a modal one-line editor sharing the pane's mode tag_tail: std.ArrayList(u8) = .empty, @@ -523,9 +535,19 @@ pub const Pane = struct { } fn ensureCursorVisible(pane: *Pane) void { + // scrolloff margin, shrunk on short panes so the band stays non-empty + const margin: i32 = @min(scroll_off, @divTrunc(@as(i32, pane.rows) - 1, 2)); const off = pane.scroll(); const last = off + @as(i32, pane.rows) - 1; - if (pane.cur_row < off) pane.scrollBy(pane.cur_row - off) else if (pane.cur_row > last) pane.scrollBy(pane.cur_row - last); + if (pane.cur_row < off + margin) { + pane.scrollBy(pane.cur_row - margin - off); // scrollBy clamps at line 0 + } else if (pane.cur_row > last - margin) { + // don't scroll a file past EOF-at-bottom-row (vim's bottom clamp); + // terminals overshoot harmlessly — the vt clamps at the live bottom + var to = pane.cur_row + margin; + if (pane.file) |f| to = @min(to, @as(i32, @intCast(modal.lineCount(f.content) -| 1))); + pane.scrollBy(@max(0, to - last)); + } } /// the last navigable row: file -> last content line; terminal -> viewport bottom @@ -628,9 +650,11 @@ pub const Pardes = struct { /// press during an active middle select-drag; consumed on middle release chord_arg: ?[]u8 = null, - effects: [64]Effect = undefined, + /// pending effects, drained by the shell after each update. Growable: one + /// update can emit unbounded writes (a big selection executed is chunked + /// into 64-byte write effects) — a fixed ring overflows and panics. + effects: std.ArrayList(Effect) = .empty, effects_head: usize = 0, - effects_tail: usize = 0, /// modal yank register (gpa-owned); a yank also mirrors out to the system /// clipboard via the set_clipboard effect. @@ -678,6 +702,7 @@ pub const Pardes = struct { if (p.yank) |y| gpa.free(y); if (p.dump_out) |d| gpa.free(d); if (p.chord_arg) |a| gpa.free(a); + p.effects.deinit(gpa); p.scratch.deinit(); gpa.free(p.surface.cells); gpa.destroy(p); @@ -717,6 +742,7 @@ pub const Pardes = struct { fn newShell(p: *Pardes, id: usize, cwd: []const u8) !*Pane { std.debug.assert(p.panes[id] == null); + if (cwd.len > 256) return error.PathTooLong; // spawn effect cwd is a Buf(256) const pane = try p.gpa.create(Pane); pane.* = .{ .vt = try ghostty_vt.Terminal.init(p.gpa, .{ @@ -780,14 +806,16 @@ pub const Pardes = struct { } fn emit(p: *Pardes, e: Effect) void { - std.debug.assert(p.effects_tail - p.effects_head < p.effects.len); - p.effects[p.effects_tail % p.effects.len] = e; - p.effects_tail += 1; + p.effects.append(p.gpa, e) catch {}; } pub fn nextEffect(p: *Pardes) ?Effect { - if (p.effects_head == p.effects_tail) return null; - const e = p.effects[p.effects_head % p.effects.len]; + if (p.effects_head == p.effects.items.len) { + p.effects.clearRetainingCapacity(); + p.effects_head = 0; + return null; + } + const e = p.effects.items[p.effects_head]; p.effects_head += 1; return e; } @@ -840,13 +868,13 @@ pub const Pardes = struct { return std.fmt.allocPrint(arena, "img {s} Petscii {s} Ascii {s}", .{ iv.path, mode, pane_builtins_str }); } if (pane.file) |f| { - const fmode = if (pane.mode == .insert) "in" else "nm"; + const fmode = if (pane.mode == .insert) "INS" else "NOR"; return std.fmt.allocPrint(arena, "{s} {s}", .{ fmode, f.path }); } const mode = switch (pane.mode) { - .normal => "nm", - .insert => "in", - .tty => "sy", + .normal => "NOR", + .insert => "INS", + .tty => "TTY", }; return std.fmt.allocPrint(arena, "{s} {s}", .{ mode, pane.cwdSlice() }); } @@ -878,6 +906,7 @@ pub const Pardes = struct { fn exitTagEdit(pane: *Pane) void { pane.tag_edit = false; pane.tag_sel = false; + pane.search_edit = false; // an abandoned `/` input stays as tag text } fn tagSelBounds(pane: *Pane) struct { lo: usize, hi: usize } { @@ -1145,12 +1174,14 @@ pub const Pardes = struct { nt.greet = true; const f = p.layoutFindTerm(p.active).?; p.layoutInsert(f.col, f.idx + 1, free); + p.splitBelow(p.active, nt); p.active = free; return; } if (key.alt and !key.ctrl and key.cp == 'c') { const f = p.layoutFindTerm(p.active).?; if (p.ncol < MAX_COLS and p.col_n[f.col] > 1) { + p.absorbVWeight(p.active); p.layoutRemove(p.active); p.layoutAppendColumn(p.active); } @@ -1165,6 +1196,16 @@ pub const Pardes = struct { } else p.enterTty(p.active); return; } + // `/` search input in flight: Enter greps, Esc abandons; both restore + // the tag tail and hand focus back to the body. + if (pane.search_edit and (is(key, Key.enter) or is(key, Key.escape))) { + if (is(key, Key.enter)) p.submitSearch(pane); + pane.tag_tail.shrinkRetainingCapacity(@min(pane.search_at, pane.tag_tail.items.len)); + exitTagEdit(pane); + pane.mode = .normal; + pane.pending = 0; + return; + } // normal-mode Enter/Tab: the acme chords — look at (Enter) or execute // (Tab) the modal selection, else the file-ish word under the cursor // (on the tag tail when it's focused for editing, else the body). @@ -1522,6 +1563,129 @@ pub const Pardes = struct { if (is(key, 'y')) return p.normalYank(pane); if (is(key, 'u')) return p.doUndo(pane); if (is(key, 'U')) return p.doRedo(pane); + // file search: `/` types a pattern into the tag, n/N walk the results + if (pane.file != null and is(key, '/')) return p.startSearch(pane); + if (pane.file != null and is(key, 'n')) return p.searchStep(pane, 1); + if (pane.file != null and is(key, 'N')) return p.searchStep(pane, -1); + // terminal n/N: look the next/previous output row that looks somewhere + if (pane.isTerminal() and is(key, 'n')) return p.lookStep(pane, pl, 1); + if (pane.isTerminal() and is(key, 'N')) return p.lookStep(pane, pl, -1); + } + + // ---- file search (`/` + n/N): grep into a shell pane, look the results ---- + + /// `/` on a file pane: type the pattern into the tag tail after a " /" + /// marker — the existing modal one-line editor, visible while typing, + /// nothing that disappears. Enter/Esc are intercepted in handleKey. + fn startSearch(p: *Pardes, pane: *Pane) void { + if (!pane.tag_init) { + pane.tag_tail.appendSlice(p.gpa, file_pane_tail) catch return; + pane.tag_init = true; + } + pane.search_at = @intCast(pane.tag_tail.items.len); + pane.tag_tail.appendSlice(p.gpa, " /") catch return; + pane.search_edit = true; + pane.tag_edit = true; + pane.tag_sel = false; + pane.mode = .insert; + pane.pending = 0; + pane.tag_col = @intCast(pane.tag_tail.items.len); + } + + /// Enter on a `/` input: run `grep -nH '<pat>' <path>` in a shell in the + /// file's directory (the same path middle-click send takes), so the + /// results are ordinary persistent terminal text. Remember the results + /// pane and start n/N below its current prompt row. + fn submitSearch(p: *Pardes, pane: *Pane) void { + const f = pane.file orelse return; + const tail = pane.tag_tail.items; + const pat = std.mem.trim(u8, tail[@min(@as(usize, pane.search_at) + 2, tail.len)..], " \t\r\n"); + if (pat.len == 0) return; + const arena = p.scratch.allocator(); + var cmd: std.ArrayList(u8) = .empty; + cmd.appendSlice(arena, "grep -nH '") catch return; + for (pat) |ch| { + if (ch == '\'') cmd.appendSlice(arena, "'\\''") catch return else cmd.append(arena, ch) catch return; + } + cmd.appendSlice(arena, "' '") catch return; + cmd.appendSlice(arena, f.path) catch return; + cmd.append(arena, '\'') catch return; + const dst = p.ttyForDir(std.fs.path.dirname(f.path) orelse "/") orelse return; + const rp = p.panes[dst] orelse return; + p.padOutputBelowEdits(dst); + p.emitWrite(dst, cmd.items); + p.emitWrite(dst, "\r"); + pane.search_pane = dst; + // absolute row of the shell's cursor (the prompt echoing the grep); + // results land below it, so n scans from there — older output and + // stale results of a previous search stay behind + const sb = rp.vt.screens.active.pages.scrollbar(); + pane.search_row = @intCast(sb.total -| rp.rows + rp.vt.screens.active.cursor.y); + } + + /// n/N: select the next/previous grep result row in the results pane and + /// look it. Result rows read `path:NNN:...` and are found by their path + /// prefix; prompt/echo rows are already blanked by paneCursorLines (the + /// OSC 133 semantic row marks), so only real output is walked. + fn searchStep(p: *Pardes, pane: *Pane, delta: i32) void { + const f = pane.file orelse return; + const rid = pane.search_pane orelse return; + const rp = p.panes[rid] orelse return; + if (!rp.isTerminal()) return; + const pl = p.paneCursorLines(rp) catch return; + const nrows: i32 = @intCast(pl.lines.len); + var r = pane.search_row + delta; + while (r >= 0 and r < nrows) : (r += delta) { + const ln = pl.lines[@intCast(r)]; + if (!std.mem.startsWith(u8, ln, f.path) or ln.len <= f.path.len or ln[f.path.len] != ':') continue; + pane.search_row = r; + // select the result line in the results pane and keep it in view + rp.msel = .{ .active = true, .r0 = r, .r1 = r }; + rp.vsel.active = false; + rp.cur_row = r; + rp.cur_col = 0; + rp.cur_off = 0; + rp.cur_pinned = true; + rp.ensureCursorVisible(); + // look the leading file-ish word (path:NNN:) — the ordinary look + // path scrolls this file pane to the line + var hi: usize = 0; + while (hi < ln.len and look.isFileChar(ln[hi])) hi += 1; + p.actOnSelection(.right, rid, ln[0..hi], null); + return; + } + // no result that way (or none at all): nowhere to go, stay put + } + + /// n/N on a terminal pane (normal mode): select the next/previous output + /// row whose leading file-ish word looks somewhere — resolves to a + /// file/dir/path:NNN exactly as a right click would — and look it. The + /// scan only resolves (realpath, no pane opens, no scrolls); the one + /// chosen row gets the real look. The modal cursor is the position, so + /// the walk continues from wherever j/k left it. The look may focus what + /// it opened; focus comes back here so the next n keeps stepping. + fn lookStep(p: *Pardes, pane: *Pane, pl: PaneLines, delta: i32) void { + const id = p.active; + const nrows: i32 = @intCast(pl.lines.len); + var realbuf: [4096]u8 = undefined; + var r = pane.cur_row + delta; + while (r >= 0 and r < nrows) : (r += delta) { + const ln = pl.lines[@intCast(r)]; + var hi: usize = 0; + while (hi < ln.len and look.isFileChar(ln[hi])) hi += 1; + if (hi == 0 or look.resolve(ln[0..hi], pane.cwdSlice(), &realbuf) == .none) continue; + pane.msel = .{ .active = true, .r0 = r, .r1 = r }; + pane.vsel.active = false; + pane.cur_row = r; + pane.cur_col = 0; + pane.cur_off = 0; + pane.cur_pinned = true; + pane.ensureCursorVisible(); + p.actOnSelection(.right, id, ln[0..hi], null); + p.active = id; + return; + } + // nothing lookable that way: stay put } const InsertAt = enum { at, append, line_start, line_end, open_below, open_above }; @@ -2433,29 +2597,49 @@ pub const Pardes = struct { p.ncol += 1; } - /// Hand a dying pane's vertical weight to ONE sibling (the pane above, or - /// below for the topmost) so the rest of the column keeps its sizes — the - /// deletion mirror of splitBelow. Call while `id` is still in the layout. + /// Snap every pane in column `c` to its on-screen row count so later + /// weight edits move ONLY the panes they name: computeGeom rounds + /// round(avail*w/vsum) per pane, and with fractional weights a split or + /// absorb elsewhere in the column can jiggle a bystander by a row. + fn snapColWeights(p: *Pardes, c: usize) void { + for (0..p.col_n[c]) |k| { + const pid = p.col_terms[c][k]; + if (p.panes[pid]) |pp| pp.vweight = @floatFromInt(@max(1, p.rects[pid].h)); + } + } + + /// Hand a dying pane's rows to ONE sibling (the pane above, or below for + /// the topmost) so the rest of the column keeps its sizes bit-identical — + /// the deletion mirror of splitBelow. Call while `id` is still in the + /// layout, with rects current. fn absorbVWeight(p: *Pardes, id: usize) void { const f = p.layoutFindTerm(id) orelse return; if (p.col_n[f.col] <= 1) return; - const gone = p.panes[id] orelse return; + p.snapColWeights(f.col); const sib = if (f.idx > 0) p.col_terms[f.col][f.idx - 1] else p.col_terms[f.col][f.idx + 1]; - if (p.panes[sib]) |s| s.vweight += gone.vweight; + if (p.panes[sib]) |s| s.vweight += @as(f32, @floatFromInt(@max(1, p.rects[id].h))); } /// Open a window BELOW `src` (acme-style) without rebalancing the column: /// shrink ONLY src to its content height (cursor row kept visible) and hand - /// the freed rows to `nw` — together they fill src's old slot. + /// the freed rows to `nw` — together they fill src's old slot and the other + /// panes keep their sizes bit-identical (weights snap to row counts). fn splitBelow(p: *Pardes, src_id: usize, nw: *Pane) void { const src = p.panes[src_id] orelse return; const src_h = p.rects[src_id].h; const body: u16 = if (src_h > BOX_H) src_h - BOX_H else 1; const cur: u16 = if (src.file != null) body / 2 else @as(u16, @intCast(src.vt.screens.active.cursor.y)) + 1; - const keep = std.math.clamp(cur, 1, @max(1, body -| 1)); - const w = src.vweight; - src.vweight = w * @as(f32, @floatFromInt(BOX_H + keep)) / @as(f32, @floatFromInt(src_h)); - nw.vweight = w - src.vweight; + // cap keep so a content-full source still leaves the new pane a tag + + // a few body rows (an Alt-n from a full shell was born 0 rows tall) + const keep = std.math.clamp(cur, 1, @max(1, body -| (BOX_H + 3))); + if (p.layoutFindTerm(src_id)) |f| for (0..p.col_n[f.col]) |k| { + const pid = p.col_terms[f.col][k]; + if (p.panes[pid]) |pp| if (pp != nw) { + pp.vweight = @floatFromInt(@max(1, p.rects[pid].h)); + }; + }; + src.vweight = @floatFromInt(BOX_H + keep); + nw.vweight = @floatFromInt(@max(1, src_h -| (BOX_H + keep))); } /// a shell nobody has touched: no typing, cursor on the first prompt line, @@ -2482,6 +2666,7 @@ pub const Pardes = struct { if (n_tty != 1) return; const tt = p.panes[tty_id] orelse return; if (!isPristineTty(tt)) return; + p.computeGeom(); // a just-stacked doc has no rect yet; absorb snaps to rows p.absorbVWeight(tty_id); p.layoutRemove(tty_id); p.deinitPane(tt); @@ -2501,6 +2686,7 @@ pub const Pardes = struct { const nt = p.newShell(free, dir) catch return null; nt.greet = false; const rc = if (p.ncol > 0) p.ncol - 1 else 0; + if (p.col_n[rc] > 0) p.splitBelow(p.col_terms[rc][p.col_n[rc] - 1], nt); p.layoutInsert(rc, p.col_n[rc], free); return free; } @@ -2558,18 +2744,17 @@ pub const Pardes = struct { } /// stack a fresh doc pane at the top of the LEFT column (acme convention: - /// files left, terminals right) with a fair share, then evict a leftover - /// pristine shell + /// files left, terminals right), halving ONLY the old top pane's slot so + /// the rest of the column keeps its sizes; then evict a leftover pristine + /// shell fn stackDocLeft(p: *Pardes, free: usize, nt: *Pane) void { const lc = 0; - const ln = p.col_n[lc]; - if (ln > 0) { - var vsum: f32 = 0; - for (0..ln) |i| if (p.panes[p.col_terms[lc][i]]) |pp| { - vsum += pp.vweight; - }; - nt.vweight = vsum / @as(f32, @floatFromInt(ln)); - } + if (p.col_n[lc] > 0) if (p.panes[p.col_terms[lc][0]]) |top| { + p.snapColWeights(lc); + const h = p.rects[p.col_terms[lc][0]].h; + nt.vweight = @floatFromInt(@max(1, h / 2)); + top.vweight = @floatFromInt(@max(1, h -| h / 2)); + }; p.layoutInsert(lc, 0, free); p.active = free; p.evictLonePristineTty(lc, free); @@ -2607,7 +2792,7 @@ pub const Pardes = struct { }; switch (look.resolve(txt, cwd, &realbuf)) { .none => {}, - .url => |u| p.emit(.{ .open_link = .from(u) }), + .url => |u| if (u.len <= 256) p.emit(.{ .open_link = .from(u) }), .dir => |dir| { // focus an existing terminal on this dir, else fork one below for (p.panes, 0..) |slot, i| { @@ -2631,11 +2816,17 @@ pub const Pardes = struct { if (slot) |tt| if (tt.file) |*ff| if (std.mem.eql(u8, ff.path, target.path)) { p.active = i; if (target.line > 0 and target.line <= fileLineCount(ff.content)) { - const next = target.line - 1; + const next = (target.line - 1) -| tt.rows / 2; // center, clamp at top if (next != ff.scroll) { ff.scroll = next; ff.syntax_dirty = true; } + // land the modal cursor on the target line (and keep + // ensureCursorVisible agreeing with the recenter — + // a stale cursor would yank the view right back) + tt.cur_row = @intCast(target.line - 1); + tt.cur_col = 0; + tt.cur_pinned = true; } return; }; @@ -2643,6 +2834,11 @@ pub const Pardes = struct { const free = p.freeSlot() orelse return; const nt = p.openFileView(free, target.path, target.line) catch return; p.placeDoc(id, free, nt); + // center the target line: the pane's real body height only + // exists after placement, so lay out now and pull the + // scroll up by half a body (line 0 opens stay at the top) + p.computeGeom(); + nt.file.?.scroll -|= @max(1, p.rects[free].h -| BOX_H) / 2; }, .image => |target| { for (p.panes, 0..) |slot, i| { @@ -2918,7 +3114,7 @@ pub const Pardes = struct { if (src.scroll > 0) pane.vt.screens.active.scroll(.{ .delta_row = -@as(isize, @intCast(src.scroll)) }); } p.setCwd(i, t.cwd); - if (std.mem.startsWith(u8, src.tag, "sy ")) pane.mode = .tty; + if (std.mem.startsWith(u8, src.tag, "TTY ")) pane.mode = .tty; p.restoreTail(pane, src.tag); }, .file => { @@ -3077,6 +3273,8 @@ pub const Pardes = struct { /// re-highlight the visible window of any file whose syntax went stale /// (edit, scroll, load) — visible-range-first so big files stay snappy fn refreshDirtyHighlights(p: *Pardes) void { + const tz = tracy.zone(@src(), "refreshDirtyHighlights"); + defer tz.end(); for (p.panes) |slot| { const pane = slot orelse continue; if (pane.file == null) continue; @@ -3226,6 +3424,8 @@ pub const Pardes = struct { } fn renderPane(p: *Pardes, arena: std.mem.Allocator, pane: *Pane, r: Rect, active: bool) !void { + const tz = tracy.zone(@src(), "renderPane"); + defer tz.end(); if (r.w <= GUTTER or r.h == 0) return; const s = &p.surface; const th = p.theme(); @@ -3278,7 +3478,9 @@ pub const Pardes = struct { .fg = if (th.fg) |c| .{ .rgb = c } else .default, .bg = if (th.bg) |c| .{ .rgb = c } else .default, }; + const tz_body = tracy.zone(@src(), "bodyText"); const body = try p.bodyText(arena, pane); + tz_body.end(); var it = std.mem.splitScalar(u8, body, '\n'); var i: u16 = 0; while (it.next()) |line| : (i += 1) { @@ -3288,14 +3490,24 @@ pub const Pardes = struct { // file line-number gutter: mute the first PREFIX_W columns. Cheap // chrome, not gated on colors_on; selection/cursor passes still win. + // The cursor row's number takes the tag style (same row math as the + // cursor pass below) so the eye finds the current line. if (pane.file != null) { + const goff = pane.scroll(); + const gcur = pane.vt.screens.active.cursor; + const gcrow = if (pane.cur_pinned) pane.cur_row else @as(i32, @intCast(gcur.y)) + goff; + const cur_vr: i32 = if (active and !pane.tag_edit) gcrow - goff else -1; var vr: u16 = 0; while (vr < body_h) : (vr += 1) { + const on_cursor = @as(i32, vr) == cur_vr; var c: u16 = 0; while (c < PREFIX_W and c < tw) : (c += 1) { const cell = s.at(tx + c, r.y + BOX_H + vr); cell.default = false; // paints blank gutter rows too - cell.style.fg = .{ .rgb = th.lineno }; + if (on_cursor) { + cell.style.fg = .{ .rgb = th.tag_fg }; + cell.style.bg = .{ .rgb = th.tag_bg }; + } else cell.style.fg = .{ .rgb = th.lineno }; } } } @@ -3303,6 +3515,8 @@ pub const Pardes = struct { // tree-sitter style byte; content starts after the lineno gutter if (p.colors_on) if (pane.file) |*f| { if (f.highlights.len > 0) { + const tz_recolor = tracy.zone(@src(), "synRecolor"); + defer tz_recolor.end(); var flines = std.mem.splitScalar(u8, f.content, '\n'); var skip: usize = 0; while (skip < f.scroll) : (skip += 1) { diff --git a/src/syntax.zig b/src/syntax.zig index a9914fd7..0d2fa291 100644 --- a/src/syntax.zig +++ b/src/syntax.zig @@ -4,6 +4,7 @@ //! compiles queries at startup; `full` adds ~23 languages lazily on first use. const std = @import("std"); const config = @import("pardes_config"); +const tracy = @import("tracy.zig"); pub const enabled = config.syntax_highlighting; const minimal_grammars = config.syntax_minimal_grammars; @@ -140,6 +141,8 @@ pub fn highlightFile(gpa: std.mem.Allocator, path: []const u8, content: []const /// One Syn byte per content byte in [start, end). Caller frees. pub fn highlightFileRange(gpa: std.mem.Allocator, path: []const u8, content: []const u8, start_byte_raw: usize, end_byte_raw: usize) ![]u8 { + const tz = tracy.zone(@src(), "highlightFileRange"); + defer tz.end(); if (!enabled) return &.{}; const ext = std.fs.path.extension(path); const selected = (forExt(ext) catch return &.{}) orelse return &.{}; diff --git a/src/tracy.zig b/src/tracy.zig new file mode 100644 index 00000000..3b0b8621 --- /dev/null +++ b/src/tracy.zig @@ -0,0 +1,50 @@ +//! Minimal Tracy client (the Zig compiler's pattern): `zone(@src(), "name")` +//! ... `.end()`, plus `frameMark()`. Everything compiles to a no-op unless the +//! build sets -Dtracy=<path-to-tracy-checkout>, which also compiles and links +//! TracyClient.cpp. Capture with tracy-capture, inspect with tracy-csvexport. +const std = @import("std"); +const builtin = @import("builtin"); + +pub const enable = if (builtin.is_test) false else @import("pardes_config").enable_tracy; + +const SourceLocation = extern struct { + name: ?[*:0]const u8, + function: [*:0]const u8, + file: [*:0]const u8, + line: u32, + color: u32, +}; + +pub const Ctx = if (enable) extern struct { + id: u32, + active: i32, + + pub inline fn end(c: @This()) void { + ___tracy_emit_zone_end(c); + } +} else struct { + pub inline fn end(_: @This()) void {} +}; + +pub inline fn zone(comptime src: std.builtin.SourceLocation, comptime name: [:0]const u8) Ctx { + if (!enable) return .{}; + const static = struct { + const loc: SourceLocation = .{ + .name = name.ptr, + .function = src.fn_name.ptr, + .file = src.file.ptr, + .line = src.line, + .color = 0, + }; + }; + return ___tracy_emit_zone_begin(&static.loc, 1); +} + +pub inline fn frameMark() void { + if (!enable) return; + ___tracy_emit_frame_mark(null); +} + +extern fn ___tracy_emit_zone_begin(srcloc: *const SourceLocation, active: c_int) Ctx; +extern fn ___tracy_emit_zone_end(ctx: Ctx) void; +extern fn ___tracy_emit_frame_mark(name: ?[*:0]const u8) void; 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); diff --git a/src/tutor.txt b/src/tutor.txt index 5a93d388..94a2a20e 100644 --- a/src/tutor.txt +++ b/src/tutor.txt @@ -18,9 +18,9 @@ over. Modal editing (helix-style) is layered on top of that. Three modes (the tag shows which): - nm NORMAL block cursor, keys move/edit. DEFAULT on startup. - in INSERT keys type text at the cursor. - sy TTY keys go straight to the shell. (terminals only) + NOR NORMAL block cursor, keys move/edit. DEFAULT on startup. + INS INSERT keys type text at the cursor. + TTY TTY keys go straight to the shell. (terminals only) This tutor is in THREE parts, ordered by what's most different from editors you may know: @@ -71,7 +71,7 @@ - or select with the keyboard (`v` chars / `x` lines), then press Tab to execute or Enter to look — the acme chords on the keyboard. - THE TAG: each pane has a one-line tag: its MODE (nm/in/sy) + directory + THE TAG: each pane has a one-line tag: its MODE (NOR/INS/TTY) + directory or file path + builtins. File panes show "Save Del" by default; Save writes the current file to disk, Del closes the window. Terminal and image panes show "Del". "Delcol" still exists as a command: type it in @@ -110,11 +110,11 @@ prompt model rather than layering an editor cursor on top. On a terminal pane: - NORMAL (nm) prompt rows are HIDDEN — a clean acme-style page. You + NORMAL (NOR) prompt rows are HIDDEN — a clean acme-style page. You navigate it with the same h/j/k/l/w/b/e as a file. - INSERT (in) same clean page; keys type an insertion overlay (the + INSERT (INS) same clean page; keys type an insertion overlay (the command you're composing). Prompts still hidden. - TTY (sy) the REAL shell — prompts + typed input shown, and keys + TTY (TTY) the REAL shell — prompts + typed input shown, and keys go straight to the pty as terminal input. ESC: insert -> normal. (Esc never reaches tty.) |
