summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/gui.zig154
-rw-r--r--src/look.zig64
-rw-r--r--src/pardes.zig286
-rw-r--r--src/syntax.zig3
-rw-r--r--src/tracy.zig50
-rw-r--r--src/tty.zig225
-rw-r--r--src/tutor.txt14
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.)