From cce6b18a49870086982f9a0e1fda90ed170b9fba Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Tue, 1 Sep 2026 13:46:44 -0300 Subject: macos: one tagline rule for both hosts, a kqueue beside the inotify, and effects that compile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three things this shell had its own copy of, and in each case the fix is that it stops having one. **The tagline band.** A pane tag draws at `gui_tagline_font_percent` of the body face and the band it sits on shrinks with it, while the grid row stays body-sized — so something has to decide where the shorter band sits in the taller row. This shell decided by centring, always, which is precisely the case `config.gui_topbar_pane_border_px` exists to prevent: the topbar's unused half-band meets the first pane tag's unused half-band and the window background shows through the seam. The strip is as wide as the bands are short — on a 20-pixel cell, 4 physical pixels at the default 82%, 10 at 50%, 14 at 30% — so it grew as the tagline face shrank and read as "the tagline is wrong on the mac" rather than as one missing rule. The rule is `pardes.taglineBandOffset` in the core now and both pixel hosts call it: row zero bottom-aligned, the first pane-tag row top-aligned, the two joined by `gui_topbar_pane_border_px` in the theme's scrollbar-track colour, every row between centred, and a `Tagbottom` band on the final row flush with the window edge — with the sub-cell strip beneath it painted in that band's own colour, because the core grid holds only whole cells and a window is any height it likes. `pardes_tagline_band_offset`, `pardes_topbar_pane_border_px` and `pardes_topbar_pane_border_rgb` carry it over the C ABI as PHYSICAL pixels: the host multiplies its points by the backing scale going in and divides coming out, which is the snapping `Metrics` already does for the cell, and is what keeps a one-pixel rule one pixel instead of a two-pixel smear. **The watch.** `file_watch.zig` was one mark/reconcile transaction over `inotify`, so the tty shell, the SDL window and the detached daemon all watched nothing off Linux: an edit made outside pardes never reached the pane, and a PDF replaced on disk kept rendering the old inode. It is the same transaction over two kernels now — `init`, `wait`, `stop`, `drain`, `markDir` and `unmarkDir` are still the whole of it, and the hosts wait on a kqueue and poll it 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 mark follows rename-over saves, `markFile` catches in-place writes, and `remarkFile` re-arms the file filter once a rename has moved the inode. That is the same pair the AppKit host's DispatchSources already used for the same reason. Directory marks are deduplicated here by device and inode, because each `EVFILT_VNODE` filter needs a descriptor of its own and inotify did that deduplication itself; `stop` and `drain` wake through the one `EVFILT_USER` filter, since a kqueue cannot simply be read the way an inotify descriptor can. **The effects.** The three `crt.ci.metal` entry points are `extern "C" [[stitchable]]`. `CIKernel.kernels(withMetalString:)` compiles that source at runtime, looks for stitchable functions, and rejects the WHOLE source with "cannot find a valid stitchable Metal function in the source" when it finds none — so `ScenePostprocessor.init?` returned nil and every scene effect and panel transition silently degraded to the plain CoreText draw. The `effect_sources.zig` test pins the exact spelling of all three, and `draw-effect` in the e2e suite catches the degradation rather than the spelling. Beside them, the offscreen harness owes the core a PRESENTATION. Its window is borderless and never ordered front, so AppKit runs no display cycle and `pardes_frame_presented` — whose only caller is `draw(_:)` — never fired. The core holds pointer gestures inert while a layout mutation has not reached a backend, which for an unpresenting harness is the rest of the script: the first pane a script opened silently killed every later click, drag and Look. So `readFrame` presents what it just rendered, into a bitmap nobody reads. `PARDES_CHROME` also looks under `/Applications`, where a browser's executable lives inside an application bundle and never on `PATH`. The macOS goldens are regenerated; docs/macos.md, config.md, detached.md, web.md and the design PDF follow. --- src/CHANGELOG.md | 22 +++ src/config.zig | 11 +- src/detached/client.zig | 17 +- src/detached/server.zig | 18 +- src/effect_sources.zig | 12 +- src/file_watch.zig | 355 +++++++++++++++++++++++++++++++++++-- src/gui/gui.zig | 45 +++-- src/macos.zig | 33 ++++ src/macos/Sources/PardesView.swift | 105 ++++++++++- src/macos/pardes.h | 21 +++ src/pardes.zig | 53 +++++- src/tty/tty.zig | 35 ++-- 12 files changed, 637 insertions(+), 90 deletions(-) (limited to 'src') 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).. 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.. 0, ruleColor != UInt32(PARDES_COLOR_DEFAULT), + rows > Int(PARDES_TOPBAR_H) { + let base = Int(PARDES_TOPBAR_H) * cols + let joins = (0.. 0 { + let base = (rows - 1) * cols + for col in 0..= 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); -- cgit v1.3