summaryrefslogtreecommitdiff
path: root/src/tty/tty.zig
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-08-16 15:49:12 -0300
committerGabriel Schneider <[email protected]>2026-08-18 23:44:42 -0300
commit1551e409c31992437cb2fa864f576d45c8433801 (patch)
treee2fae8451f87b735a1360c7c2e383fdc40165789 /src/tty/tty.zig
parentbe2a9957708cbf0c478ca861c4a1f0f227bbfe10 (diff)
downloadpardes-1551e409c31992437cb2fa864f576d45c8433801.tar.gz
pardes-1551e409c31992437cb2fa864f576d45c8433801.zip
big slow change: prebuilt shaders (SPIR-V/Metal), core gui reflow, docs, web + snapshot refresh
Diffstat (limited to 'src/tty/tty.zig')
-rw-r--r--src/tty/tty.zig238
1 files changed, 127 insertions, 111 deletions
diff --git a/src/tty/tty.zig b/src/tty/tty.zig
index f6d095e5..05585f46 100644
--- a/src/tty/tty.zig
+++ b/src/tty/tty.zig
@@ -14,8 +14,10 @@ const look = @import("../look.zig");
const temp_file = @import("../temp_file.zig");
const shell_bin = @import("../shell_bin.zig");
const message = @import("../message.zig");
+const file_watch = @import("../file_watch.zig");
const selection_pipe = @import("../selection_pipe.zig");
const nested = @import("../nested.zig");
+const panel_compositor = @import("panel_compositor.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;
@@ -36,6 +38,11 @@ pub const Command = struct {
pty_eof: struct { id: usize, gen: u32 },
winsize: vaxis.Winsize,
mouse: vaxis.Mouse,
+ /// Focus reporting is part of vaxis's mouse mode (DEC 1004). A TTY
+ /// cannot report a literal pointer crossing its character grid, so
+ /// losing terminal focus is its only reliable pointer-leave signal.
+ focus_in,
+ focus_out,
paste: []const u8,
/// The bracketed-paste brackets. vaxis posts them ONLY because this
/// union declares fields with these exact names — its Loop gates every
@@ -57,26 +64,6 @@ pub const Command = struct {
};
const Loop = vaxis.Loop(@TypeOf(Command.value));
-/// The files on open panes, watched through ONE inotify instance.
-///
-/// `wd` is the descriptor for the file's CONTAINING DIRECTORY, not for the
-/// file: almost nothing rewrites a file in place. An editor, `git checkout`, a
-/// formatter — they write a temp file and rename it over the target, which
-/// swaps the inode, and a watch on the file itself keeps reporting on the old
-/// unlinked one forever. The directory sees the new inode arrive (MOVED_TO /
-/// CREATE). std.Build.Watch marks directories for exactly this reason; it uses
-/// fanotify because it watches thousands of them across mount points and needs
-/// FID-keyed dedup, whereas sixteen panes' worth of inotify is a third of the
-/// code, hands us no privilege question, and has no kernel version floor.
-///
-/// `hash` is what we last saw ON DISK. A reload only fires when it moves, so
-/// pardes's own Save — which restamps the hash from the bytes it just wrote —
-/// never looks like an external change and never touches the undo stack.
-/// A hash rather than mtime+size because we have to read the file to hand the
-/// core its bytes anyway, so the exact answer costs nothing extra and there is
-/// no clock granularity to reason about.
-const Watch = struct { wd: c_int, hash: u64 };
-
/// One language query, owned by the worker that runs it. Everything the
/// backend may read is copied in here before the worker starts: the core goes
/// on editing the moment the effect is drained, so a borrowed slice would be a
@@ -333,6 +320,81 @@ test "Kitty PDF fragments never declare pixels beyond their clipped bottom" {
try std.testing.expect(kittyPlacement(height, 80, 16, 640, 256) == null);
}
+test "Linux host watch closes initial race and reloads rename-over PDF while idle" {
+ if (comptime builtin.os.tag != .linux or !pardes.pdf_enabled) return;
+ const io = std.testing.io;
+ const gpa = std.testing.allocator;
+ var tmp = std.testing.tmpDir(.{});
+ defer tmp.cleanup();
+ const original = try pardes.pdf.makeOutlineTestPdf(gpa);
+ defer gpa.free(original);
+ const replacement = try pardes.pdf.makeNoOutlineTestPdf(gpa);
+ defer gpa.free(replacement);
+ try tmp.dir.writeFile(io, .{ .sub_path = "live.pdf", .data = original });
+ // Prepare both editor-style temporary inodes before marking the directory
+ // so the only post-arm wake below is the second rename.
+ try tmp.dir.writeFile(io, .{ .sub_path = "initial.pdf", .data = replacement });
+ try tmp.dir.writeFile(io, .{ .sub_path = "live-replacement.pdf", .data = original });
+ var path_buf: [256]u8 = undefined;
+ const path = try std.fmt.bufPrint(&path_buf, ".zig-cache/tmp/{s}/live.pdf", .{tmp.sub_path});
+
+ const fd = libc.inotify_init1(linux.IN.CLOEXEC | linux.IN.NONBLOCK);
+ if (fd < 0) return error.InotifyInitFailed;
+ defer _ = libc.close(fd);
+ var watches: file_watch.Table = @splat(null);
+ defer for (0..watches.len) |id| file_watch.watchPane(
+ fd,
+ &watches,
+ @intCast(id),
+ null,
+ 0,
+ .{ .text = 0 },
+ );
+
+ const core = try pardes.Pardes.init(gpa, .{ .file = path, .cols = 80, .rows = 28 });
+ defer core.deinit();
+ try std.testing.expectEqual(@as(usize, 3), core.panes[0].?.pdf.?.page_count);
+
+ // The core opened the three-page inode, but the host has not drained its
+ // watch effect yet. Replace it now: install-then-reconcile must discover
+ // the one-page document even though no source existed for this first edge.
+ try tmp.dir.rename("initial.pdf", tmp.dir, "live.pdf", io);
+ var armed = false;
+ while (core.nextEffect()) |effect| switch (effect) {
+ .watch => |watch| if (watch.pane == 0 and watch.on) {
+ armed = true;
+ try std.testing.expect(!file_watch.applyEffect(core, io, gpa, fd, &watches, 0, true));
+ },
+ else => {},
+ };
+ try std.testing.expect(armed and watches[0] != null);
+ try std.testing.expectEqual(@as(usize, 1), core.panes[0].?.pdf.?.page_count);
+
+ try tmp.dir.rename("live-replacement.pdf", tmp.dir, "live.pdf", io);
+ var events: [4096]u8 = undefined;
+ const event_bytes = libc.read(fd, &events, events.len);
+ try std.testing.expect(event_bytes > 0);
+
+ // This is the same pass the watcher thread schedules; no key, mouse, or
+ // synthetic core file_changed event participates in the transaction.
+ try std.testing.expect(!file_watch.reloadChanged(core, io, gpa, &watches));
+ const pane = core.panes[0].?;
+ try std.testing.expectEqual(@as(usize, 3), pane.pdf.?.page_count);
+ const disk_identity = try file_watch.identify(io, path);
+ switch (watches[0].?.generation) {
+ .pdf => |accepted| try std.testing.expect(accepted != null and accepted.?.eql(disk_identity)),
+ .text => return error.PdfWatchStoredTextGeneration,
+ }
+ try std.testing.expect(std.mem.indexOf(u8, pane.msg[0..pane.msg_len], "reloaded") != null);
+
+ core.native_images = true;
+ var frame = std.heap.ArenaAllocator.init(gpa);
+ defer frame.deinit();
+ const surface = try core.render(frame.allocator());
+ try std.testing.expect(surface.nimages > 0);
+ try std.testing.expectEqual(@as(u32, 0), surface.images[0].?.native.page);
+}
+
fn kittyImageRepresentable(place: pardes.ImagePlace) bool {
return place.iw > 0 and place.ih > 0 and
place.iw <= std.math.maxInt(u16) and place.ih <= std.math.maxInt(u16);
@@ -439,9 +501,10 @@ pub fn run(init: std.process.Init, opts: pardes.Options) !void {
var frame_arena: std.heap.ArenaAllocator = .init(allocs.frame);
defer frame_arena.deinit();
- // shells emit OSC 133 prompt marks via this rc (prompt hiding, click-move)
- writeFile(shell_bin.bash_rc_path, shell_bin.bash_rc);
- writeFile(shell_bin.fish_rc_path, shell_bin.fish_rc);
+ // Private, complete before any fork and retained until the last possible
+ // spawn; children borrow only these stable in-struct path buffers.
+ var prompt_rcs = shell_bin.PromptRcs.init();
+ defer prompt_rcs.deinit();
// macos: apple's bash 3.2 prints the zsh-deprecation banner into every
// pane unless this is in the env BEFORE bash starts (the rc is too late)
if (comptime builtin.os.tag.isDarwin()) _ = setenv("BASH_SILENCE_DEPRECATION_WARNING", "1", 1);
@@ -471,7 +534,7 @@ pub fn run(init: std.process.Init, opts: pardes.Options) !void {
// file a positional path argument opened. -1 off linux: watchPane goes
// quiet and the core simply never gets a file_changed event.
var inotify_fd: c_int = if (builtin.os.tag == .linux) libc.inotify_init1(linux.IN.CLOEXEC) else -1;
- var watches: [pardes.MAX_PANES]?Watch = @splat(null);
+ var watches: file_watch.Table = @splat(null);
var watch_task: ?std.Io.Future(anyerror!void) = null;
var loop: Loop = .init(io, &tty, &vx);
defer {
@@ -525,7 +588,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, &gens, io, gpa, allocs.lsp, &loop, &vx, &tty, &lsp_task, &pipe_tasks, inotify_fd, &watches, false);
+ drainEffects(core, &prompt_rcs, &ptys, &gens, io, gpa, allocs.lsp, &loop, &vx, &tty, &lsp_task, &pipe_tasks, inotify_fd, &watches, false);
try loop.start();
defer loop.stop();
@@ -621,6 +684,8 @@ pub fn run(init: std.process.Init, opts: pardes.Options) !void {
.nop => {},
.tick => core.update(.tick),
.quit => stop = true,
+ .focus_in => {},
+ .focus_out => core.update(.pointer_leave),
.winsize => |ws| {
try vx.resize(gpa, tty.writer(), ws);
if (comptime pardes.pdf_enabled)
@@ -761,35 +826,13 @@ pub fn run(init: std.process.Init, opts: pardes.Options) !void {
tz_event.end();
if (stop) break;
- // A watched directory moved. The wake says only THAT something
- // happened, so re-read every watched pane and hand the core the ones
- // whose bytes actually changed — that comparison is what keeps our own
- // Save, and any write that lands on identical content, out of the undo
- // stack. Reading here rather than on the watcher thread because the
- // core is the only thing that knows which pane a path belongs to, and
- // opening a file pane already reads synchronously on this thread.
if (check_files) {
check_files = false;
- for (&watches, 0..) |*slot, id| {
- if (slot.* == null) continue;
- const pane = core.panes[id] orelse continue;
- const f = pane.file orelse continue;
- const bytes = look.readFile(gpa, f.path) catch continue;
- defer gpa.free(bytes);
- const h = std.hash.Wyhash.hash(0, bytes);
- if (h == slot.*.?.hash) continue;
- slot.*.?.hash = h;
- core.update(.{ .file_changed = .{ .pane = @intCast(id), .bytes = bytes } });
- // the hash moving IS "someone else wrote this file" (our own
- // Save restamped it above), so the pane says who did what to it
- // — and it says it after the update, which cannot clear a
- // message because only a key or a mouse event does
- var mbuf: [256]u8 = undefined;
- core.setMessage(id, message.stamp(&mbuf, "reloaded", f.path));
- }
+ if (file_watch.reloadChanged(core, io, gpa, &watches))
+ loop.postEvent(.files_changed) catch {};
}
- drainEffects(core, &ptys, &gens, io, gpa, allocs.lsp, &loop, &vx, &tty, &lsp_task, &pipe_tasks, inotify_fd, &watches, true);
+ drainEffects(core, &prompt_rcs, &ptys, &gens, io, gpa, allocs.lsp, &loop, &vx, &tty, &lsp_task, &pipe_tasks, inotify_fd, &watches, true);
if (core.quit) break;
// Restore builtin: swap in a core rebuilt from the dump; the live
@@ -811,7 +854,14 @@ pub fn run(init: std.process.Init, opts: pardes.Options) !void {
// the replay core's pane ids mean new things, and the dying core's
// `watch off` effects go into a queue nobody drains — drop the lot
// here. The new core emits its own `on`s as it builds its panes.
- for (0..watches.len) |wid| watchPane(inotify_fd, &watches, @intCast(wid), null, 0);
+ for (0..watches.len) |wid| file_watch.watchPane(
+ inotify_fd,
+ &watches,
+ @intCast(wid),
+ null,
+ 0,
+ .{ .text = 0 },
+ );
var image_iterator = kitty_handles.valueIterator();
while (image_iterator.next()) |handle| vx.freeImage(tty.writer(), handle.id);
kitty_handles.clearRetainingCapacity();
@@ -858,8 +908,14 @@ pub fn run(init: std.process.Init, opts: pardes.Options) !void {
// ---- render: surface -> vaxis, cell for cell ----
_ = frame_arena.reset(.retain_capacity);
const tz_core = tracy.zone(@src(), "core.render");
- const surface = try core.render(frame_arena.allocator());
+ const canonical = try core.render(frame_arena.allocator());
tz_core.end();
+ const surface = try panel_compositor.compose(
+ frame_arena.allocator(),
+ canonical,
+ canonical.panelTracks(),
+ core.theme().bg,
+ );
const tz_cells = tracy.zone(@src(), "surface->vaxis");
const win = vx.window();
win.clear();
@@ -883,6 +939,10 @@ pub fn run(init: std.process.Init, opts: pardes.Options) !void {
// aliasing a fixed terminal cache slot.
for (surface.images[0..surface.nimages]) |maybe| {
const place = maybe orelse continue;
+ // Keep the placement in Surface so the terminal-side cache stays
+ // live, but do not pin native pixels over a panel whose cells are
+ // currently moving through the TTY grid.
+ if (panel_compositor.hidesAttachment(surface.panelTracks(), place.serial)) continue;
if (comptime pardes.pdf_enabled) {
if (!kittyImageRepresentable(place)) continue;
}
@@ -947,14 +1007,15 @@ pub fn run(init: std.process.Init, opts: pardes.Options) !void {
const tz_render = tracy.zone(@src(), "vx.render");
try vx.render(tty.writer());
tz_render.end();
+ core.acknowledgePanelPresentation(canonical.panelTracks());
tracy.frameMark();
- // A theme transition is the only reason this otherwise event-driven
+ // Active core animation is the only reason this otherwise event-driven
// frontend redraws on a clock. Sleep on the loop thread, then make the
// next pass a tick; input and worker events can queue during the short
// sleep and are drained in that pass. No timer worker can outlive the
// loop, and at the exact endpoint we return to an indefinite wait.
- if (core.themeAnimationActive()) {
+ if (core.animationActive()) {
std.Io.sleep(io, .fromMilliseconds(pardes.animation.frame_ms), .awake) catch {};
pending = .tick;
}
@@ -963,6 +1024,7 @@ pub fn run(init: std.process.Init, opts: pardes.Options) !void {
fn drainEffects(
core: *pardes.Pardes,
+ prompt_rcs: *const shell_bin.PromptRcs,
ptys: *[pardes.MAX_PANES]?Pty,
gens: *[pardes.MAX_PANES]u32,
io: std.Io,
@@ -974,7 +1036,7 @@ fn drainEffects(
lsp_task: *?std.Io.Future(anyerror!void),
pipe_tasks: *PipeTasks,
inotify_fd: c_int,
- watches: *[pardes.MAX_PANES]?Watch,
+ watches: *file_watch.Table,
threads_ok: bool,
) void {
while (core.nextEffect()) |effect| switch (effect) {
@@ -996,7 +1058,7 @@ fn drainEffects(
cwd_buf[cwd.len] = 0;
cwd_z = @ptrCast(&cwd_buf);
}
- const child = forkShell(core.shellBin(), cwd_z, core.screen_h, core.screen_w);
+ const child = forkShell(core, sp.pane, prompt_rcs, core.shellBin(), cwd_z, core.screen_h, core.screen_w);
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). The
// slot needs no occupancy reset: nothing is remembered, and the next
@@ -1032,7 +1094,10 @@ fn drainEffects(
_ = libc.close(fd);
// our own write is about to come back as a watch event: restamp
// from the bytes we just put there so it reads as "no change"
- if (watches[sf.pane]) |*w| w.hash = std.hash.Wyhash.hash(0, f.content);
+ if (watches[sf.pane]) |*w| if (w.serial == pane.serial) switch (w.generation) {
+ .text => w.generation = .{ .text = std.hash.Wyhash.hash(0, f.content) },
+ .pdf => {},
+ };
// ...and say so on the pane's message row. AFTER the write, not
// beside it: every `continue` above is a save that did not happen
// and must not be reported as one.
@@ -1138,15 +1203,8 @@ fn drainEffects(
pipe_tasks.add(.{ .id = request.id, .future = future });
},
.watch => |w| {
- // starting, the path and the on-disk bytes are read off the core
- // (same split as save_file); stopping, the pane is already gone
- var path: ?[]const u8 = null;
- var hash: u64 = 0;
- if (w.on) if (core.panes[w.pane]) |pane| if (pane.file) |f| {
- path = f.path;
- hash = std.hash.Wyhash.hash(0, f.content);
- };
- watchPane(inotify_fd, watches, w.pane, path, hash);
+ if (file_watch.applyEffect(core, io, gpa, inotify_fd, watches, w.pane, w.on))
+ loop.postEvent(.files_changed) catch {};
},
.quit => {},
};
@@ -1187,42 +1245,6 @@ fn pipeWorker(
loop.postEvent(.{ .pipe_done = response }) catch response.deinit(gpa);
}
-/// Mark or unmark one pane's file (`path` null = unmark). Linux only: on any
-/// other OS this returns silently, the core never receives a file_changed
-/// event, and the feature is simply off — a state the core has to tolerate
-/// regardless, since the browser shell has no filesystem at all.
-/// ponytail: darwin wants the FSEvents half of std.Build.Watch here.
-fn watchPane(fd: c_int, watches: *[pardes.MAX_PANES]?Watch, id: u8, path: ?[]const u8, hash: u64) void {
- if (comptime builtin.os.tag != .linux) return;
- if (fd < 0) return;
- if (watches[id]) |old| {
- // inotify hands out ONE descriptor per directory, so two panes on
- // files in the same directory share it: drop the mark only when the
- // last of them lets go, or closing one blinds the other.
- var shared = false;
- for (watches, 0..) |other, i| {
- const o = other orelse continue;
- if (i != id and o.wd == old.wd) shared = true;
- }
- if (!shared) _ = libc.inotify_rm_watch(fd, old.wd);
- watches[id] = null;
- }
- const p = path orelse return;
- const dir = std.fs.path.dirname(p) orelse ".";
- var dbuf: [4096:0]u8 = undefined;
- if (dir.len >= dbuf.len) return;
- @memcpy(dbuf[0..dir.len], dir);
- dbuf[dir.len] = 0;
- // CLOSE_WRITE, not MODIFY: one event when a writer is DONE rather than one
- // per write(2), which is most of the debounce for free. MOVED_TO and
- // CREATE catch the rename-over and the delete-then-recreate that are how
- // files are actually replaced (see Watch).
- const mask = linux.IN.CLOSE_WRITE | linux.IN.MOVED_TO | linux.IN.CREATE | linux.IN.ONLYDIR;
- const wd = libc.inotify_add_watch(fd, dbuf[0..dir.len :0], mask);
- if (wd < 0) return;
- watches[id] = .{ .wd = wd, .hash = hash };
-}
-
/// Block on the inotify fd and wake the loop. Deliberately does NOT parse the
/// events: the loop re-reads every watched pane anyway, so the only thing an
/// event carries that we need is THAT something happened, and parsing would
@@ -1297,12 +1319,12 @@ fn ttyTakenAt(ctx: ?*anyopaque, pane: usize) bool {
return look.ttyTaken(pt.pid, pt.file.handle);
}
-fn forkShell(bin: []const u8, cwd: ?[*:0]const u8, rows: u16, cols: u16) struct { file: std.Io.File, pid: posix.pid_t } {
+fn forkShell(core: *pardes.Pardes, pane: usize, prompt_rcs: *const shell_bin.PromptRcs, bin: []const u8, cwd: ?[*:0]const u8, rows: u16, cols: u16) struct { file: std.Io.File, pid: posix.pid_t } {
var master: c_int = undefined;
// resolved BEFORE the fork, into this frame, which the child inherits:
// nothing between fork and exec may allocate, and a PATH search would
var path_buf: [std.fs.max_path_bytes]u8 = undefined;
- const spawn = shell_bin.resolve(bin, &path_buf);
+ const spawn = shell_bin.resolve(bin, &path_buf, prompt_rcs);
const ws = posix.winsize{ .row = rows, .col = cols, .xpixel = 0, .ypixel = 0 };
const pid = forkpty(&master, null, null, &ws);
if (pid == 0) {
@@ -1316,6 +1338,7 @@ fn forkShell(bin: []const u8, cwd: ?[*:0]const u8, rows: u16, cols: u16) struct
_ = execv(spawn.path, &spawn.argv);
_exit(127);
}
+ if (pid > 0) core.acknowledgeShell(pane, std.mem.span(spawn.path), spawn.argv[1] != null);
return .{ .file = .{ .handle = master, .flags = .{ .nonblocking = false } }, .pid = pid };
}
@@ -1398,13 +1421,6 @@ fn vaxisColor(c: pardes.Color) vaxis.Color {
};
}
-fn writeFile(path: [*:0]const u8, data: []const u8) void {
- const fd = libc.open(path, .{ .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) {