From 9085cb5bfdd0b78ff3a62c0c71fc231dd7b5052a Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Sun, 9 Aug 2026 10:41:33 -0300 Subject: replace ArrayLists with bounded storage --- src/gui/fonts.zig | 16 +- src/gui/gui.zig | 475 ++++++++++++++++++++++++++++++++++++++---------------- 2 files changed, 343 insertions(+), 148 deletions(-) (limited to 'src/gui') diff --git a/src/gui/fonts.zig b/src/gui/fonts.zig index e21f4674..1ad7dd84 100644 --- a/src/gui/fonts.zig +++ b/src/gui/fonts.zig @@ -65,7 +65,8 @@ pub var want: ?[]const u8 = null; /// four in vendor/stb/font.c plus a UTF-16BE decode, and this file is where it /// would land. pub fn list(arena: std.mem.Allocator, want_name: ?[]const u8) []const Font { - var out: std.ArrayList(Font) = .empty; + var found: [max_fonts]Font = undefined; + var found_len: usize = 0; // Zig 0.16 moved the filesystem behind std.Io; the blocking // single-threaded implementation is the synchronous walk a sans-IO core // wants, the same one look.find uses. @@ -87,7 +88,7 @@ pub fn list(arena: std.mem.Allocator, want_name: ?[]const u8) []const Font { var w = dir.walkSelectively(arena) catch continue; defer w.deinit(); var steps: usize = 0; - while (steps < max_steps and out.items.len < max_fonts) { + while (steps < max_steps and found_len < found.len) { steps += 1; // an unreadable dir burns a step too, so it cannot spin const e = (w.next(io) catch continue) orelse break; if (e.kind == .directory) { @@ -103,21 +104,22 @@ pub fn list(arena: std.mem.Allocator, want_name: ?[]const u8) []const Font { if (want_name) |wn| { if (!std.mem.eql(u8, wn, name)) continue; } else if (!monospaced(path)) continue; - out.append(arena, .{ + found[found_len] = .{ .name = arena.dupe(u8, name) catch break, .path = arena.dupe(u8, path) catch break, - }) catch break; - if (want_name != null) return out.items; // asked for one, found it + }; + found_len += 1; + if (want_name != null) return arena.dupe(Font, found[0..found_len]) catch &.{}; // asked for one, found it } } // readdir order is undefined; sort so the picker is the same list twice // running and n/N walks a font's own variants in a row - std.mem.sort(Font, out.items, {}, struct { + std.mem.sort(Font, found[0..found_len], {}, struct { fn lt(_: void, a: Font, b: Font) bool { return std.mem.lessThan(u8, a.name, b.name); } }.lt); - return out.items; + return arena.dupe(Font, found[0..found_len]) catch &.{}; } /// Is every glyph in this font the same width? The terminal grid IS a diff --git a/src/gui/gui.zig b/src/gui/gui.zig index 39d8c575..951564f0 100644 --- a/src/gui/gui.zig +++ b/src/gui/gui.zig @@ -560,6 +560,19 @@ const Msg = union(enum) { /// a pardes launched inside this one sent us a builtin command line (see /// lookThread); gpa-owned, like `output` bytes command: []u8, + + fn deinit(m: Msg, gpa: std.mem.Allocator, lsp_allocator: std.mem.Allocator) void { + switch (m) { + .output => |o| gpa.free(o.bytes), + .lsp => |l| lsp_allocator.free(l.rows), + .pipe => |response_value| { + var response = response_value; + response.deinit(gpa); + }, + .command => |line| gpa.free(line), + .eof, .files_changed => {}, + } + } }; /// The files on open panes, watched through ONE inotify instance. Same shape @@ -590,54 +603,133 @@ const LspJob = struct { } }; +const LspWorkers = struct { + active: std.atomic.Value(usize) = .init(0), + + fn start(workers: *LspWorkers) void { + _ = workers.active.fetchAdd(1, .monotonic); + } + + fn finish(workers: *LspWorkers) void { + _ = workers.active.fetchSub(1, .release); + } + + fn wait(workers: *LspWorkers) void { + var spins: u8 = 0; + while (workers.active.load(.acquire) != 0) { + spins +%= 1; + if (spins == 0) std.Thread.yield() catch {} else std.atomic.spinLoopHint(); + } + } +}; + +const max_pipe_tasks = 16; + const PipeTask = struct { id: u32, future: std.Io.Future(anyerror!void), }; +const PipeTasks = struct { + items: [max_pipe_tasks]PipeTask = undefined, + len: usize = 0, + + fn add(tasks: *PipeTasks, task: PipeTask) bool { + if (tasks.len == tasks.items.len) return false; + tasks.items[tasks.len] = task; + tasks.len += 1; + return true; + } + + fn finish(tasks: *PipeTasks, io: std.Io, id: u32) void { + for (tasks.items[0..tasks.len], 0..) |*task, i| if (task.id == id) { + task.future.await(io) catch {}; + tasks.len -= 1; + std.mem.copyForwards(PipeTask, tasks.items[i..tasks.len], tasks.items[i + 1 .. tasks.len + 1]); + return; + }; + } + + fn cancelAll(tasks: *PipeTasks, io: std.Io) void { + for (tasks.items[0..tasks.len]) |*task| task.future.cancel(io) catch {}; + tasks.len = 0; + } +}; + +const queue_capacity = 512; + +const MessageBatch = struct { + items: [queue_capacity]Msg = undefined, + len: usize = 0, + + fn slice(batch: *MessageBatch) []Msg { + return batch.items[0..batch.len]; + } +}; + const Queue = struct { gpa: std.mem.Allocator, + lsp_allocator: std.mem.Allocator, + lsp_workers: *LspWorkers, sdl_wake: bool, // wake a blocking SDL_WaitEventTimeout on cross-thread push // 0.16 has no std.Thread.Mutex; critical sections here are a few - // instructions, so spinning on the lock-free std.atomic.Mutex is enough + // instructions, so spinning on the lock-free std.atomic.Mutex is enough. mutex: std.atomic.Mutex = .unlocked, - items: std.ArrayList(Msg) = .empty, + items: [queue_capacity]Msg = undefined, + head: usize = 0, + len: usize = 0, closed: bool = false, fn lock(q: *Queue) void { while (!q.mutex.tryLock()) std.atomic.spinLoopHint(); } + fn removeAt(q: *Queue, offset: usize) Msg { + const removed = q.items[(q.head + offset) % q.items.len]; + var i = offset; + while (i + 1 < q.len) : (i += 1) + q.items[(q.head + i) % q.items.len] = q.items[(q.head + i + 1) % q.items.len]; + q.len -= 1; + return removed; + } + + /// Output and other refreshable work are lossy under sustained + /// backpressure. EOF and pipe completions are admitted by evicting queued + /// non-critical messages, so descriptors and futures reach the loop. fn push(q: *Queue, m: Msg) void { q.lock(); if (q.closed) { q.mutex.unlock(); - switch (m) { - .output => |o| q.gpa.free(o.bytes), - .lsp => |l| q.gpa.free(l.rows), - .pipe => |response_value| { - var response = response_value; - response.deinit(q.gpa); - }, - .command => |line| q.gpa.free(line), - .eof, .files_changed => {}, - } + m.deinit(q.gpa, q.lsp_allocator); return; } - q.items.append(q.gpa, m) catch { - q.mutex.unlock(); - switch (m) { - .output => |o| q.gpa.free(o.bytes), - .lsp => |l| q.gpa.free(l.rows), - .pipe => |response_value| { - var response = response_value; - response.deinit(q.gpa); - }, - .command => |line| q.gpa.free(line), - .eof, .files_changed => {}, + if (q.len == q.items.len) { + const incoming_critical = switch (m) { + .pipe, .eof => true, + else => false, + }; + if (!incoming_critical) { + q.mutex.unlock(); + m.deinit(q.gpa, q.lsp_allocator); + return; } - return; - }; + var offset: usize = 0; + while (offset < q.len) : (offset += 1) { + const queued_critical = switch (q.items[(q.head + offset) % q.items.len]) { + .pipe, .eof => true, + else => false, + }; + if (!queued_critical) break; + } + if (offset == q.len) { + q.mutex.unlock(); + m.deinit(q.gpa, q.lsp_allocator); + return; + } + q.removeAt(offset).deinit(q.gpa, q.lsp_allocator); + } + q.items[(q.head + q.len) % q.items.len] = m; + q.len += 1; q.mutex.unlock(); if (q.sdl_wake) { var sev = std.mem.zeroes(c.SDL_Event); @@ -646,30 +738,41 @@ const Queue = struct { } } - /// take the pending messages (caller iterates + deinits outside the lock) - fn take(q: *Queue) std.ArrayList(Msg) { + fn take(q: *Queue) MessageBatch { q.lock(); defer q.mutex.unlock(); - const local = q.items; - q.items = .empty; - return local; + var batch: MessageBatch = .{}; + while (q.len > 0) { + batch.items[batch.len] = q.items[q.head]; + batch.len += 1; + q.head = (q.head + 1) % q.items.len; + q.len -= 1; + } + q.head = 0; + return batch; } - fn close(q: *Queue) void { + fn close(q: *Queue, ptys: *[pardes.MAX_PANES]?Pty, gens: *[pardes.MAX_PANES]u32) void { q.lock(); defer q.mutex.unlock(); q.closed = true; - for (q.items.items) |m| switch (m) { - .output => |o| q.gpa.free(o.bytes), - .lsp => |l| q.gpa.free(l.rows), - .pipe => |response_value| { - var response = response_value; - response.deinit(q.gpa); - }, - .command => |line| q.gpa.free(line), - .eof, .files_changed => {}, - }; - q.items.deinit(q.gpa); + while (q.len > 0) { + const m = q.items[q.head]; + switch (m) { + .eof => |e| { + _ = libc.close(e.fd); + if (gens[e.pane] == e.gen) { + if (ptys[e.pane]) |pt| if (pt.fd == e.fd) { + ptys[e.pane] = null; + }; + } + }, + else => m.deinit(q.gpa, q.lsp_allocator), + } + q.head = (q.head + 1) % q.items.len; + q.len -= 1; + } + q.head = 0; } }; @@ -790,14 +893,15 @@ fn reloadChanged(core: *pardes.Pardes, gpa: std.mem.Allocator, watches: *[pardes /// Answer a language query off the render loop and push the rows to the queue /// — the async execution model, spelled in the plumbing this shell already has /// (a detached thread and the mutex queue the pty readers use). -fn lspThread(gpa: std.mem.Allocator, job: *LspJob, q: *Queue) void { - defer job.free(gpa); - var arena: std.heap.ArenaAllocator = .init(gpa); +fn lspThread(lsp_allocator: std.mem.Allocator, workers: *LspWorkers, job: *LspJob, q: *Queue) void { + defer workers.finish(); + defer job.free(lsp_allocator); + var arena: std.heap.ArenaAllocator = .init(lsp_allocator); defer arena.deinit(); // the shell owns the result buffer; the backend only ever writes to it - var out: std.Io.Writer.Allocating = .init(gpa); + var out: std.Io.Writer.Allocating = .init(lsp_allocator); defer out.deinit(); - pardes.lsp.query(gpa, arena.allocator(), .{ + pardes.lsp.query(lsp_allocator, arena.allocator(), .{ .kind = job.kind, .path = job.path, .source = job.source, @@ -805,48 +909,51 @@ fn lspThread(gpa: std.mem.Allocator, job: *LspJob, q: *Queue) void { .arg = job.arg, .root = job.root, }, &out.writer); - const rows = gpa.dupe(u8, out.written()) catch return; + const rows = lsp_allocator.dupe(u8, out.written()) catch return; q.push(.{ .lsp = .{ .id = job.id, .rows = rows } }); } /// Copy the query out of the core and hand it to a thread. A detached thread /// per query is fine at this rate: one keystroke, one query, and the queue /// already tolerates a late push after close. -fn spawnLsp(core: *pardes.Pardes, gpa: std.mem.Allocator, q: *Queue, e: anytype) void { +fn spawnLsp(core: *pardes.Pardes, q: *Queue, e: anytype) void { + const lsp_allocator = q.lsp_allocator; const pane = core.panes[e.pane] orelse return; // a pane with no file still asks `status` (it is about the backend, not // the buffer): empty path and source, root from the pane's cwd const f = pane.file; - const job = gpa.create(LspJob) catch return; + const job = lsp_allocator.create(LspJob) catch return; job.* = .{ .id = e.id, .kind = e.kind, .offset = e.offset, - .path = gpa.dupe(u8, if (f) |ff| ff.path else "") catch { - gpa.destroy(job); + .path = lsp_allocator.dupe(u8, if (f) |ff| ff.path else "") catch { + lsp_allocator.destroy(job); return; }, - .source = gpa.dupeZ(u8, if (f) |ff| ff.content else "") catch { - gpa.free(job.path); - gpa.destroy(job); + .source = lsp_allocator.dupeZ(u8, if (f) |ff| ff.content else "") catch { + lsp_allocator.free(job.path); + lsp_allocator.destroy(job); return; }, - .arg = gpa.dupe(u8, e.arg.slice()) catch { - gpa.free(job.path); - gpa.free(job.source); - gpa.destroy(job); + .arg = lsp_allocator.dupe(u8, e.arg.slice()) catch { + lsp_allocator.free(job.path); + lsp_allocator.free(job.source); + lsp_allocator.destroy(job); return; }, - .root = gpa.dupe(u8, if (f) |ff| (std.fs.path.dirname(ff.path) orelse "/") else pane.cwdSlice()) catch { - gpa.free(job.path); - gpa.free(job.source); - gpa.free(job.arg); - gpa.destroy(job); + .root = lsp_allocator.dupe(u8, if (f) |ff| (std.fs.path.dirname(ff.path) orelse "/") else pane.cwdSlice()) catch { + lsp_allocator.free(job.path); + lsp_allocator.free(job.source); + lsp_allocator.free(job.arg); + lsp_allocator.destroy(job); return; }, }; - const th = std.Thread.spawn(.{}, lspThread, .{ gpa, job, q }) catch { - job.free(gpa); + q.lsp_workers.start(); + const th = std.Thread.spawn(.{}, lspThread, .{ lsp_allocator, q.lsp_workers, job, q }) catch { + q.lsp_workers.finish(); + job.free(lsp_allocator); return; }; th.detach(); @@ -865,20 +972,20 @@ fn spawnPipe( io: std.Io, gpa: std.mem.Allocator, q: *Queue, - tasks: *std.ArrayList(PipeTask), + tasks: *PipeTasks, request: anytype, ) void { + if (tasks.len == tasks.items.len) { + core.update(.{ .pipe_resp = .{ .id = request.id, .success = false, .outputs = &.{} } }); + return; + } const view = core.pipeRequest(request.id) orelse return; const job = selection_pipe.Job.copy(gpa, view) catch return; - tasks.ensureUnusedCapacity(gpa, 1) catch { - job.deinit(gpa); - return; - }; const future = io.concurrent(pipeThread, .{ io, gpa, job, q }) catch { job.deinit(gpa); return; }; - tasks.appendAssumeCapacity(.{ .id = request.id, .future = future }); + std.debug.assert(tasks.add(.{ .id = request.id, .future = future })); } // ---- the renderer state ---- @@ -1205,8 +1312,16 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options) !void { g.pen_x = cell_w; // ---- the core ---- - pardes.image.start(io, gpa); // stb_image allocator for image panes - defer pardes.image.stop(); + const allocs = pardes.allocators.init(gpa); + defer pardes.allocators.deinit(); + pardes.image.start(io, allocs.image); // stb_image allocator for image panes + if (comptime pardes.pdf_enabled) pardes.pdf.start(allocs.pdf); + pardes.syntax.start(allocs.tree_sitter); + defer { + pardes.image.stop(); + if (comptime pardes.pdf_enabled) pardes.pdf.stop(); + pardes.syntax.stop(); + } // Live sessions initialize at the default 80x24 grid; the real window size // arrives as a resize EVENT on the first loop pass. The core defers the // shell greeting until after the first resize (so `ls` wraps to the real @@ -1215,6 +1330,9 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options) !void { // Dump loads pre-size instead: replayed panes never greet, and sizing at // init avoids reflowing their replayed content twice. var opts = opts_in; + opts.image_allocator = allocs.image; + opts.pdf_allocator = allocs.pdf; + opts.tree_sitter_allocator = allocs.tree_sitter; var pw: c_int = 0; var ph: c_int = 0; if (opts.load_path != null) { @@ -1225,8 +1343,8 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options) !void { var core = if (opts.load_path) |lp| blk: { const bytes = try @import("../look.zig").readFile(gpa, lp); defer gpa.free(bytes); - break :blk try pardes.Pardes.initFromDump(gpa, opts, bytes); - } else try pardes.Pardes.init(gpa, opts); + break :blk try pardes.Pardes.initFromDump(allocs.pardes, opts, bytes); + } else try pardes.Pardes.init(allocs.pardes, opts); defer core.deinit(); // SDL is itself a native-pixel backend. This is deliberately set after // construction: argv image panes no longer freeze the startup capability @@ -1246,15 +1364,20 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options) !void { var gens: [pardes.MAX_PANES]u32 = @splat(0); defer for (&ptys) |*slot| if (slot.*) |pt| { _ = libc.close(pt.fd); - slot.* = null; }; - var queue: Queue = .{ .gpa = gpa, .sdl_wake = true }; - defer queue.close(); - var pipe_tasks: std.ArrayList(PipeTask) = .empty; + var lsp_workers: LspWorkers = .{}; + var queue: Queue = .{ + .gpa = gpa, + .lsp_allocator = allocs.lsp, + .lsp_workers = &lsp_workers, + .sdl_wake = true, + }; defer { - for (pipe_tasks.items) |*task| task.future.cancel(io) catch {}; - pipe_tasks.deinit(gpa); + lsp_workers.wait(); + queue.close(&ptys, &gens); } + var pipe_tasks: PipeTasks = .{}; + defer pipe_tasks.cancelAll(io); // One inotify instance for every watched pane, opened here — before any // thread exists — so the pre-loop drain below can already mark the file a // positional path argument opened. -1 off linux: watchPane goes quiet and @@ -1289,7 +1412,7 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options) !void { var feed: StdinFeed = .{}; if (test_mode) setStdinRaw() catch {}; - var frame_arena: std.heap.ArenaAllocator = .init(gpa); + var frame_arena: std.heap.ArenaAllocator = .init(allocs.frame); defer frame_arena.deinit(); var animation_clock: AnimationClock = .{}; @@ -1308,7 +1431,7 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options) !void { // 2. pty output from the reader threads var msgs = queue.take(); var check_files = false; - for (msgs.items) |m| switch (m) { + for (msgs.slice()) |m| switch (m) { .output => |o| { if (gens[o.pane] == o.gen) core.update(.{ .output = .{ .pane = o.pane, .bytes = o.bytes } }); @@ -1323,7 +1446,7 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options) !void { }, .lsp => |l| { core.update(.{ .lsp_resp = .{ .id = l.id, .rows = l.rows } }); - gpa.free(l.rows); + allocs.lsp.free(l.rows); }, .pipe => |response_value| { var response = response_value; @@ -1333,11 +1456,7 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options) !void { .outputs = response.outputs, } }); response.deinit(gpa); - for (pipe_tasks.items, 0..) |*task, i| if (task.id == response_value.id) { - task.future.await(io) catch {}; - _ = pipe_tasks.orderedRemove(i); - break; - }; + pipe_tasks.finish(io, response_value.id); }, .command => |line| { core.update(.{ .command = line }); @@ -1348,7 +1467,6 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options) !void { // a reload — or an undo entry — per write. .files_changed => check_files = true, }; - msgs.deinit(gpa); if (check_files) reloadChanged(core, gpa, &watches); // 3. steamdeck: poll gamepad axes into virtual cursor / wheel events pollGamepad(&g, core); @@ -1364,7 +1482,7 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options) !void { var o = core.opts; o.cols = core.screen_w; o.rows = core.screen_h; // pre-size: dump panes never greet - const nc = pardes.Pardes.initFromDump(gpa, o, bytes) catch break :blk; + const nc = pardes.Pardes.initFromDump(allocs.pardes, o, bytes) catch break :blk; for (&ptys) |*slot| if (slot.*) |pt| { _ = libc.kill(pt.pid, libc.SIG.KILL); slot.* = null; @@ -1656,7 +1774,7 @@ fn runWeb(opts_in: pardes.Options) !void { }, .core = undefined, .gpa = gpa, - .frame_arena = .init(gpa), + .frame_arena = undefined, }; const g = &st.g; // slot (0,0) is the space glyph (blank cells sample alpha=0 → bg only) @@ -1664,15 +1782,27 @@ fn runWeb(opts_in: pardes.Options) !void { g.pen_x = cell_w; // ---- the core: state replays from the embedded dump, no ptys ---- - pardes.image.start(std.Io.failing, gpa); // decode is synchronous; no io on wasm + const allocs = pardes.allocators.init(gpa); + errdefer pardes.allocators.deinit(); + st.frame_arena = .init(allocs.frame); + errdefer st.frame_arena.deinit(); + pardes.image.start(std.Io.failing, allocs.image); // decode is synchronous; no io on wasm + 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(); syncCanvasSize(g); // size the backing canvas before deriving the grid var opts = opts_in; + opts.image_allocator = allocs.image; + opts.pdf_allocator = allocs.pdf; + opts.tree_sitter_allocator = allocs.tree_sitter; var pw: c_int = 0; var ph: c_int = 0; _ = c.SDL_GetWindowSizeInPixels(window, &pw, &ph); opts.cols = @intCast(@max(1, @divTrunc(@as(u32, @intCast(@max(pw, 1))), cell_w))); opts.rows = @intCast(@max(1, @divTrunc(@as(u32, @intCast(@max(ph, 1))), cell_h))); - st.core = try pardes.Pardes.initFromDump(gpa, opts, embedded_dump); + st.core = try pardes.Pardes.initFromDump(allocs.pardes, opts, embedded_dump); live_web_state = st; _ = c.SDL_StartTextInput(window); @@ -1688,7 +1818,17 @@ fn webFrame(arg: ?*anyopaque) callconv(.c) void { while (c.SDL_PollEvent(&sev)) dispatch(g, core, &sev); // 2. effects drainEffectsWeb(core, st.gpa, g); - if (core.quit) return std.os.emscripten.emscripten_cancel_main_loop(); + if (core.quit) { + std.os.emscripten.emscripten_cancel_main_loop(); + live_web_state = null; + core.deinit(); + st.frame_arena.deinit(); + pardes.image.stop(); + if (comptime pardes.pdf_enabled) pardes.pdf.stop(); + pardes.syntax.stop(); + pardes.allocators.deinit(); + return; + } // 3. the backing canvas follows the page, the grid follows the canvas syncCanvasSize(g); var pw: c_int = 0; @@ -1730,12 +1870,23 @@ fn runGrid(init: std.process.Init, opts_in: pardes.Options) !void { const gpa = init.gpa; const env = init.environ_map; - pardes.image.start(io, gpa); - defer pardes.image.stop(); + const allocs = pardes.allocators.init(gpa); + defer pardes.allocators.deinit(); + pardes.image.start(io, allocs.image); + if (comptime pardes.pdf_enabled) pardes.pdf.start(allocs.pdf); + pardes.syntax.start(allocs.tree_sitter); + defer { + pardes.image.stop(); + if (comptime pardes.pdf_enabled) pardes.pdf.stop(); + pardes.syntax.stop(); + } const grid_cols = envU16(env, "PARDES_TEST_COLS") orelse 80; const grid_rows = envU16(env, "PARDES_TEST_ROWS") orelse 24; var opts = opts_in; + opts.image_allocator = allocs.image; + opts.pdf_allocator = allocs.pdf; + opts.tree_sitter_allocator = allocs.tree_sitter; if (opts.load_path != null) { opts.cols = grid_cols; opts.rows = grid_rows; @@ -1743,8 +1894,8 @@ fn runGrid(init: std.process.Init, opts_in: pardes.Options) !void { const core = if (opts.load_path) |lp| blk: { const bytes = try @import("../look.zig").readFile(gpa, lp); defer gpa.free(bytes); - break :blk try pardes.Pardes.initFromDump(gpa, opts, bytes); - } else try pardes.Pardes.init(gpa, opts); + break :blk try pardes.Pardes.initFromDump(allocs.pardes, opts, bytes); + } else try pardes.Pardes.init(allocs.pardes, opts); defer core.deinit(); // the requested grid arrives as a resize EVENT (not init opts) so the core // counts a resize and releases the shell greeting on first output @@ -1763,15 +1914,20 @@ fn runGrid(init: std.process.Init, opts_in: pardes.Options) !void { var gens: [pardes.MAX_PANES]u32 = @splat(0); defer for (&ptys) |*slot| if (slot.*) |pt| { _ = libc.close(pt.fd); - slot.* = null; }; - var queue: Queue = .{ .gpa = gpa, .sdl_wake = false }; - defer queue.close(); - var pipe_tasks: std.ArrayList(PipeTask) = .empty; + var lsp_workers: LspWorkers = .{}; + var queue: Queue = .{ + .gpa = gpa, + .lsp_allocator = allocs.lsp, + .lsp_workers = &lsp_workers, + .sdl_wake = false, + }; defer { - for (pipe_tasks.items) |*task| task.future.cancel(io) catch {}; - pipe_tasks.deinit(gpa); + lsp_workers.wait(); + queue.close(&ptys, &gens); } + var pipe_tasks: PipeTasks = .{}; + defer pipe_tasks.cancelAll(io); // no inotify here on purpose: this mode's whole contract is one frame per // scripted input event, and a reload that arrives on its own clock would // put a frame in the stream nothing asked for. -1 makes watchPane a no-op. @@ -1781,7 +1937,7 @@ fn runGrid(init: std.process.Init, opts_in: pardes.Options) !void { setStdinRaw() catch {}; // stdin may be a pipe, not a pty — best effort - var frame_arena: std.heap.ArenaAllocator = .init(gpa); + var frame_arena: std.heap.ArenaAllocator = .init(allocs.frame); defer frame_arena.deinit(); var feed: StdinFeed = .{}; @@ -1795,7 +1951,7 @@ fn runGrid(init: std.process.Init, opts_in: pardes.Options) !void { if (r.eof) break; var n_events: usize = r.n_events; var msgs = queue.take(); - for (msgs.items) |m| switch (m) { + for (msgs.slice()) |m| switch (m) { .output => |o| { if (gens[o.pane] == o.gen) core.update(.{ .output = .{ .pane = o.pane, .bytes = o.bytes } }); @@ -1812,7 +1968,7 @@ fn runGrid(init: std.process.Init, opts_in: pardes.Options) !void { }, .lsp => |l| { core.update(.{ .lsp_resp = .{ .id = l.id, .rows = l.rows } }); - gpa.free(l.rows); + allocs.lsp.free(l.rows); n_events += 1; }, .pipe => |response_value| { @@ -1824,17 +1980,12 @@ fn runGrid(init: std.process.Init, opts_in: pardes.Options) !void { } }); response.deinit(gpa); n_events += 1; - for (pipe_tasks.items, 0..) |*task, i| if (task.id == response_value.id) { - task.future.await(io) catch {}; - _ = pipe_tasks.orderedRemove(i); - break; - }; + pipe_tasks.finish(io, response_value.id); }, // both unreachable here: this mode starts neither thread .files_changed => {}, .command => |line| gpa.free(line), }; - msgs.deinit(gpa); drainEffects(core, &ptys, &gens, io, gpa, &queue, &pipe_tasks, null, -1, &watches, true); pollCwds(core, &ptys); // The grid harness polls stdin at the same 16 ms cadence as native @@ -2062,27 +2213,48 @@ fn setStdinRaw() !void { // PARDES_TEST_GRID: one text frame per render, prototype-compatible framing. fn dumpGrid(gpa: std.mem.Allocator, surface: *pardes.Surface) !void { if (surface.cols == 0 or surface.rows == 0) return; - // build the whole frame in one buffer → one write → atomic snapshots - var buf: std.ArrayList(u8) = .empty; - defer buf.deinit(gpa); const cur_x: u16 = if (surface.cursor) |cu| cu.x else 0; const cur_y: u16 = if (surface.cursor) |cu| cu.y else 0; var hdr: [128]u8 = undefined; - const hdr_str = std.fmt.bufPrint(&hdr, "\n---FRAME:{d}:{d}:{d}:{d}:{}---\n", .{ + const header = std.fmt.bufPrint(&hdr, "\n---FRAME:{d}:{d}:{d}:{d}:{}---\n", .{ surface.rows, surface.cols, cur_x, cur_y, surface.cursor != null, }) catch return; - try buf.appendSlice(gpa, hdr_str); + const footer = "---ENDFRAME---\n"; + var total = try std.math.add(usize, header.len, footer.len); + total = try std.math.add(usize, total, surface.rows); var y: u16 = 0; while (y < surface.rows) : (y += 1) { var x: u16 = 0; while (x < surface.cols) : (x += 1) { - const g = surface.at(x, y).grapheme(); - if (g.len > 0) try buf.appendSlice(gpa, g) else try buf.append(gpa, ' '); + const grapheme = surface.at(x, y).grapheme(); + total = try std.math.add(usize, total, if (grapheme.len > 0) grapheme.len else 1); + } + } + const frame = try gpa.alloc(u8, total); + defer gpa.free(frame); + var at: usize = 0; + @memcpy(frame[at..][0..header.len], header); + at += header.len; + y = 0; + while (y < surface.rows) : (y += 1) { + var x: u16 = 0; + while (x < surface.cols) : (x += 1) { + const grapheme = surface.at(x, y).grapheme(); + if (grapheme.len > 0) { + @memcpy(frame[at..][0..grapheme.len], grapheme); + at += grapheme.len; + } else { + frame[at] = ' '; + at += 1; + } } - try buf.append(gpa, '\n'); + frame[at] = '\n'; + at += 1; } - try buf.appendSlice(gpa, "---ENDFRAME---\n"); - writeFd(1, buf.items); + @memcpy(frame[at..][0..footer.len], footer); + at += footer.len; + std.debug.assert(at == frame.len); + writeFd(1, frame); } // ===================================================================== @@ -2553,7 +2725,7 @@ fn drainEffects( io: std.Io, gpa: std.mem.Allocator, queue: *Queue, - pipe_tasks: *std.ArrayList(PipeTask), + pipe_tasks: *PipeTasks, g: ?*Gui, // null in grid test mode (no SDL: clipboard effects are no-ops) inotify_fd: c_int, watches: *[pardes.MAX_PANES]?Watch, @@ -2640,7 +2812,7 @@ fn drainEffects( defer gpa.free(z); _ = c.SDL_SetClipboardText(z.ptr); }, - .lsp => |e| if (threads_ok) spawnLsp(core, gpa, queue, e), + .lsp => |e| if (threads_ok) spawnLsp(core, queue, e), .pipe => |e| if (threads_ok) spawnPipe(core, io, gpa, queue, pipe_tasks, e), .watch => |w| { // starting, the path and the on-disk bytes are read off the core @@ -2702,15 +2874,30 @@ fn drainEffectsWeb(core: *pardes.Pardes, gpa: std.mem.Allocator, g: *Gui) void { /// window.open in a tiny generated script; single quotes and backslashes are /// %-escaped so the url can't break out of the JS string literal. fn openLinkWeb(gpa: std.mem.Allocator, url: []const u8) void { - var safe: std.ArrayList(u8) = .empty; - defer safe.deinit(gpa); + const prefix = "window.open('"; + const suffix = "','_blank')"; + var escaped_len: usize = 0; + for (url) |ch| escaped_len = std.math.add(usize, escaped_len, if (ch == '\'' or ch == '\\') 3 else 1) catch return; + const script_len = std.math.add(usize, prefix.len + suffix.len, escaped_len) catch return; + const script = gpa.allocSentinel(u8, script_len, 0) catch return; + defer gpa.free(script); + @memcpy(script[0..prefix.len], prefix); + var at = prefix.len; for (url) |ch| switch (ch) { - '\'' => safe.appendSlice(gpa, "%27") catch return, - '\\' => safe.appendSlice(gpa, "%5C") catch return, - else => safe.append(gpa, ch) catch return, + '\'' => { + @memcpy(script[at..][0..3], "%27"); + at += 3; + }, + '\\' => { + @memcpy(script[at..][0..3], "%5C"); + at += 3; + }, + else => { + script[at] = ch; + at += 1; + }, }; - const script = std.fmt.allocPrintSentinel(gpa, "window.open('{s}','_blank')", .{safe.items}, 0) catch return; - defer gpa.free(script); + @memcpy(script[at..][0..suffix.len], suffix); std.os.emscripten.emscripten_run_script(script.ptr); } @@ -3125,13 +3312,19 @@ fn prepareNativeImages( uploadNativeTexture(g, gpa, cmd, place) catch continue; } - var stale: std.ArrayList(pardes.ImageCacheKey) = .empty; - defer stale.deinit(gpa); - var iterator = g.native_images.iterator(); - while (iterator.next()) |entry| - if (!surfaceHasNativeKey(surface, entry.key_ptr.*)) - stale.append(gpa, entry.key_ptr.*) catch {}; - for (stale.items) |key| releaseNativeImage(g, key); + while (true) { + var stale: [pardes.MAX_PANES]pardes.ImageCacheKey = undefined; + var stale_len: usize = 0; + var iterator = g.native_images.iterator(); + while (iterator.next()) |entry| { + if (surfaceHasNativeKey(surface, entry.key_ptr.*)) continue; + stale[stale_len] = entry.key_ptr.*; + stale_len += 1; + if (stale_len == stale.len) break; + } + for (stale[0..stale_len]) |key| releaseNativeImage(g, key); + if (stale_len < stale.len) break; + } var count: u32 = 0; for (surface.images[0..surface.nimages]) |maybe| { -- cgit v1.3