diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/CHANGELOG.md | 22 | ||||
| -rw-r--r-- | src/config.zig | 11 | ||||
| -rw-r--r-- | src/detached/client.zig | 17 | ||||
| -rw-r--r-- | src/detached/server.zig | 18 | ||||
| -rw-r--r-- | src/effect_sources.zig | 12 | ||||
| -rw-r--r-- | src/file_watch.zig | 355 | ||||
| -rw-r--r-- | src/gui/gui.zig | 45 | ||||
| -rw-r--r-- | src/macos.zig | 33 | ||||
| -rw-r--r-- | src/macos/Sources/PardesView.swift | 105 | ||||
| -rw-r--r-- | src/macos/pardes.h | 21 | ||||
| -rw-r--r-- | src/pardes.zig | 53 | ||||
| -rw-r--r-- | src/tty/tty.zig | 35 |
12 files changed, 637 insertions, 90 deletions
diff --git a/src/CHANGELOG.md b/src/CHANGELOG.md index 0b7bb12e..71e47f82 100644 --- a/src/CHANGELOG.md +++ b/src/CHANGELOG.md @@ -2,6 +2,28 @@ ## 0.0.1 +- Pane taglines sit where they do in the SDL window. The macOS shell had its own + copy of the band geometry and it only ever centred the shorter band in its + body-sized row, which is the exact case `gui_topbar_pane_border_px` exists to + prevent: the topbar's unused half-band and the first pane tag's unused + half-band meet, and the window background shows through the seam — 4 physical + pixels at the default 82%, 10 at 50%, 14 at 30% on a 20-pixel cell, so it grew + as the tagline face shrank. The rule moved into the core and both pixel hosts + now call it: row zero bottom-aligned, the first pane-tag row top-aligned, the + two joined by the themed rule, rows between centred, and a `Tagbottom` band on + the last row flush with the window edge with the sub-cell strip beneath it + painted in the band's own colour instead of the page's. +- Files reload themselves on macOS. The tty shell, the SDL window and the + detached daemon all watched with `inotify` and therefore watched nothing off + Linux: an edit made outside pardes never reached the pane, and a PDF replaced + on disk kept rendering the old inode. They now share one mark/reconcile + transaction over two kernels — `inotify`, or a `kqueue` whose queue the hosts + wait on and poll exactly as they did the old descriptor. A macOS mark is two + filters, because a kqueue directory filter reports its entries changing and + never a write to a file already inside it: the parent follows rename-over + saves, the file catches in-place writes, and the file filter is re-armed once + a rename-over has moved the inode. That is the same pair the AppKit host's + DispatchSources already used for the same reason. - Snapshot captures are deltas against the previous capture in the same script, and a scripted click may name a word (`press middle @Del 2`, `@Del#2` for the second pane on a row, `@Save-2` for a column beside one) instead of a screen diff --git a/src/config.zig b/src/config.zig index 81ecc514..a7d214b3 100644 --- a/src/config.zig +++ b/src/config.zig @@ -562,11 +562,12 @@ comptime { @compileError("config.gui_tagline_font_percent must be in 1...100"); } -/// Physical-pixel rule between the global topbar and pane taglines in the -/// SDL GUI. Their smaller font bands retain body-sized grid rows; without -/// an explicit join, centering both bands leaves the two unused half-bands -/// touching and makes a wide strip of the window background show through. -/// Zero disables the rule and joins the two bands directly. +/// Physical-pixel rule between the global topbar and pane taglines, in both +/// pixel GUIs (SDL and native macOS, which reach the shared rule in +/// `pardes.taglineBandOffset`). Their smaller font bands retain body-sized grid +/// rows; without an explicit join, centering both bands leaves the two unused +/// half-bands touching and makes a wide strip of the window background show +/// through. Zero disables the rule and joins the two bands directly. pub const gui_topbar_pane_border_px: u8 = 1; /// Fixed RGB for that rule, or null to follow the active theme's scrollbar diff --git a/src/detached/client.zig b/src/detached/client.zig index 4769cf25..fc39164b 100644 --- a/src/detached/client.zig +++ b/src/detached/client.zig @@ -1129,9 +1129,20 @@ test "detached session: a frontend that stops reading is dropped, not waited for for (0..24) |_| { if (!h.session.clients[1].attached) break; host.vtable.push_set_clipboard.?(host.ctx, text); - try h.pump(); - try good.wait(5); - while (try good.next()) |_| {} + // Read `good` back to EMPTY before the next mirror, rather than + // pumping once and taking whatever one write fitted. One pump moves at + // most one socket buffer, and that buffer is 8 KiB here + // (`net.local.stream.sendspace` on Darwin) against Linux's 208 KiB — + // so a single pump per 256 KiB mirror leaves the READING frontend + // falling behind by a quarter megabyte a round and closes it for a + // backlog it never caused. The claim under test is that a frontend + // that reads survives, so it has to actually finish reading. + for (0..256) |_| { + try h.pump(); + try good.wait(5); + while (try good.next()) |_| {} + if (h.session.clients[0].out.items.len == 0) break; + } } // Dropped rather than queued without bound, and rather than the core // blocking on it. diff --git a/src/detached/server.zig b/src/detached/server.zig index 7d286177..2c4df87b 100644 --- a/src/detached/server.zig +++ b/src/detached/server.zig @@ -141,7 +141,6 @@ const std = @import("std"); const builtin = @import("builtin"); const libc = std.c; -const linux = std.os.linux; const posix = std.posix; const pardes = @import("../pardes.zig"); const host_api = @import("../host.zig"); @@ -1221,17 +1220,13 @@ pub const Session = struct { /// then keeps its own record of what was asked and simply never gets a /// reload, which is what a host with no watcher has always done. /// - /// NONBLOCK because this descriptor is drained from `poll`, not from a - /// thread parked in `read` (tty.zig `watchFiles`): `drainInotify` must be - /// able to stop. + /// `polled` is true because this descriptor is drained from `poll`, not + /// from a thread parked in a wait (tty.zig `watchFiles`): `drainInotify` + /// must be able to stop. On linux that is IN_NONBLOCK; on macos a kqueue + /// needs nothing, since the timeout argument to `kevent(2)` decides. fn inotify(s: *Session) c_int { if (s.inotify_fd >= 0) return s.inotify_fd; - // The whole body is inside the comptime branch so that neither - // `inotify_init1` nor `linux.IN` is even analysed on a platform that has - // no inotify — the same shape file_watch.zig's `watchPath` uses. - if (comptime builtin.os.tag == .linux) { - s.inotify_fd = libc.inotify_init1(linux.IN.CLOEXEC | linux.IN.NONBLOCK); - } + s.inotify_fd = file_watch.init(true); return s.inotify_fd; } @@ -1252,8 +1247,7 @@ pub const Session = struct { /// frontend got a frame per round it could not use. The fd is IN_NONBLOCK /// (`inotify`), so the loop ends on EAGAIN. fn drainInotify(s: *Session) void { - var buf: [4096]u8 = undefined; - while (libc.read(s.inotify_fd, &buf, buf.len) > 0) s.check_files = true; + if (file_watch.drain(s.inotify_fd)) s.check_files = true; } /// Reconcile every marked pane and the theme file. Called at the END of diff --git a/src/effect_sources.zig b/src/effect_sources.zig index 12f8dd46..d4f0e38b 100644 --- a/src/effect_sources.zig +++ b/src/effect_sources.zig @@ -250,9 +250,15 @@ test "shader source provenance is GUI-only and follows the build input" { test "mac EffectCode includes the runtime panel and scene wiring" { if (comptime backend == .macos) { - try std.testing.expect(std.mem.indexOf(u8, mac_scene, "extern \"C\" float4 pardesPanelClear") != null); - try std.testing.expect(std.mem.indexOf(u8, mac_scene, "extern \"C\" float4 pardesPanel") != null); - try std.testing.expect(std.mem.indexOf(u8, mac_scene, "extern \"C\" float4 pardesScene") != null); + // `extern "C" [[stitchable]]` is what the runtime compile requires: + // CIKernel.kernels(withMetalString:) looks for stitchable functions and + // rejects the whole source without them, so ScenePostprocessor.init? + // returns nil and every scene effect and panel transition silently + // degrades to the plain CoreText draw. The spelling is pinned on + // purpose; relaxing these three matches is how that comes back. + try std.testing.expect(std.mem.indexOf(u8, mac_scene, "extern \"C\" [[stitchable]] float4 pardesPanelClear") != null); + try std.testing.expect(std.mem.indexOf(u8, mac_scene, "extern \"C\" [[stitchable]] float4 pardesPanel") != null); + try std.testing.expect(std.mem.indexOf(u8, mac_scene, "extern \"C\" [[stitchable]] float4 pardesScene") != null); try std.testing.expect(std.mem.indexOf(u8, mac_scene, "already composed in Pardes core") != null); try std.testing.expect(std.mem.indexOf(u8, mac_view, "hideCursor: !tracks.isEmpty") != null); try std.testing.expect(std.mem.indexOf(u8, mac_postprocessor, "panelClearKernel.apply") != null); diff --git a/src/file_watch.zig b/src/file_watch.zig index a5347844..dfdc0556 100644 --- a/src/file_watch.zig +++ b/src/file_watch.zig @@ -1,9 +1,13 @@ -//! Native file-watch state and the Linux mark/reconcile transaction shared by -//! the TTY and SDL hosts. Their event loops still own blocking waits, wake +//! Native file-watch state and the mark/reconcile transaction shared by the +//! TTY and SDL hosts. Their event loops still own blocking waits, wake //! coalescing, and retry scheduling; this file owns the identical synchronous //! operation each wake performs. Text buffers already own the bytes whose hash //! is their generation. Large PDFs use metadata which changes for in-place and //! rename-over saves, avoiding a second whole-document allocation. +//! +//! Two kernels, one transaction: inotify on linux, kqueue on macos. `init`, +//! `wait`, `stop`, `drain`, `markDir` and `unmarkDir` are the whole of that +//! difference, and nothing above them knows which one it is on. const std = @import("std"); const builtin = @import("builtin"); const libc = std.c; @@ -32,12 +36,27 @@ pub const Generation = union(enum) { pdf: ?Identity, }; -/// One native Linux directory mark. The descriptor belongs to the containing -/// directory because editor-style rename-over saves replace the file inode. +/// One native directory mark, plus what macos needs beside it. +/// +/// `wd` is an inotify wd on linux and an open EVTONLY directory descriptor on +/// macos. Either way it marks the CONTAINING directory, because editor-style +/// rename-over saves replace the file inode. +/// +/// `file_wd` and `kq` exist only on macos, and only because a kqueue directory +/// filter reports changes to the DIRECTORY — entries added, removed, renamed — +/// and never a write to a file already inside it. inotify's CLOSE_WRITE covers +/// that from the directory mark; kqueue has no equivalent, so an in-place save +/// needs a second filter on the file itself. `kq` is the queue both filters +/// live on, kept here so `reloadPane` can re-arm the file filter after a +/// rename-over gave the pathname a new inode, without every caller between it +/// and a host having to carry the descriptor. +/// /// `generation` is the last snapshot the core accepted, not merely one a host /// observed; serial prevents a reused pane slot from committing stale data. pub const Watch = struct { wd: c_int, + file_wd: c_int = -1, + kq: c_int = -1, serial: u32, generation: Generation, }; @@ -48,6 +67,300 @@ pub const Watch = struct { pub const theme_slot = pardes.MAX_PANES; pub const Table = [pardes.MAX_PANES + 1]?Watch; +/// Whether this OS has a watcher at all. Off it every entry point below +/// returns the inert answer: `watchPath` installs nothing, so `watches` stays +/// empty and `reloadPane` returns false for every slot. The core then keeps its +/// own record of what was asked and simply never gets a `file_changed`, which +/// is what a host with no watcher has always done here. +pub const supported = builtin.os.tag == .linux or builtin.os.tag == .macos; + +/// The wake filter's `ident` on macos. `ident` is a directory descriptor for +/// every other filter on this queue, and no descriptor is `maxInt(usize)`, so +/// nothing can collide with it. +const wake_ident: usize = std.math.maxInt(usize); + +/// Create the descriptor a host waits on, and the ONE place the difference +/// between the two kernels is spelled. +/// +/// linux: an inotify instance; a mark is a wd added to it. +/// macos: a kqueue; a mark is an EVFILT_VNODE filter whose `ident` is an open +/// directory descriptor, plus the one EVFILT_USER filter registered here that +/// `stop` triggers to end a parked `wait`. +/// +/// `polled` is the only flag that survives the port. On linux it adds +/// IN_NONBLOCK, because a host that finds this descriptor ready in its own +/// `poll(2)` (detached/server.zig) must be able to stop draining it, while a +/// host that parks a thread in `wait` (tty.zig, gui.zig) wants the blocking +/// read. On macos neither host needs either flag: a kqueue is not inherited +/// across `fork(2)`, so there is no CLOEXEC to ask for, and whether a wait +/// blocks is the timeout argument to `kevent(2)` rather than a property of the +/// queue. +/// +/// -1 when there is no watcher to make, which every entry point below reads as +/// "mark nothing". +pub fn init(polled: bool) c_int { + switch (builtin.os.tag) { + .linux => return libc.inotify_init1(if (polled) + linux.IN.CLOEXEC | linux.IN.NONBLOCK + else + linux.IN.CLOEXEC), + .macos => { + const kq = libc.kqueue(); + if (kq < 0) return -1; + const changes = [_]libc.Kevent{.{ + .ident = wake_ident, + .filter = libc.EVFILT.USER, + .flags = libc.EV.ADD | libc.EV.CLEAR, + .fflags = 0, + .data = 0, + .udata = 0, + }}; + var events: [1]libc.Kevent = undefined; + if (libc.kevent(kq, &changes, 1, &events, 0, null) < 0) { + _ = libc.close(kq); + return -1; + } + return kq; + }, + else => return -1, + } +} + +/// Park until a marked directory changes. False ends the watcher: `stop` was +/// called, or the queue died. +/// +/// macos only, because it exists for the one thing a kqueue cannot do the way +/// an inotify descriptor can — be read. The linux hosts keep reading theirs +/// (tty.zig through `std.Io.File`, so teardown's `cancel` interrupts it), and +/// this is the shape that replaces that where there is nothing to read. +/// +/// Deliberately does NOT report WHAT changed, for the reason the watcher +/// threads in tty.zig and gui.zig already give: the loop re-reads every marked +/// pane anyway. +pub fn wait(fd: c_int) bool { + if (comptime builtin.os.tag != .macos) return false; + if (fd < 0) return false; + const none = [_]libc.Kevent{}; + var events: [8]libc.Kevent = undefined; + while (true) { + const n = libc.kevent(fd, &none, 0, &events, events.len, null); + if (n < 0) { + if (libc.errno(n) == .INTR) continue; + return false; + } + if (n == 0) continue; + for (events[0..@intCast(n)]) |event| { + if (event.filter == libc.EVFILT.USER) return false; + } + return true; + } +} + +/// Wake a host parked in `wait` so it returns BEFORE the descriptor it is +/// parked on is closed. A no-op on linux, where cancelling the future reading +/// that descriptor is what ends the watcher. +pub fn stop(fd: c_int) void { + if (comptime builtin.os.tag != .macos) return; + if (fd < 0) return; + const changes = [_]libc.Kevent{.{ + .ident = wake_ident, + .filter = libc.EVFILT.USER, + .flags = 0, + .fflags = libc.NOTE.TRIGGER, + .data = 0, + .udata = 0, + }}; + var events: [1]libc.Kevent = undefined; + _ = libc.kevent(fd, &changes, 1, &events, 0, null); +} + +/// Consume every pending edge without blocking, for a host that found the +/// descriptor ready in its own `poll(2)` rather than parking a thread on it. +/// True when at least one of them was a directory change. +/// +/// DRAINED TO EMPTY on both kernels, and for the same reason: the descriptor is +/// level-triggered, so a queue left partly full makes the next `poll` return +/// ready immediately, and each of those rounds is a whole pump. +pub fn drain(fd: c_int) bool { + if (fd < 0) return false; + switch (builtin.os.tag) { + .linux => { + var buf: [4096]u8 = undefined; + var seen = false; + while (libc.read(fd, &buf, buf.len) > 0) seen = true; + return seen; + }, + .macos => { + const none = [_]libc.Kevent{}; + const now: libc.timespec = .{ .sec = 0, .nsec = 0 }; + var events: [16]libc.Kevent = undefined; + var seen = false; + while (true) { + const n = libc.kevent(fd, &none, 0, &events, events.len, &now); + if (n <= 0) return seen; + for (events[0..@intCast(n)]) |event| { + if (event.filter != libc.EVFILT.USER) seen = true; + } + if (n < events.len) return seen; + } + }, + else => return false, + } +} + +/// Install the kernel mark on `dir_z` and return its handle, or -1. +/// +/// The handle is an inotify wd on linux and an open directory descriptor on +/// macos, and BOTH have to answer "the same directory twice is the same +/// handle": `watchPath`'s sharing loop compares handles to decide when the last +/// user of a mark is gone, and `Watch` keeps no path to compare instead. +/// inotify does that deduplication itself. On macos it is done here, by device +/// and inode, because each EVFILT_VNODE filter needs a descriptor of its own. +fn markDir(fd: c_int, dir_z: [:0]const u8, watches: *const Table) c_int { + switch (builtin.os.tag) { + .linux => { + // CLOSE_WRITE coalesces one writer's writes; MOVED_TO and CREATE + // cover rename-over and delete-then-recreate saves. + const mask = linux.IN.CLOSE_WRITE | linux.IN.MOVED_TO | linux.IN.CREATE | linux.IN.ONLYDIR; + return libc.inotify_add_watch(fd, dir_z, mask); + }, + .macos => { + // EVTONLY is the point of the descriptor: it marks the directory + // without counting as a reference that would keep an unmounting + // volume busy. DIRECTORY is inotify's ONLYDIR. + const dir_fd = libc.open(dir_z, .{ + .ACCMODE = .RDONLY, + .EVTONLY = true, + .CLOEXEC = true, + .DIRECTORY = true, + }, @as(libc.mode_t, 0)); + if (dir_fd < 0) return -1; + var want: libc.Stat = undefined; + if (libc.fstat(dir_fd, &want) != 0) { + _ = libc.close(dir_fd); + return -1; + } + for (watches) |other| { + const candidate = other orelse continue; + var have: libc.Stat = undefined; + if (libc.fstat(candidate.wd, &have) != 0) continue; + if (have.dev != want.dev or have.ino != want.ino) continue; + _ = libc.close(dir_fd); + return candidate.wd; + } + // What a DIRECTORY filter reports is its entries changing, which + // is a rename-over or a delete-then-recreate — MOVED_TO and CREATE + // above. It does NOT report a write to a file already inside it, + // which is what CLOSE_WRITE covers there; `markFile` is that half. + // RENAME and DELETE here are the marked directory itself going + // away. + // + // EV_CLEAR is not optional: without it the filter stays triggered + // once it has fired and every `wait` returns immediately forever. + const changes = [_]libc.Kevent{.{ + .ident = @intCast(dir_fd), + .filter = libc.EVFILT.VNODE, + .flags = libc.EV.ADD | libc.EV.CLEAR, + .fflags = libc.NOTE.WRITE | libc.NOTE.RENAME | libc.NOTE.DELETE, + .data = 0, + .udata = 0, + }}; + var events: [1]libc.Kevent = undefined; + if (libc.kevent(fd, &changes, 1, &events, 0, null) < 0) { + _ = libc.close(dir_fd); + return -1; + } + return dir_fd; + }, + else => return -1, + } +} + +/// The other half of a macos mark: the filter on the watched FILE, which is the +/// only thing that reports an in-place save. -1 when there is no file there yet +/// — a path that does not exist is still worth marking the directory for, and +/// the create arrives on that mark. +/// +/// Not deduplicated, unlike the directory: two panes on the same file are two +/// slots that each want their own wake, and a file filter has exactly one user. +fn markFile(kq: c_int, path_z: [:0]const u8) c_int { + if (comptime builtin.os.tag != .macos) return -1; + const file_fd = libc.open(path_z, .{ + .ACCMODE = .RDONLY, + .EVTONLY = true, + .CLOEXEC = true, + }, @as(libc.mode_t, 0)); + if (file_fd < 0) return -1; + // EXTEND and ATTRIB beside WRITE because an append and a truncate-in-place + // are both saves, and DELETE/RENAME because losing this inode is how a + // rename-over reaches the pane whose file it replaced. + const changes = [_]libc.Kevent{.{ + .ident = @intCast(file_fd), + .filter = libc.EVFILT.VNODE, + .flags = libc.EV.ADD | libc.EV.CLEAR, + .fflags = libc.NOTE.WRITE | libc.NOTE.EXTEND | libc.NOTE.ATTRIB | + libc.NOTE.RENAME | libc.NOTE.DELETE | libc.NOTE.REVOKE, + .data = 0, + .udata = 0, + }}; + var events: [1]libc.Kevent = undefined; + if (libc.kevent(kq, &changes, 1, &events, 0, null) < 0) { + _ = libc.close(file_fd); + return -1; + } + return file_fd; +} + +/// Re-point a live mark's file filter at whatever the pathname is NOW. Called +/// from `reloadPane`, because a rename-over leaves the old filter on an inode +/// that no longer answers to this name and a later in-place save would then +/// wake nobody. A no-op off macos, where the directory mark covers both cases +/// by itself and there is no second filter to move. +fn remarkFile(live: *Watch, path: ?[]const u8) void { + if (comptime builtin.os.tag != .macos) return; + if (live.kq < 0) return; + const watched = path orelse return; + var path_buf: [4096:0]u8 = undefined; + if (watched.len >= path_buf.len) return; + @memcpy(path_buf[0..watched.len], watched); + path_buf[watched.len] = 0; + // Mark first, compare second. There is no `stat` in std.c on this target — + // only `fstat` — and opening the path is what tells us both whether it is + // there and which inode it is now. + const fresh = markFile(live.kq, path_buf[0..watched.len :0]); + if (fresh < 0) return; // nothing at that name now; keep whatever we had + if (live.file_wd >= 0) { + var have: libc.Stat = undefined; + var want: libc.Stat = undefined; + // Same inode as the filter already on it? Then that one still reports + // this file and the registration just made is a duplicate to drop. + if (libc.fstat(live.file_wd, &have) == 0 and libc.fstat(fresh, &want) == 0 and + have.dev == want.dev and have.ino == want.ino) + { + _ = libc.close(fresh); + return; + } + _ = libc.close(live.file_wd); + } + live.file_wd = fresh; +} + +/// Drop one slot's marks. The DIRECTORY mark goes only when its last slot is +/// gone — several panes share it — while the file filter has exactly one user +/// and always goes. On macos a filter dies with the descriptor it was +/// registered against, so the close IS the removal. +fn unmark(fd: c_int, old: Watch, drop_dir: bool) void { + if (comptime builtin.os.tag == .macos) { + if (old.file_wd >= 0) _ = libc.close(old.file_wd); + } + if (!drop_dir) return; + switch (builtin.os.tag) { + .linux => _ = libc.inotify_rm_watch(fd, old.wd), + .macos => _ = libc.close(old.wd), + else => {}, + } +} + fn watchPath( fd: c_int, watches: *Table, @@ -56,7 +369,7 @@ fn watchPath( serial: u32, generation: Generation, ) void { - if (comptime builtin.os.tag != .linux) return; + if (comptime !supported) return; if (fd < 0) return; if (watches[slot]) |old| { var shared = false; @@ -64,8 +377,8 @@ fn watchPath( const candidate = other orelse continue; if (i != slot and candidate.wd == old.wd) shared = true; } - if (!shared) _ = libc.inotify_rm_watch(fd, old.wd); watches[slot] = null; + unmark(fd, old, !shared); } const watched_path = path orelse return; const dir = std.fs.path.dirname(watched_path) orelse "."; @@ -73,12 +386,13 @@ fn watchPath( if (dir.len >= dir_buf.len) return; @memcpy(dir_buf[0..dir.len], dir); dir_buf[dir.len] = 0; - // CLOSE_WRITE coalesces one writer's writes; MOVED_TO and CREATE cover - // rename-over and delete-then-recreate saves. - const mask = linux.IN.CLOSE_WRITE | linux.IN.MOVED_TO | linux.IN.CREATE | linux.IN.ONLYDIR; - const wd = libc.inotify_add_watch(fd, dir_buf[0..dir.len :0], mask); + const wd = markDir(fd, dir_buf[0..dir.len :0], watches); if (wd < 0) return; - watches[slot] = .{ .wd = wd, .serial = serial, .generation = generation }; + watches[slot] = .{ .wd = wd, .kq = fd, .serial = serial, .generation = generation }; + // The file half, which only macos has and only for a path that exists yet. + if (comptime builtin.os.tag == .macos) { + if (watches[slot]) |*live| remarkFile(live, watched_path); + } } /// Mark or unmark one pane pathname. Panes in the same directory share an @@ -111,6 +425,14 @@ pub fn reloadPane( const pane = core.panes[id] orelse return false; if (pane.serial != watched.serial) return false; + // Re-point the macos file filter at whatever this pathname is now, BEFORE + // deciding anything: a rename-over left the old filter on a dead inode, and + // the next in-place save would otherwise wake nobody. A no-op elsewhere, + // and a no-op here too when the inode has not moved. + if (comptime builtin.os.tag == .macos) { + if (watches[id]) |*live| remarkFile(live, if (pane.file) |f| f.path else pane.pdfPath()); + } + if (pane.file) |file| { const bytes = look.readFile(gpa, file.path) catch return false; defer gpa.free(bytes); @@ -240,6 +562,11 @@ pub fn reloadTheme( ) bool { const watched = watches[theme_slot] orelse return false; const request = core.themeFileRequest(watched.serial) orelse return false; + // Same re-arm as reloadPane's, for the same reason: a .zon saved by rename + // moves the inode the file filter is on. + if (comptime builtin.os.tag == .macos) { + if (watches[theme_slot]) |*entry| remarkFile(entry, request.path); + } const bytes = look.readFile(gpa, request.path) catch |err| { core.failThemeFile(watched.serial, err); return false; @@ -295,7 +622,7 @@ test "file identity changes for in-place and rename-over writes" { } test "theme watch reloads valid ZON and keeps the last theme across a bad save" { - if (comptime builtin.os.tag != .linux or pardes.platform == .web) return; + if (comptime !supported or pardes.platform == .web) return; const io = std.testing.io; const gpa = std.testing.allocator; var tmp = std.testing.tmpDir(.{}); @@ -322,8 +649,8 @@ test "theme watch reloads valid ZON and keeps the last theme across a bad save" else => {}, } else return error.MissingThemeFileEffect; - const fd = libc.inotify_init1(linux.IN.CLOEXEC | linux.IN.NONBLOCK); - if (fd < 0) return error.InotifyInitFailed; + const fd = init(true); + if (fd < 0) return error.WatchInitFailed; defer _ = libc.close(fd); var watches: Table = @splat(null); defer _ = applyThemeEffect(core, gpa, fd, &watches, 0, false, false); diff --git a/src/gui/gui.zig b/src/gui/gui.zig index 11e9f53b..8442c197 100644 --- a/src/gui/gui.zig +++ b/src/gui/gui.zig @@ -29,7 +29,6 @@ const std = @import("std"); const builtin = @import("builtin"); const posix = std.posix; const libc = std.c; -const linux = std.os.linux; // inotify constants; referenced only on linux 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"); @@ -1160,6 +1159,12 @@ fn spawnReader(gpa: std.mem.Allocator, pt: Pty, pane: u8, gen: u32, q: *Queue) v /// the pty readers, and ended the same way — teardown closes the fd, the read /// fails, the thread returns. fn watchThread(fd: c_int, q: *Queue) void { + // A kqueue cannot be read, so the macos arm parks in kevent(2) instead and + // is released by the teardown's `file_watch.stop`. See file_watch.wait. + if (comptime builtin.os.tag != .linux) { + while (file_watch.wait(fd)) q.push(.files_changed); + return; + } var buf: [4096]u8 = undefined; while (true) { const n = libc.read(fd, &buf, buf.len); @@ -2100,13 +2105,17 @@ fn localSession( } var pipe_tasks: PipeTasks = .{}; defer pipe_tasks.cancelAll(io); - // One inotify instance for every watched pane, opened here — before any - // thread exists — so the pre-loop drain below can already mark the 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; + // One watcher for every watched pane, opened here — before any thread + // exists — so the pre-loop drain below can already mark the file a + // positional path argument opened. `false`: this host parks a thread in it + // rather than polling it. -1 where there is no watcher to make: watchPane + // goes quiet and the core simply never gets a file_changed event. + var inotify_fd: c_int = file_watch.init(false); defer if (inotify_fd >= 0) { - _ = libc.close(inotify_fd); // ends the detached watcher's read + // `stop` releases a kqueue wait (macos); the close ends the blocking + // read (linux). Both leave watchThread on its way out. + file_watch.stop(inotify_fd); + _ = libc.close(inotify_fd); inotify_fd = -1; }; var watches: file_watch.Table = @splat(null); @@ -5345,24 +5354,10 @@ const ResolvedCell = struct { clear_bg: bool = false, }; -fn topbarPaneBorderPixels(cell_h: u32, tagline_h: u32) u32 { - const spare = cell_h -| tagline_h; - return @min(@as(u32, config.gui_topbar_pane_border_px), spare * 2); -} - -/// Vertical offset of a tagline band inside its body-sized logical row. Row -/// zero and the first pane-tag row face a shared rule instead of centering two -/// independent bands. A Tagbottom band on the final grid row faces the window -/// edge, eliminating the matching unused half-band at the bottom. -fn taglineBandOffset(row: u16, canvas_h: f32, cell_h: u32, tagline_h: u32) u32 { - const spare = cell_h -| tagline_h; - const border = topbarPaneBorderPixels(cell_h, tagline_h); - if (row == 0) return spare - border / 2; - if (row == pardes.TOPBAR_H) return border - border / 2; - const rows: u32 = @max(1, @as(u32, @intFromFloat(@floor(canvas_h / @as(f32, @floatFromInt(cell_h)))))); - if (@as(u32, row) + 1 >= rows) return spare; - return spare / 2; -} +// Both moved to the core so the AppKit shell can call the SAME rule over the C +// ABI instead of keeping a second copy of it — see pardes.taglineBandOffset. +const topbarPaneBorderPixels = pardes.topbarPaneBorderPixels; +const taglineBandOffset = pardes.taglineBandOffset; fn topbarPaneBorderHeight(surface: *const pardes.Surface, cell_h: u32, tagline_h: u32) ?u32 { if (surface.rows <= pardes.TOPBAR_H) return null; diff --git a/src/macos.zig b/src/macos.zig index 9bbe3ac2..4d59fb90 100644 --- a/src/macos.zig +++ b/src/macos.zig @@ -829,6 +829,35 @@ export fn pardes_gui_tagline_font_percent() u8 { return taglineFontPercent(if (state) |*st| st.core else null); } +/// Where that smaller band sits inside its body-sized row, and the rule between +/// the topbar band and the first pane-tag band. Both answers come from the core +/// rather than being reimplemented here, because a second copy of this geometry +/// is exactly what left the native shell centring every band while the SDL +/// shell joined them (`pardes.taglineBandOffset`). +/// +/// PHYSICAL PIXELS, like the SDL shell's: a host working in points multiplies +/// by its backing scale on the way in and divides on the way out, which is the +/// same snapping it already does for the cell itself. +export fn pardes_tagline_band_offset(row: u16, canvas_h: f32, cell_h: u32, tagline_h: u32) u32 { + return pardes.taglineBandOffset(row, canvas_h, cell_h, tagline_h); +} + +export fn pardes_topbar_pane_border_px(cell_h: u32, tagline_h: u32) u32 { + return pardes.topbarPaneBorderPixels(cell_h, tagline_h); +} + +/// Colour of that rule: the compiled override when a build pins one, otherwise +/// the active theme's scrollbar track — the same resolution the SDL shell does +/// at `src/gui/gui.zig:3813`. PARDES_COLOR_DEFAULT before there is a session to +/// ask, which the host reads as "do not draw the rule yet". +export fn pardes_topbar_pane_border_rgb() u32 { + const rgb = pardes.config.gui_topbar_pane_border_rgb orelse fromTheme: { + const st = state orelse return color_default; + break :fromTheme st.core.chromeTheme().scroll_track; + }; + return @as(u32, rgb[0]) << 16 | @as(u32, rgb[1]) << 8 | rgb[2]; +} + test "tagline percent falls back before init and follows live core state" { try std.testing.expectEqual(pardes.config.gui_tagline_font_percent, taglineFontPercent(null)); @@ -2142,6 +2171,9 @@ test "pardes.h declares every export the way it is defined" { try expectSameAbi(@TypeOf(c.pardes_should_quit), @TypeOf(pardes_should_quit)); try expectSameAbi(@TypeOf(c.pardes_animating), @TypeOf(pardes_animating)); try expectSameAbi(@TypeOf(c.pardes_gui_tagline_font_percent), @TypeOf(pardes_gui_tagline_font_percent)); + try expectSameAbi(@TypeOf(c.pardes_tagline_band_offset), @TypeOf(pardes_tagline_band_offset)); + try expectSameAbi(@TypeOf(c.pardes_topbar_pane_border_px), @TypeOf(pardes_topbar_pane_border_px)); + try expectSameAbi(@TypeOf(c.pardes_topbar_pane_border_rgb), @TypeOf(pardes_topbar_pane_border_rgb)); try expectSameAbi(@TypeOf(c.pardes_scene), @TypeOf(pardes_scene)); try expectSameAbi(@TypeOf(c.pardes_postprocessor_unavailable), @TypeOf(pardes_postprocessor_unavailable)); try expectSameAbi(@TypeOf(c.pardes_panel_animation_failed), @TypeOf(pardes_panel_animation_failed)); @@ -2216,6 +2248,7 @@ test "pardes.h matches the Zig boundary" { try expectEqual(@as(u32, c.PARDES_COLOR_INDEXED), color_indexed); try expectEqual(@as(u8, c.PARDES_CELL_DEFAULT), cell_flag_default); try expectEqual(@as(u8, c.PARDES_CELL_TAGLINE), cell_flag_tagline); + try expectEqual(@as(u16, c.PARDES_TOPBAR_H), pardes.TOPBAR_H); try expectEqual(@as(u32, c.PARDES_SCENE_CRT), scene_flag_crt); try expectEqual(@as(u32, c.PARDES_SCENE_RIPPLE), scene_flag_ripple); try expectEqual(@as(u32, c.PARDES_SCENE_GLITCH), scene_flag_glitch); diff --git a/src/macos/Sources/PardesView.swift b/src/macos/Sources/PardesView.swift index aa8ac425..10c7fd8b 100644 --- a/src/macos/Sources/PardesView.swift +++ b/src/macos/Sources/PardesView.swift @@ -327,11 +327,17 @@ private struct Metrics { private struct TaglineMetrics { let percent: UInt8 let fonts: [CTFont] - let baseline: CGFloat + /// The tagline face's OWN ascent. Deliberately not folded together with the + /// band's top offset the way it used to be: that offset is per-row now, and + /// a baseline carrying one row's offset would pin every band back to centre. + let ascent: CGFloat let xOffset: CGFloat - let top: CGFloat let height: CGFloat let asciiGlyphs: [[CGGlyph]] + /// Kept so the band rules below can work in the physical pixels the core + /// states them in, and hand back points. + private let scale: CGFloat + private let bodyCellHeight: CGFloat init(body: Metrics, percent: UInt8, scale rawScale: CGFloat) { let scale = max(1, rawScale) @@ -339,6 +345,8 @@ private struct TaglineMetrics { (value * scale).rounded(rule) / scale } self.percent = percent + self.scale = scale + bodyCellHeight = body.cellHeight let size = CTFontGetSize(body.fonts[0]) * CGFloat(percent) / 100 let faces = body.fonts.map { CTFontCreateCopyWithAttributes($0, size, nil, nil) } fonts = faces @@ -349,9 +357,7 @@ private struct TaglineMetrics { // clamp here too: a malformed/custom host value must not paint a tag // over the body row below it. height = min(body.cellHeight, measuredHeight) - top = snap(max(0, (body.cellHeight - height) / 2), .toNearestOrAwayFromZero) - let ascent = max(1 / scale, snap(CTFontGetAscent(face), .toNearestOrAwayFromZero)) - baseline = snap(top + ascent, .toNearestOrAwayFromZero) + ascent = max(1 / scale, snap(CTFontGetAscent(face), .toNearestOrAwayFromZero)) xOffset = snap(max(0, (body.cellWidth - advance(face, 0x4D)) / 2), .toNearestOrAwayFromZero) asciiGlyphs = faces.map { font in var chars = Array(UniChar(0)..<UniChar(128)) @@ -360,6 +366,33 @@ private struct TaglineMetrics { return glyphs } } + + private var cellPixels: UInt32 { UInt32(max(1, (bodyCellHeight * scale).rounded())) } + private var bandPixels: UInt32 { UInt32(max(1, (height * scale).rounded())) } + + /// Where this band sits inside row `row`, measured down from the row's top. + /// The rule belongs to the core (`pardes_tagline_band_offset`) so that this + /// shell and the SDL one cannot disagree about it: row zero and the first + /// pane-tag row meet at a shared rule, a Tagbottom band on the last row + /// faces the window edge, and everything between is centred. + func top(row: Int, canvasHeight: CGFloat) -> CGFloat { + CGFloat(pardes_tagline_band_offset( + UInt16(clamping: row), Float(canvasHeight * scale), cellPixels, bandPixels)) / scale + } + + /// Thickness of the rule joining the topbar band to the first pane-tag band, + /// zero when the two are meant to join directly. + var borderThickness: CGFloat { + CGFloat(pardes_topbar_pane_border_px(cellPixels, bandPixels)) / scale + } + + /// Top of that rule. Placed the way the SDL shell places it, in WHOLE + /// pixels: half the rule above the topbar/pane boundary, floored, so a + /// one-pixel rule lands on the boundary instead of smeared across two rows. + var borderTop: CGFloat { + let px = pardes_topbar_pane_border_px(cellPixels, bandPixels) + return (CGFloat(cellPixels * UInt32(PARDES_TOPBAR_H)) - CGFloat(px / 2)) / scale + } } private func modifiers(_ flags: NSEvent.ModifierFlags) -> UInt32 { @@ -794,9 +827,15 @@ final class PardesView: NSView { // that feel slow. Antialiasing is off because touching rects share an // edge, and blending that edge twice draws a visible seam. ctx.setShouldAntialias(false) + // Band geometry is stated against the whole canvas: the last row's rule + // depends on where the window edge is, not just on the row index. + let canvasHeight = bounds.height for row in 0..<rows { let base = row * cols let y = CGFloat(row) * cellHeight + // One offset per ROW, not per run: it is the same answer for every + // cell in the row, and it is a call across the ABI. + let bandTop = taglines.top(row: row, canvasHeight: canvasHeight) var start = 0 var color = resolve(cells[base], block: blockY == row && blockX == 0, ground: ground, clearGround: clearGround).bg @@ -814,7 +853,7 @@ final class PardesView: NSView { // bgClear runs are the ground showing through, and the ground is // already clear — painting them would be painting the hole shut. if color != bgClear { - let bandY = tagline ? y + taglines.top : y + let bandY = tagline ? y + bandTop : y let bandHeight = tagline ? taglines.height : cellHeight fill(ctx, CGRect(x: CGFloat(start) * cellWidth, y: bandY, width: CGFloat(col - start) * cellWidth, height: bandHeight), @@ -826,6 +865,50 @@ final class PardesView: NSView { } } + // The rule joining the topbar band to the first pane-tag band. Both were + // centred in their own rows until now, and centring two reduced-height + // bands is exactly what leaves their unused half-bands touching with a + // strip of window background showing between them — the case + // config.gui_topbar_pane_border_px exists to close. Drawn only when that + // first pane row really carries a band, so a session whose second row is + // pane BODY gets no stray line across it. + let ruleThickness = taglines.borderThickness + let ruleColor = pardes_topbar_pane_border_rgb() + if ruleThickness > 0, ruleColor != UInt32(PARDES_COLOR_DEFAULT), + rows > Int(PARDES_TOPBAR_H) { + let base = Int(PARDES_TOPBAR_H) * cols + let joins = (0..<cols).contains { col in + let flags = cells[base + col].flags + return flags & UInt8(PARDES_CELL_DEFAULT) == 0 + && flags & UInt8(PARDES_CELL_TAGLINE) != 0 + } + if joins { + fill(ctx, CGRect(x: 0, y: taglines.borderTop, width: bounds.width, + height: ruleThickness), ruleColor, 1) + } + } + + // The core grid contains only complete cells, so any window height that + // is not a whole multiple of the cell leaves a strip below the last row. + // A Tagbottom band there is bottom-aligned against the window edge, and + // without this that strip shows the page colour under it. + let gridBottom = CGFloat(rows) * cellHeight + if gridBottom < canvasHeight, rows > 0 { + let base = (rows - 1) * cols + for col in 0..<cols { + let cell = cells[base + col] + guard cell.flags & UInt8(PARDES_CELL_DEFAULT) == 0, + cell.flags & UInt8(PARDES_CELL_TAGLINE) != 0 else { continue } + let bg = resolve(cell, block: false, ground: ground, + clearGround: clearGround).bg + if bg != bgClear { + fill(ctx, CGRect(x: 0, y: gridBottom, width: bounds.width, + height: canvasHeight - gridBottom), bg, 1) + } + break + } + } + // CTFontDrawGlyphs lays glyph outlines out with +y up, and isFlipped hands // us a y-down CTM, so drawing text directly in view space renders every // line mirrored. Un-flip once for the whole glyph pass and convert each @@ -852,6 +935,7 @@ final class PardesView: NSView { for row in 0..<rows { drawRow(ctx, cells, base: row * cols, cols: cols, baseline: height - (CGFloat(row) * cellHeight + metrics.ascent), + taglineTop: taglines.top(row: row, canvasHeight: canvasHeight), blockCol: blockY == row ? blockX : -1) } ctx.restoreGState() @@ -876,7 +960,8 @@ final class PardesView: NSView { let fg = resolve(cell, block: false, ground: themeBG ?? pardesDefaultBG, clearGround: false).fg let tagline = cell.flags & UInt8(PARDES_CELL_TAGLINE) != 0 - let caretY = CGFloat(y) * cellHeight + (tagline ? taglines.top : 0) + let caretY = CGFloat(y) * cellHeight + + (tagline ? taglines.top(row: y, canvasHeight: bounds.height) : 0) let caretHeight = tagline ? taglines.height : cellHeight ctx.setShouldAntialias(false) fill(ctx, CGRect(x: CGFloat(x) * cellWidth, y: caretY, @@ -1044,6 +1129,10 @@ final class PardesView: NSView { base: Int, cols: Int, baseline: CGFloat, + /// Where this row's tagline band starts, from the row's top. Passed in + /// rather than recomputed per cell: it is one answer per row, and the + /// glyph has to sit in the band the background pass painted. + taglineTop: CGFloat, blockCol: Int ) { var runFace = Face.regular @@ -1069,7 +1158,7 @@ final class PardesView: NSView { if cell.flags & UInt8(PARDES_CELL_DEFAULT) != 0 { continue } let tagline = cell.flags & UInt8(PARDES_CELL_TAGLINE) != 0 let cellBaseline = tagline - ? baseline + metrics.ascent - taglines.baseline + ? baseline + metrics.ascent - (taglineTop + taglines.ascent) : baseline // clearGround: false — this pass only reads `fg`, and a glyph is // never the hole in the ground. diff --git a/src/macos/pardes.h b/src/macos/pardes.h index e9d7bb41..8b798df6 100644 --- a/src/macos/pardes.h +++ b/src/macos/pardes.h @@ -257,6 +257,27 @@ uint32_t pardes_theme_bg(void); // polls it and rebuilds only tagline glyph/metric state. uint8_t pardes_gui_tagline_font_percent(void); +// Where a reduced-height tagline band sits inside its body-sized grid row, in +// PHYSICAL PIXELS down from the row's top, and the rule joining the topbar band +// to the first pane-tag band. The core owns this geometry so that this shell and +// the SDL one cannot disagree about it; centring every band is what this shell +// used to do, and it left a strip of window background between the two. +// +// Points in, pixels out: multiply by the backing scale before calling and divide +// the answer back, the same snapping the cell metrics already use. +uint32_t pardes_tagline_band_offset(uint16_t row, float canvas_h, uint32_t cell_h, + uint32_t tagline_h); +uint32_t pardes_topbar_pane_border_px(uint32_t cell_h, uint32_t tagline_h); + +// Colour of that rule: a compiled override, else the theme's scrollbar track. +// PARDES_COLOR_DEFAULT before there is a session to ask — do not draw it then. +uint32_t pardes_topbar_pane_border_rgb(void); + +// Rows the global topbar occupies, and therefore the row index of the FIRST +// pane tagline — the one the rule above joins the topbar band to. Mirrors +// pardes.TOPBAR_H, and src/macos.zig asserts the two agree. +#define PARDES_TOPBAR_H 1u + // Persistent full-window effect switches and their display-clock time. A zero // flags word means draw the CoreText frame directly; any nonzero combination // is one postprocess pass. Snapshot by value, with no borrowed storage. diff --git a/src/pardes.zig b/src/pardes.zig index bc9a0939..1eae2113 100644 --- a/src/pardes.zig +++ b/src/pardes.zig @@ -199,6 +199,36 @@ pub const MAX_JUMPS = 64; pub const TOPBAR_H: u16 = 1; pub const BOX_H: u16 = 1; +/// Where a reduced-height tagline band sits inside its body-sized grid row, in +/// physical pixels down from the row's top. ONE rule for both pixel hosts: the +/// SDL shell (`src/gui/gui.zig`) and the AppKit shell (`src/macos.zig`, over the +/// C ABI) both call this. It lived in gui.zig, the native shell grew its own +/// copy that only ever centred, and centring is precisely the case +/// `config.gui_topbar_pane_border_px` exists to avoid: two half-bands touching +/// with a strip of window background showing between them, widening as the +/// tagline face shrinks. +/// +/// Row zero and the first pane-tag row face a shared rule instead of centering +/// two independent bands. A Tagbottom band on the final grid row faces the +/// window edge, eliminating the matching unused half-band at the bottom. +pub fn taglineBandOffset(row: u16, canvas_h: f32, cell_h: u32, tagline_h: u32) u32 { + const spare = cell_h -| tagline_h; + const border = topbarPaneBorderPixels(cell_h, tagline_h); + if (row == 0) return spare - border / 2; + if (row == TOPBAR_H) return border - border / 2; + const rows: u32 = @max(1, @as(u32, @intFromFloat(@floor(canvas_h / @as(f32, @floatFromInt(cell_h)))))); + if (@as(u32, row) + 1 >= rows) return spare; + return spare / 2; +} + +/// The rule between the topbar band and the first pane-tag band, clamped to the +/// spare pixels those two bands have between them so a wide compiled value +/// cannot paint over either. +pub fn topbarPaneBorderPixels(cell_h: u32, tagline_h: u32) u32 { + const spare = cell_h -| tagline_h; + return @min(@as(u32, config.gui_topbar_pane_border_px), spare * 2); +} + /// A place the keyboard has been: a pane AND a spot in it, which is the whole /// upgrade over the stack of bare pane ids this replaces — Ctrl-o can now /// rewind WITHIN a pane, and a Jumplist row can name a line. @@ -330,7 +360,10 @@ fn drainForAttach(p: *Pardes) ?AttachRequest { } test "Attach asks for a session and tears nothing down" { - if (comptime !hosted) return; // no unix socket on this platform, so no word + // `can_attach` and not `hosted`: 29ac9be compiled both words out of a + // frontend that never polls `takeAttach`, which is macOS — hosted, with a + // unix socket, and still no word to run here. + if (comptime !can_attach) return; const gpa = std.testing.allocator; const p = try Pardes.init(gpa, .{ .tty_only = true }); defer p.deinit(); @@ -356,7 +389,7 @@ test "Attach asks for a session and tears nothing down" { } test "Detach asks the frontend to leave, and says so when there is nothing to leave" { - if (comptime !hosted) return; // no unix socket on this platform, so no word + if (comptime !can_attach) return; // compiled out where nothing polls `takeAttach` — see `can_attach` const gpa = std.testing.allocator; const p = try Pardes.init(gpa, .{ .tty_only = true }); defer p.deinit(); @@ -1384,12 +1417,20 @@ test "a Look on a directory does not type ls into an occupied terminal" { while (p.nextEffect()) |_| {} // /tmp rather than a made-up name: the look resolves against the real - // filesystem, so the directory has to exist for this arm to be reached - p.setCwd(0, "/tmp"); + // filesystem, so the directory has to exist for this arm to be reached — + // and in the spelling `resolve` HANDS BACK, which is the realpath. `/tmp` + // is itself on Linux and a symlink to `/private/tmp` on Darwin, and the + // `.dir` arm matches a pane by `cwdSlice()` against that realpath, on the + // documented invariant that pane paths are canonical (see `lookAt`). A + // literal "/tmp" therefore matched nothing on a Mac and forked a second + // terminal for a directory that already had one. + var realbuf: [4096]u8 = undefined; + const tmp = look.resolve("/tmp", "/", &realbuf).dir; + p.setCwd(0, tmp); var host: FakeTtyQuery = .{}; host.install(p); host.taken[0] = true; - p.lookAt(0, "/tmp"); + p.lookAt(0, tmp); var spawned = false; while (p.nextEffect()) |effect| switch (effect) { @@ -1402,7 +1443,7 @@ test "a Look on a directory does not type ls into an occupied terminal" { // ...and the same look reuses that very pane once its program is gone: the // core keeps no state of its own about it, so recovery needs nothing reset host.taken[0] = false; - p.lookAt(0, "/tmp"); + p.lookAt(0, tmp); var sent: [64]u8 = undefined; var sent_len: usize = 0; while (p.nextEffect()) |effect| switch (effect) { diff --git a/src/tty/tty.zig b/src/tty/tty.zig index f8f7150b..e8a36035 100644 --- a/src/tty/tty.zig +++ b/src/tty/tty.zig @@ -11,7 +11,6 @@ const std = @import("std"); const builtin = @import("builtin"); const posix = std.posix; const libc = std.c; -const linux = std.os.linux; const vaxis = @import("vaxis"); const pardes = @import("../pardes.zig"); const tracy = @import("../tracy.zig"); @@ -343,8 +342,8 @@ 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; +test "host watch closes initial race and reloads rename-over PDF while idle" { + if (comptime !file_watch.supported or !pardes.pdf_enabled) return; const io = std.testing.io; const gpa = std.testing.allocator; var tmp = std.testing.tmpDir(.{}); @@ -361,8 +360,8 @@ test "Linux host watch closes initial race and reloads rename-over PDF while idl 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; + const fd = file_watch.init(true); + if (fd < 0) return error.WatchInitFailed; defer _ = libc.close(fd); var watches: file_watch.Table = @splat(null); defer for (0..pardes.MAX_PANES) |id| file_watch.watchPane( @@ -394,9 +393,7 @@ test "Linux host watch closes initial race and reloads rename-over PDF while idl 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); + try std.testing.expect(file_watch.drain(fd)); // This is the same pass the watcher thread schedules; no key, mouse, or // synthetic core file_changed event participates in the transaction. @@ -699,11 +696,12 @@ fn localSession( .kitty = kitty_handles, .frame = &frame_arena, .paste_buf = paste_buf, - // One inotify instance for every watched pane, opened here — before - // any thread exists — so the pre-loop effect drain below can already - // mark the file a positional path argument opened. -1 off linux: + // One watcher for every watched pane, opened here — before any thread + // exists — so the pre-loop effect drain below can already mark the file + // a positional path argument opened. `false`: this host parks a thread + // in it rather than polling it. -1 where there is no watcher to make: // watchPane goes quiet and the core simply never gets a file_changed. - .inotify_fd = if (builtin.os.tag == .linux) libc.inotify_init1(linux.IN.CLOEXEC) else -1, + .inotify_fd = file_watch.init(false), .fs = fs, }; // The protocol client's reader threads narrate server state through this @@ -746,8 +744,10 @@ fn localSession( sh.lsp_task = null; } sh.pipe_tasks.cancelAll(io); - // same contract as the pty readers: cancel unblocks the watcher's - // read, and only then is the fd safe to close + // same contract as the pty readers: the watcher has to be off the + // descriptor before it is closed. `stop` is what releases a kqueue wait + // (macos); `cancel` is what interrupts the blocking read (linux). + file_watch.stop(sh.inotify_fd); if (sh.watch_task) |*t| { t.cancel(io) catch {}; sh.watch_task = null; @@ -1679,6 +1679,13 @@ fn pipeWorker( /// per open file pane; filter by basename here if it ever shows up in a /// profile. fn watchFiles(io: std.Io, fd: c_int, loop: *Loop) anyerror!void { + // A kqueue cannot be read, so there is no std.Io.File to wrap and no + // `cancel` to interrupt: this arm parks in kevent(2) and the teardown's + // `file_watch.stop` is what releases it. See file_watch.wait. + if (comptime builtin.os.tag != .linux) { + while (file_watch.wait(fd)) loop.postEvent(.files_changed) catch break; + return; + } const file: std.Io.File = .{ .handle = fd, .flags = .{ .nonblocking = false } }; var read_buf: [4096]u8 = undefined; var reader = file.readerStreaming(io, &read_buf); |
