const filesystem = @import("fs.zig"); const ninep_io = @import("9p_io.zig"); const std = @import("std"); const builtin = @import("builtin"); const posix = std.posix; const libc = std.c; const pardes = @import("pardes.zig"); const look = @import("look.zig"); const message = pardes.Pardes.Message; const layout = @import("layout.zig"); const file_watch = @import("file_watch.zig"); const image = if (pardes.pdf_enabled) @import("image.zig") else struct {}; const host_io = @import("host_io.zig"); const fonts = @import("fonts.zig"); // the shared fallback preference order const tracy = @import("tracy.zig"); // no-op unless -Dtracy names a checkout const selection_pipe = @import("selection_pipe.zig"); // Job, runJob and Tasks const crash = @import("crash.zig"); pub const panic = std.debug.FullPanic(struct { fn call(msg: []const u8, ret_addr: ?usize) noreturn { crash.record(msg); std.debug.defaultPanic(msg, ret_addr); } }.call); extern "c" fn setenv(name: [*:0]const u8, value: [*:0]const u8, overwrite: c_int) c_int; 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); } const TIOCSWINSZ: c_int = @bitCast(@as(u32, if (@hasDecl(posix.T, "IOCSWINSZ")) posix.T.IOCSWINSZ else 0x80087467)); pub const std_options: std.Options = .{ .logFn = logFn }; fn logFn( comptime level: std.log.Level, comptime scope: @EnumLiteral(), comptime format: []const u8, args: anytype, ) void { if (scope != .macos and scope != .dump and std.c.getenv("PARDES_LOG") == null) return; std.log.defaultLog(level, scope, format, args); } const log = std.log.scoped(.macos); pub const Cell = extern struct { text: [8]u8, fg: u32, bg: u32, attrs: u16, len: u8, flags: u8, }; pub const Scene = extern struct { flags: u32 = 0, time_seconds: f32 = 0, frame: u32 = 0, }; pub const PanelBox = layout.Box; pub const PanelTrack = layout.Track; pub const Image = extern struct { serial: u32, page: u32, revision: u32, cell_x: u16, cell_y: u16, cell_w: u16, cell_h: u16, dst_x: u32, dst_y: u32, dst_w: u32, dst_h: u32, src_x: u32, src_y: u32, src_w: u32, src_h: u32, offset_y: f32, iw: u32, ih: u32, rgba: [*]const u8, paper_bg: u32, }; pub const Runtime = extern struct { userdata: ?*anyopaque = null, wakeup: ?*const fn (?*anyopaque) callconv(.c) void = null, set_clipboard: ?*const fn (?*anyopaque, [*]const u8, usize) callconv(.c) void = null, read_clipboard: ?*const fn (?*anyopaque) callconv(.c) void = null, }; const color_default: u32 = 0x01000000; const color_indexed: u32 = 0x02000000; const cell_flag_default: u8 = 1; const cell_flag_tagline: u8 = 2; const cell_flag_hover: u8 = 4; 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; const scene_wrap_ns: u64 = 4096 * std.time.ns_per_s; const max_tick_catch_up_ns: u64 = 4 * pardes.layout.Animation.frame_ns; const theme_watch_pane: u8 = @intCast(pardes.MAX_PANES); const watch_slot_count = pardes.MAX_PANES + 1; const Pty = struct { file: std.Io.File, pid: posix.pid_t, gen: u32, reader: std.Io.Future(anyerror!void), }; const WatchedFile = struct { path: []u8, serial: u32, generation_on_disk: file_watch.Generation, generation: u32, retries_left: u8 = 1, }; const FileWatches = struct { entries: [watch_slot_count]?WatchedFile = @splat(null), generations: [watch_slot_count]u32 = @splat(0), dirty: [watch_slot_count]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 { 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); } 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)); 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 queued results never cancel a newer LSP request" { const gpa = std.testing.allocator; const core = try pardes.Pardes.init(gpa, .{ .tty_only = true }); defer core.deinit(); var st: State = .{ .gpa = gpa, .threaded = undefined, .io = std.testing.io, .core = core, .runtime = .{}, .config_arena = .init(gpa), .prompt_rcs = .{}, }; defer st.config_arena.deinit(); defer st.inbox.close(gpa, std.testing.io); core.lspRequest(0, .status, ""); const old_id = core.lsp_wait.?.id; core.lspRequest(0, .status, ""); const current_id = core.lsp_wait.?.id; st.lsp_task = .{ .id = current_id, .future = .{ .any_future = null, .result = {} } }; st.inbox.push(gpa, std.testing.io, .{ .lsp_done = .{ .id = old_id, .rows = try gpa.dupe(u8, "stale result\n") } }); try std.testing.expect(drainInbox(&st)); try std.testing.expect(st.lsp_task != null); try std.testing.expectEqual(current_id, st.lsp_task.?.id); try std.testing.expectEqual(current_id, core.lsp_wait.?.id); st.inbox.push(gpa, std.testing.io, .{ .lsp_done = .{ .id = current_id, .rows = try gpa.dupe(u8, "") } }); try std.testing.expect(drainInbox(&st)); try std.testing.expect(st.lsp_task == null); try std.testing.expect(core.lsp_wait == null); } test "mac worker setup failures finish matching LSP and pipe requests" { const gpa = std.testing.allocator; var failing_vtable = std.testing.io.vtable.*; failing_vtable.concurrent = std.Io.failingConcurrent; const failing_io: std.Io = .{ .userdata = std.testing.io.userdata, .vtable = &failing_vtable }; for (0..2) |failure| { const core = try pardes.Pardes.init(gpa, .{ .tty_only = true }); defer core.deinit(); const pane = try core.setTestFile("abc"); var failing = std.testing.FailingAllocator.init(gpa, .{ .fail_index = if (failure == 0) 0 else std.math.maxInt(usize), }); var st: State = .{ .gpa = failing.allocator(), .threaded = undefined, .io = failing_io, .core = core, .runtime = .{}, .config_arena = .init(gpa), .prompt_rcs = .{}, }; defer st.config_arena.deinit(); defer st.inbox.close(gpa, std.testing.io); core.lspRequest(core.active, .status, ""); const req: host_io.Lsp.Request = .{ .id = core.lsp_wait.?.id, .kind = .status, .pane = @intCast(core.active), .offset = 0, .arg = "", }; lspRequest(&st, req); try std.testing.expect(core.lsp_wait == null); try std.testing.expectEqualStrings("abc", pane.file.?.content); pane.cur_col = 2; pane.vsel = .{ .active = true, .row = 0, .col = 0, .explicit = true }; core.update(.{ .key = .{ .cp = '|' } }); core.update(.{ .key = .{ .cp = 't', .text = "tr a-z A-Z" } }); core.update(.{ .key = .{ .cp = pardes.Key.enter } }); const pipe_id = core.pipe_wait.?.id; pipeRequest(&st, pipe_id); try std.testing.expect(core.pipe_wait == null); try std.testing.expectEqualStrings("abc", pane.file.?.content); try std.testing.expect(st.lsp_task == null); try std.testing.expectEqual(@as(usize, 0), st.pipe_tasks.len); } } test "mac host watcher covers file and directory vnode events, debounce, and generation callback" { 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); } const Msg = union(enum) { output: struct { pane: u8, gen: u32, bytes: []u8 }, eof: struct { pane: u8, gen: u32 }, lsp_done: struct { id: u32, rows: ?[]u8 }, lsp_status: []u8, pipe: selection_pipe.Response, fn free(m: Msg, gpa: std.mem.Allocator) void { switch (m) { .output => |o| gpa.free(o.bytes), .eof => {}, .lsp_done => |d| if (d.rows) |rows| gpa.free(rows), .lsp_status => |t| gpa.free(t), .pipe => |r| { var response = r; response.deinit(gpa); }, } } }; const inbox_capacity = 512; const inbox_output_limit = inbox_capacity - pardes.MAX_PANES - selection_pipe.Tasks.capacity - 2; const MessageBatch = struct { items: [inbox_capacity]Msg = undefined, len: usize = 0, fn slice(batch: *MessageBatch) []Msg { return batch.items[0..batch.len]; } }; const Inbox = struct { mutex: std.Io.Mutex = .init, space: std.Io.Condition = .init, items: [inbox_capacity]Msg = undefined, len: usize = 0, closed: bool = false, wake_pending: std.atomic.Value(bool) = .init(false), fn removeAt(q: *Inbox, offset: usize) Msg { const removed = q.items[offset]; std.mem.copyForwards(Msg, q.items[offset .. q.len - 1], q.items[offset + 1 .. q.len]); q.len -= 1; return removed; } fn pushOutput(q: *Inbox, gpa: std.mem.Allocator, io: std.Io, output: @FieldType(Msg, "output")) std.Io.Cancelable!void { errdefer gpa.free(output.bytes); q.mutex.lockUncancelable(io); defer q.mutex.unlock(io); while (q.len >= inbox_output_limit and !q.closed) try q.space.wait(io, &q.mutex); if (q.closed) return error.Canceled; q.items[q.len] = .{ .output = output }; q.len += 1; } fn push(q: *Inbox, gpa: std.mem.Allocator, io: std.Io, m: Msg) void { std.debug.assert(m != .output); q.mutex.lockUncancelable(io); defer q.mutex.unlock(io); if (q.closed) { m.free(gpa); return; } if (m != .pipe) { var offset: usize = 0; while (offset < q.len) : (offset += 1) { const old = q.items[offset]; const replaced = switch (m) { .eof => |end| old == .eof and old.eof.pane == end.pane, .lsp_done => old == .lsp_done, .lsp_status => old == .lsp_status, .output, .pipe => unreachable, }; if (replaced) { q.removeAt(offset).free(gpa); break; } } } std.debug.assert(q.len < q.items.len); q.items[q.len] = m; q.len += 1; } fn take(q: *Inbox, io: std.Io) MessageBatch { q.mutex.lockUncancelable(io); defer q.mutex.unlock(io); var batch: MessageBatch = .{ .len = q.len }; @memcpy(batch.items[0..q.len], q.items[0..q.len]); q.len = 0; q.space.broadcast(io); return batch; } fn close(q: *Inbox, gpa: std.mem.Allocator, io: std.Io) void { q.mutex.lockUncancelable(io); defer q.mutex.unlock(io); q.closed = true; for (q.items[0..q.len]) |msg| msg.free(gpa); q.len = 0; q.space.broadcast(io); } }; test "mac inbox coalesces completions at the tail without reordering terminal output" { const gpa = std.testing.allocator; var inbox: Inbox = .{}; defer inbox.close(gpa, std.testing.io); inbox.push(gpa, std.testing.io, .{ .lsp_done = .{ .id = std.math.maxInt(u32), .rows = try gpa.dupe(u8, "old result") } }); inbox.push(gpa, std.testing.io, .{ .lsp_status = try gpa.dupe(u8, "old status") }); try inbox.pushOutput(gpa, std.testing.io, .{ .pane = 0, .gen = std.math.maxInt(u32), .bytes = try gpa.dupe(u8, "old output") }); inbox.push(gpa, std.testing.io, .{ .eof = .{ .pane = 0, .gen = std.math.maxInt(u32) } }); try inbox.pushOutput(gpa, std.testing.io, .{ .pane = 0, .gen = 0, .bytes = try gpa.dupe(u8, "new output") }); inbox.push(gpa, std.testing.io, .{ .eof = .{ .pane = 0, .gen = 0 } }); inbox.push(gpa, std.testing.io, .{ .lsp_done = .{ .id = 0, .rows = try gpa.dupe(u8, "new result") } }); inbox.push(gpa, std.testing.io, .{ .lsp_status = try gpa.dupe(u8, "new status") }); var batch = inbox.take(std.testing.io); defer for (batch.slice()) |msg| msg.free(gpa); try std.testing.expectEqual(@as(usize, 5), batch.len); try std.testing.expectEqualStrings("old output", batch.items[0].output.bytes); try std.testing.expectEqualStrings("new output", batch.items[1].output.bytes); try std.testing.expectEqual(@as(u32, 0), batch.items[2].eof.gen); try std.testing.expectEqual(@as(u32, 0), batch.items[3].lsp_done.id); try std.testing.expectEqualStrings("new result", batch.items[3].lsp_done.rows.?); try std.testing.expectEqualStrings("new status", batch.items[4].lsp_status); inbox.close(gpa, std.testing.io); inbox.push(gpa, std.testing.io, .{ .lsp_done = .{ .id = 1, .rows = try gpa.dupe(u8, "closed") } }); try std.testing.expectEqual(@as(usize, 0), inbox.len); } test "mac inbox admits every retained task completion when output fills the queue" { const gpa = std.testing.allocator; var inbox: Inbox = .{}; defer inbox.close(gpa, std.testing.io); var tasks: selection_pipe.Tasks = .{}; defer tasks.cancelAll(std.testing.io); for (0..inbox_output_limit) |_| try inbox.pushOutput(gpa, std.testing.io, .{ .pane = 0, .gen = 1, .bytes = try gpa.dupe(u8, "output"), }); for (0..pardes.MAX_PANES) |pane| inbox.push(gpa, std.testing.io, .{ .eof = .{ .pane = @intCast(pane), .gen = 1 } }); inbox.push(gpa, std.testing.io, .{ .lsp_done = .{ .id = 1, .rows = try gpa.dupe(u8, "result") } }); for (0..selection_pipe.Tasks.capacity) |index| { const id: u32 = @intCast(index); try std.testing.expect(tasks.add(.{ .id = id, .future = .{ .any_future = null, .result = {} } })); if (index == 0) { const output = try gpa.dupe(u8, "filtered"); const outputs = gpa.dupe([]u8, &.{output}) catch |err| { gpa.free(output); return err; }; inbox.push(gpa, std.testing.io, .{ .pipe = .{ .id = id, .success = true, .outputs = outputs } }); } else inbox.push(gpa, std.testing.io, .{ .pipe = .{ .id = id, .success = false, .outputs = &.{}, .failure = .{ .kind = .exit, .code = 1, .stderr = try gpa.dupe(u8, "no match") }, } }); } inbox.push(gpa, std.testing.io, .{ .lsp_status = try gpa.dupe(u8, "current status") }); try std.testing.expectEqual(inbox_capacity, inbox.len); var batch = inbox.take(std.testing.io); defer for (batch.slice()) |msg| msg.free(gpa); var eof_count: usize = 0; var lsp_count: usize = 0; var pipe_count: usize = 0; var output_count: usize = 0; for (batch.slice()) |msg| switch (msg) { .eof => eof_count += 1, .lsp_done => lsp_count += 1, .pipe => |result| { pipe_count += 1; tasks.finish(std.testing.io, result.id); }, .output => |output| { output_count += 1; try std.testing.expectEqualStrings("output", output.bytes); }, .lsp_status => |status| try std.testing.expectEqualStrings("current status", status), }; try std.testing.expectEqual(pardes.MAX_PANES, eof_count); try std.testing.expectEqual(@as(usize, 1), lsp_count); try std.testing.expectEqual(selection_pipe.Tasks.capacity, pipe_count); try std.testing.expectEqual(inbox_output_limit, output_count); try std.testing.expectEqual(@as(usize, 0), tasks.len); } test "mac inbox preserves output under backpressure and wakes on take cancel and close" { const gpa = std.testing.allocator; const io = std.testing.io; const Producer = struct { fn run(q: *Inbox, done: *std.Io.Event) !void { defer done.set(std.testing.io); defer q.push(std.testing.allocator, std.testing.io, .{ .eof = .{ .pane = 0, .gen = 1 } }); try q.pushOutput(std.testing.allocator, std.testing.io, .{ .pane = 0, .gen = 1, .bytes = try std.testing.allocator.dupe(u8, "tail\x1b[0m"), }); try q.pushOutput(std.testing.allocator, std.testing.io, .{ .pane = 0, .gen = 1, .bytes = try std.testing.allocator.dupe(u8, "é😀"), }); } }; for ([_]enum { take, cancel, close }{ .take, .cancel, .close }) |action| { var inbox: Inbox = .{}; defer inbox.close(gpa, io); for (0..inbox_output_limit) |_| try inbox.pushOutput(gpa, io, .{ .pane = 0, .gen = 1, .bytes = try gpa.dupe(u8, "before"), }); var done: std.Io.Event = .unset; var future = try io.concurrent(Producer.run, .{ &inbox, &done }); defer future.cancel(io) catch {}; for (0..1000) |_| { if (inbox.space.state.load(.acquire).waiters != 0) break; try io.sleep(.fromMilliseconds(1), .awake); } try std.testing.expectEqual(@as(u16, 1), inbox.space.state.load(.acquire).waiters); switch (action) { .take => { var before = inbox.take(io); defer for (before.slice()) |msg| msg.free(gpa); try std.testing.expectEqual(inbox_output_limit, before.len); for (before.slice()) |msg| try std.testing.expectEqualStrings("before", msg.output.bytes); try done.waitTimeout(io, .{ .duration = .{ .raw = .fromSeconds(2), .clock = .awake } }); try future.await(io); var after = inbox.take(io); defer for (after.slice()) |msg| msg.free(gpa); try std.testing.expectEqual(@as(usize, 3), after.len); try std.testing.expectEqualStrings("tail\x1b[0m", after.items[0].output.bytes); try std.testing.expectEqualStrings("é😀", after.items[1].output.bytes); try std.testing.expect(after.items[2] == .eof); }, .cancel => { try std.testing.expectError(error.Canceled, future.cancel(io)); var batch = inbox.take(io); defer for (batch.slice()) |msg| msg.free(gpa); try std.testing.expectEqual(inbox_output_limit + 1, batch.len); for (batch.slice()[0..inbox_output_limit]) |msg| try std.testing.expectEqualStrings("before", msg.output.bytes); try std.testing.expect(batch.items[inbox_output_limit] == .eof); }, .close => { inbox.close(gpa, io); try done.waitTimeout(io, .{ .duration = .{ .raw = .fromSeconds(2), .clock = .awake } }); try std.testing.expectError(error.Canceled, future.await(io)); try std.testing.expectEqual(@as(usize, 0), inbox.len); }, } } } const State = struct { ninep: ?*ninep_io.Listener = null, gpa: std.mem.Allocator, threaded: *std.Io.Threaded, io: std.Io, core: *pardes.Pardes, started: bool = false, runtime: Runtime, tag_cells: [pardes.MAX_TAG_LAYERS][]Cell = @splat(&.{}), layer_cells: [pardes.MAX_PANES][]Cell = @splat(&.{}), cells: []Cell = &.{}, previous_cells: []Cell = &.{}, changed_cells: []u8 = &.{}, panel_diff_len: usize = 0, frame_len: usize = 0, frame_cols: u16 = 0, frame_rows: u16 = 0, images: []Image = &.{}, images_len: usize = 0, panel_tracks: [pardes.MAX_PANES * 2]PanelTrack = undefined, panel_tracks_len: usize = 0, ptys: [pardes.MAX_PANES]?Pty = @splat(null), inbox: Inbox = .{}, lsp_task: ?host_io.Lsp.Task = null, pipe_tasks: selection_pipe.Tasks = .{}, file_watches: FileWatches = .{}, gens: [pardes.MAX_PANES]u32 = @splat(0), scroll_lag: f32 = 0, scroll_lag_x: f32 = 0, scroll_velocity: f32 = 0, scroll_velocity_x: f32 = 0, scroll_last_ns: i128 = 0, /// Where the gesture turned. A coast keeps delivering THERE rather than /// wherever the pointer has drifted to since — the same rule the SDL /// shell keeps in scroll_col/scroll_row. scroll_col: u16 = 0, scroll_row: u16 = 0, scroll_coasting: bool = false, rotate_lag: f32 = 0, rotate_velocity: f32 = 0, rotate_last_ns: i128 = 0, rotate_coasting: bool = false, scene_ns: u64 = 0, last_tick_ns: u64 = 0, tick_bank_ns: u64 = 0, cwd_stale: [pardes.MAX_PANES]bool = @splat(false), config_arena: std.heap.ArenaAllocator, prompt_rcs: host_io.Shell.PromptFiles, }; var state: ?State = null; export fn pardes_init(runtime: ?*const Runtime, cols_arg: u16, rows_arg: u16) c_int { if (state != null) return 1; // already up; deinit first initCore(runtime, cols_arg, rows_arg) catch |err| { log.err("init failed: {t}", .{err}); return 2; }; return 0; } fn initCore(runtime: ?*const Runtime, cols_arg: u16, rows_arg: u16) !void { const gpa = std.heap.smp_allocator; const allocs = pardes.memory.init(gpa); errdefer pardes.memory.deinit(); const threaded = try gpa.create(std.Io.Threaded); errdefer gpa.destroy(threaded); threaded.* = .init(gpa, .{}); errdefer threaded.deinit(); const io = threaded.io(); var config_arena: std.heap.ArenaAllocator = .init(gpa); errdefer config_arena.deinit(); var opts: pardes.Options = .{ .tty_only = true, // The app is a real bare-tty startup, like the terminal and SDL // shells, so it names the same layout rather than the bare shell a // core booted for a test gets. .boot = .tty, .frame_allocator = allocs.frame, .image_allocator = allocs.image, .pdf_allocator = allocs.pdf, .tree_sitter_allocator = allocs.tree_sitter, }; if (captureEnv(config_arena.allocator())) |*env| { const found = pardes.config.User.load(io, config_arena.allocator(), env); opts.startup_config = found.bytes; opts.startup_config_path = found.path; opts.config_dir = found.dir; if (found.dir) |d| crash.setDir(d); } pardes.image.start(io, allocs.image); errdefer pardes.image.stop(); if (comptime pardes.pdf_enabled) pardes.pdf.start(allocs.pdf); errdefer if (comptime pardes.pdf_enabled) pardes.pdf.stop(); pardes.syntax.start(allocs.tree_sitter); errdefer pardes.syntax.stop(); const core = try pardes.Pardes.init(allocs.pardes, opts); errdefer core.deinit(); // The workspace tag row is the macOS menu bar's business here (see // docs/macos.md): the build option says whether this shell keeps the // acme topbar or hands its commands to native chrome. The e2e harness // opts back in with PARDES_WORKSPACE_TAG=1 — its scripts speak to a // grid that has the row. Applied before the first resize so the layout // has never seen it the other way. core.settings.workspace_tag = if (std.c.getenv("PARDES_WORKSPACE_TAG")) |v| std.mem.eql(u8, std.mem.span(v), "1") else @import("pardes_config").workspace_tag; core.native_images = true; var prompt_rcs = host_io.Shell.prepare(); errdefer prompt_rcs.deinit(); state = .{ .gpa = gpa, .threaded = threaded, .io = io, .core = core, .config_arena = config_arena, .prompt_rcs = prompt_rcs, .runtime = if (runtime) |r| r.* else .{}, }; const st = &state.?; st.ninep = ninep_io.start(io, gpa, core); core.host = hostFor(st); const cols = @max(1, cols_arg); const rows = @max(1, rows_arg); core.update(.{ .resize = .{ .cols = cols, .rows = rows } }); while (core.nextEffect()) |effect| core.perform(effect); st.started = true; if (st.ninep) |listener| listener.watch(st, wakeNinep) catch |err| core.reportError(0, "9p wake", err); for (&st.ptys, 0..) |*slot, id| if (slot.*) |*pt| startReader(st, pt, @intCast(id)); pardes.lsp.setStatusSink(st, lspStatusSink); // From here the core is touched only inside the entry points above, // each of which takes the turn for its own duration; between them the // 9P connections have it. pardes.turn.rest(); } fn wakeNinep(ctx: ?*anyopaque) void { const st: *State = @ptrCast(@alignCast(ctx orelse return)); wake(st); } export fn pardes_deinit() void { pardes.turn.wake(); const st = &(state orelse return); if (st.ninep) |listener| { listener.deinit(st.gpa); st.ninep = null; } pardes.lsp.setStatusSink(null, null); if (st.lsp_task) |*t| { t.future.cancel(st.io) catch {}; st.lsp_task = null; } st.pipe_tasks.cancelAll(st.io); 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); }; if (st.file_watches.entries[theme_watch_pane] != null) { const generation = st.file_watches.stop(st.gpa, theme_watch_pane); hostWatchFile(theme_watch_pane, generation, null, 0); } for (0..pardes.MAX_PANES) |pane| reap(st, @intCast(pane)); st.inbox.close(st.gpa, st.io); st.file_watches.deinit(st.gpa); if (st.cells.len > 0) st.gpa.free(st.cells); for (st.layer_cells) |cells| st.gpa.free(cells); for (st.tag_cells) |cells| st.gpa.free(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.core.deinit(); pardes.image.stop(); 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.memory.deinit(); state = null; } export fn pardes_should_quit() bool { pardes.turn.wake(); defer pardes.turn.rest(); const st = &(state orelse return true); return st.core.quit; } fn encodeSceneEffects(effects: layout.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 advanceSceneClock(st: *State, elapsed_ns: u64) void { st.scene_ns = (st.scene_ns +| elapsed_ns) % scene_wrap_ns; } const TickSpend = struct { elapsed_ns: u64, steps: u32, bank_ns: u64 }; fn spendTickTime(previous_ns: u64, now_ns: u64, bank_ns: u64) TickSpend { const measured = if (previous_ns == 0 or now_ns <= previous_ns) pardes.layout.Animation.frame_ns else now_ns - previous_ns; const elapsed = @min(measured, max_tick_catch_up_ns); var bank = bank_ns +| elapsed; var steps: u32 = 0; while (bank >= pardes.layout.Animation.frame_ns) : (steps += 1) bank -= pardes.layout.Animation.frame_ns; return .{ .elapsed_ns = elapsed, .steps = steps, .bank_ns = bank }; } test "the animation clock spends real time, not callbacks" { const frame = pardes.layout.Animation.frame_ns; const expectEqual = std.testing.expectEqual; const first = spendTickTime(0, 999 * std.time.ns_per_s, 0); try expectEqual(@as(u32, 1), first.steps); try expectEqual(frame, first.elapsed_ns); const on_time = spendTickTime(1_000, 1_000 + frame, 0); try expectEqual(@as(u32, 1), on_time.steps); try expectEqual(@as(u64, 0), on_time.bank_ns); const late = spendTickTime(1_000, 1_000 + 2 * frame, 0); try expectEqual(@as(u32, 2), late.steps); var bank: u64 = 0; var steps: u32 = 0; for (0..3) |_| { const partial = spendTickTime(1_000, 1_000 + 6 * std.time.ns_per_ms, bank); bank = partial.bank_ns; steps += partial.steps; } try expectEqual(@as(u32, 1), steps); try expectEqual(@as(u64, 2 * std.time.ns_per_ms), bank); const stall = spendTickTime(1_000, 1_000 + 10 * std.time.ns_per_s, 0); try expectEqual(max_tick_catch_up_ns, stall.elapsed_ns); try expectEqual(@as(u32, @intCast(max_tick_catch_up_ns / frame)), stall.steps); try expectEqual(@as(u32, 1), spendTickTime(5_000, 4_000, 0).steps); } export fn pardes_animating() bool { pardes.turn.wake(); defer pardes.turn.rest(); const st = &(state orelse return false); return st.core.animationActive() or st.rotate_coasting or st.scroll_coasting; } export fn pardes_theme_bg() u32 { pardes.turn.wake(); defer pardes.turn.rest(); const th = if (state) |*st| st.core.theme() else &pardes.themes[0]; const bg = th.bg orelse return color_default; return @as(u32, bg[0]) << 16 | @as(u32, bg[1]) << 8 | bg[2]; } /// The percentage the shell must paint window backgrounds at, read once per /// pump beside pardes_theme_bg(). /// /// Reading it also ACKNOWLEDGES whatever `WindowOpacity` asked for. The SDL /// shell has to answer that request because an SDL surface can arrive without /// an alpha channel and the core must not claim a value the compositor /// refused; an AppKit window always composites per pixel, so there is nothing /// here to refuse and no rollback for the core to perform. /// /// The theme's own decision stays separate: a theme with no background of its /// own (pardes_theme_bg == PARDES_COLOR_DEFAULT) drops the ground entirely, /// whatever percentage is set here. export fn pardes_window_opacity() u8 { pardes.turn.wake(); defer pardes.turn.rest(); const st = &(state orelse return 100); st.core.settings.window_opacity_pending = false; return st.core.settings.window_opacity; } /// Public AppKit backdrop strength, independent of background paint opacity. export fn pardes_window_blur() u8 { pardes.turn.wake(); defer pardes.turn.rest(); return if (state) |st| st.core.settings.window_blur else 0; } fn taglineFontPercent(core: ?*const pardes.Pardes) u8 { return if (core) |p| p.settings.font.tagline_percent else pardes.config.gui_tagline_font_percent; } export fn pardes_gui_tagline_font_percent() u8 { pardes.turn.wake(); defer pardes.turn.rest(); return taglineFontPercent(if (state) |*st| st.core else null); } export fn pardes_tagline_band_offset(row: u16, canvas_h: f32, cell_h: u32, tagline_h: u32) u32 { pardes.turn.wake(); defer pardes.turn.rest(); 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); } export fn pardes_tagline_origin_col(col: u16, row: u16) f32 { pardes.turn.wake(); defer pardes.turn.rest(); const st = &(state orelse return @floatFromInt(col)); return pardes.taglineOriginColForFrame(st.core, col, row); } export fn pardes_grid_col_at(x: f32, row: u16, body_w: f32, tagline_w: f32) u16 { pardes.turn.wake(); defer pardes.turn.rest(); const st = &(state orelse return pardes.gridColAt(null, x, row, body_w, tagline_w)); return pardes.gridColAt(st.core, x, row, body_w, tagline_w); } export fn pardes_topbar_pane_border_rgb() u32 { pardes.turn.wake(); defer pardes.turn.rest(); const rgb = pardes.config.gui_topbar_pane_border_rgb orelse fromTheme: { const st = state orelse return color_default; break :fromTheme st.core.chromeTheme().border; }; return @as(u32, rgb[0]) << 16 | @as(u32, rgb[1]) << 8 | rgb[2]; } export fn pardes_tag_active_bg() u32 { pardes.turn.wake(); defer pardes.turn.rest(); const st = state orelse return color_default; const rgb = st.core.chromeTheme().tag_active_bg; return @as(u32, rgb[0]) << 16 | @as(u32, rgb[1]) << 8 | rgb[2]; } export fn pardes_tagline_bg() u32 { pardes.turn.wake(); defer pardes.turn.rest(); const st = state orelse return color_default; const rgb = st.core.chromeTheme().tag_bg; return @as(u32, rgb[0]) << 16 | @as(u32, rgb[1]) << 8 | rgb[2]; } export fn pardes_fallback_font_count() u32 { return fonts.fallback_names.len; } export fn pardes_fallback_font_name(index: u32, len: *u32) ?[*]const u8 { if (index >= fonts.fallback_names.len) { len.* = 0; return null; } const name = fonts.fallback_names[index]; len.* = @intCast(name.len); return name.ptr; } 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)); } test "the fallback preference order crosses the ABI intact and ends at the boundary" { try std.testing.expectEqual(@as(u32, fonts.fallback_names.len), pardes_fallback_font_count()); try std.testing.expect(pardes_fallback_font_count() > 0); for (fonts.fallback_names, 0..) |want, i| { var len: u32 = 0; const got = pardes_fallback_font_name(@intCast(i), &len) orelse return error.MissingFallbackName; try std.testing.expectEqualStrings(want, got[0..len]); } var len: u32 = 12345; try std.testing.expect(pardes_fallback_font_name(pardes_fallback_font_count(), &len) == null); try std.testing.expectEqual(@as(u32, 0), len); } export fn pardes_scene() Scene { pardes.turn.wake(); defer pardes.turn.rest(); const st = &(state orelse return .{}); const seconds = @as(f64, @floatFromInt(st.scene_ns)) / @as(f64, std.time.ns_per_s); return .{ .flags = currentSceneFlags(st), .time_seconds = @floatCast(seconds), .frame = @intFromFloat(seconds * @as(f64, @floatFromInt(scene_frame_hz))), }; } export fn pardes_postprocessor_unavailable() void { pardes.turn.wake(); defer pardes.turn.rest(); const st = &(state orelse return); st.core.disableSceneEffects(); st.core.settings.panel_transition = .off; st.core.abandonPanelAnimations(); st.scene_ns = 0; } export fn pardes_panel_animation_failed() void { pardes.turn.wake(); defer pardes.turn.rest(); const st = &(state orelse return); st.core.abandonPanelAnimations(); } fn refreshCwds(ctx: ?*anyopaque) void { const st = hostState(ctx); for (&st.cwd_stale, 0..) |*stale, id| { if (!stale.*) continue; stale.* = false; const pt = st.ptys[id] orelse continue; var buf: [1024]u8 = undefined; if (host_io.shellCwd(pt.pid, &buf)) |wd| st.core.setCwd(id, wd); } } 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, path: []const u8, mode: pardes.WatchMode) void { const native = filesystem.localPath(path) orelse 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; }; hostWatchFile(pane, generation, native.ptr, native.len); if (mode == .baseline_disk and value.file != null) { const bytes = filesystem.read(st.core, path) catch return; defer st.core.gpa.free(bytes); st.file_watches.restampText(pane, path, std.hash.Wyhash.hash(0, bytes)); return; } _ = reloadWatchedFile(st, pane, false); } fn setThemeFileWatch(st: *State, request_generation: u32, on: bool, animate: bool) void { const stopped = st.file_watches.stop(st.gpa, theme_watch_pane); hostWatchFile(theme_watch_pane, stopped, null, 0); if (!on) return; const request = st.core.themeFileRequest(request_generation) orelse return; const bytes = filesystem.readFile(st.gpa, request.path) catch |err| { st.core.failThemeFile(request_generation, err); return; }; defer st.gpa.free(bytes); if (!st.core.loadThemeFile(request_generation, bytes, animate)) return; const callback_generation = st.file_watches.replace( st.gpa, theme_watch_pane, request.path, request_generation, .{ .text = std.hash.Wyhash.hash(0, bytes) }, ) catch return; const watched = st.file_watches.entries[theme_watch_pane].?; hostWatchFile(theme_watch_pane, callback_generation, watched.path.ptr, watched.path.len); _ = reloadWatchedTheme(st, false); } fn reloadWatchedTheme(st: *State, announce: bool) bool { const watched = if (st.file_watches.entries[theme_watch_pane]) |*entry| entry else return false; const request = st.core.themeFileRequest(watched.serial) orelse return false; if (!std.mem.eql(u8, watched.path, request.path)) return false; const bytes = filesystem.readFile(st.gpa, watched.path) catch |err| { st.core.failThemeFile(watched.serial, err); return false; }; defer st.gpa.free(bytes); const hash = std.hash.Wyhash.hash(0, bytes); switch (watched.generation_on_disk) { .text => |accepted| if (accepted == hash) return false, .pdf => unreachable, } if (!st.core.loadThemeFile(watched.serial, bytes, true)) return false; const live = if (st.file_watches.entries[theme_watch_pane]) |*entry| entry else return false; if (live.serial != watched.serial) return false; live.generation_on_disk = .{ .text = hash }; live.retries_left = 0; if (announce) { var mbuf: [256]u8 = undefined; st.core.setMessage(request.pane, message.stamp(&mbuf, "reloaded theme", request.path)); } return true; } 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 = filesystem.read(st.core, watched.path) catch { retryWatchedFile(st, pane, generation); break :text .no_change; }; defer st.core.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; } 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; }; 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; } export fn pardes_watch_changed(pane: u8, generation: u32) void { pardes.turn.wake(); defer pardes.turn.rest(); const st = &(state orelse return); if (pane >= watch_slot_count) return; if (st.file_watches.notify(pane, generation)) wake(st); } fn drainInbox(st: *State) bool { var batch = st.inbox.take(st.io); var did = batch.len > 0; for (batch.slice()) |msg| { defer msg.free(st.gpa); switch (msg) { .output => |o| { if (st.gens[o.pane] != o.gen) continue; st.cwd_stale[o.pane] = true; st.core.update(.{ .output = .{ .pane = o.pane, .bytes = o.bytes } }); }, .eof => |e| { if (st.gens[e.pane] != e.gen) continue; reap(st, e.pane); st.core.update(.{ .eof = .{ .pane = e.pane } }); }, .lsp_done => |d| { st.core.update(.{ .lsp_resp = .{ .id = d.id, .rows = d.rows } }); if (st.lsp_task) |*t| if (t.id == d.id) { t.future.await(st.io) catch {}; st.lsp_task = null; }; }, .lsp_status => |text| { var mbuf: [256]u8 = undefined; st.core.setStatus(st.core.active, message.stamp(&mbuf, "lsp", text)); }, .pipe => |value| { st.core.update(.{ .pipe_resp = .{ .id = value.id, .success = value.success, .outputs = value.outputs, .failure = value.failure, } }); st.pipe_tasks.finish(st.io, value.id); }, } } for (0..pardes.MAX_PANES) |pane| { const id: u8 = @intCast(pane); if (!st.file_watches.takeDirty(id)) continue; if (reloadWatchedFile(st, id, true)) did = true; } if (st.file_watches.takeDirty(theme_watch_pane)) { if (reloadWatchedTheme(st, true)) did = true; } return did; } export fn pardes_tick() bool { pardes.turn.wake(); defer pardes.turn.rest(); const st = &(state orelse return false); st.inbox.wake_pending.store(false, .release); var did = drainInbox(st); if (st.ninep) |listener| { const drained = listener.tick(); did = did or drained.count != 0; if (drained.pending) wake(st); } while (st.core.nextEffect()) |effect| { did = true; st.core.perform(effect); } pardes.turn.settle(); if (restoreCore(st)) did = true; pardes.turn.restoreSettled(); return did; } fn restoreCore(st: *State) bool { if (st.core.quit) return false; const path = st.core.takeRestore() orelse return false; const bytes = filesystem.readRestore(st.gpa, path, st.core.settings.dump_dir.get()) catch |err| { st.core.reportError(st.core.active, "Restore", err); return false; }; defer st.gpa.free(bytes); const replacement = st.core.restore(bytes) catch |err| { st.core.reportError(st.core.active, "Restore", err); return false; }; if (st.lsp_task) |*task| { task.future.cancel(st.io) catch {}; st.lsp_task = null; } st.pipe_tasks.cancelAll(st.io); for (0..pardes.MAX_PANES) |pane| { reap(st, @intCast(pane)); st.gens[pane] +%= 1; } var stale = st.inbox.take(st.io); for (stale.slice()) |msg| msg.free(st.gpa); for (0..watch_slot_count) |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); }; if (st.ninep) |listener| listener.reset(replacement); replacement.host = hostFor(st); st.core.deinit(); st.core = replacement; clearFrame(st); st.cwd_stale = @splat(false); st.scroll_lag = 0; st.scroll_lag_x = 0; st.scroll_velocity = 0; st.scroll_velocity_x = 0; st.scroll_last_ns = 0; st.scroll_coasting = false; st.rotate_lag = 0; st.rotate_velocity = 0; st.rotate_last_ns = 0; st.rotate_coasting = false; st.scene_ns = 0; st.last_tick_ns = 0; st.tick_bank_ns = 0; return true; } test "mac Restore keeps host state and rejects callbacks from the old core" { const gpa = std.testing.allocator; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); const core = try pardes.Pardes.init(gpa, .{ .tty_only = true, .cols = 60, .rows = 16 }); var st: State = .{ .gpa = gpa, .threaded = undefined, .io = std.testing.io, .core = core, .runtime = .{}, .config_arena = .init(gpa), .prompt_rcs = .{}, .frame_len = 7, .rotate_coasting = true, }; defer st.core.deinit(); defer st.config_arena.deinit(); defer st.file_watches.deinit(gpa); defer st.inbox.close(gpa, std.testing.io); const marker = try st.config_arena.allocator().dupe(u8, "host configuration"); _ = try core.setTestFile("saved body\n"); const old_serial = core.panes[0].?.serial; st.gens[0] = 23; const old_watch = try st.file_watches.replace(gpa, 0, "/test.txt", old_serial, .{ .text = 0 }); try core.dumpState(); try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "restore.zon", .data = core.dump_out.? }); while (core.nextEffect()) |_| {} var command: [512]u8 = undefined; core.update(.{ .command = try std.fmt.bufPrint(&command, "Restore .zig-cache/tmp/{s}/missing.zon", .{tmp.sub_path}) }); try std.testing.expect(!restoreCore(&st)); try std.testing.expectEqual(core, st.core); try std.testing.expectEqual(old_watch, st.file_watches.entries[0].?.generation); try std.testing.expect(st.rotate_coasting); core.lspRequest(0, .status, ""); const old_request = core.lsp_wait.?.id; st.lsp_task = .{ .id = old_request, .future = .{ .any_future = null, .result = {} } }; st.inbox.push(gpa, std.testing.io, .{ .lsp_done = .{ .id = old_request, .rows = try gpa.dupe(u8, "old result") } }); for (0..selection_pipe.Tasks.capacity) |index| { const id: u32 = @intCast(index); try std.testing.expect(st.pipe_tasks.add(.{ .id = id, .future = .{ .any_future = null, .result = {} } })); st.inbox.push(gpa, std.testing.io, .{ .pipe = .{ .id = id, .success = false, .outputs = &.{}, .failure = .{ .kind = .exit, .code = 1, .stderr = try gpa.dupe(u8, "old filter failure") }, } }); } try st.inbox.pushOutput(gpa, std.testing.io, .{ .pane = 0, .gen = 23, .bytes = try gpa.dupe(u8, "old PTY output") }); core.update(.{ .command = try std.fmt.bufPrint(&command, "Restore .zig-cache/tmp/{s}/restore.zon", .{tmp.sub_path}) }); try std.testing.expect(restoreCore(&st)); try std.testing.expect(st.core != core); try std.testing.expect(st.core.panes[0].?.serial > old_serial); try std.testing.expectEqualStrings("saved body\n", st.core.panes[0].?.file.?.content); try std.testing.expectEqualStrings("host configuration", marker); try std.testing.expectEqual(@as(usize, 0), st.frame_len); try std.testing.expect(!st.rotate_coasting); try std.testing.expectEqual(@as(u32, 24), st.gens[0]); try std.testing.expect(!st.file_watches.notify(0, old_watch)); try std.testing.expect(st.file_watches.generations[0] > old_watch); try std.testing.expectEqual(@as(usize, 0), st.inbox.len); try std.testing.expectEqual(@as(usize, 0), st.pipe_tasks.len); try std.testing.expect(st.lsp_task == null); try std.testing.expect(!drainInbox(&st)); try std.testing.expect(!st.cwd_stale[0]); st.core.lspRequest(0, .status, ""); const new_request = st.core.lsp_wait.?.id; try std.testing.expect(new_request > old_request); st.lsp_task = .{ .id = new_request, .future = .{ .any_future = null, .result = {} } }; st.inbox.push(gpa, std.testing.io, .{ .lsp_done = .{ .id = new_request, .rows = &.{} } }); try std.testing.expect(drainInbox(&st)); try std.testing.expect(st.lsp_task == null); try std.testing.expect(st.core.lsp_wait == null); const pane = st.core.panes[0].?; pane.cur_col = 4; pane.vsel = .{ .active = true, .row = 0, .col = 0, .explicit = true }; st.core.update(.{ .key = .{ .cp = '|' } }); st.core.update(.{ .key = .{ .cp = 't', .text = "tr a-z A-Z" } }); st.core.update(.{ .key = .{ .cp = pardes.Key.enter } }); const pipe_id = st.core.pipe_wait.?.id; try std.testing.expect(st.pipe_tasks.add(.{ .id = pipe_id, .future = .{ .any_future = null, .result = {} } })); const output = try gpa.dupe(u8, "FRESH"); const outputs = gpa.dupe([]u8, &.{output}) catch |err| { gpa.free(output); return err; }; st.inbox.push(gpa, std.testing.io, .{ .pipe = .{ .id = pipe_id, .success = true, .outputs = outputs } }); try std.testing.expect(drainInbox(&st)); try std.testing.expect(st.core.pipe_wait == null); try std.testing.expectEqual(@as(usize, 0), st.pipe_tasks.len); try std.testing.expectEqualStrings("FRESH body\n", pane.file.?.content); } test "mac Restore cancels a PTY reader waiting for output capacity" { const gpa = std.testing.allocator; const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); var st: State = .{ .gpa = gpa, .threaded = undefined, .io = io, .core = try pardes.Pardes.init(gpa, .{ .tty_only = true, .cols = 40, .rows = 12 }), .runtime = .{}, .config_arena = .init(gpa), .prompt_rcs = .{}, }; defer st.core.deinit(); defer st.config_arena.deinit(); defer st.file_watches.deinit(gpa); defer st.inbox.close(gpa, io); try st.core.dumpState(); try tmp.dir.writeFile(io, .{ .sub_path = "restore.zon", .data = st.core.dump_out.? }); while (st.core.nextEffect()) |_| {} for (0..inbox_output_limit) |_| try st.inbox.pushOutput(gpa, io, .{ .pane = 0, .gen = 1, .bytes = try gpa.dupe(u8, "retained output"), }); const child = try host_io.forkShell(null, 0, &st.prompt_rcs, "/bin/sh", "", 12, 40, null); st.gens[0] = 1; st.ptys[0] = .{ .file = child.file, .pid = child.pid, .gen = 1, .reader = .{ .any_future = null, .result = {} } }; defer { if (st.ptys[0]) |pt| if (pt.pid == child.pid) reap(&st, 0); if (libc.waitpid(child.pid, null, posix.W.NOHANG) == 0) { _ = libc.kill(child.pid, libc.SIG.KILL); _ = libc.waitpid(child.pid, null, 0); } } startReader(&st, &st.ptys[0].?, 0); try std.testing.expect(host_io.writeFd(child.file.handle, "printf 'after-full'; exit\n")); for (0..1000) |_| { if (st.inbox.space.state.load(.acquire).waiters != 0) break; try io.sleep(.fromMilliseconds(1), .awake); } try std.testing.expectEqual(@as(u16, 1), st.inbox.space.state.load(.acquire).waiters); var command: [512]u8 = undefined; st.core.update(.{ .command = try std.fmt.bufPrint(&command, "Restore .zig-cache/tmp/{s}/restore.zon", .{tmp.sub_path}) }); try std.testing.expect(restoreCore(&st)); try std.testing.expect(st.ptys[0] == null); try std.testing.expectEqual(@as(usize, 0), st.inbox.len); try std.testing.expectEqual(@as(u16, 0), st.inbox.space.state.load(.acquire).waiters); try st.inbox.pushOutput(gpa, io, .{ .pane = 0, .gen = st.gens[0], .bytes = try gpa.dupe(u8, "fresh output") }); var batch = st.inbox.take(io); defer for (batch.slice()) |msg| msg.free(gpa); try std.testing.expectEqual(@as(usize, 1), batch.len); try std.testing.expectEqualStrings("fresh output", batch.items[0].output.bytes); } export fn pardes_animation_tick() bool { pardes.turn.wake(); defer pardes.turn.rest(); const st = &(state orelse return false); const now: u64 = @intCast(@max(0, monotonicNs())); const spend = spendTickTime(st.last_tick_ns, now, st.tick_bank_ns); st.last_tick_ns = now; st.tick_bank_ns = spend.bank_ns; var changed = false; if (currentSceneFlags(st) != 0) { advanceSceneClock(st, spend.elapsed_ns); changed = true; } for (0..spend.steps) |_| { if (st.core.animationActive()) { st.core.update(.tick); changed = true; } if (st.scroll_coasting) { const spent_now = spendScroll( st, st.scroll_velocity * scroll_fling_step, st.scroll_velocity_x * scroll_fling_step, st.scroll_col, st.scroll_row, ); st.scroll_velocity *= scroll_fling_decay; st.scroll_velocity_x *= scroll_fling_decay; const decayed = @abs(st.scroll_velocity) < scroll_fling_stop and @abs(st.scroll_velocity_x) < scroll_fling_stop; // Only a step that actually delivered a press can report an edge; // the many steps between two rows cross nothing and say nothing. const at_edge = spent_now.ticks != 0 and !spent_now.moved; if (decayed or at_edge) { st.scroll_velocity = 0; st.scroll_velocity_x = 0; st.scroll_coasting = false; st.scroll_lag = 0; st.scroll_lag_x = 0; } changed = true; } if (st.rotate_coasting) { spendRotation(st, st.rotate_velocity * rotation_fling_step); st.rotate_velocity *= rotation_fling_decay; if (@abs(st.rotate_velocity) < rotation_fling_stop) { st.rotate_velocity = 0; st.rotate_coasting = false; st.rotate_lag = 0; } changed = true; } } if (!changed) { st.tick_bank_ns = 0; st.last_tick_ns = 0; } return changed; } export fn pardes_key(cp_arg: u32, text_ptr: ?[*]const u8, len: usize, mods: u32) void { pardes.turn.wake(); defer pardes.turn.rest(); const st = &(state orelse return); if (cp_arg > std.math.maxInt(u21)) return; const text: []const u8 = if (text_ptr) |p| p[0..len] else ""; st.core.update(.{ .key = .{ .cp = @intCast(cp_arg), .text = text, .ctrl = mods & 1 != 0, .alt = mods & 2 != 0, .shift = mods & 4 != 0, } }); } export fn pardes_paste(text_ptr: ?[*]const u8, len: usize) void { pardes.turn.wake(); defer pardes.turn.rest(); const st = &(state orelse return); const text: []const u8 = if (text_ptr) |p| p[0..len] else ""; st.core.update(.{ .paste = text }); } export fn pardes_mouse(button_arg: c_int, kind_arg: c_int, col: u16, row: u16, mods: u32) void { pardes.turn.wake(); defer pardes.turn.rest(); mouseWithHit(button_arg, kind_arg, col, row, mods, null, null); } export fn pardes_mouse_pixel(button_arg: c_int, kind_arg: c_int, col: u16, row: u16, mods: u32, x: f32, y: f32, bw: f32, bh: f32, tw: f32, th: f32) void { pardes.turn.wake(); defer pardes.turn.rest(); const st = &(state orelse return); const source = st.core.presentation.pointerFractional(st.core.screen_w, st.core.screen_h, x / @max(1, bw), y / @max(1, bh)); var hit: ?pardes.Mouse.BodyHit = null; for (&st.core.surface.body_layers) |*layer| { const point = source orelse break; hit = layer.hitAt(point.x * bw, point.y * bh, bw, bh, tw, th); if (hit != null) break; } var tag_hit: ?pardes.TagHit = null; for (&st.core.surface.tag_layers) |*layer| { const point = source orelse break; tag_hit = layer.hitAt(point.x * bw, point.y * bh, bw, bh, tw, th); if (tag_hit != null) break; } mouseWithHit(button_arg, kind_arg, col, row, mods, hit, tag_hit); } fn mouseWithHit(button_arg: c_int, kind_arg: c_int, col: u16, row: u16, mods: u32, hit: ?pardes.Mouse.BodyHit, tag_hit: ?pardes.TagHit) void { const st = &(state orelse return); const button: pardes.Mouse.Button = switch (button_arg) { 0 => .left, 1 => .middle, 2 => .right, 3 => .wheel_up, 4 => .wheel_down, 5 => .wheel_left, 6 => .wheel_right, 7 => .none, 8 => .back, 9 => .forward, else => return, }; const kind: pardes.Mouse.Kind = switch (kind_arg) { 0 => .press, 1 => .release, 2 => .motion, 3 => .drag, else => return, }; st.core.update(.{ .mouse = .{ .body_hit = hit, .tag_hit = tag_hit, .button = button, .kind = kind, .col = col, .row = row, .ctrl = mods & 1 != 0, } }); } export fn pardes_pointer_leave() void { pardes.turn.wake(); defer pardes.turn.rest(); 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 { pardes.turn.wake(); defer pardes.turn.rest(); const st = &(state orelse return); st.scroll_col = col; st.scroll_row = row; noteScrollVelocity(st, delta_rows, delta_cols); _ = spendScroll(st, delta_rows, delta_cols, col, row); } /// A finger back on the pad, which catches a coast the way a hand catches a /// spinning dial. Also drops whatever the last gesture left banked, so the /// first millimetre of a new swipe cannot inherit a nearly-complete row. export fn pardes_scroll_begin() void { pardes.turn.wake(); defer pardes.turn.rest(); const st = &(state orelse return); st.scroll_lag = 0; st.scroll_lag_x = 0; st.scroll_velocity = 0; st.scroll_velocity_x = 0; st.scroll_coasting = false; st.scroll_last_ns = monotonicNs(); } /// Fingers lifted. A swipe that was still moving when it ended becomes a /// fling, measured from the speed of the last events rather than from any one /// of them, exactly as pardes_rotate_end measures the dial. /// /// A host whose windowing system flings for it need do nothing to suppress /// this: its first momentum event is an ordinary pardes_scroll, and every /// pardes_scroll cancels the coast before spending its own travel. The system's /// momentum simply takes the gesture over, and the tail of that momentum is /// below the fling floor, so its end arms nothing. export fn pardes_scroll_end() void { pardes.turn.wake(); defer pardes.turn.rest(); const st = &(state orelse return); const last = st.scroll_last_ns; st.scroll_last_ns = 0; st.scroll_coasting = false; // A hand that came to rest before lifting is not throwing anything. if (last == 0 or monotonicNs() - last > 90 * std.time.ns_per_ms) { st.scroll_velocity = 0; st.scroll_velocity_x = 0; return; } st.scroll_velocity = scrollFling(st.scroll_velocity); st.scroll_velocity_x = scrollFling(st.scroll_velocity_x); st.scroll_coasting = st.scroll_velocity != 0 or st.scroll_velocity_x != 0; } export fn pardes_rotate(degrees: f32) void { pardes.turn.wake(); defer pardes.turn.rest(); const st = &(state orelse return); if (degrees == 0) { st.rotate_lag = 0; st.rotate_velocity = 0; st.rotate_coasting = false; st.rotate_last_ns = monotonicNs(); return; } noteRotationVelocity(st, degrees); spendRotation(st, degrees); } export fn pardes_rotate_end() void { pardes.turn.wake(); defer pardes.turn.rest(); const st = &(state orelse return); const last = st.rotate_last_ns; st.rotate_last_ns = 0; st.rotate_coasting = false; if (last == 0 or monotonicNs() - last > 90 * std.time.ns_per_ms) { st.rotate_velocity = 0; return; } st.rotate_velocity = rotationFling(st.rotate_velocity); st.rotate_coasting = st.rotate_velocity != 0; } fn monotonicNs() i128 { var ts: libc.timespec = undefined; if (libc.clock_gettime(.MONOTONIC, &ts) != 0) return 0; return @as(i128, ts.sec) * std.time.ns_per_s + ts.nsec; } fn noteRotationVelocity(st: *State, degrees: f32) void { const now = monotonicNs(); const last = st.rotate_last_ns; st.rotate_last_ns = now; st.rotate_coasting = false; if (last == 0 or now == 0) return; const dt_ns = now - last; if (dt_ns <= 0 or dt_ns > 200 * std.time.ns_per_ms) return; const seconds: f32 = @floatCast(@as(f64, @floatFromInt(dt_ns)) / @as(f64, std.time.ns_per_s)); const sample = degrees / seconds; if (!std.math.isFinite(sample)) return; st.rotate_velocity = st.rotate_velocity * 0.35 + sample * 0.65; } fn spendRotation(st: *State, degrees: f32) void { var left = takeRotationNotches(&st.rotate_lag, degrees); while (left != 0) { const back = left > 0; // counterclockwise left += if (back) -1 else 1; st.core.update(.{ .key = .{ .cp = if (back) 'N' else 'n' } }); } } export fn pardes_command(text_ptr: ?[*]const u8, len: usize) void { pardes.turn.wake(); defer pardes.turn.rest(); const st = &(state orelse return); const text: []const u8 = if (text_ptr) |p| p[0..len] else ""; if (text.len == 0) return; st.core.update(.{ .command = text }); } export fn pardes_resize(cols_arg: u16, rows_arg: u16, cell_w: u16, cell_h: u16) void { pardes.turn.wake(); defer pardes.turn.rest(); const st = &(state orelse return); const cols = @max(1, cols_arg); const rows = @max(1, rows_arg); st.core.update(.{ .resize = .{ .cols = cols, .rows = rows, .cell_pixels = if (@hasField(pardes.CellPixels, "w")) .{ .w = @max(1, cell_w), .h = @max(1, cell_h) } else .{}, } }); } export fn pardes_frame() u32 { pardes.turn.wake(); defer pardes.turn.rest(); const st = &(state orelse return 0); const tz = tracy.zone(@src(), "pardes_frame"); defer tz.end(); _ = restoreCore(st); st.core.pump(hostFor(st)) catch |err| { log.err("render failed: {t}", .{err}); clearFrame(st); return 0; }; if (restoreCore(st)) { st.core.pump(hostFor(st)) catch |err| { log.err("render failed: {t}", .{err}); clearFrame(st); return 0; }; } tracy.frameMark(); return @intCast(st.frame_len); } fn clearFrame(st: *State) void { st.frame_len = 0; st.frame_cols = 0; st.frame_rows = 0; st.images_len = 0; st.panel_tracks_len = 0; st.panel_diff_len = 0; } fn blankTagCells(cells: []Cell, cols: u16, rows: u16, source: []const pardes.Cell, layers: []const pardes.TagLayer) void { for (layers) |layer| { if (layer.cols == 0 or layer.viewport.y >= rows or layer.viewport.x >= cols) continue; const start = @as(usize, layer.viewport.y) * cols + layer.viewport.x; const count = @min(layer.viewport.w, cols - layer.viewport.x); for (cells[start..][0..count], source[start..][0..count]) |*cell, was| { // The tag text is the layer's to draw, but a look-hover on a tag // word is a canonical fact: carry the hover bit through so the // glass overlay still finds the affordance rect. cell.* = encodeCell(.{}); cell.flags |= @as(u8, @intFromBool(was.hover)) * cell_flag_hover; } } } fn tagCursorCovered(core: *const pardes.Pardes) bool { const cursor = core.surface.cursor orelse return false; for (core.surface.tag_layers) |layer| { if (layer.cols != 0 and cursor.y == layer.viewport.y and cursor.x >= layer.viewport.x and cursor.x - layer.viewport.x < layer.viewport.w) return true; } return false; } fn presentFrame(ctx: ?*anyopaque, surface: *const pardes.Surface) void { const st = hostState(ctx); const tz = tracy.zone(@src(), "presentFrame"); defer tz.end(); clearFrame(st); const count: usize = @as(usize, surface.cols) * surface.rows; if (count != st.cells.len) { if (count == 0) { if (st.cells.len > 0) st.gpa.free(st.cells); st.cells = &.{}; } else { const resized = if (st.cells.len == 0) st.gpa.alloc(Cell, count) else st.gpa.realloc(st.cells, count); st.cells = resized catch return; } } st.frame_len = count; st.frame_cols = surface.cols; st.frame_rows = surface.rows; { const tz_cells = tracy.zone(@src(), "encodeCells"); defer tz_cells.end(); for (surface.cells, st.cells[0..count]) |cell, *out| out.* = encodeCell(cell); } blankTagCells(st.cells[0..count], surface.cols, surface.rows, surface.cells, &surface.tag_layers); collectPanelDiff(st, surface, count); collectImages(st, surface); collectPanelTracks(st, surface); } fn collectPanelTracks(st: *State, surface: *const pardes.Surface) void { const source = surface.panelTracks(); const len = @min(source.len, st.panel_tracks.len); @memcpy(st.panel_tracks[0..len], source[0..len]); st.panel_tracks_len = len; } 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); blankTagCells(st.previous_cells[0..count], surface.cols, surface.rows, surface.previous_cells, surface.previous_tag_layers); for (surface.cell_diffs, st.changed_cells[0..count]) |diff, *out| out.* = encodeChanged(diff); st.panel_diff_len = count; } fn encodeChanged(diff: pardes.PanelCellDiff) u8 { return if (diff.changed()) 255 else 0; } fn collectImages(st: *State, surface: *const pardes.Surface) void { if (comptime !pardes.pdf_enabled) return; if (surface.nimages == 0) return; if (st.images.len < surface.nimages) { const resized = if (st.images.len == 0) st.gpa.alloc(Image, surface.nimages) else st.gpa.realloc(st.images, surface.nimages); st.images = resized catch return; } for (surface.images[0..surface.nimages]) |maybe| { const place = maybe orelse continue; if (place.iw == 0 or place.ih == 0 or place.rgba.len == 0) continue; const geometry = place.native.geometry orelse image.NativeGeometry{ .src = .{ .x = 0, .y = 0, .w = @intCast(place.iw), .h = @intCast(place.ih) }, .dst = .{ .x = 0, .y = 0, .w = @as(u32, place.w) * st.core.cell_pixels.w, .h = @as(u32, place.h) * st.core.cell_pixels.h, }, }; if (geometry.dst.w == 0 or geometry.dst.h == 0) continue; if (geometry.src.w == 0 or geometry.src.h == 0) continue; st.images[st.images_len] = .{ .serial = place.serial, .page = place.native.page, .revision = place.native.revision, .cell_x = place.x, .cell_y = place.y, .cell_w = place.w, .cell_h = place.h, .dst_x = geometry.dst.x, .dst_y = geometry.dst.y, .dst_w = geometry.dst.w, .dst_h = geometry.dst.h, .src_x = geometry.src.x, .src_y = geometry.src.y, .src_w = geometry.src.w, .src_h = geometry.src.h, .offset_y = place.native.pixel_offset_y, .iw = @intCast(place.iw), .ih = @intCast(place.ih), .rgba = place.rgba.ptr, .paper_bg = if (place.native.paper_bg) |bg| @as(u32, bg[0]) << 16 | @as(u32, bg[1]) << 8 | bg[2] else color_default, }; st.images_len += 1; } } export fn pardes_frame_images() u32 { pardes.turn.wake(); defer pardes.turn.rest(); const st = &(state orelse return 0); return @intCast(st.images_len); } export fn pardes_frame_image_list() ?[*]const Image { pardes.turn.wake(); defer pardes.turn.rest(); const st = &(state orelse return null); return if (st.images_len == 0) null else st.images.ptr; } export fn pardes_frame_panel_tracks() u32 { pardes.turn.wake(); defer pardes.turn.rest(); const st = &(state orelse return 0); return @intCast(st.panel_tracks_len); } export fn pardes_frame_panel_track_list() ?[*]const PanelTrack { pardes.turn.wake(); defer pardes.turn.rest(); const st = &(state orelse return null); return if (st.panel_tracks_len == 0) null else st.panel_tracks[0..].ptr; } export fn pardes_frame_presented(animated_panels: bool) bool { pardes.turn.wake(); defer pardes.turn.rest(); 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 { pardes.turn.wake(); defer pardes.turn.rest(); 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 { pardes.turn.wake(); defer pardes.turn.rest(); 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 { pardes.turn.wake(); defer pardes.turn.rest(); 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 { pardes.turn.wake(); defer pardes.turn.rest(); const st = &(state orelse return 0); return st.frame_cols; } export fn pardes_frame_rows() u16 { pardes.turn.wake(); defer pardes.turn.rest(); const st = &(state orelse return 0); return st.frame_rows; } export fn pardes_cursor_x() i32 { pardes.turn.wake(); defer pardes.turn.rest(); const st = &(state orelse return -1); if (tagCursorCovered(st.core)) return -1; return if (st.core.surface.cursor) |c| c.x else -1; } export fn pardes_tag_text_inset() u32 { return pardes.TAG_TEXT_INSET; } export fn pardes_grip_columns() u32 { return @import("config.zig").GUTTER; } export fn pardes_tag_layer_limit() u32 { return pardes.MAX_TAG_LAYERS; } export fn pardes_tag_layer_value(index: u32, field: u32) u32 { pardes.turn.wake(); defer pardes.turn.rest(); const st = &(state orelse return 0); if (index >= pardes.MAX_TAG_LAYERS) return 0; const layer = &st.core.surface.tag_layers[index]; return switch (field) { 0 => layer.cols, 1 => @intFromEnum(layer.kind), 2 => layer.id, 3 => layer.serial, 4 => layer.viewport.x, 5 => layer.viewport.y, 6 => layer.viewport.w, 7 => layer.viewport.h, 8 => if (layer.cursor) |c| c.col else 65535, 9 => if (layer.cursor) |c| @intFromBool(c.bar) else 0, 10 => @as(u32, layer.bg[0]) << 16 | @as(u32, layer.bg[1]) << 8 | layer.bg[2], 11 => @intFromBool(layer.kind == .pane and st.core.settings.tag_bottom), 12 => rgb: { const border = st.core.chromeTheme().border; break :rgb @as(u32, border[0]) << 16 | @as(u32, border[1]) << 8 | border[2]; }, else => 0, }; } export fn pardes_tag_layer_cells(index: u32) ?[*]const Cell { pardes.turn.wake(); defer pardes.turn.rest(); const st = &(state orelse return null); if (index >= pardes.MAX_TAG_LAYERS) return null; const layer = &st.core.surface.tag_layers[index]; if (layer.cols == 0) return null; const cells = &st.tag_cells[index]; if (cells.len != layer.cells.len) cells.* = st.gpa.realloc(cells.*, layer.cells.len) catch return null; for (layer.cells, cells.*) |source, *out| out.* = encodeCell(source); return cells.ptr; } export fn pardes_body_layer_value(index: u32, field: u32) u32 { pardes.turn.wake(); defer pardes.turn.rest(); const st = &(state orelse return 0); if (index >= pardes.MAX_PANES) return 0; const layer = &st.core.surface.body_layers[index]; return switch (field) { 0 => layer.rows, 1 => layer.cols, 2 => layer.context_rows, 3 => layer.viewport.x, 4 => layer.viewport.y, 5 => layer.viewport.w, 6 => layer.viewport.h, 7 => layer.serial, 8 => if (layer.cursor) |c| c.x else 65535, 9 => if (layer.cursor) |c| c.y else 65535, 10 => if (layer.cursor) |c| @intFromBool(c.bar) else 0, else => @intFromBool(layer.hasContextBorderAfter(field - 11)), }; } export fn pardes_body_layer_cells(index: u32) ?[*]const Cell { pardes.turn.wake(); defer pardes.turn.rest(); const st = &(state orelse return null); if (index >= pardes.MAX_PANES) return null; const layer = &st.core.surface.body_layers[index]; if (layer.rows == 0) return null; const cells = &st.layer_cells[index]; if (cells.len != layer.cells.len) cells.* = st.gpa.realloc(cells.*, layer.cells.len) catch return null; for (layer.cells, cells.*) |cell, *out| out.* = encodeCell(cell); return cells.ptr; } export fn pardes_body_layer_limit() u32 { return pardes.MAX_PANES; } export fn pardes_row_metrics(body_w: u16, body_h: u16, tagline_w: u16, tagline_h: u16) void { pardes.turn.wake(); defer pardes.turn.rest(); const st = &(state orelse return); st.core.update(.{ .resize = .{ .cols = st.core.screen_w, .rows = st.core.screen_h, .cell_pixels = st.core.cell_pixels, .row_metrics = .{ .body_w = @max(1, body_w), .body_h = @max(1, body_h), .tagline_w = @max(1, tagline_w), .tagline_h = @max(1, tagline_h) } } }); } export fn pardes_pointer_shape() u32 { pardes.turn.wake(); defer pardes.turn.rest(); const st = &(state orelse return 0); return @intFromEnum(st.core.surface.pointer_shape); } export fn pardes_cursor_y() i32 { pardes.turn.wake(); defer pardes.turn.rest(); const st = &(state orelse return -1); if (tagCursorCovered(st.core)) return -1; return if (st.core.surface.cursor) |c| c.y else -1; } export fn pardes_cursor_bar() bool { pardes.turn.wake(); defer pardes.turn.rest(); const st = &(state orelse return false); return if (st.core.surface.cursor) |c| c.bar else false; } export fn pardes_take_haptic() c_int { pardes.turn.wake(); defer pardes.turn.rest(); const st = &(state orelse return 0); return switch (st.core.takeHaptic()) { .none => 0, .exec => 1, .look => 2, }; } var font_path_z: [4096:0]u8 = undefined; export fn pardes_font_take(size_hundredths: *u16) ?[*:0]const u8 { pardes.turn.wake(); defer pardes.turn.rest(); const st = &(state orelse return null); if (comptime !pardes.font_picker) return null; const want = st.core.takeFontRequest() orelse return null; size_hundredths.* = st.core.settings.font.requested_size_hundredths; 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; } export fn pardes_font_observe( effective_name: ?[*]const u8, len: usize, point_hundredths: u16, ) bool { pardes.turn.wake(); defer pardes.turn.rest(); 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); } export fn pardes_font_ack( effective_name: ?[*]const u8, len: usize, point_hundredths: u16, ) bool { pardes.turn.wake(); defer pardes.turn.rest(); 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); } export fn pardes_font_reject() void { pardes.turn.wake(); defer pardes.turn.rest(); const st = &(state orelse return); st.core.rejectFont(); } var active_path_z: [4096:0]u8 = undefined; export fn pardes_active_path() ?[*:0]const u8 { pardes.turn.wake(); defer pardes.turn.rest(); const st = &(state orelse return null); const path = activeFilePath(st) orelse return null; if (path.len == 0 or path.len >= active_path_z.len) return null; @memcpy(active_path_z[0..path.len], path); active_path_z[path.len] = 0; return &active_path_z; } export fn pardes_active_dirty() bool { pardes.turn.wake(); defer pardes.turn.rest(); const st = &(state orelse return false); const pane = st.core.panes[st.core.active] orelse return false; const f = if (pane.file) |*x| x else return false; if (f.output != null) return false; return f.revision != f.saved_revision; } fn activeFilePath(st: *State) ?[]const u8 { const pane = st.core.panes[st.core.active] orelse return null; if (pane.file) |*f| return if (f.output == null) f.path else null; if (comptime pardes.pdf_enabled) if (pane.pdfPath()) |path| return path; if (pane.image) |*iv| return iv.path; return null; } const vtable: pardes.Host.VTable = .{ .present = presentFrame, .poll_frame = refreshCwds, .spawn = spawnShell, .pty_write = ptyWrite, .pty_resize = ptyResize, .pty_signal = ptySignal, .tty_taken = ttyTaken, .write_file = writeFile, .write_dump = writeDump, .watch_file = watchFile, .watch_theme = watchTheme, .dump_themes = dumpThemes, .set_clipboard = setClipboard, .read_clipboard = readClipboard, .open_link = openLink, .lsp = lspRequest, .pipe = pipeRequest, }; fn hostFor(st: *State) pardes.Host { return .{ .ctx = st, .vtable = &vtable }; } fn lspRequest(ctx: ?*anyopaque, req: host_io.Lsp.Request) void { const st = hostState(ctx); const job = host_io.Lsp.snapshot(st.gpa, st.core, req) catch |err| { st.core.update(.{ .lsp_resp = .{ .id = req.id, .rows = null } }); return st.core.reportError(req.pane, "lsp", err); }; if (st.lsp_task) |*old| { old.future.cancel(st.io) catch {}; st.lsp_task = null; } const future = st.io.concurrent(lspWorker, .{ st, job }) catch |err| { job.free(st.gpa); st.core.update(.{ .lsp_resp = .{ .id = req.id, .rows = null } }); return st.core.reportError(req.pane, "lsp", err); }; st.lsp_task = .{ .id = req.id, .future = future }; } fn pipeRequest(ctx: ?*anyopaque, id: u32) void { const st = hostState(ctx); if (st.pipe_tasks.full()) { st.core.update(.{ .pipe_resp = .{ .id = id, .success = false, .outputs = &.{} } }); return; } const view = st.core.pipeRequest(id) orelse return; const job = selection_pipe.Job.copy(st.gpa, view) catch |err| { st.core.update(.{ .pipe_resp = .{ .id = id, .success = false, .outputs = &.{} } }); return st.core.reportError(st.core.active, "pipe", err); }; const future = st.io.concurrent(pipeWorker, .{ st, job }) catch |err| { job.deinit(st.gpa); st.core.update(.{ .pipe_resp = .{ .id = id, .success = false, .outputs = &.{} } }); return st.core.reportError(st.core.active, "pipe", err); }; std.debug.assert(st.pipe_tasks.add(.{ .id = id, .future = future })); } fn pipeWorker(st: *State, job: *selection_pipe.Job) anyerror!void { defer job.deinit(st.gpa); const response = selection_pipe.runJob(st.gpa, st.io, job); st.inbox.push(st.gpa, st.io, .{ .pipe = response }); wake(st); } fn lspWorker(st: *State, job: *host_io.Lsp.Job) anyerror!void { host_io.Lsp.work(st.gpa, job, st, deliverLspRows); } fn deliverLspRows(ctx: ?*anyopaque, id: u32, rows: ?[]u8) void { const st: *State = @ptrCast(@alignCast(ctx orelse return)); st.inbox.push(st.gpa, st.io, .{ .lsp_done = .{ .id = id, .rows = rows } }); wake(st); } fn lspStatusSink(ctx: ?*anyopaque, text: []const u8) void { const st: *State = @ptrCast(@alignCast(ctx orelse return)); const copy = st.gpa.dupe(u8, text) catch return; st.inbox.push(st.gpa, st.io, .{ .lsp_status = copy }); wake(st); } fn hostState(ctx: ?*anyopaque) *State { return @ptrCast(@alignCast(ctx.?)); } fn spawnShell(ctx: ?*anyopaque, pane: u8, cwd: []const u8) void { const st = hostState(ctx); const core = st.core; reap(st, pane); st.gens[pane] +%= 1; const gen = st.gens[pane]; const child = host_io.forkShell(core, pane, &st.prompt_rcs, core.shellBin(), cwd, core.screen_h, core.screen_w, st.ninep) catch |err| return core.reportError(pane, "shell", err); st.ptys[pane] = .{ .file = child.file, .pid = child.pid, .gen = gen, .reader = .{ .any_future = null, .result = {} }, }; var lbuf: [1024]u8 = undefined; if (host_io.shellCwd(child.pid, &lbuf)) |wd| core.setCwd(pane, wd); if (st.started) if (st.ptys[pane]) |*pt| startReader(st, pt, pane); } fn ptyWrite(ctx: ?*anyopaque, pane: u8, bytes: []const u8) void { const st = hostState(ctx); if (st.ptys[pane]) |pt| _ = host_io.writeFd(pt.file.handle, bytes); } fn ptyResize(ctx: ?*anyopaque, pane: u8, cols: u16, rows: u16) void { const st = hostState(ctx); const pt = st.ptys[pane] orelse return; const ws: posix.winsize = .{ .row = rows, .col = cols, .xpixel = 0, .ypixel = 0 }; _ = posix.system.ioctl(pt.file.handle, TIOCSWINSZ, @intFromPtr(&ws)); } fn ptySignal(ctx: ?*anyopaque, pane: u8, sig: pardes.PtySignal) void { const st = hostState(ctx); if (st.ptys[pane]) |pt| host_io.signalTty(pt.pid, pt.file.handle, sig); } fn ttyTaken(ctx: ?*anyopaque, pane: u8) bool { const st = hostState(ctx); const pt = st.ptys[pane] orelse return false; return host_io.ttyTaken(pt.pid, pt.file.handle); } fn writeFile(ctx: ?*anyopaque, pane: u8, path: []const u8, bytes: []const u8) void { const st = hostState(ctx); filesystem.write(st.core, path, bytes) catch |err| return st.core.saveFailed(pane, "save", err); st.file_watches.restampText(pane, path, std.hash.Wyhash.hash(0, bytes)); var mbuf: [256]u8 = undefined; st.core.setMessage(pane, message.stamp(&mbuf, "saved", path)); } fn writeDump(ctx: ?*anyopaque, bytes: []const u8) void { const st = hostState(ctx); var pbuf: [1024:0]u8 = undefined; const path = pardes.dump.outPath(&pbuf, st.core.settings.dump_dir.get()) orelse return; filesystem.write(st.core, path, bytes) catch |err| return st.core.reportError(0, "dump", err); st.core.setLastDump(path); } fn watchFile(ctx: ?*anyopaque, pane: u8, path: []const u8, on: bool, mode: pardes.WatchMode) void { const st = hostState(ctx); if (!on or path.len == 0) { const generation = st.file_watches.stop(st.gpa, pane); hostWatchFile(pane, generation, null, 0); return; } setFileWatch(st, pane, path, mode); } fn watchTheme(ctx: ?*anyopaque, generation: u32, on: bool) void { const st = hostState(ctx); setThemeFileWatch(st, generation, on, st.started); } fn dumpThemes(ctx: ?*anyopaque, pane: u8) void { const st = hostState(ctx); const config_dir = st.core.opts.config_dir orelse return; const out_dir = pardes.config.User.dumpThemes(st.io, st.gpa, config_dir, pardes.themes) catch |err| { st.core.reportError(pane, "dump themes", err); return; }; defer st.gpa.free(out_dir); var mbuf: [256]u8 = undefined; st.core.setMessage(pane, message.stamp(&mbuf, "dumped themes", out_dir)); } fn setClipboard(ctx: ?*anyopaque, text: []const u8) void { const st = hostState(ctx); const cb = st.runtime.set_clipboard orelse return; cb(st.runtime.userdata, text.ptr, text.len); } fn readClipboard(ctx: ?*anyopaque) void { const st = hostState(ctx); const cb = st.runtime.read_clipboard orelse return; cb(st.runtime.userdata); } fn openLink(_: ?*anyopaque, url: []const u8) void { look.openLink(url); } fn startReader(st: *State, pt: *Pty, id: u8) void { pt.reader = st.io.concurrent(readPty, .{ st, st.io, pt.file, id, pt.gen }) catch |err| { log.err("pane {d} has no reader ({t}); it will not show output", .{ id, err }); return; }; } fn reap(st: *State, pane: u8) void { var pt = st.ptys[pane] orelse return; st.ptys[pane] = null; pt.reader.cancel(st.io) catch {}; _ = libc.close(pt.file.handle); _ = libc.waitpid(pt.pid, null, posix.W.NOHANG); } fn readPty(st: *State, io: std.Io, pty: std.Io.File, id: u8, gen: u32) anyerror!void { var read_buf: [0x10000]u8 = undefined; var reader = pty.readerStreaming(io, &read_buf); while (true) { var buf: [0x10000]u8 = undefined; var vec = [_][]u8{&buf}; const n = reader.interface.readVec(&vec) catch break; if (n == 0) break; const bytes = st.gpa.dupe(u8, buf[0..n]) catch break; st.inbox.pushOutput(st.gpa, io, .{ .pane = id, .gen = gen, .bytes = bytes }) catch break; wake(st); } st.inbox.push(st.gpa, st.io, .{ .eof = .{ .pane = id, .gen = gen } }); wake(st); } fn wake(st: *State) void { const cb = st.runtime.wakeup orelse return; if (st.inbox.wake_pending.swap(true, .acq_rel)) return; cb(st.runtime.userdata); } fn captureEnv(arena: std.mem.Allocator) ?std.process.Environ.Map { var map: std.process.Environ.Map = .init(arena); const environ = std.c.environ; var i: usize = 0; while (environ[i]) |entry| : (i += 1) { const line = std.mem.span(entry); const eq = std.mem.indexOfScalar(u8, line, '=') orelse continue; map.put(line[0..eq], line[eq + 1 ..]) catch return null; } return map; } fn encodeColor(color: pardes.Color) u32 { return switch (color) { .default => color_default, .index => |index| color_indexed | @as(u32, index), .rgb => |rgb| (@as(u32, rgb[0]) << 16) | (@as(u32, rgb[1]) << 8) | rgb[2], }; } 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), .flags = encodeCellFlags(cell.default, cell.style.font_role, cell.hover), .len = if (cell.default) 1 else cell.len, }; 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; attrs |= @as(u16, @intFromBool(style.dim)) << 1; attrs |= @as(u16, @intFromBool(style.italic)) << 2; attrs |= @as(u16, @intFromBool(style.blink)) << 3; attrs |= @as(u16, @intFromBool(style.reverse)) << 4; attrs |= @as(u16, @intFromBool(style.invisible)) << 5; attrs |= @as(u16, @intFromBool(style.strikethrough)) << 6; attrs |= @as(u16, @intFromEnum(style.ul)) << 8; return attrs; } fn encodeCellFlags(default: bool, role: pardes.FontRole, hover: bool) u8 { return @as(u8, @intFromBool(default)) * cell_flag_default | @as(u8, @intFromBool(role == .tagline)) * cell_flag_tagline | @as(u8, @intFromBool(hover)) * cell_flag_hover; } fn noteScrollVelocity(st: *State, delta_rows: f32, delta_cols: f32) void { const now = monotonicNs(); const last = st.scroll_last_ns; st.scroll_last_ns = now; // Any real travel ends a coast: that is what lets a host with momentum of // its own take the gesture over without either side knowing about it. st.scroll_coasting = false; if (last == 0 or now == 0) return; const dt_ns = now - last; // A gap this long is a new gesture, not a fast one. Without the guard the // pause between two separate swipes would read as enormous speed. if (dt_ns <= 0 or dt_ns > 200 * std.time.ns_per_ms) return; const seconds: f32 = @floatCast(@as(f64, @floatFromInt(dt_ns)) / @as(f64, std.time.ns_per_s)); const rows = delta_rows / seconds; const cols = delta_cols / seconds; if (!std.math.isFinite(rows) or !std.math.isFinite(cols)) return; // Weighted toward the newest sample: a flick is decided by how the hand was // moving when it left, not by how the swipe started. st.scroll_velocity = st.scroll_velocity * 0.35 + rows * 0.65; st.scroll_velocity_x = st.scroll_velocity_x * 0.35 + cols * 0.65; } /// Spend accumulated travel as wheel presses, and report whether the pane under /// the gesture actually moved. /// /// A coast has to stop at the end of a document instead of spinning the rest of /// its velocity against the edge — the SDL shell stops for the same reason in /// stepScroll. Only the vertical axis can be measured that way (a pane's /// horizontal position is not Pane.scroll()), so sideways travel counts as /// movement; a purely horizontal fling therefore rides its decay out, which is /// what it would do anyway once the drift guard has swallowed it. const Spent = struct { /// Whole wheel presses delivered. Most coast steps cross no row at all, /// which is why "did not move" cannot be read off `moved` alone. ticks: u32 = 0, moved: bool = false, }; fn spendScroll(st: *State, delta_rows: f32, delta_cols: f32, col: u16, row: u16) Spent { const pane = if (st.core.paneAt(col, row)) |id| st.core.panes[id] else null; var out: Spent = .{}; var down_left = takeScrollTicks(&st.scroll_lag, delta_rows); while (down_left != 0) { const down = down_left > 0; down_left += if (down) -1 else 1; out.ticks += 1; const was = if (pane) |p| p.scroll() else 0; st.core.update(.{ .mouse = .{ .button = if (down) .wheel_down else .wheel_up, .kind = .press, .col = col, .row = row, } }); if (pane) |p| { if (p.scroll() != was) out.moved = true; } else out.moved = true; } var right_left = takeScrollTicks(&st.scroll_lag_x, delta_cols); while (right_left != 0) { const right = right_left > 0; right_left += if (right) -1 else 1; st.core.update(.{ .mouse = .{ .button = if (right) .wheel_right else .wheel_left, .kind = .press, .col = col, .row = row, } }); out.ticks += 1; out.moved = true; } return out; } // Rows per second. The floor is what separates a flick from a swipe that // merely ended while still in contact; the ceiling stops a jab from launching // the page past anything the eye can follow. Step and decay are stated at // 60 Hz and spent by the same measured display clock the rotation fling uses, // so a 120 Hz screen coasts the same distance in the same time. const scroll_fling_floor: f32 = 8; const scroll_fling_max: f32 = 90; const scroll_fling_step: f32 = 1.0 / 60.0; const scroll_fling_decay: f32 = 0.94; const scroll_fling_stop: f32 = 2; fn scrollFling(speed: f32) f32 { if (!std.math.isFinite(speed)) return 0; const excess = @min(@abs(speed) - scroll_fling_floor, scroll_fling_max); if (excess < scroll_fling_stop) return 0; return std.math.copysign(excess, speed); } fn takeScrollTicks(lag: *f32, delta_rows: f32) i32 { if (!std.math.isFinite(delta_rows)) return 0; const next = std.math.clamp(lag.* + delta_rows, -256, 256); if (!std.math.isFinite(next)) return 0; const whole: i32 = @intFromFloat(@trunc(next)); lag.* = next - @as(f32, @floatFromInt(whole)); return whole; } const rotation_notch_degrees: f32 = 10; const rotation_fling_floor: f32 = 70; const rotation_fling_max: f32 = 400; const rotation_fling_step: f32 = 1.0 / 60.0; const rotation_fling_decay: f32 = 0.94; const rotation_fling_stop: f32 = 18; fn rotationFling(speed: f32) f32 { const excess = @min(@abs(speed) - rotation_fling_floor, rotation_fling_max); if (excess < rotation_fling_stop) return 0; return std.math.copysign(excess, speed); } fn takeRotationNotches(lag: *f32, degrees: f32) i32 { if (!std.math.isFinite(degrees)) return 0; const limit = rotation_notch_degrees * 64; const next = std.math.clamp(lag.* + degrees, -limit, limit); if (!std.math.isFinite(next)) return 0; const whole: i32 = @intFromFloat(@trunc(next / rotation_notch_degrees)); lag.* = next - @as(f32, @floatFromInt(whole)) * rotation_notch_degrees; return whole; } fn expectSameAbi(comptime C: type, comptime Z: type) !void { const c_fn = @typeInfo(C).@"fn"; const z_fn = @typeInfo(Z).@"fn"; try std.testing.expectEqual(c_fn.params.len, z_fn.params.len); inline for (c_fn.params, z_fn.params) |cp, zp| try std.testing.expectEqual(@sizeOf(cp.type.?), @sizeOf(zp.type.?)); try std.testing.expectEqual(@sizeOf(c_fn.return_type.?), @sizeOf(z_fn.return_type.?)); } test "pardes.h declares every export the way it is defined" { const c = @import("pardes.h"); 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_tagline_band_offset), @TypeOf(pardes_tagline_band_offset)); try expectSameAbi(@TypeOf(c.pardes_topbar_pane_border_rgb), @TypeOf(pardes_topbar_pane_border_rgb)); try expectSameAbi(@TypeOf(c.pardes_topbar_pane_border_px), @TypeOf(pardes_topbar_pane_border_px)); try expectSameAbi(@TypeOf(c.pardes_tag_active_bg), @TypeOf(pardes_tag_active_bg)); try expectSameAbi(@TypeOf(c.pardes_tagline_origin_col), @TypeOf(pardes_tagline_origin_col)); try expectSameAbi(@TypeOf(c.pardes_grid_col_at), @TypeOf(pardes_grid_col_at)); try expectSameAbi(@TypeOf(c.pardes_topbar_pane_border_rgb), @TypeOf(pardes_topbar_pane_border_rgb)); try expectSameAbi(@TypeOf(c.pardes_tagline_bg), @TypeOf(pardes_tagline_bg)); try expectSameAbi(@TypeOf(c.pardes_fallback_font_count), @TypeOf(pardes_fallback_font_count)); try expectSameAbi(@TypeOf(c.pardes_fallback_font_name), @TypeOf(pardes_fallback_font_name)); 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_scroll_begin), @TypeOf(pardes_scroll_begin)); try expectSameAbi(@TypeOf(c.pardes_scroll_end), @TypeOf(pardes_scroll_end)); try expectSameAbi(@TypeOf(c.pardes_rotate), @TypeOf(pardes_rotate)); try expectSameAbi(@TypeOf(c.pardes_rotate_end), @TypeOf(pardes_rotate_end)); try expectSameAbi(@TypeOf(c.pardes_command), @TypeOf(pardes_command)); 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_pointer_shape), @TypeOf(pardes_pointer_shape)); try expectSameAbi(@TypeOf(c.pardes_mouse_pixel), @TypeOf(pardes_mouse_pixel)); try expectSameAbi(@TypeOf(c.pardes_row_metrics), @TypeOf(pardes_row_metrics)); try expectSameAbi(@TypeOf(c.pardes_tag_text_inset), @TypeOf(pardes_tag_text_inset)); try expectSameAbi(@TypeOf(c.pardes_grip_columns), @TypeOf(pardes_grip_columns)); try expectSameAbi(@TypeOf(c.pardes_tag_layer_limit), @TypeOf(pardes_tag_layer_limit)); try expectSameAbi(@TypeOf(c.pardes_tag_layer_value), @TypeOf(pardes_tag_layer_value)); try expectSameAbi(@TypeOf(c.pardes_tag_layer_cells), @TypeOf(pardes_tag_layer_cells)); try expectSameAbi(@TypeOf(c.pardes_body_layer_value), @TypeOf(pardes_body_layer_value)); try expectSameAbi(@TypeOf(c.pardes_body_layer_cells), @TypeOf(pardes_body_layer_cells)); 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)); try expectSameAbi(@TypeOf(c.pardes_window_opacity), @TypeOf(pardes_window_opacity)); try expectSameAbi(@TypeOf(c.pardes_window_blur), @TypeOf(pardes_window_blur)); } test "pardes.h matches the Zig boundary" { const c = @import("pardes.h"); const expectEqual = std.testing.expectEqual; try expectEqual(@sizeOf(c.pardes_cell_s), @sizeOf(Cell)); try expectEqual(@offsetOf(c.pardes_cell_s, "text"), @offsetOf(Cell, "text")); try expectEqual(@offsetOf(c.pardes_cell_s, "fg"), @offsetOf(Cell, "fg")); try expectEqual(@offsetOf(c.pardes_cell_s, "bg"), @offsetOf(Cell, "bg")); 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 field.name; try expectEqual(@offsetOf(c.pardes_panel_track_s, c_name), @offsetOf(PanelTrack, field.name)); } try expectEqual(@sizeOf(c.pardes_image_s), @sizeOf(Image)); inline for (@typeInfo(Image).@"struct".fields) |field| try expectEqual(@offsetOf(c.pardes_image_s, field.name), @offsetOf(Image, field.name)); try expectEqual(@sizeOf(c.pardes_runtime_s), @sizeOf(Runtime)); 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(u8, c.PARDES_CELL_HOVER), cell_flag_hover); 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); try expectEqual(@as(u8, c.PARDES_PANEL_OPENING), @intFromEnum(layout.Phase.opening)); try expectEqual(@as(u8, c.PARDES_PANEL_MOVING), @intFromEnum(layout.Phase.moving)); try expectEqual(@as(u8, c.PARDES_PANEL_CLOSING), @intFromEnum(layout.Phase.closing)); try expectEqual(@as(u8, c.PARDES_PANEL_OFF), @intFromEnum(layout.Transition.off)); try expectEqual(@as(u8, c.PARDES_PANEL_SLIDE), @intFromEnum(layout.Transition.slide)); try expectEqual(@as(u8, c.PARDES_PANEL_ZOOM), @intFromEnum(layout.Transition.zoom)); try expectEqual(@as(u8, c.PARDES_PANEL_DISSOLVE), @intFromEnum(layout.Transition.dissolve)); try expectEqual(@as(u8, c.PARDES_PANEL_ASCII), @intFromEnum(layout.Transition.ascii)); try expectEqual(@as(u8, c.PARDES_PANEL_VERTICAL), @intFromEnum(layout.Transition.vertical)); try expectEqual(@as(u8, c.PARDES_PANEL_EDGES), @intFromEnum(layout.Transition.edges)); try expectEqual(@as(u8, c.PARDES_PANEL_FALL), @intFromEnum(layout.Transition.fall)); try expectEqual(@as(u8, c.PARDES_PANEL_WAVE), @intFromEnum(layout.Transition.wave)); try expectEqual(@as(u8, c.PARDES_PANEL_CURTAIN), @intFromEnum(layout.Transition.curtain)); try expectEqual(@as(u8, c.PARDES_PANEL_SCRAMBLE), @intFromEnum(layout.Transition.scramble)); try expectEqual(@as(u8, c.PARDES_PANEL_TYPEWRITER), @intFromEnum(layout.Transition.typewriter)); try expectEqual(@as(u21, c.PARDES_KEY_ENTER), pardes.Key.enter); try expectEqual(@as(u21, c.PARDES_KEY_ESCAPE), pardes.Key.escape); try expectEqual(@as(u21, c.PARDES_KEY_TAB), pardes.Key.tab); try expectEqual(@as(u21, c.PARDES_KEY_BACKSPACE), pardes.Key.backspace); try expectEqual(@as(u21, c.PARDES_KEY_UP), pardes.Key.up); try expectEqual(@as(u21, c.PARDES_KEY_DOWN), pardes.Key.down); try expectEqual(@as(u21, c.PARDES_KEY_LEFT), pardes.Key.left); try expectEqual(@as(u21, c.PARDES_KEY_RIGHT), pardes.Key.right); try expectEqual(@as(u21, c.PARDES_KEY_HOME), pardes.Key.home); try expectEqual(@as(u21, c.PARDES_KEY_END), pardes.Key.end); try expectEqual(@as(u21, c.PARDES_KEY_PAGE_UP), pardes.Key.page_up); try expectEqual(@as(u21, c.PARDES_KEY_PAGE_DOWN), pardes.Key.page_down); try expectEqual(@as(u21, c.PARDES_KEY_DELETE), pardes.Key.delete); try expectEqual(c.PARDES_MOUSE_LEFT, @intFromEnum(pardes.Mouse.Button.left)); try expectEqual(c.PARDES_MOUSE_MIDDLE, @intFromEnum(pardes.Mouse.Button.middle)); try expectEqual(c.PARDES_MOUSE_RIGHT, @intFromEnum(pardes.Mouse.Button.right)); try expectEqual(c.PARDES_MOUSE_WHEEL_UP, @intFromEnum(pardes.Mouse.Button.wheel_up)); try expectEqual(c.PARDES_MOUSE_WHEEL_DOWN, @intFromEnum(pardes.Mouse.Button.wheel_down)); try expectEqual(c.PARDES_MOUSE_WHEEL_LEFT, @intFromEnum(pardes.Mouse.Button.wheel_left)); try expectEqual(c.PARDES_MOUSE_WHEEL_RIGHT, @intFromEnum(pardes.Mouse.Button.wheel_right)); try expectEqual(c.PARDES_MOUSE_NONE, @intFromEnum(pardes.Mouse.Button.none)); try expectEqual(c.PARDES_MOUSE_BACK, @intFromEnum(pardes.Mouse.Button.back)); try expectEqual(c.PARDES_MOUSE_FORWARD, @intFromEnum(pardes.Mouse.Button.forward)); try expectEqual(c.PARDES_MOUSE_PRESS, @intFromEnum(pardes.Mouse.Kind.press)); try expectEqual(c.PARDES_MOUSE_RELEASE, @intFromEnum(pardes.Mouse.Kind.release)); try expectEqual(c.PARDES_MOUSE_MOTION, @intFromEnum(pardes.Mouse.Kind.motion)); try expectEqual(c.PARDES_MOUSE_DRAG, @intFromEnum(pardes.Mouse.Kind.drag)); try expectEqual(c.PARDES_HAPTIC_NONE, @intFromEnum(pardes.Haptic.none)); try expectEqual(c.PARDES_HAPTIC_EXEC, @intFromEnum(pardes.Haptic.exec)); try expectEqual(c.PARDES_HAPTIC_LOOK, @intFromEnum(pardes.Haptic.look)); try expectEqual(@as(u16, c.PARDES_ATTR_BOLD), encodeAttrs(.{ .bold = true })); try expectEqual(@as(u16, c.PARDES_ATTR_DIM), encodeAttrs(.{ .dim = true })); try expectEqual(@as(u16, c.PARDES_ATTR_ITALIC), encodeAttrs(.{ .italic = true })); try expectEqual(@as(u16, c.PARDES_ATTR_BLINK), encodeAttrs(.{ .blink = true })); try expectEqual(@as(u16, c.PARDES_ATTR_REVERSE), encodeAttrs(.{ .reverse = true })); try expectEqual(@as(u16, c.PARDES_ATTR_INVISIBLE), encodeAttrs(.{ .invisible = true })); try expectEqual(@as(u16, c.PARDES_ATTR_STRIKETHROUGH), encodeAttrs(.{ .strikethrough = true })); try expectEqual( @as(u16, c.PARDES_UL_CURLY) << c.PARDES_ATTR_UL_SHIFT, encodeAttrs(.{ .ul = .curly }), ); try expectEqual(@as(u8, 0), encodeCellFlags(false, .body, false)); try expectEqual(cell_flag_hover, encodeCellFlags(false, .body, true)); try expectEqual(cell_flag_tagline, encodeCellFlags(false, .tagline, false)); try expectEqual(cell_flag_default | cell_flag_tagline, encodeCellFlags(true, .tagline, false)); 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 st: State = undefined; st.scene_ns = scene_wrap_ns - (std.time.ns_per_ms * 5); advanceSceneClock(&st, std.time.ns_per_ms * 5); try expectEqual(@as(u64, 0), st.scene_ns); advanceSceneClock(&st, std.time.ns_per_ms * 7); try expectEqual(@as(u64, std.time.ns_per_ms * 7), st.scene_ns); } test "the mac panel ABI hands the shader the core's order verbatim" { const source = [_]PanelTrack{ .{ .serial = 12, .pane = 1, .phase = .moving, .effect = .zoom }, .{ .serial = 15, .pane = 2, .phase = .moving, .effect = .dissolve }, .{ .serial = 11, .pane = 3, .phase = .opening, .effect = .slide }, .{ .serial = 16, .pane = 2, .phase = .closing, .effect = .vertical }, }; var st: State = undefined; st.panel_tracks = undefined; st.panel_tracks_len = 0; var surface: pardes.Surface = std.mem.zeroes(pardes.Surface); @memcpy(surface.panel_tracks[0..source.len], &source); surface.npanel_tracks = source.len; collectPanelTracks(&st, &surface); try std.testing.expectEqual(source.len, st.panel_tracks_len); for (source, st.panel_tracks[0..st.panel_tracks_len]) |want, got| try std.testing.expectEqual(want.serial, got.serial); } test "mac panel mask is a literal normalized grayscale texture" { try std.testing.expectEqual(@as(u8, 0), encodeChanged(.unchanged)); try std.testing.expectEqual(@as(u8, 255), encodeChanged(.visual)); try std.testing.expectEqual(@as(u8, 255), encodeChanged(.{ .ascii = .{ .from = 'a', .to = 'z' } })); } 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_count")); 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" { const expectEqual = std.testing.expectEqual; try expectEqual(@as(u32, 0x01000000), encodeColor(.default)); try expectEqual(@as(u32, 0x02000021), encodeColor(.{ .index = 33 })); try expectEqual(@as(u32, 0x00112233), encodeColor(.{ .rgb = .{ 0x11, 0x22, 0x33 } })); } test "sub-row scroll spends whole notches and keeps the remainder" { const expectEqual = std.testing.expectEqual; var lag: f32 = 0; try expectEqual(@as(i32, 0), takeScrollTicks(&lag, 0.25)); try expectEqual(@as(i32, 0), takeScrollTicks(&lag, 0.25)); try expectEqual(@as(i32, 0), takeScrollTicks(&lag, 0.25)); try expectEqual(@as(i32, 1), takeScrollTicks(&lag, 0.25)); try expectEqual(@as(f32, 0), lag); try expectEqual(@as(i32, -2), takeScrollTicks(&lag, -2.5)); try expectEqual(@as(i32, 0), takeScrollTicks(&lag, 0.25)); lag = 0; try expectEqual(@as(i32, 0), takeScrollTicks(&lag, std.math.nan(f32))); try expectEqual(@as(i32, 0), takeScrollTicks(&lag, std.math.inf(f32))); try expectEqual(@as(f32, 0), lag); try expectEqual(@as(i32, 256), takeScrollTicks(&lag, 1e9)); } test "trackpad rotation spends whole search steps and keeps the remainder" { const expectEqual = std.testing.expectEqual; var lag: f32 = 0; try expectEqual(@as(i32, 0), takeRotationNotches(&lag, 7)); try expectEqual(@as(i32, 1), takeRotationNotches(&lag, 5)); try expectEqual(@as(f32, 2), lag); try expectEqual(@as(i32, -1), takeRotationNotches(&lag, -12)); try expectEqual(@as(f32, 0), lag); lag = 0; try expectEqual(@as(i32, 18), takeRotationNotches(&lag, 180)); lag = 0; try expectEqual(@as(i32, 0), takeRotationNotches(&lag, std.math.nan(f32))); try expectEqual(@as(i32, 0), takeRotationNotches(&lag, -std.math.inf(f32))); try expectEqual(@as(f32, 0), lag); try expectEqual(@as(i32, 64), takeRotationNotches(&lag, 1e9)); } test "the dial flings in proportion to the release, and not at all when placed" { try std.testing.expectEqual(@as(f32, 0), rotationFling(0)); try std.testing.expectEqual(@as(f32, 0), rotationFling(40)); try std.testing.expectEqual(@as(f32, 0), rotationFling(rotation_fling_floor)); try std.testing.expectEqual(@as(f32, 0), rotationFling(rotation_fling_floor + 5)); try std.testing.expectEqual(@as(f32, 130), rotationFling(200)); try std.testing.expectEqual(@as(f32, -130), rotationFling(-200)); try std.testing.expectEqual(rotation_fling_max, rotationFling(100_000)); try std.testing.expectEqual(-rotation_fling_max, rotationFling(-100_000)); const travel = struct { fn of(speed: f32) f32 { return @abs(rotationFling(speed)) * rotation_fling_step / (1 - rotation_fling_decay); } }.of; try std.testing.expect(travel(200) / rotation_notch_degrees < 5); try std.testing.expect(travel(200) / rotation_notch_degrees >= 3); try std.testing.expect(travel(100_000) / rotation_notch_degrees < 12); try std.testing.expect(travel(100_000) / rotation_notch_degrees > 8); } test "a live session renders on the draw and animates only on the display clock" { try std.testing.expectEqual(@as(c_int, 0), pardes_init(null, 80, 24)); defer pardes_deinit(); const st = &state.?; try std.testing.expect(st.ptys[0] != null); _ = pardes_tick(); _ = pardes_tick(); try std.testing.expectEqual(@as(u16, 0), pardes_frame_cols()); try std.testing.expect(pardes_frame_cells() == null); try std.testing.expectEqual(@as(u32, 80 * 24), pardes_frame()); try std.testing.expectEqual(@as(u16, 80), pardes_frame_cols()); try std.testing.expectEqual(@as(u16, 24), pardes_frame_rows()); try std.testing.expect(pardes_frame_cells() != null); for ([_][]const u8{ "Theme dark", "Theme acme" }) |command| { pardes_command(command.ptr, command.len); _ = pardes_tick(); _ = pardes_frame(); } try std.testing.expect(pardes_animating()); const step = st.core.chrome_animation.step; _ = pardes_tick(); _ = pardes_frame(); _ = pardes_tick(); _ = pardes_frame(); try std.testing.expectEqual(step, st.core.chrome_animation.step); try std.testing.expectEqual(@as(usize, 0), st.core.in_len); try std.testing.expect(pardes_animation_tick()); try std.testing.expectEqual(step + 1, st.core.chrome_animation.step); _ = pardes_tick(); _ = pardes_frame(); try std.testing.expectEqual(step + 1, st.core.chrome_animation.step); } test "mac row metrics ABI preserves PDF cell dimensions and the unrendered grid" { const gpa = std.testing.allocator; const core = try pardes.Pardes.init(gpa, .{ .tty_only = true, .cols = 80, .rows = 24 }); defer core.deinit(); state = .{ .gpa = gpa, .threaded = undefined, .io = std.testing.io, .core = core, .runtime = .{}, .config_arena = .init(gpa), .prompt_rcs = .{} }; defer { state.?.config_arena.deinit(); state = null; } if (comptime pardes.pdf_enabled) core.cell_pixels = .{ .w = 22, .h = 39 }; const pixels = core.cell_pixels; try std.testing.expectEqual(@as(u16, 0), core.surface.cols); pardes_row_metrics(22, 39, 17, 30); try std.testing.expectEqualDeep(pixels, core.cell_pixels); try std.testing.expectEqual(@as(u16, 80), core.screen_w); try std.testing.expectEqual(@as(u16, 24), core.screen_h); try std.testing.expectEqualDeep(pardes.RowMetrics{ .body_w = 22, .body_h = 39, .tagline_w = 17, .tagline_h = 30 }, core.row_metrics); } test "mac tag layer ABI preserves logical capacity and physical grip" { const gpa = std.testing.allocator; const core = try pardes.Pardes.init(gpa, .{ .tty_only = true, .cols = 8, .rows = 3 }); defer core.deinit(); state = .{ .gpa = gpa, .threaded = undefined, .io = std.testing.io, .core = core, .runtime = .{}, .config_arena = .init(gpa), .prompt_rcs = .{} }; defer { for (state.?.tag_cells) |cells| gpa.free(cells); state.?.config_arena.deinit(); state = null; } var logical: [12]pardes.Cell = @splat(.{}); logical[11].text[0] = 'Z'; logical[11].len = 1; logical[11].default = false; core.surface.tag_layers[0] = .{ .kind = .pane, .id = 1, .serial = 42, .viewport = .{ .x = 2, .y = 1, .w = 6, .h = 1 }, .cols = 12, .cells = &logical, .bg = .{ 0x12, 0x34, 0x56 }, .cursor = .{ .col = 11, .bar = true } }; defer core.surface.tag_layers[0] = .{}; try std.testing.expectEqual(@as(u32, 12), pardes_tag_layer_value(0, 0)); try std.testing.expectEqual(@as(u32, 0x123456), pardes_tag_layer_value(0, 10)); try std.testing.expectEqual(@as(u32, 0), pardes_tag_layer_value(0, 11)); core.settings.tag_bottom = true; try std.testing.expectEqual(@as(u32, 1), pardes_tag_layer_value(0, 11)); core.surface.tag_layers[0].kind = .column; try std.testing.expectEqual(@as(u32, 0), pardes_tag_layer_value(0, 11)); core.surface.tag_layers[0].kind = .pane; const border = core.chromeTheme().border; const border_rgb = @as(u32, border[0]) << 16 | @as(u32, border[1]) << 8 | border[2]; try std.testing.expectEqual(border_rgb, pardes_tag_layer_value(0, 12)); try std.testing.expectEqual(pardes.config.gui_topbar_pane_border_rgb orelse border, .{ @as(u8, @intCast(pardes_topbar_pane_border_rgb() >> 16)), @as(u8, @truncate(pardes_topbar_pane_border_rgb() >> 8)), @as(u8, @truncate(pardes_topbar_pane_border_rgb())), }); try std.testing.expectEqual(@as(u32, 11), pardes_tag_layer_value(0, 8)); try std.testing.expectEqual(@as(u8, 'Z'), pardes_tag_layer_cells(0).?[11].text[0]); var canonical: [24]Cell = @splat(encodeCell(.{ .text = .{'G'} ++ .{0} ** 6, .len = 1, .default = false })); // A look-hover under the layer is a canonical fact and survives the blank; // the glyph it marks does not, because the layer redraws that text itself. var source: [24]pardes.Cell = @splat(.{}); source[11].hover = true; blankTagCells(&canonical, 8, 3, &source, &core.surface.tag_layers); try std.testing.expectEqual(@as(u8, 'G'), canonical[9].text[0]); try std.testing.expectEqual(@as(u8, 'G'), canonical[18].text[0]); try std.testing.expectEqual(encodeCell(.{}).len, canonical[10].len); try std.testing.expectEqual(@as(u8, 0), canonical[10].flags & cell_flag_hover); try std.testing.expectEqual(cell_flag_hover, canonical[11].flags & cell_flag_hover); } test "mac window blur builtin reaches the ABI without changing opacity" { try std.testing.expectEqual(@as(u8, 0), pardes_window_blur()); const gpa = std.testing.allocator; const core = try pardes.Pardes.init(gpa, .{ .tty_only = true, .cols = 8, .rows = 3 }); defer core.deinit(); state = .{ .gpa = gpa, .threaded = undefined, .io = std.testing.io, .core = core, .runtime = .{}, .config_arena = .init(gpa), .prompt_rcs = .{} }; defer { state.?.config_arena.deinit(); state = null; } try std.testing.expect(core.executeBuiltinLine(0, "WindowOpacity 71")); for ([_][]const u8{ "WindowBlur 0", "WindowBlur 40", "WindowBlur 100", "WindowBlur 101", "WindowBlur 0" }, [_]u8{ 0, 40, 100, 100, 0 }) |command, expected| { try std.testing.expect(core.executeBuiltinLine(0, command)); try std.testing.expectEqual(expected, pardes_window_blur()); try std.testing.expectEqual(@as(u8, 71), pardes_window_opacity()); } }