summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/CHANGELOG.md22
-rw-r--r--src/config.zig11
-rw-r--r--src/detached/client.zig17
-rw-r--r--src/detached/server.zig18
-rw-r--r--src/effect_sources.zig12
-rw-r--r--src/file_watch.zig355
-rw-r--r--src/gui/gui.zig45
-rw-r--r--src/macos.zig33
-rw-r--r--src/macos/Sources/PardesView.swift105
-rw-r--r--src/macos/pardes.h21
-rw-r--r--src/pardes.zig53
-rw-r--r--src/tty/tty.zig35
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);