summaryrefslogtreecommitdiff
path: root/src/macos.zig
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-08-16 15:49:12 -0300
committerGabriel Schneider <[email protected]>2026-08-18 23:44:42 -0300
commit1551e409c31992437cb2fa864f576d45c8433801 (patch)
treee2fae8451f87b735a1360c7c2e383fdc40165789 /src/macos.zig
parentbe2a9957708cbf0c478ca861c4a1f0f227bbfe10 (diff)
downloadpardes-1551e409c31992437cb2fa864f576d45c8433801.tar.gz
pardes-1551e409c31992437cb2fa864f576d45c8433801.zip
big slow change: prebuilt shaders (SPIR-V/Metal), core gui reflow, docs, web + snapshot refresh
Diffstat (limited to 'src/macos.zig')
-rw-r--r--src/macos.zig843
1 files changed, 788 insertions, 55 deletions
diff --git a/src/macos.zig b/src/macos.zig
index 100019ef..9f971e5f 100644
--- a/src/macos.zig
+++ b/src/macos.zig
@@ -29,13 +29,12 @@ const temp_file = @import("temp_file.zig");
const shell_bin = @import("shell_bin.zig");
const message = @import("message.zig");
const nested = @import("nested.zig");
+const panel_animation = @import("panel_animation.zig");
+const file_watch = @import("file_watch.zig");
/// The geometry types the pixel-attachment ABI carries. Behind the same
/// comptime gate the placements themselves are: a build without MuPDF emits no
/// attachments, so nothing here is analysed.
const image = if (pardes.pdf_enabled) @import("image.zig") else struct {};
-const fonts = if (pardes.font_picker) @import("fonts.zig") else struct {
- pub const want: ?[]const u8 = null;
-};
const user_config = @import("user_config.zig");
extern "c" fn forkpty(amaster: *c_int, name: ?[*:0]u8, termp: ?*const anyopaque, winp: ?*const posix.winsize) c_int;
@@ -44,6 +43,21 @@ extern "c" fn chdir(path: [*:0]const u8) c_int;
extern "c" fn _exit(status: c_int) noreturn;
extern "c" fn setenv(name: [*:0]const u8, value: [*:0]const u8, overwrite: c_int) c_int;
+/// Implemented by FileWatcher.swift in the app and e2e host. Zig-only unit
+/// tests have no AppKit runloop and compile this call away; the shipped static
+/// library leaves the symbol for its Swift executable to satisfy directly.
+extern "c" fn pardes_host_watch_file(
+ pane: u8,
+ generation: u32,
+ path: ?[*]const u8,
+ path_len: usize,
+) void;
+
+fn hostWatchFile(pane: u8, generation: u32, path: ?[*]const u8, path_len: usize) void {
+ if (comptime builtin.is_test) return;
+ pardes_host_watch_file(pane, generation, path, path_len);
+}
+
// TIOCSWINSZ: absent from std.c.T on darwin — _IOW('t', 103, winsize). Same
// constant the tty and gui shells spell for the same reason.
const TIOCSWINSZ: c_int = @bitCast(@as(u32, if (@hasDecl(posix.T, "IOCSWINSZ")) posix.T.IOCSWINSZ else 0x80087467));
@@ -84,6 +98,20 @@ pub const Cell = extern struct {
flags: u8,
};
+/// Sync with: pardes_scene_s. Persistent full-window effects share one host
+/// postprocess, so one plain snapshot carries both its switches and clock.
+pub const Scene = extern struct {
+ flags: u32 = 0,
+ time_seconds: f32 = 0,
+ frame: u32 = 0,
+};
+
+/// Sync with pardes_panel_{box,track}_s. The core's backend-neutral Track is
+/// already an extern POD record, so the native boundary can publish it without
+/// translating the easing vocabulary into a second representation.
+pub const PanelBox = panel_animation.Box;
+pub const PanelTrack = panel_animation.Track;
+
/// Sync with: pardes_image_s. One rasterized attachment — a PDF page, or an
/// image pane's pixels — and where on the grid it goes.
///
@@ -136,6 +164,14 @@ pub const Runtime = extern struct {
const color_default: u32 = 0x01000000;
const color_indexed: u32 = 0x02000000;
const cell_flag_default: u8 = 1;
+const cell_flag_tagline: u8 = 2;
+const scene_flag_crt: u32 = 1 << 0;
+const scene_flag_ripple: u32 = 1 << 1;
+const scene_flag_glitch: u32 = 1 << 2;
+const scene_frame_hz: u32 = 60;
+/// Keep the float-valued time and noise frame precise, then repeat after a
+/// little over an hour. None of the effects has state across this boundary.
+const scene_frame_wrap: u32 = 4096 * scene_frame_hz;
// ---------------------------------------------------------------- state
@@ -149,6 +185,192 @@ const Pty = struct {
reader: std.Io.Future(anyerror!void),
};
+/// Main-thread ownership for the host's per-pane vnode sources. A path is
+/// copied rather than borrowed from Pane: a queued callback may outlive the
+/// effect which replaced that pane slot, and exact path equality is the final
+/// guard before any bytes reach the core.
+const WatchedFile = struct {
+ path: []u8,
+ serial: u32,
+ generation_on_disk: file_watch.Generation,
+ generation: u32,
+ /// One self-scheduled reconciliation after a transient read/reopen race.
+ /// A real host edge replenishes it; a malformed stable file therefore
+ /// tries twice and then sleeps rather than becoming an idle busy loop.
+ retries_left: u8 = 1,
+};
+
+const FileWatches = struct {
+ entries: [pardes.MAX_PANES]?WatchedFile = @splat(null),
+ generations: [pardes.MAX_PANES]u32 = @splat(0),
+ dirty: [pardes.MAX_PANES]bool = @splat(false),
+
+ fn nextGeneration(watches: *FileWatches, pane: u8) u32 {
+ watches.generations[pane] +%= 1;
+ if (watches.generations[pane] == 0) watches.generations[pane] = 1;
+ return watches.generations[pane];
+ }
+
+ fn replace(
+ watches: *FileWatches,
+ gpa: std.mem.Allocator,
+ pane: u8,
+ path: []const u8,
+ serial: u32,
+ generation_on_disk: file_watch.Generation,
+ ) !u32 {
+ // Allocate first. If memory is tight, the caller can explicitly stop
+ // the old source; silently retaining a watch for a reused pane would be
+ // worse than having no watch at all.
+ const owned = try gpa.dupe(u8, path);
+ if (watches.entries[pane]) |old| gpa.free(old.path);
+ const generation = watches.nextGeneration(pane);
+ watches.entries[pane] = .{
+ .path = owned,
+ .serial = serial,
+ .generation_on_disk = generation_on_disk,
+ .generation = generation,
+ };
+ watches.dirty[pane] = false;
+ return generation;
+ }
+
+ fn stop(watches: *FileWatches, gpa: std.mem.Allocator, pane: u8) u32 {
+ if (watches.entries[pane]) |old| gpa.free(old.path);
+ watches.entries[pane] = null;
+ watches.dirty[pane] = false;
+ return watches.nextGeneration(pane);
+ }
+
+ /// Coalesce any number of vnode events into one main-thread re-read.
+ /// The Swift side already debounces a burst; this bit is the second, cheap
+ /// edge which prevents two queued callbacks from applying one snapshot
+ /// twice. A stale generation can never dirty a reused pane slot.
+ fn notify(watches: *FileWatches, pane: u8, generation: u32) bool {
+ const watched = if (watches.entries[pane]) |*entry| entry else return false;
+ if (watched.generation != generation) return false;
+ watched.retries_left = 1;
+ const first = !watches.dirty[pane];
+ watches.dirty[pane] = true;
+ return first;
+ }
+
+ fn retry(watches: *FileWatches, pane: u8, generation: u32) bool {
+ const watched = if (watches.entries[pane]) |*entry| entry else return false;
+ if (watched.generation != generation or watched.retries_left == 0) return false;
+ watched.retries_left -= 1;
+ const first = !watches.dirty[pane];
+ watches.dirty[pane] = true;
+ return first;
+ }
+
+ fn takeDirty(watches: *FileWatches, pane: u8) bool {
+ const value = watches.dirty[pane];
+ watches.dirty[pane] = false;
+ return value;
+ }
+
+ fn restampText(watches: *FileWatches, pane: u8, path: []const u8, hash: u64) void {
+ const watched = if (watches.entries[pane]) |*entry| entry else return;
+ if (!std.mem.eql(u8, watched.path, path)) return;
+ switch (watched.generation_on_disk) {
+ .text => watched.generation_on_disk = .{ .text = hash },
+ .pdf => {},
+ }
+ }
+
+ fn deinit(watches: *FileWatches, gpa: std.mem.Allocator) void {
+ for (&watches.entries) |*entry| {
+ if (entry.*) |old| gpa.free(old.path);
+ entry.* = null;
+ }
+ watches.dirty = @splat(false);
+ }
+};
+
+test "mac file watch generations own paths, coalesce, and reject stale callbacks" {
+ var watches: FileWatches = .{};
+ defer watches.deinit(std.testing.allocator);
+
+ var first_path = [_]u8{ '/', 't', 'm', 'p', '/', 'a' };
+ const first = try watches.replace(
+ std.testing.allocator,
+ 3,
+ &first_path,
+ 41,
+ .{ .text = 11 },
+ );
+ first_path[first_path.len - 1] = 'x';
+ try std.testing.expectEqualStrings("/tmp/a", watches.entries[3].?.path);
+ try std.testing.expect(watches.notify(3, first));
+ try std.testing.expect(!watches.notify(3, first));
+ try std.testing.expect(watches.takeDirty(3));
+ try std.testing.expect(!watches.takeDirty(3));
+
+ const second = try watches.replace(
+ std.testing.allocator,
+ 3,
+ "/tmp/b",
+ 42,
+ .{ .text = 22 },
+ );
+ try std.testing.expect(second != first);
+ try std.testing.expect(!watches.notify(3, first));
+ try std.testing.expect(watches.notify(3, second));
+ try std.testing.expectEqual(@as(u8, 1), watches.entries[3].?.retries_left);
+ try std.testing.expect(watches.takeDirty(3));
+ try std.testing.expect(watches.retry(3, second));
+ try std.testing.expect(!watches.retry(3, second));
+ try std.testing.expect(watches.takeDirty(3));
+ // A stable malformed file cannot self-wake forever, but a later real vnode
+ // edge replenishes exactly one retry for the new external transaction.
+ try std.testing.expect(watches.notify(3, second));
+ try std.testing.expectEqual(@as(u8, 1), watches.entries[3].?.retries_left);
+ try std.testing.expect(watches.takeDirty(3));
+ watches.restampText(3, "/tmp/not-b", 99);
+ try std.testing.expectEqual(@as(u64, 22), watches.entries[3].?.generation_on_disk.text);
+ watches.restampText(3, "/tmp/b", 33);
+ try std.testing.expectEqual(@as(u64, 33), watches.entries[3].?.generation_on_disk.text);
+
+ const stopped = watches.stop(std.testing.allocator, 3);
+ try std.testing.expect(stopped != second);
+ try std.testing.expect(!watches.notify(3, second));
+ try std.testing.expect(!watches.retry(3, second));
+ try std.testing.expect(watches.entries[3] == null);
+
+ _ = try watches.replace(std.testing.allocator, 4, "/tmp/book.pdf", 77, .{ .pdf = null });
+ watches.restampText(4, "/tmp/book.pdf", 123);
+ try std.testing.expect(watches.entries[4].?.generation_on_disk.pdf == null);
+}
+
+const file_watcher_swift = @embedFile("macos/Sources/FileWatcher.swift");
+
+test "mac host watcher covers file and directory vnode events, debounce, and generation callback" {
+ // Linux cannot compile AppKit/Dispatch Swift. Keep the critical architecture
+ // check reachable there: atomic saves need the parent, in-place writes need
+ // a rearmed file source, and all mutation returns through the generation ABI.
+ try std.testing.expect(std.mem.indexOf(
+ u8,
+ file_watcher_swift,
+ "deletingLastPathComponent()",
+ ) != null);
+ try std.testing.expect(std.mem.indexOf(
+ u8,
+ file_watcher_swift,
+ "self.reopenFileSource()",
+ ) != null);
+ try std.testing.expect(std.mem.indexOf(
+ u8,
+ file_watcher_swift,
+ "DispatchQueue.main.asyncAfter",
+ ) != null);
+ try std.testing.expect(std.mem.indexOf(
+ u8,
+ file_watcher_swift,
+ "pardes_watch_changed(self.pane, self.generation)",
+ ) != null);
+}
+
/// What a reader task hands the main thread. `gen` travels with the message so
/// a shell that was replaced while its read was in flight cannot have its
/// stragglers parsed into the pty that took its slot.
@@ -276,6 +498,12 @@ const State = struct {
arena: std.heap.ArenaAllocator,
runtime: Runtime,
cells: []Cell = &.{},
+ /// Frozen canonical grid paired with `changed_cells` while a content or
+ /// lifecycle transition is active. Both are encoded at frame time so the
+ /// native renderer never borrows core-owned Cell layout across the ABI.
+ previous_cells: []Cell = &.{},
+ changed_cells: []u8 = &.{},
+ panel_diff_len: usize = 0,
frame_len: usize = 0,
/// The grid `cells` actually holds. Not read back off the core: a render
/// can move screen_w/screen_h and then fail, and a host that sized its
@@ -287,8 +515,13 @@ const State = struct {
/// accessors must never describe a different frame than the cell count.
images: []Image = &.{},
images_len: usize = 0,
+ /// This frame's panel transitions, copied out of Surface in deterministic
+ /// paint order: moving, opening, then frozen closing tombstones.
+ panel_tracks: [pardes.MAX_PANES * 2]PanelTrack = undefined,
+ panel_tracks_len: usize = 0,
ptys: [pardes.MAX_PANES]?Pty = @splat(null),
inbox: Inbox = .{},
+ file_watches: FileWatches = .{},
/// Per-slot spawn generation, owned by the main thread. A reader carries a
/// copy in every message it posts; anything that no longer matches belongs
/// to a shell this slot has already replaced.
@@ -313,6 +546,9 @@ const State = struct {
/// velocity because during the gesture that velocity is a MEASUREMENT —
/// spending it then would double every twist under the hand making it.
rotate_coasting: bool = false,
+ /// Display-clock time for the persistent Core Image scene pass. Input and
+ /// pty pumps never spend it; pardes_animation_tick is the only writer.
+ scene_frame: u32 = 0,
/// Panes whose shell has produced output since we last read its cwd.
///
/// The cwd is wanted for pane tags and for resolving a relative Look, and
@@ -329,6 +565,8 @@ const State = struct {
sock_fd: c_int = -1,
/// Owns the bytes of the user config, which Options only borrows.
config_arena: std.heap.ArenaAllocator,
+ /// Private prompt snippets borrowed by every child argv until exec.
+ prompt_rcs: shell_bin.PromptRcs,
};
var state: ?State = null;
@@ -397,10 +635,10 @@ fn initCore(runtime: ?*const Runtime, cols_arg: u16, rows_arg: u16) !void {
// capability, because there it is a question rather than a fact.
core.native_images = true;
- // Shells emit OSC 133 prompt marks through these, which is what makes
- // prompt hiding and click-to-move work.
- writeFile(shell_bin.bash_rc_path, shell_bin.bash_rc);
- writeFile(shell_bin.fish_rc_path, shell_bin.fish_rc);
+ // Complete private files before any fork; State retains their path buffers
+ // for every later shell spawn and removes the files at app teardown.
+ var prompt_rcs = shell_bin.PromptRcs.init();
+ errdefer prompt_rcs.deinit();
// Apple's bash 3.2 prints the zsh-deprecation banner into every pane unless
// this is in the environment BEFORE bash starts — the rc file is too late.
if (comptime builtin.os.tag.isDarwin()) _ = setenv("BASH_SILENCE_DEPRECATION_WARNING", "1", 1);
@@ -412,6 +650,7 @@ fn initCore(runtime: ?*const Runtime, cols_arg: u16, rows_arg: u16) !void {
.core = core,
.arena = .init(allocs.frame),
.config_arena = config_arena,
+ .prompt_rcs = prompt_rcs,
.runtime = if (runtime) |r| r.* else .{},
};
const st = &state.?;
@@ -420,8 +659,8 @@ fn initCore(runtime: ?*const Runtime, cols_arg: u16, rows_arg: u16) !void {
core.tty_query = .{ .ctx = st, .taken = &ttyTakenAt };
// The real grid, delivered as an EVENT and not as Options.cols/rows: the
- // core defers each shell's greeting until it has seen a resize, and the
- // first forkpty below takes its winsize straight off the core.
+ // core defers an integrated shell's greeting until this resize and OSC
+ // 133 B; the first forkpty below takes its winsize straight off the core.
const cols = @max(1, cols_arg);
const rows = @max(1, rows_arg);
core.update(.{ .resize = .{ .cols = cols, .rows = rows } });
@@ -482,6 +721,14 @@ export fn pardes_deinit() void {
// the process, which is what its detach() already said.
nested.unlisten(st.sock_fd);
st.sock_fd = -1;
+ // Cancel host directory sources while their generation table still exists.
+ // A debounce block already queued on the main runloop may call back later;
+ // state=null below and the bumped generation each make that callback inert.
+ for (0..pardes.MAX_PANES) |pane| if (st.file_watches.entries[pane] != null) {
+ const id: u8 = @intCast(pane);
+ const generation = st.file_watches.stop(st.gpa, id);
+ hostWatchFile(id, generation, null, 0);
+ };
// Every reader is joined here, before anything it touches is freed. The
// runtime joins its tasks on exit, so a reader left parked in read(2) would
// hang the process instead of the app quitting.
@@ -489,7 +736,10 @@ export fn pardes_deinit() void {
// Only now is the inbox quiet. Anything still queued owns gpa bytes and
// would show up as a leak rather than as the shutdown it actually is.
st.inbox.close(st.gpa);
+ st.file_watches.deinit(st.gpa);
if (st.cells.len > 0) st.gpa.free(st.cells);
+ if (st.previous_cells.len > 0) st.gpa.free(st.previous_cells);
+ if (st.changed_cells.len > 0) st.gpa.free(st.changed_cells);
if (st.images.len > 0) st.gpa.free(st.images);
st.arena.deinit();
st.core.deinit();
@@ -497,6 +747,7 @@ export fn pardes_deinit() void {
if (comptime pardes.pdf_enabled) pardes.pdf.stop();
pardes.syntax.stop();
st.config_arena.deinit();
+ st.prompt_rcs.deinit();
st.threaded.deinit();
st.gpa.destroy(st.threaded);
pardes.allocators.deinit();
@@ -508,13 +759,29 @@ export fn pardes_should_quit() bool {
return st.core.quit;
}
-/// Something on screen moves on its own and wants ~60 Hz ticks until it stops:
-/// a theme transition fading, or the rotation dial coasting after a flick.
-/// Both are spent by pardes_tick, so this is the host's only cue to keep
-/// pumping — an idle pardes costs nothing precisely because it says false.
+fn encodeSceneEffects(effects: panel_animation.SceneEffect) u32 {
+ var flags: u32 = 0;
+ if (effects.crt) flags |= scene_flag_crt;
+ if (effects.ripple) flags |= scene_flag_ripple;
+ if (effects.glitch) flags |= scene_flag_glitch;
+ return flags;
+}
+
+fn currentSceneFlags(st: *const State) u32 {
+ return encodeSceneEffects(st.core.settings.scene_effects);
+}
+
+fn advanceSceneFrame(frame: *u32) void {
+ frame.* = (frame.* + 1) % scene_frame_wrap;
+}
+
+/// Something on screen moves on its own and wants ~60 Hz ticks: a finite core
+/// transition, a persistent scene shader, or the rotation dial coasting after
+/// a flick. All are spent only by pardes_animation_tick, so input and pty pumps
+/// cannot make frame-count animation run faster than the display clock.
export fn pardes_animating() bool {
const st = &(state orelse return false);
- return st.core.themeAnimationActive() or st.rotate_coasting;
+ return st.core.animationActive() or st.rotate_coasting;
}
/// The colour the host should paint everything the grid does not: the window
@@ -540,6 +807,60 @@ export fn pardes_theme_bg() u32 {
return @as(u32, bg[0]) << 16 | @as(u32, bg[1]) << 8 | bg[2];
}
+fn taglineFontPercent(core: ?*const pardes.Pardes) u8 {
+ return if (core) |p| p.settings.font.tagline_percent else pardes.config.gui_tagline_font_percent;
+}
+
+/// The smaller face used for pane taglines, as a percentage of the body face.
+/// Grid geometry always comes from the body face. Before init the compiled
+/// default lets the host construct its metrics; afterwards it pulls the live
+/// core value so a TaglineSize command is visible on the next host read.
+export fn pardes_gui_tagline_font_percent() u8 {
+ return taglineFontPercent(if (state) |*st| st.core else null);
+}
+
+test "tagline percent falls back before init and follows live core state" {
+ try std.testing.expectEqual(pardes.config.gui_tagline_font_percent, taglineFontPercent(null));
+
+ const core = try pardes.Pardes.init(std.testing.allocator, .{ .tty_only = true });
+ defer core.deinit();
+ const changed: u8 = if (pardes.config.gui_tagline_font_percent == 37) 38 else 37;
+ core.settings.font.tagline_percent = changed;
+ try std.testing.expectEqual(changed, taglineFontPercent(core));
+}
+
+/// One coherent snapshot for the host's single scene postprocess. The clock is
+/// frame based, just like pane/theme transitions: it advances on the scheduled
+/// display callback and never on an input or pty drain.
+export fn pardes_scene() Scene {
+ const st = &(state orelse return .{});
+ return .{
+ .flags = currentSceneFlags(st),
+ .time_seconds = @as(f32, @floatFromInt(st.scene_frame)) / @as(f32, @floatFromInt(scene_frame_hz)),
+ .frame = st.scene_frame,
+ };
+}
+
+/// The host could not construct or repeatedly submit the shared Metal/Core
+/// Image pass. Stop claiming effects are enabled when only the canonical grid
+/// can be presented, stop its otherwise-unbounded display-clock wakeups, and
+/// snap any current panels before the direct canonical fallback is drawn.
+export fn pardes_postprocessor_unavailable() void {
+ const st = &(state orelse return);
+ st.core.disableSceneEffects();
+ st.core.settings.panel_transition = .off;
+ st.core.abandonPanelAnimations();
+ st.scene_frame = 0;
+}
+
+/// One transient postprocess submission failed and the host will draw the
+/// canonical grid for this frame. A later retry may keep scene effects, but it
+/// must not resume a half-finished panel transition after that canonical frame.
+export fn pardes_panel_animation_failed() void {
+ const st = &(state orelse return);
+ st.core.abandonPanelAnimations();
+}
+
/// Re-read the cwd of every shell that just spoke, and only those.
///
/// A pane's tag shows this and a relative `Look` resolves against it, so it has
@@ -562,6 +883,154 @@ fn refreshCwds(st: *State) void {
}
}
+fn paneWatchPath(core: *const pardes.Pardes, pane: u8) ?[]const u8 {
+ const value = core.panes[pane] orelse return null;
+ if (value.file) |file| return file.path;
+ if (comptime pardes.pdf_enabled) if (value.pdfPath()) |path| return path;
+ return null;
+}
+
+fn watchInitialGeneration(st: *State, pane: u8, path: []const u8) ?file_watch.Generation {
+ const value = st.core.panes[pane] orelse return null;
+ if (value.file) |file| if (std.mem.eql(u8, file.path, path))
+ return .{ .text = std.hash.Wyhash.hash(0, file.content) };
+ if (comptime pardes.pdf_enabled) if (value.pdf) |pdf| if (std.mem.eql(u8, pdf.path, path))
+ return .{ .pdf = null };
+ return null;
+}
+
+fn setFileWatch(st: *State, pane: u8, on: bool) void {
+ if (!on) {
+ const generation = st.file_watches.stop(st.gpa, pane);
+ hostWatchFile(pane, generation, null, 0);
+ return;
+ }
+ const path = paneWatchPath(st.core, pane) orelse {
+ const generation = st.file_watches.stop(st.gpa, pane);
+ hostWatchFile(pane, generation, null, 0);
+ return;
+ };
+ const value = st.core.panes[pane] orelse return;
+ const generation_on_disk = watchInitialGeneration(st, pane, path) orelse return;
+ const generation = st.file_watches.replace(
+ st.gpa,
+ pane,
+ path,
+ value.serial,
+ generation_on_disk,
+ ) catch {
+ const stopped = st.file_watches.stop(st.gpa, pane);
+ hostWatchFile(pane, stopped, null, 0);
+ return;
+ };
+ const watched = st.file_watches.entries[pane].?;
+ hostWatchFile(pane, generation, watched.path.ptr, watched.path.len);
+ // The document was opened before this source existed. Reconcile once only
+ // AFTER source.activate() so a replacement in that gap is either observed
+ // here or produces a later directory edge; there is no open-before-watch
+ // window in which both mechanisms can miss it.
+ _ = reloadWatchedFile(st, pane, false);
+}
+
+/// Read and apply on the main thread. Swift only says that this path or its
+/// parent changed. Text hashes an exact bounded snapshot; PDFs may be much
+/// larger than that bound and MuPDF reopens the path itself, so they compare a
+/// cheap inode/size/time identity instead. Both transactions enter through the
+/// same success-reporting core seam and only then advance their baseline.
+const WatchReload = enum { no_change, committed, changed_uncommitted };
+
+fn retryWatchedFile(st: *State, pane: u8, generation: u32) void {
+ if (st.file_watches.retry(pane, generation)) wake(st);
+}
+
+fn reloadWatchedFile(st: *State, pane: u8, announce: bool) bool {
+ const watched = if (st.file_watches.entries[pane]) |*entry| entry else return false;
+ const generation = watched.generation;
+ const current_path = paneWatchPath(st.core, pane) orelse return false;
+ if (!std.mem.eql(u8, watched.path, current_path)) return false;
+ const current = st.core.panes[pane] orelse return false;
+ if (current.serial != watched.serial) return false;
+
+ const result: WatchReload = switch (watched.generation_on_disk) {
+ .text => |old_hash| text: {
+ if (current.file == null) break :text .no_change;
+ const bytes = look.readFile(st.gpa, watched.path) catch {
+ retryWatchedFile(st, pane, generation);
+ break :text .no_change;
+ };
+ defer st.gpa.free(bytes);
+ const hash = std.hash.Wyhash.hash(0, bytes);
+ if (hash == old_hash) break :text .no_change;
+ if (!st.core.reloadWatchedFile(pane, bytes)) {
+ retryWatchedFile(st, pane, generation);
+ break :text .no_change;
+ }
+
+ // The call is synchronous, but retain the same lifetime guards as
+ // the async edge: future refactors cannot bless a reused slot just
+ // because it happens to carry the same pathname.
+ const after = st.core.panes[pane] orelse break :text .no_change;
+ const active = if (st.file_watches.entries[pane]) |*entry| entry else break :text .no_change;
+ if (active.generation != generation or after.serial != active.serial)
+ break :text .no_change;
+ active.generation_on_disk = .{ .text = hash };
+ active.retries_left = 0;
+ break :text .committed;
+ },
+ .pdf => |old_identity| pdf: {
+ if (comptime !pardes.pdf_enabled) break :pdf .no_change;
+ const state_before = if (current.pdf) |*pdf_before| pdf_before else break :pdf .no_change;
+ if (!std.mem.eql(u8, state_before.path, watched.path)) break :pdf .no_change;
+ const before = file_watch.identify(st.io, watched.path) catch {
+ retryWatchedFile(st, pane, generation);
+ break :pdf .no_change;
+ };
+ if (old_identity) |old| if (old.eql(before)) break :pdf .no_change;
+ if (!st.core.reloadWatchedFile(pane, &.{})) {
+ retryWatchedFile(st, pane, generation);
+ break :pdf .no_change;
+ }
+
+ const after = st.core.panes[pane] orelse break :pdf .changed_uncommitted;
+ if (after.serial != watched.serial) break :pdf .changed_uncommitted;
+ const pdf_state = if (after.pdf) |*pdf_after| pdf_after else break :pdf .changed_uncommitted;
+ if (!std.mem.eql(u8, pdf_state.path, watched.path)) break :pdf .changed_uncommitted;
+ const after_identity = file_watch.identify(st.io, watched.path) catch {
+ retryWatchedFile(st, pane, generation);
+ break :pdf .changed_uncommitted;
+ };
+ // The identity must bracket the complete synchronous MuPDF
+ // transaction. If the path moved during it, leave the old baseline
+ // in place and spend one bounded retry from the already-armed
+ // source; correctness does not depend on receiving a second edge.
+ if (!before.eql(after_identity)) {
+ retryWatchedFile(st, pane, generation);
+ break :pdf .changed_uncommitted;
+ }
+ const active = if (st.file_watches.entries[pane]) |*entry| entry else break :pdf .changed_uncommitted;
+ if (active.generation != generation or active.serial != after.serial)
+ break :pdf .changed_uncommitted;
+ active.generation_on_disk = .{ .pdf = after_identity };
+ active.retries_left = 0;
+ break :pdf .committed;
+ },
+ };
+ if (result == .committed and announce) {
+ var mbuf: [256]u8 = undefined;
+ st.core.setMessage(pane, message.stamp(&mbuf, "reloaded", watched.path));
+ }
+ return result != .no_change;
+}
+
+/// FileWatcher.swift calls this from DispatchQueue.main after its short quiet
+/// period. Do not touch the core here: schedule the ordinary pump so all file
+/// IO and state mutation stay in pardes_tick with pty/nested messages.
+export fn pardes_watch_changed(pane: u8, generation: u32) void {
+ const st = &(state orelse return);
+ if (pane >= pardes.MAX_PANES) return;
+ if (st.file_watches.notify(pane, generation)) wake(st);
+}
+
/// The core's `tty_query`, asked only where a command line is about to be typed:
/// is a program holding this pane's tty instead of the prompt we forked?
/// `look.ttyTaken` answers `false` on darwin until it grows a libproc
@@ -609,19 +1078,30 @@ export fn pardes_tick() bool {
.command => |c| st.core.update(.{ .command = c }),
}
}
+ for (0..pardes.MAX_PANES) |pane| {
+ const id: u8 = @intCast(pane);
+ if (!st.file_watches.takeDirty(id)) continue;
+ if (reloadWatchedFile(st, id, true)) changed = true;
+ }
refreshCwds(st);
if (drainEffects(st, true)) changed = true;
- // ...and ADVANCE the transition, which is the whole reason the host keeps
- // ticking. The tty and SDL loops call `core.update(.tick)` on their own
- // clocks; this host has no loop of its own, so the pump IS the clock — and
- // without this the fade never moved: `chromeTheme()` stayed on the OLD
- // theme's chrome forever, so every tagline kept its previous colours until
- // the next launch, and `themeAnimationActive()` never went false, so the
- // 16 ms re-pump in AppDelegate.pump spun for the rest of the session.
- if (st.core.themeAnimationActive()) {
+ return changed;
+}
+
+/// Advance exactly one display-clock frame. Event pumps deliberately never
+/// call this: a burst of key, mouse, or pty notifications is work to drain,
+/// not elapsed animation time.
+export fn pardes_animation_tick() bool {
+ const st = &(state orelse return false);
+ var changed = false;
+ if (st.core.animationActive()) {
st.core.update(.tick);
changed = true;
}
+ if (currentSceneFlags(st) != 0) {
+ advanceSceneFrame(&st.scene_frame);
+ changed = true;
+ }
// ...and the dial, for the same reason and off the same clock: one frame
// of coast per tick, decayed, until it is slower than a notch a second.
if (st.rotate_coasting) {
@@ -693,6 +1173,11 @@ export fn pardes_mouse(button_arg: c_int, kind_arg: c_int, col: u16, row: u16, m
} });
}
+export fn pardes_pointer_leave() void {
+ const st = &(state orelse return);
+ st.core.update(.pointer_leave);
+}
+
export fn pardes_scroll(delta_rows: f32, delta_cols: f32, col: u16, row: u16) void {
const st = &(state orelse return);
var down_left = takeScrollTicks(&st.scroll_lag, delta_rows);
@@ -843,6 +1328,8 @@ export fn pardes_frame() u32 {
st.frame_cols = 0;
st.frame_rows = 0;
st.images_len = 0;
+ st.panel_tracks_len = 0;
+ st.panel_diff_len = 0;
const surface = st.core.render(st.arena.allocator()) catch |err| {
log.err("render failed: {t}", .{err});
return 0;
@@ -863,21 +1350,62 @@ export fn pardes_frame() u32 {
st.frame_len = count;
st.frame_cols = surface.cols;
st.frame_rows = surface.rows;
- for (surface.cells, st.cells[0..count]) |cell, *out| {
- out.* = .{
- .text = @splat(0),
- .fg = encodeColor(cell.style.fg),
- .bg = encodeColor(cell.style.bg),
- .attrs = encodeAttrs(cell.style),
- .len = if (cell.default) 1 else cell.len,
- .flags = @intFromBool(cell.default),
- };
- if (cell.default) out.text[0] = ' ' else @memcpy(out.text[0..cell.len], cell.grapheme());
- }
+ for (surface.cells, st.cells[0..count]) |cell, *out| out.* = encodeCell(cell);
+ collectPanelDiff(st, surface, count);
collectImages(st, surface);
+ collectPanelTracks(st, surface);
return @intCast(count);
}
+/// Flatten tracks into the C-visible order the shader composites them. Pane
+/// slots are stable tie-breakers because Surface publishes them in slot order.
+fn collectPanelTracks(st: *State, surface: *const pardes.Surface) void {
+ st.panel_tracks_len = copyPanelTracksInPaintOrder(surface.panelTracks(), &st.panel_tracks);
+}
+
+fn copyPanelTracksInPaintOrder(source: []const PanelTrack, out: []PanelTrack) usize {
+ var len: usize = 0;
+ for ([_]panel_animation.Phase{ .moving, .opening, .closing }) |phase| {
+ for (source) |track| {
+ if (!track.active() or track.phase != phase) continue;
+ std.debug.assert(len < out.len);
+ out[len] = track;
+ len += 1;
+ }
+ }
+ return len;
+}
+
+/// Copy the old/new semantic transition data as one all-or-nothing snapshot.
+/// A missing allocation disables the optional diff for this frame; it never
+/// leaves a previous grid paired with a mask from another render.
+fn collectPanelDiff(st: *State, surface: *const pardes.Surface, count: usize) void {
+ if (!surface.hasPanelDiff() or count == 0) return;
+ if (st.previous_cells.len != count) {
+ const resized = if (st.previous_cells.len == 0)
+ st.gpa.alloc(Cell, count)
+ else
+ st.gpa.realloc(st.previous_cells, count);
+ st.previous_cells = resized catch return;
+ }
+ if (st.changed_cells.len != count) {
+ const resized = if (st.changed_cells.len == 0)
+ st.gpa.alloc(u8, count)
+ else
+ st.gpa.realloc(st.changed_cells, count);
+ st.changed_cells = resized catch return;
+ }
+ for (surface.previous_cells, st.previous_cells[0..count]) |cell, *out|
+ out.* = encodeCell(cell);
+ for (surface.changed_cells, st.changed_cells[0..count]) |changed, *out|
+ out.* = encodeChanged(changed);
+ st.panel_diff_len = count;
+}
+
+fn encodeChanged(changed: u8) u8 {
+ return if (changed == 0) 0 else 255;
+}
+
/// Flatten Surface.images into the flat C array the host walks.
///
/// A dropped attachment is a page that does not draw, never a wrong one, so
@@ -946,11 +1474,51 @@ export fn pardes_frame_image_list() ?[*]const Image {
return if (st.images_len == 0) null else st.images.ptr;
}
+export fn pardes_frame_panel_tracks() u32 {
+ const st = &(state orelse return 0);
+ return @intCast(st.panel_tracks_len);
+}
+
+export fn pardes_frame_panel_track_list() ?[*]const PanelTrack {
+ const st = &(state orelse return null);
+ return if (st.panel_tracks_len == 0) null else st.panel_tracks[0..].ptr;
+}
+
+/// AppKit calls this only after its destination context has accepted the
+/// frame. The boolean keeps the ABI POD-only: animated presentation uses the
+/// borrowed records from `pardes_frame`, while a direct fallback commits the
+/// canonical grid with an empty snapshot.
+export fn pardes_frame_presented(animated_panels: bool) bool {
+ const st = &(state orelse return false);
+ const was_animating = st.core.animationActive();
+ if (animated_panels)
+ st.core.acknowledgePanelPresentation(st.panel_tracks[0..st.panel_tracks_len])
+ else
+ st.core.acknowledgePanelPresentation(&.{});
+ return !was_animating and st.core.animationActive();
+}
+
export fn pardes_frame_cells() ?[*]const Cell {
const st = &(state orelse return null);
return if (st.frame_len == 0) null else st.cells.ptr;
}
+export fn pardes_frame_previous_cells() ?[*]const Cell {
+ const st = &(state orelse return null);
+ return if (st.panel_diff_len != st.frame_len or st.panel_diff_len == 0)
+ null
+ else
+ st.previous_cells.ptr;
+}
+
+export fn pardes_frame_changed_cells() ?[*]const u8 {
+ const st = &(state orelse return null);
+ return if (st.panel_diff_len != st.frame_len or st.panel_diff_len == 0)
+ null
+ else
+ st.changed_cells.ptr;
+}
+
export fn pardes_frame_cols() u16 {
const st = &(state orelse return 0);
return st.frame_cols;
@@ -992,22 +1560,53 @@ export fn pardes_take_haptic() c_int {
/// shape as the haptic above, and the same one the SDL shell uses on this
/// exact variable.
///
-/// A copy rather than the borrowed slice: `fonts.want` is a length and no
-/// terminator, and C wants a string. One static buffer because there is one
-/// core and the header promises the value only until the next call.
+/// A copy rather than the borrowed State slice: C wants a terminator. One
+/// static buffer because there is one core and the header promises the value
+/// only until the next call.
var font_path_z: [4096:0]u8 = undefined;
export fn pardes_font_take() ?[*:0]const u8 {
- _ = state orelse return null;
+ const st = &(state orelse return null);
if (comptime !pardes.font_picker) return null;
- const want = fonts.want orelse return null;
- fonts.want = null;
+ const want = st.core.takeFontRequest() orelse return null;
if (want.len >= font_path_z.len) return null;
@memcpy(font_path_z[0..want.len], want);
font_path_z[want.len] = 0;
return &font_path_z;
}
+/// Observe the face already on screen without resolving an unrelated Font
+/// request. Initial state, host-only zoom and display-scale changes use this.
+export fn pardes_font_observe(
+ effective_name: ?[*]const u8,
+ len: usize,
+ point_hundredths: u16,
+) bool {
+ const st = &(state orelse return false);
+ const ptr = effective_name orelse return false;
+ if (len == 0 or len > 255 or point_hundredths == 0) return false;
+ return st.core.observeFont(ptr[0..len], point_hundredths, .points);
+}
+
+/// Commit what CoreText accepted for the request returned by font_take.
+export fn pardes_font_ack(
+ effective_name: ?[*]const u8,
+ len: usize,
+ point_hundredths: u16,
+) bool {
+ const st = &(state orelse return false);
+ const ptr = effective_name orelse return false;
+ if (len == 0 or len > 255 or point_hundredths == 0) return false;
+ return st.core.acknowledgeFont(ptr[0..len], point_hundredths, .points);
+}
+
+/// Resolve a taken request which CoreText could not load without claiming the
+/// fallback/previous face was the requested one.
+export fn pardes_font_reject() void {
+ const st = &(state orelse return);
+ st.core.rejectFont();
+}
+
/// The FILE behind the focused pane, or null when there is none — a terminal,
/// an output buffer (`+Search` names a directory, not a document), or nothing
/// focused at all. A PDF and an image both count: they are real paths on disk,
@@ -1052,11 +1651,11 @@ fn activeFilePath(st: *State) ?[]const u8 {
/// Perform the IO the core queued. `threads_ok` is false for the one drain
/// inside pardes_init, which runs before any reader task exists.
///
-/// ponytail: the lsp, pipe and watch effects do no work here. Each wants real
-/// machinery — a worker plus a snapshot of the pane's file for lsp
-/// (src/tty/tty.zig:919), a job copy for pipe, and FSEvents for watch, since
-/// inotify is Linux-only. Lift tty.zig's implementations when the app is past
-/// first light. Pipe and watch may simply be dropped; lsp may NOT, see below.
+/// ponytail: lsp and pipe still do no work here. Each wants real machinery — a
+/// worker plus a snapshot of the pane's file for lsp (src/tty/tty.zig:919), and
+/// a job copy for pipe. Watch is deliberately different: FileWatcher.swift
+/// owns its per-directory DispatchSource and only returns a debounced hint;
+/// this main-thread drain owns the bytes, hash and shared text/PDF core event.
fn drainEffects(st: *State, threads_ok: bool) bool {
const core = st.core;
var did = false;
@@ -1084,7 +1683,7 @@ fn drainEffects(st: *State, threads_ok: bool) bool {
cwd_buf[cwd.len] = 0;
cwd_z = @ptrCast(&cwd_buf);
}
- const child = forkShell(core.shellBin(), cwd_z, core.screen_h, core.screen_w);
+ const child = forkShell(core, sp.pane, &st.prompt_rcs, core.shellBin(), cwd_z, core.screen_h, core.screen_w);
st.ptys[sp.pane] = .{
.file = child.file,
.pid = child.pid,
@@ -1118,6 +1717,14 @@ fn drainEffects(st: *State, threads_ok: bool) bool {
if (fd < 0) continue;
writeFd(fd, f.content);
_ = libc.close(fd);
+ // The directory source will observe our own close. Move its
+ // baseline first so that notification is a hash no-op instead
+ // of manufacturing an external reload and undo boundary.
+ st.file_watches.restampText(
+ sf.pane,
+ f.path,
+ std.hash.Wyhash.hash(0, f.content),
+ );
// After the write, not beside it: every `continue` above is a
// save that did not happen and must not be reported as one.
var mbuf: [256]u8 = undefined;
@@ -1163,7 +1770,8 @@ fn drainEffects(st: *State, threads_ok: bool) bool {
// stays armed, the retroactive indent never fires, and every
// dot-Tab for the rest of the session does nothing at all.
.lsp => |q| core.update(.{ .lsp_resp = .{ .id = q.id, .rows = "" } }),
- .pipe, .watch => {},
+ .watch => |watch| setFileWatch(st, watch.pane, watch.on),
+ .pipe => {},
.quit => {},
}
}
@@ -1231,13 +1839,13 @@ fn wake(st: *State) void {
// ---------------------------------------------------------------- helpers
-fn forkShell(bin: []const u8, cwd: ?[*:0]const u8, rows: u16, cols: u16) struct { file: std.Io.File, pid: posix.pid_t } {
+fn forkShell(core: *pardes.Pardes, pane: usize, prompt_rcs: *const shell_bin.PromptRcs, bin: []const u8, cwd: ?[*:0]const u8, rows: u16, cols: u16) struct { file: std.Io.File, pid: posix.pid_t } {
var master: c_int = undefined;
// Resolved BEFORE the fork, into this frame, which the child inherits:
// nothing between fork and exec may allocate, so a PATH search cannot
// happen there.
var path_buf: [std.fs.max_path_bytes]u8 = undefined;
- const spawn = shell_bin.resolve(bin, &path_buf);
+ const spawn = shell_bin.resolve(bin, &path_buf, prompt_rcs);
const ws = posix.winsize{ .row = rows, .col = cols, .xpixel = 0, .ypixel = 0 };
const pid = forkpty(&master, null, null, &ws);
if (pid == 0) {
@@ -1245,6 +1853,7 @@ fn forkShell(bin: []const u8, cwd: ?[*:0]const u8, rows: u16, cols: u16) struct
_ = execv(spawn.path, &spawn.argv);
_exit(127);
}
+ if (pid > 0) core.acknowledgeShell(pane, std.mem.span(spawn.path), spawn.argv[1] != null);
return .{ .file = .{ .handle = master, .flags = .{ .nonblocking = false } }, .pid = pid };
}
@@ -1264,13 +1873,6 @@ fn writeFd(fd: c_int, data: []const u8) void {
}
}
-fn writeFile(path: [*:0]const u8, contents: []const u8) void {
- const fd = libc.open(path, .{ .ACCMODE = .WRONLY, .CREAT = true, .TRUNC = true }, @as(libc.mode_t, 0o644));
- if (fd < 0) return;
- defer _ = libc.close(fd);
- writeFd(fd, contents);
-}
-
/// Rebuild the process environment as a Map, because a library never sees the
/// std.process.Init that main() gets one from. Only the config-path lookup
/// reads it, and the arena owns the copies for the life of the process.
@@ -1294,6 +1896,22 @@ fn encodeColor(color: pardes.Color) u32 {
};
}
+fn encodeCell(cell: pardes.Cell) Cell {
+ var out: Cell = .{
+ .text = @splat(0),
+ .fg = encodeColor(cell.style.fg),
+ .bg = encodeColor(cell.style.bg),
+ .attrs = encodeAttrs(cell.style),
+ .len = if (cell.default) 1 else cell.len,
+ .flags = encodeCellFlags(cell.default, cell.style.font_role),
+ };
+ if (cell.default)
+ out.text[0] = ' '
+ else
+ @memcpy(out.text[0..cell.len], cell.grapheme());
+ return out;
+}
+
fn encodeAttrs(style: pardes.CellStyle) u16 {
var attrs: u16 = 0;
attrs |= @as(u16, @intFromBool(style.bold)) << 0;
@@ -1307,6 +1925,11 @@ fn encodeAttrs(style: pardes.CellStyle) u16 {
return attrs;
}
+fn encodeCellFlags(default: bool, role: pardes.FontRole) u8 {
+ return @as(u8, @intFromBool(default)) * cell_flag_default |
+ @as(u8, @intFromBool(role == .tagline)) * cell_flag_tagline;
+}
+
/// Spend accumulated sub-row travel as whole wheel notches, keeping the
/// remainder. The core has no fractional scroll — both other shells do this
/// too — and the clamp is so that an absurd delta (a momentum-phase kinetic
@@ -1404,11 +2027,18 @@ test "pardes.h declares every export the way it is defined" {
try expectSameAbi(@TypeOf(c.pardes_init), @TypeOf(pardes_init));
try expectSameAbi(@TypeOf(c.pardes_deinit), @TypeOf(pardes_deinit));
try expectSameAbi(@TypeOf(c.pardes_tick), @TypeOf(pardes_tick));
+ try expectSameAbi(@TypeOf(c.pardes_animation_tick), @TypeOf(pardes_animation_tick));
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_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));
try expectSameAbi(@TypeOf(c.pardes_key), @TypeOf(pardes_key));
try expectSameAbi(@TypeOf(c.pardes_paste), @TypeOf(pardes_paste));
try expectSameAbi(@TypeOf(c.pardes_mouse), @TypeOf(pardes_mouse));
+ try expectSameAbi(@TypeOf(c.pardes_pointer_leave), @TypeOf(pardes_pointer_leave));
+ try expectSameAbi(@TypeOf(c.pardes_watch_changed), @TypeOf(pardes_watch_changed));
try expectSameAbi(@TypeOf(c.pardes_scroll), @TypeOf(pardes_scroll));
try expectSameAbi(@TypeOf(c.pardes_rotate), @TypeOf(pardes_rotate));
try expectSameAbi(@TypeOf(c.pardes_rotate_end), @TypeOf(pardes_rotate_end));
@@ -1416,15 +2046,23 @@ test "pardes.h declares every export the way it is defined" {
try expectSameAbi(@TypeOf(c.pardes_resize), @TypeOf(pardes_resize));
try expectSameAbi(@TypeOf(c.pardes_frame), @TypeOf(pardes_frame));
try expectSameAbi(@TypeOf(c.pardes_frame_cells), @TypeOf(pardes_frame_cells));
+ try expectSameAbi(@TypeOf(c.pardes_frame_previous_cells), @TypeOf(pardes_frame_previous_cells));
+ try expectSameAbi(@TypeOf(c.pardes_frame_changed_cells), @TypeOf(pardes_frame_changed_cells));
try expectSameAbi(@TypeOf(c.pardes_frame_cols), @TypeOf(pardes_frame_cols));
try expectSameAbi(@TypeOf(c.pardes_frame_rows), @TypeOf(pardes_frame_rows));
try expectSameAbi(@TypeOf(c.pardes_frame_images), @TypeOf(pardes_frame_images));
try expectSameAbi(@TypeOf(c.pardes_frame_image_list), @TypeOf(pardes_frame_image_list));
+ try expectSameAbi(@TypeOf(c.pardes_frame_panel_tracks), @TypeOf(pardes_frame_panel_tracks));
+ try expectSameAbi(@TypeOf(c.pardes_frame_panel_track_list), @TypeOf(pardes_frame_panel_track_list));
+ try expectSameAbi(@TypeOf(c.pardes_frame_presented), @TypeOf(pardes_frame_presented));
try expectSameAbi(@TypeOf(c.pardes_cursor_x), @TypeOf(pardes_cursor_x));
try expectSameAbi(@TypeOf(c.pardes_cursor_y), @TypeOf(pardes_cursor_y));
try expectSameAbi(@TypeOf(c.pardes_cursor_bar), @TypeOf(pardes_cursor_bar));
try expectSameAbi(@TypeOf(c.pardes_take_haptic), @TypeOf(pardes_take_haptic));
try expectSameAbi(@TypeOf(c.pardes_font_take), @TypeOf(pardes_font_take));
+ try expectSameAbi(@TypeOf(c.pardes_font_observe), @TypeOf(pardes_font_observe));
+ try expectSameAbi(@TypeOf(c.pardes_font_ack), @TypeOf(pardes_font_ack));
+ try expectSameAbi(@TypeOf(c.pardes_font_reject), @TypeOf(pardes_font_reject));
try expectSameAbi(@TypeOf(c.pardes_active_path), @TypeOf(pardes_active_path));
try expectSameAbi(@TypeOf(c.pardes_active_dirty), @TypeOf(pardes_active_dirty));
try expectSameAbi(@TypeOf(c.pardes_theme_bg), @TypeOf(pardes_theme_bg));
@@ -1441,6 +2079,22 @@ test "pardes.h matches the Zig boundary" {
try expectEqual(@offsetOf(c.pardes_cell_s, "attrs"), @offsetOf(Cell, "attrs"));
try expectEqual(@offsetOf(c.pardes_cell_s, "len"), @offsetOf(Cell, "len"));
try expectEqual(@offsetOf(c.pardes_cell_s, "flags"), @offsetOf(Cell, "flags"));
+ try expectEqual(@sizeOf(c.pardes_scene_s), @sizeOf(Scene));
+ inline for (@typeInfo(Scene).@"struct".fields) |field|
+ try expectEqual(@offsetOf(c.pardes_scene_s, field.name), @offsetOf(Scene, field.name));
+ try expectEqual(@sizeOf(c.pardes_panel_box_s), @sizeOf(PanelBox));
+ inline for (@typeInfo(PanelBox).@"struct".fields) |field|
+ try expectEqual(@offsetOf(c.pardes_panel_box_s, field.name), @offsetOf(PanelBox, field.name));
+ try expectEqual(@sizeOf(c.pardes_panel_track_s), @sizeOf(PanelTrack));
+ inline for (@typeInfo(PanelTrack).@"struct".fields) |field| {
+ const c_name = comptime if (std.mem.eql(u8, field.name, "_padding"))
+ "reserved0"
+ else if (std.mem.eql(u8, field.name, "_frame_padding"))
+ "reserved1"
+ else
+ field.name;
+ try expectEqual(@offsetOf(c.pardes_panel_track_s, c_name), @offsetOf(PanelTrack, field.name));
+ }
// The attachment struct is a wide one and every field is read by hand on
// the Swift side, so its layout is checked at both ends rather than at the
// two that happen to be easy.
@@ -1452,6 +2106,19 @@ test "pardes.h matches the Zig boundary" {
try expectEqual(@as(u32, c.PARDES_COLOR_DEFAULT), color_default);
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(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);
+ try expectEqual(@as(u8, c.PARDES_PANEL_OPENING), @intFromEnum(panel_animation.Phase.opening));
+ try expectEqual(@as(u8, c.PARDES_PANEL_MOVING), @intFromEnum(panel_animation.Phase.moving));
+ try expectEqual(@as(u8, c.PARDES_PANEL_CLOSING), @intFromEnum(panel_animation.Phase.closing));
+ try expectEqual(@as(u8, c.PARDES_PANEL_OFF), @intFromEnum(panel_animation.Transition.off));
+ try expectEqual(@as(u8, c.PARDES_PANEL_SLIDE), @intFromEnum(panel_animation.Transition.slide));
+ try expectEqual(@as(u8, c.PARDES_PANEL_ZOOM), @intFromEnum(panel_animation.Transition.zoom));
+ try expectEqual(@as(u8, c.PARDES_PANEL_DISSOLVE), @intFromEnum(panel_animation.Transition.dissolve));
+ try expectEqual(@as(u8, c.PARDES_PANEL_ASCII), @intFromEnum(panel_animation.Transition.ascii));
+ try expectEqual(@as(u8, c.PARDES_PANEL_VERTICAL), @intFromEnum(panel_animation.Transition.vertical));
// Every key the host has a name for must be the codepoint the core reads.
try expectEqual(@as(u21, c.PARDES_KEY_ENTER), pardes.Key.enter);
@@ -1501,6 +2168,72 @@ test "pardes.h matches the Zig boundary" {
@as(u16, c.PARDES_UL_CURLY) << c.PARDES_ATTR_UL_SHIFT,
encodeAttrs(.{ .ul = .curly }),
);
+
+ // Font role is explicit ABI data, not something the host reconstructs
+ // from tag colours. Default and role occupy independent bits.
+ try expectEqual(@as(u8, 0), encodeCellFlags(false, .body));
+ try expectEqual(cell_flag_tagline, encodeCellFlags(false, .tagline));
+ try expectEqual(cell_flag_default | cell_flag_tagline, encodeCellFlags(true, .tagline));
+ try expectEqual(pardes.config.gui_tagline_font_percent, pardes_gui_tagline_font_percent());
+}
+
+test "scene effect flags and display clock are compact and independent" {
+ const expectEqual = std.testing.expectEqual;
+ try expectEqual(@as(u32, 0), encodeSceneEffects(.{}));
+ try expectEqual(scene_flag_crt, encodeSceneEffects(.{ .crt = true }));
+ try expectEqual(scene_flag_ripple, encodeSceneEffects(.{ .ripple = true }));
+ try expectEqual(scene_flag_glitch, encodeSceneEffects(.{ .glitch = true }));
+ try expectEqual(
+ scene_flag_crt | scene_flag_ripple | scene_flag_glitch,
+ encodeSceneEffects(.{ .crt = true, .ripple = true, .glitch = true }),
+ );
+
+ var frame: u32 = scene_frame_wrap - 1;
+ advanceSceneFrame(&frame);
+ try expectEqual(@as(u32, 0), frame);
+ advanceSceneFrame(&frame);
+ try expectEqual(@as(u32, 1), frame);
+}
+
+test "mac panel ABI paint order includes closing tombstones after live panes" {
+ const source = [_]PanelTrack{
+ .{ .serial = 11, .pane = 3, .phase = .opening, .effect = .slide },
+ .{ .serial = 12, .pane = 1, .phase = .moving, .effect = .zoom },
+ .{ .serial = 13, .pane = 0, .phase = .moving, .effect = .off },
+ .{ .serial = 14, .pane = 5, .phase = .opening, .effect = .ascii },
+ .{ .serial = 15, .pane = 2, .phase = .moving, .effect = .dissolve },
+ .{ .serial = 16, .pane = 2, .phase = .closing, .effect = .vertical },
+ };
+ var ordered: [source.len]PanelTrack = undefined;
+ const len = copyPanelTracksInPaintOrder(&source, &ordered);
+ try std.testing.expectEqual(@as(usize, 5), len);
+ try std.testing.expectEqualSlices(u32, &.{ 12, 15, 11, 14, 16 }, &.{
+ ordered[0].serial,
+ ordered[1].serial,
+ ordered[2].serial,
+ ordered[3].serial,
+ ordered[4].serial,
+ });
+}
+
+test "mac panel mask is a literal normalized grayscale texture" {
+ try std.testing.expectEqual(@as(u8, 0), encodeChanged(0));
+ try std.testing.expectEqual(@as(u8, 255), encodeChanged(1));
+ try std.testing.expectEqual(@as(u8, 255), encodeChanged(255));
+}
+
+test "mac panel ABI has the documented compact byte layout" {
+ try std.testing.expectEqual(@as(usize, 16), @sizeOf(PanelBox));
+ try std.testing.expectEqual(@as(usize, 44), @sizeOf(PanelTrack));
+ try std.testing.expectEqual(@as(usize, 0), @offsetOf(PanelTrack, "serial"));
+ try std.testing.expectEqual(@as(usize, 4), @offsetOf(PanelTrack, "pane"));
+ try std.testing.expectEqual(@as(usize, 5), @offsetOf(PanelTrack, "phase"));
+ try std.testing.expectEqual(@as(usize, 6), @offsetOf(PanelTrack, "effect"));
+ try std.testing.expectEqual(@as(usize, 7), @offsetOf(PanelTrack, "_padding"));
+ try std.testing.expectEqual(@as(usize, 8), @offsetOf(PanelTrack, "frame"));
+ try std.testing.expectEqual(@as(usize, 10), @offsetOf(PanelTrack, "_frame_padding"));
+ try std.testing.expectEqual(@as(usize, 12), @offsetOf(PanelTrack, "from"));
+ try std.testing.expectEqual(@as(usize, 28), @offsetOf(PanelTrack, "to"));
}
test "colors encode to the three tags the host decodes" {