From 1551e409c31992437cb2fa864f576d45c8433801 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Sun, 16 Aug 2026 15:49:12 -0300 Subject: big slow change: prebuilt shaders (SPIR-V/Metal), core gui reflow, docs, web + snapshot refresh --- src/tty/tty.zig | 238 ++++++++++++++++++++++++++++++-------------------------- 1 file changed, 127 insertions(+), 111 deletions(-) (limited to 'src/tty/tty.zig') 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) { -- cgit v1.3