diff options
Diffstat (limited to 'src/gui')
| -rw-r--r-- | src/gui/gui.zig | 2432 |
1 files changed, 1025 insertions, 1407 deletions
diff --git a/src/gui/gui.zig b/src/gui/gui.zig index 3876a222..a7d9d167 100644 --- a/src/gui/gui.zig +++ b/src/gui/gui.zig @@ -1,30 +1,4 @@ -//! The SDL3 GPU shell: owns an SDL window + event loop, translates SDL input -//! into core events, performs the core's effects (fork ptys, write them, -//! resize them — same duties as tty.zig, this is also native), and -//! rasterizes the core's Surface: one instanced quad per cell, glyphs from a -//! FreeType-hinted R8 atlas. Test modes: PARDES_TEST_GRID=1 is headless (no SDL, -//! stdin escape sequences in, text grid frames out); PARDES_TEST=1 keeps the -//! real renderer, drives input from stdin, and captures frames to PPM. -//! -//! ...AND THE SAME WINDOW WITH NO CORE IN IT. `--attach`, and the `Attach` -//! builtin, hand this window's screen to a detached session (src/detached/): -//! the `Pardes` lives in THAT process, and this one sends the input it collects -//! and paints the frames it is sent. The two modes share every line that -//! touches SDL — `dispatch`/`keyDown` translate an SDL_Event once, -//! `renderFrame` rasterizes a `Surface` once, `putClipboard`/`takeClipboard` -//! and `look.openLink` are the desktop once — and differ only in where a -//! translated event goes and where the cells came from. `Input` is that seam, -//! and a null `core` inside it is what "attached" MEANS here: every function -//! that reads pane rects or theme colours off the core takes an optional one -//! and falls back to the body grid, because the wire carries cells, not the -//! layout that produced them. -//! -//! An attached window does NO machine-local work whatsoever: it forks no shell, -//! writes no file and watches no path, because the session process owns all of -//! that now (src/host_io.zig, src/file_watch.zig, src/detached/server.zig). -//! The only effects still on that wire are the three that need a human's own -//! display — `set_clipboard`, `read_clipboard`, `open_link` — and those land on -//! THIS display. +const filesystem = @import("../fs.zig"); const std = @import("std"); const builtin = @import("builtin"); const posix = std.posix; @@ -32,28 +6,17 @@ const libc = std.c; const vaxis = @import("vaxis"); // test modes only: the stdin escape-seq parser const ghostty_vt = @import("ghostty-vt"); // 256-color palette for .index cells const pardes = @import("../pardes.zig"); -const host_api = @import("../host.zig"); // LspRequest, the one host type not re-exported const config = @import("../config.zig"); const look = @import("../look.zig"); -const message = @import("../message.zig"); +const message = pardes.Pardes.Message; const file_watch = @import("../file_watch.zig"); -const user_config = @import("../user_config.zig"); const deck = @import("deck.zig"); const crt = @import("crt.zig"); const fonts = @import("../fonts.zig"); // the Font builtin's half of the seam const selection_pipe = @import("../selection_pipe.zig"); -const shell_bin = @import("../shell_bin.zig"); -const nested = @import("../nested.zig"); -const fuse = @import("../fuse.zig"); -const fs_service = @import("../fs_service.zig"); +const ninep_io = @import("../9p_io.zig"); -// The other half of `--detach`, and the reason this file has an `--attach` -// branch at all: the frontend side of a detached session is a window and a -// socket, and this file is already the one that owns a window. Just the one -// import: client.zig owns the frontend's whole side of this transport — the -// name resolution, the handshake, the poll interval and the decoded messages — -// so nothing here reaches past it to server.zig or wire.zig. const detached_client = @import("../detached/client.zig"); pub const c = @cImport({ @@ -62,13 +25,8 @@ pub const c = @cImport({ @cInclude("font.h"); }); -// The machine-local half of a host — fork a pane's shell, put bytes on a disk -// — is shared with the tty shell and the detached daemon. This file used to -// carry its own `forkShell`, `writeWholeFile` and `writeFd`, ten of eleven -// lines identical to that file's and one line short of its zero-write guard. const host_io = @import("../host_io.zig"); extern "c" fn setenv(name: [*:0]const u8, value: [*:0]const u8, overwrite: c_int) c_int; -// TIOCSWINSZ: absent from std.c.T on darwin — _IOW('t', 103, winsize) const TIOCSWINSZ: c_int = @bitCast(@as(u32, if (@hasDecl(posix.T, "IOCSWINSZ")) posix.T.IOCSWINSZ else 0x80087467)); const log = std.log.scoped(.gui); @@ -83,32 +41,9 @@ const image_frag_spv = @embedFile("image.frag.spv"); const crt_vert_spv = @embedFile("crt.vert.spv"); const crt_frag_spv = @embedFile("crt.frag.spv"); -/// What the shell paints where the core painted nothing: behind the grid — the -/// strip left over when the window is not a whole number of cells tall — and -/// inside every cell the core left at its default background. The FALLBACK -/// for it, that is: the ground itself is the active theme's own background, -/// read off the core once a frame (see `ground`), and this is what a theme -/// with none of its own gets in an OPAQUE window. -/// -/// A theme declares no background (the curated `dark`, every vendored -/// `*_transparent`) to mean "wear whatever the terminal is wearing", and a -/// window has nothing to wear unless it is see-through. The AppKit shell -/// answers that by going transparent over an NSVisualEffectView -/// (pardes_theme_bg, docs/macos.md); this shell answers it with -/// `config.gui_transparent`, which asks SDL for a transparent window and lets -/// the compositor be the backdrop. That knob is off by default and costs a -/// readback per painted frame when it is on — the whole reason is written -/// where it is declared. Without it a themeless window keeps the -/// terminal-native dark it always wore. const bg_default = [3]u8{ 18, 18, 18 }; const fg_default = [3]u8{ 204, 204, 204 }; -/// The ground under this frame, and whether it is a colour at all. -/// -/// `clear` is the themeless case in a transparent window: nothing is painted -/// there, the desktop is. `rgb` still carries the fallback colour because a -/// reverse-video cell puts the ground in its FOREGROUND, where it is a real -/// colour that paints — the same rule PardesView.styleFor states on macOS. const Ground = struct { rgb: [3]u8, clear: bool, @@ -118,22 +53,11 @@ const Ground = struct { } }; -/// The ground under this frame. The theme's OWN background and not the -/// animated chrome colour: taglines fade between themes over a handful of -/// frames, document backgrounds switch the instant the theme does, and this is -/// one of those. Asked per frame, so a `Theme` command takes hold without a -/// relaunch — and asked at all because a hand-agreed constant was a black line -/// along the two edges of every light-themed window. fn ground(theme_bg: ?[3]u8, transparent: bool) Ground { if (theme_bg) |rgb| return .opaqueRgb(rgb); return .{ .rgb = bg_default, .clear = transparent }; } -// the plan9 arrow cursor, bytes verbatim from 9front /sys/src/9/port/ -// devmouse.c (Cursor arrow, 16x16 MSB-first): clr is the white outline, set -// the black ink, offset {-1,-1} puts the hot point at (1,1) in the bitmap. -// SDL_CreateCursor's scheme: data=1,mask=1 black; data=0,mask=1 white; -// mask=0 transparent — so data = set and mask = set|clr. const p9_arrow_clr = [32]u8{ 0xFF, 0xFF, 0x80, 0x01, 0x80, 0x02, 0x80, 0x0C, 0x80, 0x10, 0x80, 0x10, 0x80, 0x08, 0x80, 0x04, @@ -152,7 +76,6 @@ const p9_arrow_mask = blk: { break :blk m; }; -// 2048 fits ~2500 glyphs at the 2x native cell (~20x40). const atlas_w: u32 = 2048; const atlas_h: u32 = 2048; const Slot = struct { u: u32, v: u32 }; @@ -161,9 +84,6 @@ const GlyphKey = struct { role: pardes.FontRole, }; -/// Raster parameters for the smaller tagline face. Taglines are a real second -/// grid: their glyph cell shrinks in both axes while pane geometry remains on -/// the body grid. const TaglineRaster = struct { scale: f32, width: u32, @@ -184,9 +104,6 @@ fn taglineRaster(font: *c.UIFont, body_px: f32, body_cell_w: u32, body_cell_h: u return .{ .scale = scale, .width = @intCast(std.math.clamp(own_w, 1, fixed_w)), - // Tagline slots use only their first `height` texel rows. Centering is - // done by the cell quad, not baked into this baseline, so changing the - // live percentage changes the chrome band as well as the glyph ink. .baseline = std.math.clamp(own_ascent, 1, band_h), .height = @intCast(band_h), }; @@ -194,7 +111,6 @@ fn taglineRaster(font: *c.UIFont, body_px: f32, body_cell_w: u32, body_cell_h: u const max_fallback_fonts = 1 + fonts.fallback_names.len; const LoadedFallback = struct { face: *c.UIFont, - /// Empty for embedded Adwaita; otherwise the owned bytes FreeType borrows. bytes: []u8 = &.{}, }; @@ -205,12 +121,6 @@ const touch_click_flash_max_frames: u8 = 14; const touch_click_flash_vertices: usize = 1400; const touch_scroll_tick: f32 = 0.02; -/// SDL input can wake the loop faster than display cadence, so ticking once -/// per pass would make animation duration depend on pty traffic or mouse -/// motion — and `pump` deliberately spends no animation time of its own. This -/// monotonic gate keeps it near 60 Hz without sleeping the event loop. It is -/// consulted only AFTER a successful presentation: the current sample reaches -/// the screen before the display interval may advance it. const AnimationClock = struct { next_ns: u64 = 0, @@ -220,24 +130,19 @@ const AnimationClock = struct { return false; } if (clock.next_ns == 0) { - clock.next_ns = now_ns +| pardes.animation.frame_ns; + clock.next_ns = now_ns +| pardes.layout.Animation.frame_ns; return false; } if (now_ns < clock.next_ns) return false; - clock.next_ns = now_ns +| pardes.animation.frame_ns; + clock.next_ns = now_ns +| pardes.layout.Animation.frame_ns; return true; } }; -/// Commit exactly what the GPU accepted, then spend at most one display-clock -/// step. The order is the invariant: a persistent scene effect may keep -/// `AnimationClock` armed indefinitely, but it still cannot consume frame zero -/// of a panel/theme/hover animation created earlier in this loop before that -/// source sample is rendered. fn finishPresentedAnimationFrame( clock: *AnimationClock, core: *pardes.Pardes, - tracks: []const pardes.panel_animation.Track, + tracks: []const pardes.layout.Track, now_ns: u64, ) void { core.acknowledgePanelPresentation(tracks); @@ -248,9 +153,9 @@ test "GUI animation clock is active-only and cadence gated" { var clock: AnimationClock = .{}; try std.testing.expect(!clock.due(false, 100)); try std.testing.expect(!clock.due(true, 100)); - try std.testing.expect(!clock.due(true, 100 + pardes.animation.frame_ns - 1)); - try std.testing.expect(clock.due(true, 100 + pardes.animation.frame_ns)); - try std.testing.expect(!clock.due(false, 100 + 2 * pardes.animation.frame_ns)); + try std.testing.expect(!clock.due(true, 100 + pardes.layout.Animation.frame_ns - 1)); + try std.testing.expect(clock.due(true, 100 + pardes.layout.Animation.frame_ns)); + try std.testing.expect(!clock.due(false, 100 + 2 * pardes.layout.Animation.frame_ns)); try std.testing.expectEqual(@as(u64, 0), clock.next_ns); } @@ -261,13 +166,10 @@ test "a persistent scene presents a new panel's frame zero before advancing it" defer arena.deinit(); var clock: AnimationClock = .{}; - // The persistent effect arms the cadence gate before any panel animation - // exists—the state which used to make a due tick skip a new track's first - // sample in the old tick-before-render loop. core.settings.scene_effects.crt = true; const scene_frame = try core.render(arena.allocator()); finishPresentedAnimationFrame(&clock, core, scene_frame.panelTracks(), 100); - try std.testing.expectEqual(@as(u64, 100 + pardes.animation.frame_ns), clock.next_ns); + try std.testing.expectEqual(@as(u64, 100 + pardes.layout.Animation.frame_ns), clock.next_ns); core.settings.panel_transition = .slide; core.update(.{ .command = "Newcol" }); @@ -277,13 +179,11 @@ test "a persistent scene presents a new panel's frame zero before advancing it" try std.testing.expect(track_count > 0); for (first.panelTracks()) |track| try std.testing.expectEqual(@as(u16, 0), track.frame); - // The cadence is already due, but finishPresentedAnimationFrame consumes - // it only after acknowledging the frame-zero records above. finishPresentedAnimationFrame( &clock, core, first.panelTracks(), - 100 + pardes.animation.frame_ns, + 100 + pardes.layout.Animation.frame_ns, ); _ = arena.reset(.retain_capacity); const second = try core.render(arena.allocator()); @@ -293,22 +193,6 @@ test "a persistent scene presents a new panel's frame zero before advancing it" const max_overlay_vertices: usize = 18 + touch_click_flash_vertices + max_touch_points * (1100 + max_touch_trail_points * overlay_circle_vertices); -// Ctrl+ / Ctrl-: how far one press moves g.px, and the two sizes it stops at. -// ONE size for the whole window, not one per pane: the core lays every pane -// out on a single uniform cell grid and Surface is one flat cols×rows array, -// so a second cell size would be a different core, not a different font. -// -// The step is 2 and not 1 because 1 is a press that sometimes does nothing — -// cell_w is round(advance × scale), the shipped face advances ~0.51px per px -// of size, and half the 1px steps therefore round to the same column width. A -// key that visibly works only every other press reads as a broken key. At 2 -// both axes move at every size in the range, on both parities of the ladder; -// that is what the test below walks. -// -// The ends are where a terminal stops being one. 8px is a 4×8 cell — the -// smallest thing with a glyph still in it — and 72px is 37×76, about thirty -// columns across a laptop screen. Past either the grid is not small or large, -// it is wedged, and there is no reset binding to get back out of it. const font_px_step: f32 = 2.0; const font_px_min: f32 = 8.0; const font_px_max: f32 = 72.0; @@ -316,8 +200,6 @@ const font_px_max: f32 = 72.0; test "every font size step moves the cell, and the ends are reachable exactly" { const font = c.ui_font_new(font_ttf.ptr, @intCast(font_ttf.len)).?; defer c.ui_font_free(font); - // Walk the whole range rather than only the 27px runtime ladder, so both - // odd and even rungs are exercised and a rounding stall cannot hide. var px = font_px_min + font_px_step; while (px <= font_px_max) : (px += 1.0) { var cw: c_int = 0; @@ -329,8 +211,6 @@ test "every font size step moves the cell, and the ends are reachable exactly" { c.ui_font_cell_metrics(font, c.ui_font_scale_for_height(font, px - font_px_step), &pw, &ph, &asc); try std.testing.expect(cw > pw and ch > ph); } - // ...and the clamp dispatch runs parks on each end instead of walking off - // it, from any size a press can leave g.px on try std.testing.expectEqual(font_px_max, std.math.clamp(font_px_max + font_px_step, font_px_min, font_px_max)); try std.testing.expectEqual(font_px_min, std.math.clamp(font_px_min - font_px_step, font_px_min, font_px_max)); } @@ -436,16 +316,11 @@ test "tagline glyph shrinks into its own cell and stays vertically centered" { const t = inkBounds(&tagline, @intCast(stride), @intCast(tag.width), @intCast(tag.height)).?; try std.testing.expect(t.max_x - t.min_x < b.max_x - b.min_x); try std.testing.expect(t.max_y - t.min_y < b.max_y - b.min_y); - // Compare doubled centers to avoid floating point. Hinting may move either - // glyph by a pixel, but the smaller one must not hug an edge of the slot. const body_center_x: isize = @intCast(b.min_x + b.max_x); const tag_center_x: isize = @intCast(t.min_x + t.max_x); const body_center_y: isize = @intCast(b.min_y + b.max_y); const centered_top: isize = @intCast((@as(u32, @intCast(cell_h)) - tag.height) / 2); const tag_center_y: isize = @as(isize, @intCast(t.min_y + t.max_y)) + centered_top * 2; - // Compare positions in their own grids. Both faces are centred within - // their natural monospace advance; the smaller face is not padded back - // out to a body-width cell. const body_slot_center: isize = @intCast(@as(u32, @intCast(cell_w)) - 1); const tag_slot_center: isize = @intCast(tag.width - 1); try std.testing.expect(@abs((tag_center_x - tag_slot_center) - (body_center_x - body_slot_center)) <= 2); @@ -470,7 +345,6 @@ test "tagline percentage changes measured band height without body metrics" { try std.testing.expect(small.height < configured.height); try std.testing.expect(configured.height <= full.height); try std.testing.expect(full.height <= @as(u32, @intCast(cell_h))); - // The body measurement is an input and remains the same one-row grid. try std.testing.expectEqual(body_scale, c.ui_font_scale_for_height(font, 27.0)); } @@ -493,17 +367,13 @@ test "installed Nerd Symbols fallback covers Yazi directory icons" { if (std.mem.eql(u8, font.name, "SymbolsNerdFont-Regular")) break font; } else return; - const bytes = try look.readFile(std.testing.allocator, candidate.path); + const bytes = try filesystem.readFile(std.testing.allocator, candidate.path); defer std.testing.allocator.free(bytes); const face = c.ui_font_new(bytes.ptr, @intCast(bytes.len)) orelse return error.FontInit; defer c.ui_font_free(face); - // Yazi's default directory icon ``. try std.testing.expectEqual(@as(c_int, 1), c.ui_font_has_glyph(face, 0xe5ff)); } -// One instance per body cell; a tagline cell adds its compact-grid foreground -// instance. The vertex shader expands each to a 2-triangle quad with -// gl_VertexIndex. Coords are NDC (y up), uv into the atlas, colors 0..1. const CellInstance = extern struct { x0: f32, y0: f32, @@ -560,18 +430,9 @@ const ImageInstance = extern struct { }; const initial_image_capacity: u32 = pardes.MAX_PANES; -/// `CellInstance.effect` is an effect id in its low bits and flags in its top -/// two. Both shaders that read the field mask the id off with `0x3fffffff`; -/// widening this pair means widening that mask with it. const old_layer_bit: u32 = 0x8000_0000; -/// This cell's background IS the see-through ground: emit the glyph and let -/// the compositor keep the rest. Only ever set when `Ground.clear` holds, so -/// an opaque window never reaches the branch. const clear_bg_bit: u32 = 0x4000_0000; -/// Image placement retained across pane destruction. Deliberately does not -/// contain ImagePlace.rgba: the producer owns those bytes, while the renderer -/// retains only the already-uploaded GPU texture identified by `key`. const SavedImagePlace = struct { key: pardes.ImageCacheKey, pane: u8, @@ -607,8 +468,8 @@ const SavedImagePlace = struct { const PreparedImage = struct { place: SavedImagePlace, texture: *c.SDL_GPUTexture, - track: ?pardes.panel_animation.Track = null, - clip: ?pardes.panel_animation.Box = null, + track: ?pardes.layout.Track = null, + clip: ?pardes.layout.Box = null, old_layer: bool = false, }; @@ -642,7 +503,7 @@ test "shader instance ABI carries aligned transition vectors" { try std.testing.expectEqual(@as(usize, 64), @offsetOf(ImageInstance, "effect")); try std.testing.expectEqual(@as(usize, 32), @sizeOf([8]f32)); - const tracks = [_]pardes.panel_animation.Track{ + const tracks = [_]pardes.layout.Track{ .{ .pane = 0, .phase = .opening, .effect = .slide }, .{ .pane = 1, .phase = .moving, .effect = .slide }, .{ .pane = 2, .phase = .moving, .effect = .slide }, @@ -656,19 +517,19 @@ test "shader instance ABI carries aligned transition vectors" { } test "GUI paint plan snaps history effects without a frozen grid" { - const tracks = [_]pardes.panel_animation.Track{ + const tracks = [_]pardes.layout.Track{ .{ .pane = 0, .phase = .opening, .effect = .ascii }, .{ .pane = 1, .phase = .closing, .effect = .vertical }, }; try std.testing.expectEqual(@as(usize, 1), makePaintPlan(&tracks, false).len); const ready = makePaintPlan(&tracks, true); try std.testing.expectEqual(@as(usize, 3), ready.len); - try std.testing.expectEqual(pardes.panel_animation.Phase.opening, ready.batches[1].track.?.phase); - try std.testing.expectEqual(pardes.panel_animation.Phase.closing, ready.batches[2].track.?.phase); + try std.testing.expectEqual(pardes.layout.Phase.opening, ready.batches[1].track.?.phase); + try std.testing.expectEqual(pardes.layout.Phase.closing, ready.batches[2].track.?.phase); } test "closing tombstone overlays but never owns canonical cells" { - const closing: pardes.panel_animation.Track = .{ + const closing: pardes.layout.Track = .{ .pane = 0, .phase = .closing, .effect = .vertical, @@ -678,7 +539,7 @@ test "closing tombstone overlays but never owns canonical cells" { const plan = makePaintPlan(&.{closing}, true); try std.testing.expectEqual(@as(usize, 0), paintBatchAt(&plan, 4, 4)); try std.testing.expectEqual(@as(usize, 2), plan.len); - try std.testing.expectEqual(pardes.panel_animation.Phase.closing, plan.batches[1].track.?.phase); + try std.testing.expectEqual(pardes.layout.Phase.closing, plan.batches[1].track.?.phase); } fn updateCoreResize(core: *pardes.Pardes, cols: u16, rows: u16, cell_w: u32, cell_h: u32) bool { @@ -699,8 +560,6 @@ fn updateCoreResize(core: *pardes.Pardes, cols: u16, rows: u16, cell_w: u32, cel return true; } -// ---- touch: per-finger tracking + shared scroll/tap machines ---- - const TouchSample = struct { x: f32 = 0, y: f32 = 0, pressure: f32 = 1 }; const TouchPoint = struct { @@ -716,8 +575,6 @@ const TouchPoint = struct { const TouchNormPoint = struct { x: f32 = 0, y: f32 = 0 }; -/// A finger event with SDL's normalized 0..1 coordinates — the one shape both -/// real SDL_EVENT_FINGER_* and the synthetic test OSC feed into the machine. const Finger = struct { kind: enum { down, motion, up, cancel }, id: u64, x: f32, y: f32, pressure: f32 }; const Touch = struct { @@ -770,8 +627,6 @@ const Touch = struct { return .{ .x = sum.x * 0.5, .y = sum.y * 0.5 }; } - /// keep scroll state in sync with the set of active fingers: exactly two - /// active fingers begin (or re-key) a pair; anything else resets it. fn syncPair(t: *Touch) void { var ids: [2]u64 = .{ 0, 0 }; var count: usize = 0; @@ -793,8 +648,6 @@ const Touch = struct { t.scroll = .{ .active = true, .ids = ids, .last_center = t.pairCenter(ids) orelse .{} }; } - /// finger lift: a pair that never scrolled is a two-finger TAP — returns - /// its center (computed with the lifting finger's final position). fn finishPair(t: *Touch, f: Finger) ?TouchNormPoint { if (!t.scroll.active or (t.scroll.ids[0] != f.id and t.scroll.ids[1] != f.id)) return null; const tap = f.kind == .up and !t.scroll.scrolled; @@ -837,7 +690,6 @@ fn takeScrollTicks(accum: *f32) i32 { return ticks; } -/// The SDL boundary owns touch policy: two-finger scroll and tap-as-execute. fn handleFinger(t: *Touch, in: *Input, f: Finger, win_w: f32, win_h: f32, cell_w: f32, cell_h: f32, tagline_w: f32) void { std.debug.assert(cell_w > 0 and cell_h > 0); return handlePairFinger(t, in, f, win_w, win_h, cell_w, cell_h, tagline_w); @@ -889,61 +741,54 @@ fn normCell(norm: f32, win: f32, cell: f32) u16 { return @intFromFloat(@max(0, @floor(px / cell))); } -// ---- pty plumbing: reader threads feed a mutex-protected queue ---- +const Pty = struct { + fd: c_int, + pid: libc.pid_t, + serial: u32, + kill_at: i64 = 0, + reader: ?std.Thread = null, + stop: [2]c_int = .{ -1, -1 }, +}; -const Pty = struct { fd: c_int, pid: libc.pid_t }; +const RetiredShell = struct { pid: libc.pid_t = 0, kill_at: i64 = 0 }; const Msg = union(enum) { output: struct { pane: u8, gen: u32, bytes: []u8 }, - eof: struct { pane: u8, gen: u32, fd: c_int }, - /// a language query finished on its own thread (see lspThread) - lsp: struct { id: u32, rows: []u8 }, - /// a language SERVER changed state; narrated by the client's reader - /// threads through the status sink, lsp-allocator-owned + eof: struct { pane: u8, gen: u32, failure: ?anyerror = null }, + lsp: struct { id: u32, rows: ?[]u8 }, lsp_status: []u8, - /// a selection-filter worker finished; every stdout is gpa-owned pipe: selection_pipe.Response, - /// something happened in a watched directory (see watchThread) files_changed, - /// a pardes launched inside this one sent us a builtin command line (see - /// lookThread); gpa-owned, like `output` bytes - command: []u8, - /// `--fs`: the /dev/fuse descriptor has requests on it. Carries nothing — - /// the drain lives in pollFrame, and this only ends a blocking - /// SDL_WaitEventTimeout. Posted by the poll thread and, when a batch hits - /// its cap, by pollFrame itself. Lossy under backpressure on purpose: a - /// full queue already holds something that will wake the loop. fs_ready, 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), + .lsp => |l| if (l.rows) |rows| lsp_allocator.free(rows), .lsp_status => |t| lsp_allocator.free(t), .pipe => |response_value| { var response = response_value; response.deinit(gpa); }, - .command => |line| gpa.free(line), .eof, .files_changed, .fs_ready => {}, } } }; -/// The shared snapshot/worker pair. This file carried its own `LspJob` with -/// "tty.zig's LspJob, and copied for the same reason" over the top; both copies -/// are now one module, and the AppKit shell — which had neither — uses it too. -const lsp_host = @import("../lsp_host.zig"); - const LspWorkers = struct { + const capacity = 16; active: std.atomic.Value(usize) = .init(0), - fn start(workers: *LspWorkers) void { - _ = workers.active.fetchAdd(1, .monotonic); + fn start(workers: *LspWorkers) bool { + var count = workers.active.load(.monotonic); + while (count < capacity) + count = workers.active.cmpxchgWeak(count, count + 1, .monotonic, .monotonic) orelse return true; + return false; } fn finish(workers: *LspWorkers) void { - _ = workers.active.fetchSub(1, .release); + const previous = workers.active.fetchSub(1, .release); + std.debug.assert(previous > 0); } fn wait(workers: *LspWorkers) void { @@ -955,17 +800,13 @@ const LspWorkers = struct { } }; -const max_pipe_tasks = 16; - -/// Moved to `selection_pipe.Tasks`, beside the Job it tracks — tty.zig carried -/// this same table verbatim. -const PipeTask = selection_pipe.Tasks.Task; const PipeTasks = selection_pipe.Tasks; const queue_capacity = 512; +const output_capacity = queue_capacity - pardes.MAX_PANES; const MessageBatch = struct { - items: [queue_capacity]Msg = undefined, + items: [queue_capacity + PipeTasks.capacity + 2]Msg = undefined, len: usize = 0, fn slice(batch: *MessageBatch) []Msg { @@ -978,16 +819,77 @@ const Queue = struct { 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. - mutex: std.atomic.Mutex = .unlocked, + mutex: libc.pthread_mutex_t = .{}, + space: libc.pthread_cond_t = .{}, items: [queue_capacity]Msg = undefined, head: usize = 0, len: usize = 0, closed: bool = false, + files_changed: bool = false, + readers: [pardes.MAX_PANES]?u32 = @splat(null), + waiting: usize = 0, + lsp_id: ?u32 = null, + completions: [PipeTasks.capacity + 1]Msg = undefined, + completion_len: usize = 0, fn lock(q: *Queue) void { - while (!q.mutex.tryLock()) std.atomic.spinLoopHint(); + std.debug.assert(libc.pthread_mutex_lock(&q.mutex) == .SUCCESS); + } + + fn unlock(q: *Queue) void { + std.debug.assert(libc.pthread_mutex_unlock(&q.mutex) == .SUCCESS); + } + + fn wake(q: *Queue) void { + if (q.sdl_wake) { + var sev = std.mem.zeroes(c.SDL_Event); + sev.type = c.SDL_EVENT_USER; + _ = c.SDL_PushEvent(&sev); + } + } + + fn acceptReader(q: *Queue, pane: u8, gen: u32) void { + q.lock(); + defer q.unlock(); + std.debug.assert(q.readers[pane] == null); + q.readers[pane] = gen; + } + + fn cancelReader(q: *Queue, pane: u8, gen: u32) void { + q.lock(); + defer q.unlock(); + if (q.readers[pane] != gen) return; + q.readers[pane] = null; + var i: usize = 0; + while (i < q.len) { + const msg = q.items[(q.head + i) % q.items.len]; + const matches = switch (msg) { + .output => |o| o.pane == pane and o.gen == gen, + .eof => |e| e.pane == pane and e.gen == gen, + else => false, + }; + if (matches) q.removeAt(i).deinit(q.gpa, q.lsp_allocator) else i += 1; + } + std.debug.assert(libc.pthread_cond_broadcast(&q.space) == .SUCCESS); + } + + fn pushOutput(q: *Queue, pane: u8, gen: u32, bytes: []u8) bool { + q.lock(); + while (!q.closed and q.readers[pane] == gen and q.len >= output_capacity) { + q.waiting += 1; + std.debug.assert(libc.pthread_cond_wait(&q.space, &q.mutex) == .SUCCESS); + q.waiting -= 1; + } + if (q.closed or q.readers[pane] != gen) { + q.unlock(); + q.gpa.free(bytes); + return false; + } + q.items[(q.head + q.len) % q.items.len] = .{ .output = .{ .pane = pane, .gen = gen, .bytes = bytes } }; + q.len += 1; + q.unlock(); + q.wake(); + return true; } fn removeAt(q: *Queue, offset: usize) Msg { @@ -999,54 +901,64 @@ const Queue = struct { 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 discardLsp(q: *Queue) void { + for (q.completions[0..q.completion_len], 0..) |msg, i| if (msg == .lsp) { + msg.deinit(q.gpa, q.lsp_allocator); + q.completion_len -= 1; + std.mem.copyForwards(Msg, q.completions[i..q.completion_len], q.completions[i + 1 .. q.completion_len + 1]); + return; + }; + } + fn push(q: *Queue, m: Msg) void { + if (m == .output) { + _ = q.pushOutput(m.output.pane, m.output.gen, m.output.bytes); + return; + } q.lock(); if (q.closed) { - q.mutex.unlock(); + q.unlock(); m.deinit(q.gpa, q.lsp_allocator); return; } - 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; - } - 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); - sev.type = c.SDL_EVENT_USER; - _ = c.SDL_PushEvent(&sev); + switch (m) { + .files_changed => q.files_changed = true, + .lsp, .pipe => { + if (m == .lsp) { + if (q.lsp_id != m.lsp.id) { + q.unlock(); + m.deinit(q.gpa, q.lsp_allocator); + return; + } + q.discardLsp(); + } + std.debug.assert(q.completion_len < q.completions.len); + q.completions[q.completion_len] = m; + q.completion_len += 1; + }, + else => { + if (m == .eof) { + if (q.readers[m.eof.pane] != m.eof.gen) { + q.unlock(); + return; + } + std.debug.assert(q.len < q.items.len); + } else if (q.len >= output_capacity) { + q.unlock(); + m.deinit(q.gpa, q.lsp_allocator); + return; + } + q.items[(q.head + q.len) % q.items.len] = m; + q.len += 1; + }, } + q.unlock(); + q.wake(); } fn take(q: *Queue) MessageBatch { q.lock(); - defer q.mutex.unlock(); + defer q.unlock(); var batch: MessageBatch = .{}; while (q.len > 0) { batch.items[batch.len] = q.items[q.head]; @@ -1055,139 +967,638 @@ const Queue = struct { q.len -= 1; } q.head = 0; + if (q.files_changed) { + batch.items[batch.len] = .files_changed; + batch.len += 1; + q.files_changed = false; + } + @memcpy(batch.items[batch.len..][0..q.completion_len], q.completions[0..q.completion_len]); + batch.len += q.completion_len; + q.completion_len = 0; + std.debug.assert(libc.pthread_cond_broadcast(&q.space) == .SUCCESS); return batch; } - fn close(q: *Queue, ptys: *[pardes.MAX_PANES]?Pty, gens: *[pardes.MAX_PANES]u32) void { + fn discardCompletions(q: *Queue) void { + q.lock(); + defer q.unlock(); + q.lsp_id = null; + for (q.completions[0..q.completion_len]) |msg| msg.deinit(q.gpa, q.lsp_allocator); + q.completion_len = 0; + } + + fn close(q: *Queue) void { q.lock(); - defer q.mutex.unlock(); q.closed = true; + q.files_changed = false; + q.readers = @splat(null); + std.debug.assert(libc.pthread_cond_broadcast(&q.space) == .SUCCESS); + q.unlock(); + q.discardCompletions(); + q.lock(); + defer q.unlock(); 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), - } + m.deinit(q.gpa, q.lsp_allocator); q.head = (q.head + 1) % q.items.len; q.len -= 1; } q.head = 0; } + + fn deinit(q: *Queue) void { + q.close(); + std.debug.assert(q.waiting == 0); + std.debug.assert(libc.pthread_cond_destroy(&q.space) == .SUCCESS); + std.debug.assert(libc.pthread_mutex_destroy(&q.mutex) == .SUCCESS); + } +}; + +test "GUI completion survives a full output queue" { + const gpa = std.testing.allocator; + var workers: LspWorkers = .{}; + var queue: Queue = .{ .gpa = gpa, .lsp_allocator = gpa, .lsp_workers = &workers, .sdl_wake = false, .lsp_id = 7 }; + defer queue.deinit(); + queue.acceptReader(0, 1); + for (0..output_capacity) |_| queue.push(.{ .output = .{ + .pane = 0, + .gen = 1, + .bytes = try gpa.dupe(u8, "output"), + } }); + for (0..PipeTasks.capacity) |id| queue.push(.{ .pipe = .{ + .id = @intCast(id), + .success = false, + .outputs = &.{}, + .failure = .{ .stderr = try gpa.dupe(u8, "pipe failure") }, + } }); + queue.push(.{ .lsp = .{ .id = 7, .rows = try gpa.dupe(u8, "completion") } }); + var batch = queue.take(); + defer for (batch.slice()) |msg| msg.deinit(gpa, gpa); + try std.testing.expectEqual(output_capacity + PipeTasks.capacity + 1, batch.len); + for (batch.items[0..output_capacity]) |msg| { + try std.testing.expect(msg == .output); + try std.testing.expectEqualStrings("output", msg.output.bytes); + } + for (batch.items[output_capacity..][0..PipeTasks.capacity], 0..) |msg, id| { + try std.testing.expect(msg == .pipe); + try std.testing.expectEqual(@as(u32, @intCast(id)), msg.pipe.id); + } + var found = false; + for (batch.slice()) |msg| if (msg == .lsp) { + try std.testing.expectEqual(@as(u32, 7), msg.lsp.id); + try std.testing.expectEqualStrings("completion", msg.lsp.rows.?); + found = true; + }; + try std.testing.expect(found); + try std.testing.expectEqual(@as(usize, 0), queue.take().len); +} + +const PtyTests = struct { + fn waitBlocked(queue: *Queue) !void { + const until = shellClock() + 2_000; + while (shellClock() < until) { + queue.lock(); + const waiting = queue.waiting; + queue.unlock(); + if (waiting != 0) return; + try std.testing.io.sleep(.fromMilliseconds(1), .awake); + } + return error.ReaderDidNotBlock; + } + + fn tail(queue: *Queue, bytes: []u8) void { + if (queue.pushOutput(0, 1, bytes)) queue.push(.{ .eof = .{ .pane = 0, .gen = 1 } }); + } }; -/// The registered `lsp.setStatusSink` target, called from the protocol -/// client's reader threads: dupe with the concurrent lsp allocator, push to -/// the mutex queue. A push after close is disposed by the queue itself. +test "GUI PTY backpressure retains byte order and tail before EOF" { + const gpa = std.testing.allocator; + var workers: LspWorkers = .{}; + var queue: Queue = .{ .gpa = gpa, .lsp_allocator = gpa, .lsp_workers = &workers, .sdl_wake = false }; + defer queue.deinit(); + queue.acceptReader(0, 1); + for (0..output_capacity) |_| queue.push(.{ .output = .{ .pane = 0, .gen = 1, .bytes = try gpa.dupe(u8, "before") } }); + const tail = try gpa.dupe(u8, "tail"); + var thread: ?std.Thread = std.Thread.spawn(.{}, PtyTests.tail, .{ &queue, tail }) catch |err| { + gpa.free(tail); + return err; + }; + defer { + queue.cancelReader(0, 1); + if (thread) |owned| owned.join(); + } + try PtyTests.waitBlocked(&queue); + queue.push(.files_changed); + queue.push(.files_changed); + var first = queue.take(); + defer for (first.slice()) |msg| msg.deinit(gpa, gpa); + try std.testing.expectEqual(output_capacity + 1, first.len); + for (first.items[0..output_capacity]) |msg| try std.testing.expectEqualStrings("before", msg.output.bytes); + try std.testing.expect(first.items[output_capacity] == .files_changed); + thread.?.join(); + thread = null; + var last = queue.take(); + defer for (last.slice()) |msg| msg.deinit(gpa, gpa); + try std.testing.expectEqual(@as(usize, 2), last.len); + try std.testing.expectEqualStrings("tail", last.items[0].output.bytes); + try std.testing.expect(last.items[1] == .eof); + try std.testing.expectEqual(@as(usize, 0), queue.waiting); +} + +test "GUI PTY backpressure cancellation and close release owned producers" { + const gpa = std.testing.allocator; + for ([_]bool{ false, true }) |close| { + var workers: LspWorkers = .{}; + var queue: Queue = .{ .gpa = gpa, .lsp_allocator = gpa, .lsp_workers = &workers, .sdl_wake = false }; + defer queue.deinit(); + queue.acceptReader(0, 1); + for (0..output_capacity) |_| queue.push(.{ .output = .{ .pane = 0, .gen = 1, .bytes = try gpa.dupe(u8, "before") } }); + const tail = try gpa.dupe(u8, "cancelled"); + const thread = std.Thread.spawn(.{}, PtyTests.tail, .{ &queue, tail }) catch |err| { + gpa.free(tail); + return err; + }; + defer { + queue.cancelReader(0, 1); + thread.join(); + } + try PtyTests.waitBlocked(&queue); + if (close) queue.close() else queue.cancelReader(0, 1); + if (!close) { + queue.acceptReader(0, 2); + queue.push(.{ .eof = .{ .pane = 0, .gen = 1 } }); + try std.testing.expect(queue.pushOutput(0, 2, try gpa.dupe(u8, "replacement"))); + queue.push(.{ .eof = .{ .pane = 0, .gen = 2 } }); + var batch = queue.take(); + defer for (batch.slice()) |msg| msg.deinit(gpa, gpa); + try std.testing.expectEqual(@as(usize, 2), batch.len); + try std.testing.expectEqualStrings("replacement", batch.items[0].output.bytes); + try std.testing.expectEqual(@as(u32, 2), batch.items[1].eof.gen); + } else try std.testing.expectEqual(@as(usize, 0), queue.take().len); + } +} + +test "GUI PTY EOF reserve cannot evict terminal output" { + const gpa = std.testing.allocator; + var workers: LspWorkers = .{}; + var queue: Queue = .{ .gpa = gpa, .lsp_allocator = gpa, .lsp_workers = &workers, .sdl_wake = false }; + defer queue.deinit(); + for (0..pardes.MAX_PANES) |pane| queue.acceptReader(@intCast(pane), 1); + for (0..output_capacity) |_| queue.push(.{ .output = .{ .pane = 0, .gen = 1, .bytes = try gpa.dupe(u8, "before") } }); + for (0..pardes.MAX_PANES) |pane| queue.push(.{ .eof = .{ .pane = @intCast(pane), .gen = 1 } }); + var batch = queue.take(); + defer for (batch.slice()) |msg| msg.deinit(gpa, gpa); + try std.testing.expectEqual(queue_capacity, batch.len); + for (batch.items[0..output_capacity]) |msg| try std.testing.expectEqualStrings("before", msg.output.bytes); + for (batch.items[output_capacity..batch.len], 0..) |msg, pane| try std.testing.expectEqual(pane, msg.eof.pane); +} + +test "GUI PTY reader delivers a real shell tail before EOF and joins on close" { + const gpa = std.testing.allocator; + const core = try pardes.Pardes.init(gpa, .{ .tty_only = true }); + defer core.deinit(); + var workers: LspWorkers = .{}; + var queue: Queue = .{ .gpa = gpa, .lsp_allocator = gpa, .lsp_workers = &workers, .sdl_wake = false }; + defer queue.deinit(); + var ptys: [pardes.MAX_PANES]?Pty = @splat(null); + var gens: [pardes.MAX_PANES]u32 = @splat(1); + var shell: Shell = undefined; + shell.core = core; + shell.gpa = gpa; + shell.ptys = &ptys; + shell.gens = &gens; + shell.queue = &queue; + shell.retired_shells = @splat(.{}); + defer shell.shutdownPtys(); + const rcs: host_io.Shell.PromptFiles = .{}; + const child = try host_io.forkShell(null, 0, &rcs, "/bin/sh", "", 24, 80, null); + ptys[0] = .{ .fd = child.file.handle, .pid = child.pid, .serial = core.panes[0].?.serial }; + try spawnReader(gpa, &ptys[0].?, 0, 1, &queue); + try std.testing.expect(host_io.writeFd(child.file.handle, "printf '\\120\\101\\122\\104\\105\\123\\055\\124\\101\\111\\114'; exit\n")); + var bytes: std.ArrayList(u8) = .empty; + defer bytes.deinit(gpa); + var eof = false; + const until = shellClock() + 2_000; + while (!eof and shellClock() < until) { + var batch = queue.take(); + defer for (batch.slice()) |msg| msg.deinit(gpa, gpa); + for (batch.slice()) |msg| switch (msg) { + .output => |o| { + try std.testing.expect(!eof); + try bytes.appendSlice(gpa, o.bytes); + }, + .eof => |e| { + try std.testing.expect(e.failure == null); + eof = true; + }, + else => return error.UnexpectedMessage, + }; + if (!eof) try std.testing.io.sleep(.fromMilliseconds(1), .awake); + } + try std.testing.expect(eof); + try std.testing.expect(std.mem.indexOf(u8, bytes.items, "PARDES-TAIL") != null); + shell.closePty(0); + try std.testing.expect(queue.readers[0] == null); + try std.testing.expect(ptys[0] == null or ptys[0].?.reader == null); + try std.testing.expectEqual(@as(c_int, -1), libc.fcntl(child.file.handle, libc.F.GETFD)); +} + +test "GUI PTY Restore joins a real reader waiting for queue space" { + const gpa = std.testing.allocator; + var core = try pardes.Pardes.init(gpa, .{ .tty_only = true }); + defer core.deinit(); + try core.dumpState(); + var workers: LspWorkers = .{}; + var queue: Queue = .{ .gpa = gpa, .lsp_allocator = gpa, .lsp_workers = &workers, .sdl_wake = false }; + defer queue.deinit(); + queue.acceptReader(1, 1); + for (0..output_capacity) |_| queue.push(.{ .output = .{ .pane = 1, .gen = 1, .bytes = try gpa.dupe(u8, "other pane") } }); + var ptys: [pardes.MAX_PANES]?Pty = @splat(null); + var gens: [pardes.MAX_PANES]u32 = @splat(1); + var shell: Shell = undefined; + shell.core = core; + shell.gpa = gpa; + shell.ptys = &ptys; + shell.gens = &gens; + shell.queue = &queue; + shell.retired_shells = @splat(.{}); + defer shell.shutdownPtys(); + const rcs: host_io.Shell.PromptFiles = .{}; + const child = try host_io.forkShell(null, 0, &rcs, "/bin/sh", "", 24, 80, null); + ptys[0] = .{ .fd = child.file.handle, .pid = child.pid, .serial = core.panes[0].?.serial }; + try spawnReader(gpa, &ptys[0].?, 0, 1, &queue); + try std.testing.expect(host_io.writeFd(child.file.handle, "printf 'after-full'; exit\n")); + try PtyTests.waitBlocked(&queue); + const old_serial = core.panes[0].?.serial; + const replacement = try core.restore(core.dump_out.?); + shell.stopPtys(); + core.deinit(); + core = replacement; + shell.core = replacement; + try std.testing.expect(core.panes[0].?.serial != old_serial); + try std.testing.expectEqual(@as(usize, 0), queue.waiting); + try std.testing.expect(queue.readers[0] == null); + try std.testing.expectEqual(@as(c_int, -1), libc.fcntl(child.file.handle, libc.F.GETFD)); + queue.cancelReader(1, 1); + queue.acceptReader(0, gens[0]); + queue.push(.{ .eof = .{ .pane = 0, .gen = 1 } }); + try std.testing.expect(queue.pushOutput(0, gens[0], try gpa.dupe(u8, "fresh"))); + var batch = queue.take(); + defer for (batch.slice()) |msg| msg.deinit(gpa, gpa); + try std.testing.expectEqual(@as(usize, 1), batch.len); + try std.testing.expectEqualStrings("fresh", batch.items[0].output.bytes); +} + +test "GUI PTY deletion joins an idle reader and retains only its owned child for reaping" { + const gpa = std.testing.allocator; + const core = try pardes.Pardes.init(gpa, .{ .tty_only = true }); + defer core.deinit(); + var workers: LspWorkers = .{}; + var queue: Queue = .{ .gpa = gpa, .lsp_allocator = gpa, .lsp_workers = &workers, .sdl_wake = false }; + defer queue.deinit(); + var ptys: [pardes.MAX_PANES]?Pty = @splat(null); + var gens: [pardes.MAX_PANES]u32 = @splat(1); + var shell: Shell = undefined; + shell.core = core; + shell.gpa = gpa; + shell.ptys = &ptys; + shell.gens = &gens; + shell.queue = &queue; + shell.retired_shells = @splat(.{}); + defer shell.shutdownPtys(); + const rcs: host_io.Shell.PromptFiles = .{}; + const child = try host_io.forkShell(null, 0, &rcs, "/bin/sh", "", 24, 80, null); + ptys[0] = .{ .fd = child.file.handle, .pid = child.pid, .serial = core.panes[0].?.serial }; + try spawnReader(gpa, &ptys[0].?, 0, 1, &queue); + try core.removePane(0); + shell.reconcilePtys(); + try std.testing.expect(queue.readers[0] == null); + try std.testing.expectEqual(@as(usize, 0), queue.waiting); + try std.testing.expectEqual(@as(usize, 0), queue.take().len); + try std.testing.expectEqual(@as(c_int, -1), libc.fcntl(child.file.handle, libc.F.GETFD)); + for (shell.retired_shells) |retired| try std.testing.expect(retired.pid == 0 or retired.pid == child.pid); + shell.shutdownPtys(); + try std.testing.expectEqual(@as(libc.pid_t, -1), libc.waitpid(child.pid, null, libc.W.NOHANG)); + try std.testing.expectEqual(libc.E.CHILD, libc.errno(-1)); +} + +test "GUI PTY file watcher stops and joins without closing its watched descriptor" { + if (!file_watch.supported) return error.SkipZigTest; + const gpa = std.testing.allocator; + var workers: LspWorkers = .{}; + var queue: Queue = .{ .gpa = gpa, .lsp_allocator = gpa, .lsp_workers = &workers, .sdl_wake = false }; + defer queue.deinit(); + const fd = file_watch.init(true); + if (fd < 0) return error.WatchInitFailed; + defer _ = libc.close(fd); + const stop = try stopPipe(); + defer for (stop) |pipe_fd| { + _ = libc.close(pipe_fd); + }; + const thread = try std.Thread.spawn(.{}, watchThread, .{ fd, stop[0], &queue }); + _ = host_io.writeFd(stop[1], "x"); + thread.join(); + try std.testing.expect(libc.fcntl(fd, libc.F.GETFD) >= 0); + try std.testing.expectEqual(@as(usize, 0), queue.take().len); +} + +test "GUI completion failure survives backlog and leaves the document unchanged" { + const gpa = std.testing.allocator; + const core = try pardes.Pardes.init(gpa, .{ .tty_only = true }); + defer core.deinit(); + const pane = try core.setTestFile("abc"); + pane.cur_col = 1; + core.lspRequest(core.active, .completion, ""); + const id = core.lsp_wait.?.id; + var workers: LspWorkers = .{}; + var queue: Queue = .{ .gpa = gpa, .lsp_allocator = gpa, .lsp_workers = &workers, .sdl_wake = false, .lsp_id = id }; + defer queue.deinit(); + queue.acceptReader(0, 1); + for (0..output_capacity) |_| queue.push(.{ .output = .{ + .pane = 0, + .gen = 1, + .bytes = try gpa.dupe(u8, "output"), + } }); + queue.push(.{ .lsp = .{ .id = id, .rows = null } }); + var gens: [pardes.MAX_PANES]u32 = @splat(1); + var ptys: [pardes.MAX_PANES]?Pty = @splat(null); + var shell: Shell = undefined; + shell.core = core; + shell.queue = &queue; + shell.gpa = gpa; + shell.lsp_allocator = gpa; + shell.gens = &gens; + shell.ptys = &ptys; + shell.retired_shells = @splat(.{}); + shell.saw_event = false; + shell.drainQueue(); + try std.testing.expect(core.lsp_wait == null); + try std.testing.expectEqualStrings("abc", pane.file.?.content); + try std.testing.expectEqual(@as(i32, 1), pane.cur_col); + try std.testing.expect(shell.saw_event); +} + +test "GUI completions retain their arrival order across LSP and pipes" { + const gpa = std.testing.allocator; + var workers: LspWorkers = .{}; + var queue: Queue = .{ .gpa = gpa, .lsp_allocator = gpa, .lsp_workers = &workers, .sdl_wake = false, .lsp_id = 2 }; + defer queue.deinit(); + queue.push(.{ .pipe = .{ .id = 1, .success = true, .outputs = &.{} } }); + queue.push(.{ .lsp = .{ .id = 2, .rows = try gpa.dupe(u8, "second") } }); + queue.push(.{ .pipe = .{ .id = 3, .success = false, .outputs = &.{} } }); + queue.push(.{ .lsp = .{ .id = 1, .rows = try gpa.dupe(u8, "late") } }); + var batch = queue.take(); + defer for (batch.slice()) |msg| msg.deinit(gpa, gpa); + try std.testing.expectEqual(@as(usize, 3), batch.len); + try std.testing.expectEqual(@as(u32, 1), batch.items[0].pipe.id); + try std.testing.expectEqual(@as(u32, 2), batch.items[1].lsp.id); + try std.testing.expectEqual(@as(u32, 3), batch.items[2].pipe.id); +} + +test "GUI completion reset frees queued payloads and rejects late LSP workers" { + const gpa = std.testing.allocator; + var workers: LspWorkers = .{}; + var queue: Queue = .{ .gpa = gpa, .lsp_allocator = gpa, .lsp_workers = &workers, .sdl_wake = false, .lsp_id = 9 }; + defer queue.deinit(); + queue.acceptReader(0, 1); + queue.push(.{ .output = .{ .pane = 0, .gen = 1, .bytes = try gpa.dupe(u8, "retained") } }); + queue.push(.{ .lsp = .{ .id = 9, .rows = try gpa.dupe(u8, "current") } }); + queue.push(.{ .lsp = .{ .id = 8, .rows = try gpa.dupe(u8, "late old request") } }); + try std.testing.expectEqualStrings("current", queue.completions[0].lsp.rows.?); + for (0..PipeTasks.capacity) |id| { + const outputs = try gpa.alloc([]u8, 1); + outputs[0] = try gpa.dupe(u8, "cancelled result"); + queue.push(.{ .pipe = .{ .id = @intCast(id), .success = true, .outputs = outputs } }); + } + queue.discardCompletions(); + queue.push(.{ .lsp = .{ .id = 9, .rows = try gpa.dupe(u8, "late before Restore") } }); + try std.testing.expectEqual(@as(usize, 0), queue.completion_len); + var batch = queue.take(); + defer for (batch.slice()) |msg| msg.deinit(gpa, gpa); + try std.testing.expectEqual(@as(usize, 1), batch.len); + try std.testing.expectEqualStrings("retained", batch.items[0].output.bytes); + + queue.lsp_id = 10; + queue.push(.{ .lsp = .{ .id = 10, .rows = null } }); + queue.push(.{ .lsp = .{ .id = 9, .rows = try gpa.dupe(u8, "late after Restore") } }); + var next = queue.take(); + defer for (next.slice()) |msg| msg.deinit(gpa, gpa); + try std.testing.expectEqual(@as(usize, 1), next.len); + try std.testing.expectEqual(@as(u32, 10), next.items[0].lsp.id); + try std.testing.expect(next.items[0].lsp.rows == null); + + queue.push(.{ .lsp = .{ .id = 10, .rows = try gpa.dupe(u8, "closing result") } }); + queue.push(.{ .pipe = .{ + .id = 17, + .success = false, + .outputs = &.{}, + .failure = .{ .stderr = try gpa.dupe(u8, "closing failure") }, + } }); + queue.close(); + queue.push(.{ .lsp = .{ .id = 10, .rows = try gpa.dupe(u8, "late closed result") } }); + queue.push(.{ .pipe = .{ + .id = 18, + .success = false, + .outputs = &.{}, + .failure = .{ .stderr = try gpa.dupe(u8, "late closed failure") }, + } }); + try std.testing.expectEqual(@as(usize, 0), queue.take().len); +} + fn lspStatusSink(ctx: ?*anyopaque, text: []const u8) void { const q: *Queue = @ptrCast(@alignCast(ctx orelse return)); const copy = q.lsp_allocator.dupe(u8, text) catch return; q.push(.{ .lsp_status = copy }); } -fn readPtyThread(gpa: std.mem.Allocator, fd: c_int, pane: u8, gen: u32, q: *Queue) void { +fn readPtyThread(gpa: std.mem.Allocator, fd: c_int, stop: c_int, pane: u8, gen: u32, q: *Queue) void { var buf: [0x10000]u8 = undefined; + var failure: ?anyerror = null; while (true) { + var fds = [_]libc.pollfd{ + .{ .fd = stop, .events = libc.POLL.IN, .revents = 0 }, + .{ .fd = fd, .events = libc.POLL.IN, .revents = 0 }, + }; + if (libc.poll(&fds, fds.len, -1) < 0) { + if (libc.errno(-1) == .INTR) continue; + failure = error.PollFailed; + break; + } + if (fds[0].revents != 0) return; + if (fds[1].revents == 0) continue; const n = libc.read(fd, &buf, buf.len); if (n < 0) { if (libc.errno(n) == .INTR) continue; - break; // EIO when the child exits: treat as EOF + if (libc.errno(n) != .IO) failure = error.ReadFailed; + break; } if (n == 0) break; - const bytes = gpa.dupe(u8, buf[0..@intCast(n)]) catch break; - q.push(.{ .output = .{ .pane = pane, .gen = gen, .bytes = bytes } }); + const bytes = gpa.dupe(u8, buf[0..@intCast(n)]) catch |err| { + failure = err; + break; + }; + if (!q.pushOutput(pane, gen, bytes)) return; } - q.push(.{ .eof = .{ .pane = pane, .gen = gen, .fd = fd } }); + q.push(.{ .eof = .{ .pane = pane, .gen = gen, .failure = failure } }); } -fn spawnReader(gpa: std.mem.Allocator, pt: Pty, pane: u8, gen: u32, q: *Queue) void { - const th = std.Thread.spawn(.{}, readPtyThread, .{ gpa, pt.fd, pane, gen, q }) catch return; - th.detach(); +fn stopPipe() ![2]c_int { + var fds: [2]c_int = undefined; + if (libc.pipe(&fds) != 0) return error.PipeFailed; + errdefer for (fds) |fd| { + _ = libc.close(fd); + }; + for (fds) |fd| if (libc.fcntl(fd, libc.F.SETFD, @as(c_int, 1)) < 0) return error.PipeFailed; + return fds; } -/// Block on the inotify fd and wake the loop. Deliberately does NOT parse the -/// events: the loop re-reads every watched pane anyway, so the only thing an -/// event carries that we need is THAT something happened, and parsing would -/// mean sharing the watch table with the thread that mutates it. Detached like -/// the pty readers, and ended the same way — teardown closes the fd, the read -/// fails, the thread returns. -fn watchThread(fd: c_int, q: *Queue) void { - // A kqueue cannot be read, so the macos arm parks in kevent(2) instead and - // is released by the teardown's `file_watch.stop`. See file_watch.wait. - if (comptime builtin.os.tag != .linux) { - while (file_watch.wait(fd)) q.push(.files_changed); - return; - } - var buf: [4096]u8 = undefined; - while (true) { - const n = libc.read(fd, &buf, buf.len); - if (n < 0) { - if (libc.errno(n) == .INTR) continue; - break; - } - if (n == 0) break; - q.push(.files_changed); - } +fn spawnReader(gpa: std.mem.Allocator, pt: *Pty, pane: u8, gen: u32, q: *Queue) !void { + std.debug.assert(pt.reader == null and pt.fd >= 0); + const stop = try stopPipe(); + errdefer for (stop) |fd| { + _ = libc.close(fd); + }; + q.acceptReader(pane, gen); + errdefer q.cancelReader(pane, gen); + pt.reader = try std.Thread.spawn(.{}, readPtyThread, .{ gpa, pt.fd, stop[0], pane, gen, q }); + pt.stop = stop; } -/// Block on the nested-instance socket and hand the loop each command line a -/// pardes started inside this one sends. Detached like the pty readers and the -/// watcher — but NOT ended the way they are: close(2) does not release a -/// thread parked in accept4 on linux, so this one simply dies with the -/// process. The window that leaves is one connection accepted between the last -/// drain and process exit pushing into a queue nobody empties again; Queue -/// frees a push made after close(), and the process is on its way out anyway. -fn lookThread(gpa: std.mem.Allocator, fd: c_int, q: *Queue) void { - var buf: [nested.max_line]u8 = undefined; - while (nested.acceptLine(fd, &buf)) |line| { - const owned = gpa.dupe(u8, line) catch continue; - q.push(.{ .command = owned }); +fn watchThread(fd: c_int, stop: c_int, q: *Queue) void { + while (true) { + var fds = [_]libc.pollfd{ + .{ .fd = stop, .events = libc.POLL.IN, .revents = 0 }, + .{ .fd = fd, .events = libc.POLL.IN, .revents = 0 }, + }; + if (libc.poll(&fds, fds.len, -1) < 0) { + if (libc.errno(-1) == .INTR) continue; + return; + } + if (fds[0].revents != 0) return; + if ((fds[1].revents & (libc.POLL.ERR | libc.POLL.HUP | libc.POLL.NVAL)) != 0) return; + if (fds[1].revents != 0 and file_watch.drain(fd)) q.push(.files_changed); } } -/// Answer a language query off the render loop and push the rows to the queue. -/// The snapshot and the query body are `lsp_host`'s; what stays here is this -/// shell's own plumbing — a detached thread, the refcount that teardown joins -/// on, and the mutex queue the pty readers already use. -fn lspThread(lsp_allocator: std.mem.Allocator, workers: *LspWorkers, job: *lsp_host.Job, q: *Queue) void { +fn lspThread(lsp_allocator: std.mem.Allocator, workers: *LspWorkers, job: *host_io.Lsp.Job, q: *Queue) void { defer workers.finish(); - lsp_host.work(lsp_allocator, job, q, pushLspRows); + host_io.Lsp.work(lsp_allocator, job, q, pushLspRows); } -fn pushLspRows(ctx: ?*anyopaque, id: u32, rows: []u8) void { +fn pushLspRows(ctx: ?*anyopaque, id: u32, rows: ?[]u8) void { const q: *Queue = @ptrCast(@alignCast(ctx orelse return)); q.push(.{ .lsp = .{ .id = 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, q: *Queue, e: host_api.LspRequest) void { +fn spawnLsp(core: *pardes.Pardes, q: *Queue, e: host_io.Lsp.Request) void { const lsp_allocator = q.lsp_allocator; - const job = lsp_host.snapshot(lsp_allocator, core, e) orelse return; - q.lsp_workers.start(); - const th = std.Thread.spawn(.{}, lspThread, .{ lsp_allocator, q.lsp_workers, job, q }) catch { + q.lock(); + q.lsp_id = e.id; + q.discardLsp(); + q.unlock(); + if (!q.lsp_workers.start()) { + core.update(.{ .lsp_resp = .{ .id = e.id, .rows = null } }); + return core.reportError(e.pane, "lsp", error.WorkersBusy); + } + const job = host_io.Lsp.snapshot(lsp_allocator, core, e) catch |err| { + q.lsp_workers.finish(); + core.update(.{ .lsp_resp = .{ .id = e.id, .rows = null } }); + return core.reportError(e.pane, "lsp", err); + }; + const th = std.Thread.spawn(.{}, lspThread, .{ lsp_allocator, q.lsp_workers, job, q }) catch |err| { q.lsp_workers.finish(); job.free(lsp_allocator); - return; + core.update(.{ .lsp_resp = .{ .id = e.id, .rows = null } }); + return core.reportError(e.pane, "lsp", err); }; th.detach(); } +test "GUI LSP worker limit rejects before allocation and recovers after owned workers exit" { + const gpa = std.testing.allocator; + const core = try pardes.Pardes.init(gpa, .{ .tty_only = true }); + defer core.deinit(); + const pane = try core.setTestFile("abc"); + pane.cur_col = 1; + var workers: LspWorkers = .{}; + var gate: std.Io.Event = .unset; + var threads: [LspWorkers.capacity]?std.Thread = @splat(null); + defer { + gate.set(std.testing.io); + for (threads) |thread| if (thread) |owned| owned.join(); + workers.wait(); + } + for (&threads) |*thread| { + try std.testing.expect(workers.start()); + thread.* = std.Thread.spawn(.{}, struct { + fn run(active: *LspWorkers, ready: *std.Io.Event) void { + defer active.finish(); + ready.waitUncancelable(std.testing.io); + } + }.run, .{ &workers, &gate }) catch |err| { + workers.finish(); + return err; + }; + } + try std.testing.expectEqual(@as(usize, LspWorkers.capacity), workers.active.load(.monotonic)); + try std.testing.expect(!workers.start()); + var failing = std.testing.FailingAllocator.init(gpa, .{ .fail_index = 0 }); + var queue: Queue = .{ .gpa = gpa, .lsp_allocator = failing.allocator(), .lsp_workers = &workers, .sdl_wake = false }; + defer queue.deinit(); + core.lspRequest(core.active, .completion, ""); + const rejected = core.lsp_wait.?.id; + queue.lsp_id = rejected -% 1; + queue.push(.{ .lsp = .{ .id = queue.lsp_id.?, .rows = try gpa.dupe(u8, "obsolete queued result") } }); + spawnLsp(core, &queue, .{ + .id = rejected, + .kind = .completion, + .pane = @intCast(core.active), + .offset = 1, + .arg = "", + }); + try std.testing.expect(core.lsp_wait == null); + try std.testing.expectEqualStrings("abc", pane.file.?.content); + try std.testing.expectEqual(@as(i32, 1), pane.cur_col); + try std.testing.expect(std.mem.indexOf(u8, pane.msg[0..pane.msg_len], "WorkersBusy") != null); + try std.testing.expectEqual(@as(usize, 0), failing.alloc_index); + try std.testing.expect(!failing.has_induced_failure); + try std.testing.expectEqual(rejected, queue.lsp_id.?); + try std.testing.expectEqual(@as(usize, 0), queue.completion_len); + queue.push(.{ .lsp = .{ .id = rejected -% 1, .rows = try gpa.dupe(u8, "obsolete late result") } }); + try std.testing.expectEqual(@as(usize, 0), queue.completion_len); + try std.testing.expectEqual(@as(usize, LspWorkers.capacity), workers.active.load(.monotonic)); + + gate.set(std.testing.io); + workers.wait(); + try std.testing.expectEqual(@as(usize, 0), workers.active.load(.monotonic)); + queue.lsp_allocator = gpa; + core.lspRequest(core.active, .status, ""); + const accepted = core.lsp_wait.?.id; + spawnLsp(core, &queue, .{ + .id = accepted, + .kind = .status, + .pane = @intCast(core.active), + .offset = 0, + .arg = "", + }); + workers.wait(); + var batch = queue.take(); + defer for (batch.slice()) |msg| msg.deinit(gpa, gpa); + try std.testing.expectEqual(@as(usize, 1), batch.len); + try std.testing.expectEqual(accepted, batch.items[0].lsp.id); + try std.testing.expect(batch.items[0].lsp.rows != null); + core.update(.{ .lsp_resp = .{ .id = accepted, .rows = batch.items[0].lsp.rows } }); + try std.testing.expect(core.lsp_wait == null); + try std.testing.expectEqual(@as(usize, 0), workers.active.load(.monotonic)); + try std.testing.expectEqualStrings("abc", pane.file.?.content); +} + fn pipeThread(io: std.Io, gpa: std.mem.Allocator, job: *selection_pipe.Job, q: *Queue) anyerror!void { defer job.deinit(gpa); const response = selection_pipe.runJob(gpa, io, job); q.push(.{ .pipe = response }); } -/// Copy every borrowed core byte before the tracked worker starts. The queue -/// owns the response and already has close-time disposal for a late answer. fn spawnPipe( core: *pardes.Pardes, io: std.Io, @@ -1201,15 +1612,78 @@ fn spawnPipe( return; } const view = core.pipeRequest(id) orelse return; - const job = selection_pipe.Job.copy(gpa, view) catch return; - const future = io.concurrent(pipeThread, .{ io, gpa, job, q }) catch { + const job = selection_pipe.Job.copy(gpa, view) catch |err| { + core.update(.{ .pipe_resp = .{ .id = id, .success = false, .outputs = &.{} } }); + return core.reportError(core.active, "pipe", err); + }; + const future = io.concurrent(pipeThread, .{ io, gpa, job, q }) catch |err| { job.deinit(gpa); - return; + core.update(.{ .pipe_resp = .{ .id = id, .success = false, .outputs = &.{} } }); + return core.reportError(core.active, "pipe", err); }; std.debug.assert(tasks.add(.{ .id = id, .future = future })); } -// ---- the renderer state ---- +test "GUI 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 workers: LspWorkers = .{}; + var queue: Queue = .{ + .gpa = gpa, + .lsp_allocator = failing.allocator(), + .lsp_workers = &workers, + .sdl_wake = false, + }; + defer queue.deinit(); + var tasks: PipeTasks = .{}; + 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 = "", + }; + if (failure == 0) { + queue.lsp_id = req.id -% 1; + queue.push(.{ .lsp = .{ .id = queue.lsp_id.?, .rows = try gpa.dupe(u8, "old queued result") } }); + spawnLsp(core, &queue, req); + try std.testing.expectEqual(req.id, queue.lsp_id.?); + try std.testing.expectEqual(@as(usize, 0), queue.completion_len); + queue.push(.{ .lsp = .{ .id = req.id -% 1, .rows = try gpa.dupe(u8, "late old result") } }); + try std.testing.expectEqual(@as(usize, 0), queue.completion_len); + } else { + var shell: Shell = undefined; + shell.core = core; + shell.threads_ok = false; + lsp(&shell, 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; + spawnPipe(core, failing_io, failing.allocator(), &queue, &tasks, pipe_id); + try std.testing.expect(core.pipe_wait == null); + try std.testing.expectEqualStrings("abc", pane.file.?.content); + try std.testing.expectEqual(@as(usize, 0), workers.active.load(.monotonic)); + try std.testing.expectEqual(@as(usize, 0), tasks.len); + try std.testing.expectEqual(@as(usize, 0), queue.len); + } +} const Gui = struct { window: *c.SDL_Window, @@ -1237,21 +1711,11 @@ const Gui = struct { prepared_images: std.ArrayListUnmanaged(PreparedImage) = .empty, font: *c.UIFont, - /// the file behind `font`, when it is one the Font builtin loaded. Empty - /// for the font the binary ships with, which is @embedFile'd and not ours - /// to free — FreeType borrows these bytes for the face lifetime. font_bytes: []u8 = &.{}, font_name: [255]u8 = @splat(0), font_name_len: u8 = 0, - /// Faces are discovered and opened once at startup. `glyphs` below caches - /// the raster result by codepoint, so fallback probing happens once per - /// glyph/atlas epoch rather than once per cell or frame. fallbacks: [max_fallback_fonts]?LoadedFallback = @splat(null), fallback_count: usize = 0, - /// the cell height the metrics are asked for, in pixels. A field and not - /// the local constant it used to be because refitFont reads it: changing - /// the FACE has to re-ask at the same size, and changing the SIZE (the - /// Ctrl+/Ctrl- this leaves the path for) is writing here and calling that. px: f32, scale: f32, tagline_scale: f32, @@ -1263,9 +1727,6 @@ const Gui = struct { ascent: i32, tagline_baseline: i32, - // glyph atlas: CPU staging bitmap + codepoint/role → texel slot, pen-walk alloc. - // The slot is also the fallback-resolution cache: after first rasterization - // every cell/frame takes the hash hit without probing any face again. atlas_stage: []u8, glyphs: std.AutoHashMap(GlyphKey, Slot), pen_x: u32 = 0, @@ -1277,16 +1738,6 @@ const Gui = struct { live_ctrl: bool = false, live_alt: bool = false, - // Fractional wheel scroll, one pane at a time. SDL's exact floating-point - // distance is batched until the next render. The core still moves only at - // whole-row boundaries; scroll_lag retains the sub-row picture position. - // - // ponytail: one accumulator, so exactly one pane can be offset and only the - // MOUSE wheel fills it — the deck's left stick and a two-finger touch - // scroll still hand the core their whole rows on the spot (they have - // their own sub-tick accumulators, and neither aims well enough to miss - // the fractional rendering). Both are one call site each: point them at - // scroll_delta the way the wheel arm of dispatch does. scroll_pane: ?usize = null, scroll_rect: pardes.Rect = .{ .x = 0, .y = 0, .w = 0, .h = 0 }, scroll_body_y: u16 = 0, // that rect's first BODY row (Tagbottom moves it) @@ -1297,26 +1748,16 @@ const Gui = struct { scroll_edge: []pardes.Cell = &.{}, // the row that just left the pane scroll_edge_len: u16 = 0, - // Crt builtin: the scene renders into this texture, then a fullscreen - // CRT pass warps it onto the real target scene_tex: ?*c.SDL_GPUTexture = null, scene_tex_w: u32 = 0, scene_tex_h: u32 = 0, - /// Resize-time scene target creation can fail transiently. Present the - /// direct frame meanwhile, then stop retrying after a small bounded run. scene_failures: u8 = 0, scene_target_failed: bool = false, - /// Exact postprocess state used by the last submitted frame. Input maps - /// through this snapshot, not through config/time sampled a frame later. presented_scene: crt.Frame = .{}, - /// Physical pointer state is retained separately from its mapped grid - /// cell. Ripple/glitch can move the displayed source under a stationary - /// hand, so every accepted scene frame remaps this same window point. pointer_present: bool = false, pointer_mapped: bool = false, pointer_cell: ?MouseCell = null, - // PARDES_TEST frame capture (render into an offscreen target, dump PPM) capture: bool = false, capture_dir: []const u8 = "", capture_tex: ?*c.SDL_GPUTexture = null, @@ -1325,28 +1766,13 @@ const Gui = struct { capture_xfer: ?*c.SDL_GPUTransferBuffer = null, capture_xfer_size: u32 = 0, - // Software present. A Vulkan swapchain needs a presentable surface, which - // a compositor without linux-dmabuf cannot provide (p9wl and other - // software/remote Wayland stacks: the driver reports "this surface does - // not support presenting"). The GPU still renders, so render offscreen - // exactly like capture does and blit the readback through SDL_Renderer, - // which goes out over wl_shm. - // - // `config.gui_transparent` takes the same path deliberately rather than by - // failure: SDL's GPU API refuses to claim a transparent window at all, and - // SDL_Renderer is the presenter that does honour one. soft_present: bool = false, - /// The window was created with SDL_WINDOW_TRANSPARENT, so a themeless - /// ground is nothing at all instead of `bg_default`. Implies - /// `soft_present`; read per frame by `ground`. transparent: bool = false, soft_renderer: ?*c.SDL_Renderer = null, soft_texture: ?*c.SDL_Texture = null, soft_tex_w: u32 = 0, soft_tex_h: u32 = 0, - // Steam Deck: gamepad-driven virtual cursor in SDL window coordinates. - // Conversion to physical render pixels happens once in mouseCell. gamepad: ?*c.SDL_Gamepad = null, pad_x: f32 = 0, pad_y: f32 = 0, @@ -1356,10 +1782,6 @@ const Gui = struct { }; fn setGuiFontName(g: *Gui, fallback: []const u8) void { - // Config says "effective", so ask the rasterizer what it accepted rather - // than echoing the picker label (a filename stem which need not be the - // face's own identity). Some old/synthetic faces have neither a - // PostScript nor family name; only those retain the known-good label. const name = if (c.ui_font_name(g.font)) |name_z| std.mem.span(name_z) else fallback; const len = @min(name.len, g.font_name.len); @memcpy(g.font_name[0..len], name[0..len]); @@ -1381,8 +1803,6 @@ fn acknowledgeGuiFont(g: *const Gui, core: *pardes.Pardes) void { } fn loadFallbackFonts(g: *Gui, gpa: std.mem.Allocator) void { - // The shipped face is the first fallback whenever Font selects a narrower - // user face. A second FT_Face is cheap and keeps both lifetimes independent. if (c.ui_font_new(font_ttf.ptr, @intCast(font_ttf.len))) |face| { g.fallbacks[0] = .{ .face = face }; g.fallback_count = 1; @@ -1392,7 +1812,7 @@ fn loadFallbackFonts(g: *Gui, gpa: std.mem.Allocator) void { defer arena_state.deinit(); for (fonts.fallbacks(arena_state.allocator())) |candidate| { if (g.fallback_count == g.fallbacks.len) break; - const bytes = look.readFile(gpa, candidate.path) catch continue; + const bytes = filesystem.readFile(gpa, candidate.path) catch continue; const len = std.math.cast(c_int, bytes.len) orelse { gpa.free(bytes); continue; @@ -1420,11 +1840,9 @@ fn fontForCodepoint(g: *const Gui, cp: u32) *c.UIFont { const face = loaded.?.face; if (c.ui_font_has_glyph(face, @intCast(cp)) != 0) return face; } - // Preserve FreeType's useful .notdef box when no face has the codepoint. return g.font; } -/// A cell's on-screen rect: exactly cell_w×cell_h at (0,0). const CellLayout = struct { w: f32, h: f32, x_off: f32, y_off: f32 }; const MouseCell = struct { col: u16, row: u16 }; const WindowGeometry = struct { @@ -1449,12 +1867,6 @@ fn windowGeometry(window: *c.SDL_Window) WindowGeometry { }; } -/// This window in whole cells, which is the grid the core is asked to be. The -/// one derivation of it: `pollFrame` follows the window with it every frame, -/// `refitFont` re-asks after Ctrl+/Ctrl- has moved the cell under it, and both -/// attach paths tell the session what this window can show with it. A window -/// that is not a whole number of cells across has to round the same way in all -/// four places or the last row lands off the bottom edge. const GridCells = struct { cols: u16, rows: u16 }; fn windowCells(g: *const Gui) GridCells { @@ -1494,47 +1906,27 @@ fn compactTaglineLayout(g: *const Gui, origin_col: f32) CellLayout { return .{ .w = tag_w, .h = @floatFromInt(g.cell_h), - // emitInstance still receives the canonical surface column. Offset - // the smaller grid so its column zero is the pane's physical left. .x_off = origin_col * (body_w - tag_w), .y_off = 0, }; } -/// Where a tagline cell's compact band begins. The origin rule itself is -/// `pardes.taglineOriginCol` — moved to the core so the AppKit shell can call -/// the SAME rule over the C ABI instead of advancing its tag rows on body -/// pitch, which is the second copy of this that already went wrong once (see -/// `taglineBandOffset`). -/// -/// `core` is null in an attached window, and then EVERY tagline cell takes the -/// last line's fallback: the wire carries cells, not the pane rects that placed -/// them, so there is no band origin to compact against. That is the same answer -/// `gridCellAtDimensions` reaches for the same reason, which is what keeps the -/// two honest — a click lands on the glyph it was aimed at, because both sides -/// map through the body grid. The visible cost is one tagline row's worth of -/// loose tracking. fn taglineLayoutForCell( g: *const Gui, core: ?*const pardes.Pardes, col: u16, row: u16, - track: ?pardes.panel_animation.Track, + track: ?pardes.layout.Track, ) CellLayout { if (row < pardes.TOPBAR_H) return compactTaglineLayout(g, 0); const p = core orelse return compactTaglineLayout(g, @floatFromInt(col)); return compactTaglineLayout(g, pardes.taglineOriginCol(p, col, row, track)); } -/// `panel_animation.Box.contains` under this file's older name. Kept as an -/// alias rather than renamed at three call sites so the predicate has exactly -/// one definition — it was a fourth copy of the same half-open cell test the -/// core, `taglineOriginCol` and ScenePostprocessor.swift all make. -const boxContains = pardes.panel_animation.Box.contains; +const boxContains = pardes.layout.Box.contains; -// EFFECT_CODE_PANEL_HOST_BEGIN const PaintBatch = struct { - track: ?pardes.panel_animation.Track = null, + track: ?pardes.layout.Track = null, cell_start: u32 = 0, cell_count: u32 = 0, image_start: u32 = 0, @@ -1542,21 +1934,15 @@ const PaintBatch = struct { }; const PaintPlan = struct { - // One static batch plus live tracks and presentation-only closing - // tombstones. Slot reuse can legitimately expose both for one pane id. batches: [pardes.MAX_PANES * 2 + 1]PaintBatch = @splat(.{}), len: usize = 1, }; -/// Painter order shared by cells and pixel attachments. Core hit testing walks -/// the reverse order, so the visually top panel receives the click too. -fn makePaintPlan(tracks: []const pardes.panel_animation.Track, has_diff: bool) PaintPlan { +fn makePaintPlan(tracks: []const pardes.layout.Track, has_diff: bool) PaintPlan { var plan: PaintPlan = .{}; - for ([_]pardes.panel_animation.Phase{ .moving, .opening, .closing }) |phase| { + for ([_]pardes.layout.Phase{ .moving, .opening, .closing }) |phase| { for (tracks) |track| { if (!track.active() or track.phase != phase) continue; - // These effects have no honest fallback without the frozen grid. - // Render the canonical frame rather than materializing garbage. if (track.effect.needsPreviousGrid() and !has_diff) continue; std.debug.assert(plan.len < plan.batches.len); plan.batches[plan.len].track = track; @@ -1580,21 +1966,21 @@ fn paintBatchForSerial(plan: *const PaintPlan, serial: u32) usize { return 0; } -fn panelCellCoord(track: pardes.panel_animation.Track, col: u16, row: u16) u32 { +fn panelCellCoord(track: pardes.layout.Track, col: u16, row: u16) u32 { const source = track.contentBox(); const x0: u16 = @intFromFloat(@max(0.0, @floor(source.x))); const y0: u16 = @intFromFloat(@max(0.0, @floor(source.y))); return @as(u32, row -| y0) << 16 | @as(u32, col -| x0); } -fn panelGridSize(track: pardes.panel_animation.Track) u32 { +fn panelGridSize(track: pardes.layout.Track) u32 { const source = track.contentBox(); const cols: u16 = @intFromFloat(@min(@as(f32, std.math.maxInt(u16)), @max(1.0, @ceil(source.w)))); const rows: u16 = @intFromFloat(@min(@as(f32, std.math.maxInt(u16)), @max(1.0, @ceil(source.h)))); return @as(u32, rows) << 16 | @as(u32, cols); } -fn ndcBox(box: pardes.panel_animation.Box, layout: CellLayout, win_w: f32, win_h: f32) [4]f32 { +fn ndcBox(box: pardes.layout.Box, layout: CellLayout, win_w: f32, win_h: f32) [4]f32 { const px0 = layout.x_off + box.x * layout.w; const py0 = layout.y_off + box.y * layout.h; const px1 = px0 + box.w * layout.w; @@ -1609,7 +1995,7 @@ fn ndcBox(box: pardes.panel_animation.Box, layout: CellLayout, win_w: f32, win_h fn setTransitionFields( instance: anytype, - track: ?pardes.panel_animation.Track, + track: ?pardes.layout.Track, layout: CellLayout, win_w: f32, win_h: f32, @@ -1645,24 +2031,12 @@ fn setTransitionFields( instance.serial = active.serial; instance.cell_coord = cell_coord; } -// EFFECT_CODE_PANEL_HOST_END - -// ===================================================================== -// entry -// ===================================================================== pub const run = runNative; -/// `attach` is `--attach[=<name>]`: empty means "the session there is" (see -/// `detached_client.resolve`). It is a parameter rather than an `Options` field -/// because it says nothing to the core — this process does not have one when it -/// is set. fn runNative(init: std.process.Init, opts_in: pardes.Options, attach: ?[]const u8) !void { const gpa = init.gpa; const env = init.environ_map; - // PARDES_TEST_GRID owns the process when it is set, and it is headless. - // There is no window to hand to a session, so refuse the combination - // rather than silently dropping the flag a harness meant. if (env.get("PARDES_TEST_GRID") != null) { if (attach != null) { log.err("--attach needs a window; PARDES_TEST_GRID is headless", .{}); @@ -1678,14 +2052,6 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options, attach: ?[]const u return error.SdlInit; } - // The trackpads only exist through SDL's built-in HIDAPI Steam Deck - // driver (it registers the two touchpads and disables "lizard mode"). - // It's default-on on Linux; pin it so intent is explicit. NOTE: this is - // NOT enough in Game Mode — Steam Input there hands the app a - // touchpad-less virtual gamepad instead of the real Neptune controller, - // so the pads go dead no matter what the app does. The only fix is to - // set "Disable Steam Input" on pardes (Steam -> Properties -> Controller), - // after which the real controller enumerates and the touchpad events flow. _ = c.SDL_SetHint("SDL_JOYSTICK_HIDAPI", "1"); // SDL_HINT_JOYSTICK_HIDAPI _ = c.SDL_SetHint("SDL_JOYSTICK_HIDAPI_STEAMDECK", "1"); // ..._STEAMDECK if (!c.SDL_Init(c.SDL_INIT_VIDEO | c.SDL_INIT_GAMEPAD)) { @@ -1694,11 +2060,6 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options, attach: ?[]const u } var win_flags: c.SDL_WindowFlags = c.SDL_WINDOW_RESIZABLE; if (!test_mode) win_flags |= c.SDL_WINDOW_HIGH_PIXEL_DENSITY; - // A see-through buffer, so a theme with no background of its own shows the - // compositor's backdrop instead of `bg_default`. Asked for at CREATION - // because that is the only time it can be: X11 picks the 32-bit visual - // here, and the Wayland backend decides here whether to keep an opaque - // region on the surface. if (config.gui_transparent) win_flags |= c.SDL_WINDOW_TRANSPARENT; const window = c.SDL_CreateWindow("pardes", 1120, 720, win_flags) orelse { log.err("SDL_CreateWindow: {s}", .{c.SDL_GetError()}); @@ -1707,28 +2068,10 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options, attach: ?[]const u if (c.SDL_CreateCursor(&p9_arrow_set, &p9_arrow_mask, 16, 16, 1, 1)) |cur| { _ = c.SDL_SetCursor(cur); } else log.err("SDL_CreateCursor: {s}", .{c.SDL_GetError()}); - // Keep the window's mouse ungrabbed: desktop users must be able to move - // the pointer out normally. The deck's virtual pointer is clamped and - // warped explicitly only when its controls move it (see dispatch and - // pollGamepad), so it does not need window-wide confinement. const device = c.SDL_CreateGPUDevice(c.SDL_GPU_SHADERFORMAT_SPIRV, true, null) orelse { log.err("SDL_CreateGPUDevice: {s}", .{c.SDL_GetError()}); return error.SdlInit; }; - // A failed claim is not fatal: it means the compositor has no presentable - // Vulkan surface (no linux-dmabuf), which is the normal case under p9wl and - // other software/remote Wayland compositors. Rendering still works, so keep - // the device and present the readback through SDL_Renderer instead. - // PARDES_SOFT_PRESENT=1 takes that path on a compositor that could present, - // which is how the path is exercised without a remote display. - // - // A transparent window does not even attempt the claim. It is not a - // compositor's shortcoming and there is nothing to retry: SDL_gpu.c fails - // SDL_ClaimWindowForGPUDevice for SDL_WINDOW_TRANSPARENT unconditionally, - // because D3D12 has no transparent swapchain and the API says no - // everywhere rather than only where it must. SDL_Renderer's own vulkan and - // opengl backends do honour one, and that is the presenter this path - // already had. var soft_present = false; var soft_renderer: ?*c.SDL_Renderer = null; const force_soft = if (env.get("PARDES_SOFT_PRESENT")) |raw| @@ -1748,7 +2091,6 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options, attach: ?[]const u }; soft_present = true; } - // present mode: PARDES_SDL_PRESENT env override, else immediate → mailbox → vsync const present_mode: c.SDL_GPUPresentMode = blk: { if (soft_present) break :blk c.SDL_GPU_PRESENTMODE_VSYNC; if (env.get("PARDES_SDL_PRESENT")) |raw| { @@ -1771,15 +2113,11 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options, attach: ?[]const u if (!soft_present) { _ = c.SDL_SetGPUSwapchainParameters(device, window, c.SDL_GPU_SWAPCHAINCOMPOSITION_SDR, present_mode); } - // Without a claimed window there is no swapchain format to ask for, so - // pick a colour-target format the device does support; the readback and - // the SDL_Texture agree on it below. const swapchain_format = if (soft_present) softTargetFormat(device) else c.SDL_GetGPUSwapchainTextureFormat(device, window); - // ---- font + cell metrics ---- const font = c.ui_font_new(font_ttf.ptr, @intCast(font_ttf.len)) orelse { log.err("ui_font_new failed", .{}); return error.FontInit; @@ -1800,7 +2138,6 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options, attach: ?[]const u _ = c.SDL_SyncWindow(window); } - // ---- glyph atlas (R8) + pipelines ---- var tex_info = std.mem.zeroes(c.SDL_GPUTextureCreateInfo); tex_info.type = c.SDL_GPU_TEXTURETYPE_2D; tex_info.format = c.SDL_GPU_TEXTUREFORMAT_R8_UNORM; @@ -1904,35 +2241,15 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options, attach: ?[]const u defer if (g.font_bytes.len != 0) gpa.free(g.font_bytes); // set by Font, if it ran defer c.ui_font_free(g.font); defer gpa.free(g.scroll_edge); // grown on demand by stepScroll - // slot (0,0) is the space glyph (blank cells sample alpha=0 → bg only) _ = c.ui_font_raster(font, scale, ' ', atlas_stage.ptr, @intCast(atlas_w), @intCast(cell_w), @intCast(cell_h), asc); g.pen_x = cell_w; - // `--attach`: this process has a window and NO core. Everything above is - // the window and the rasterizer, which an attached frontend needs exactly - // as much as a whole session does; everything below is the core, which - // lives in the detached process (src/detached/). The branch is here so both - // leave by the same door — the GPU objects, the glyph atlas and the face - // are put away by the defers above whichever mode ran. if (attach) |requested| return attachRequested(gpa, &g, requested); - // ...and the same handover arrived at from the other side: the `Attach` - // builtin gives this window to a session mid-flight. `localSession` returns - // a CONNECTED client only, and by the time it does every pane shell, watch - // and mount of the local session is already away. var attached: ?detached_client.Client = null; try localSession(init, &g, opts_in, test_mode, &attached); if (attached) |*client| return attachedLoop(gpa, &g, client); } -/// The session that lives in THIS process: the core, its pane shells, its -/// watches, its acme filesystem and the loop that pumps them. A function of its -/// own rather than the tail of `runNative` because that makes its teardown a -/// scope exit instead of a second copy of the same twelve defers — and the -/// `Attach` builtin needs exactly that teardown, in exactly that LIFO order, -/// before an attached loop may draw on the same window. -/// -/// `attached` is how a connected client leaves: it is set only after a -/// handshake is in flight, which is what makes a failed `Attach` a no-op. fn localSession( init: std.process.Init, g: *Gui, @@ -1942,8 +2259,8 @@ fn localSession( ) !void { const io = init.io; const gpa = init.gpa; - const allocs = pardes.allocators.init(gpa); - defer pardes.allocators.deinit(); + const allocs = pardes.memory.init(gpa); + defer pardes.memory.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); @@ -1952,13 +2269,6 @@ fn localSession( 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 - // pane width) — pre-sizing at init would mean no resize ever fires and the - // greeting never runs (panes sat blank until the first interaction). - // 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; @@ -1972,30 +2282,20 @@ fn localSession( opts.rows = @intCast(@max(1, @divTrunc(@as(u32, @intCast(@max(ph, 1))), g.cell_h))); } var core = if (opts.load_path) |lp| blk: { - const bytes = try @import("../look.zig").readFile(gpa, lp); + const bytes = try @import("../fs.zig").readFile(gpa, lp); defer gpa.free(bytes); 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 - // into their PETSCII preference, so their first render emits attachments. core.native_images = true; observeGuiFont(g, core); syncTaglineFont(g, core); - // PATH, the bash banner and the prompt rc files, in the one order that - // works. Children borrow only these stable in-struct path buffers. - var prompt_rcs = shell_bin.prepareForFork(); + var prompt_rcs = host_io.Shell.prepare(); defer prompt_rcs.deinit(); var ptys: [pardes.MAX_PANES]?Pty = @splat(null); - // per-slot spawn generation: drops a dead shell's late output/eof when its - // pane id has been respawned (see the host's spawnPane) var gens: [pardes.MAX_PANES]u32 = @splat(0); - defer for (&ptys) |*slot| if (slot.*) |pt| { - _ = libc.close(pt.fd); - }; var lsp_workers: LspWorkers = .{}; var queue: Queue = .{ .gpa = gpa, @@ -2005,42 +2305,28 @@ fn localSession( }; defer { lsp_workers.wait(); - queue.close(&ptys, &gens); + queue.deinit(); } var pipe_tasks: PipeTasks = .{}; defer pipe_tasks.cancelAll(io); - // One watcher for every watched pane, opened here — before any thread - // exists — so the pre-loop drain below can already mark the file a - // positional path argument opened. `false`: this host parks a thread in it - // rather than polling it. -1 where there is no watcher to make: watchPane - // goes quiet and the core simply never gets a file_changed event. - var inotify_fd: c_int = file_watch.init(false); + var inotify_fd: c_int = file_watch.init(true); + var watch_reader: ?std.Thread = null; + var watch_stop: [2]c_int = .{ -1, -1 }; defer if (inotify_fd >= 0) { - // `stop` releases a kqueue wait (macos); the close ends the blocking - // read (linux). Both leave watchThread on its way out. + if (watch_reader) |thread| { + _ = host_io.writeFd(watch_stop[1], "x"); + thread.join(); + } + for (watch_stop) |fd| if (fd >= 0) { + _ = libc.close(fd); + }; file_watch.stop(inotify_fd); _ = libc.close(inotify_fd); inotify_fd = -1; }; var watches: file_watch.Table = @splat(null); - // The socket a pardes launched inside this one connects to (nested.zig). - // --nested opted out of the whole mechanism, including being an outer - // instance; so does any failure to bind, and then children simply open - // their own session. - const sock_fd: c_int = if (opts.nested) -1 else nested.listen(); - defer nested.unlisten(sock_fd); - - // `--fs`: mounted before the initial spawns (they are the shells that need - // PARDES_FS) and before any thread of ours exists (the mount forks the - // setuid fusermount3 helper). Null covers both "no --fs" and "--fs but the - // mount failed"; the second is reported on a message row inside `start` and - // the session runs on without a filesystem. Teardown answers everything - // held, aborts the connection, unmounts and removes `<parent>/<pid>`; the - // parent stays, like nested.zig's socket directory. - var fs = fs_service.start(gpa, core); - // Covers the error paths only: the ordinary exit unmounts at the END OF - // THE LOOP instead, see there. - defer if (fs) |f| f.deinit(); + var fs = ninep_io.start(gpa, core); + defer if (fs) |f| f.deinit(gpa); var shell: Shell = .{ .core = core, @@ -2058,63 +2344,34 @@ fn localSession( .fs = fs, .test_mode = test_mode, }; + defer shell.shutdownPtys(); const host = shell.host(); - // `pump` installs this every pass; the pre-loop drain below happens - // outside one, so the initial spawns would otherwise reach the core's own - // virtual ptys instead of forking. core.host = host; - // initial spawns BEFORE any worker thread exists: forkpty from a - // multithreaded process can wedge the child before exec (see tty.zig). while (core.nextEffect()) |e| core.perform(e); - for (&ptys, 0..) |*slot, id| if (slot.*) |pt| spawnReader(gpa, pt, @intCast(id), gens[id], &queue); - // ...and the one file watcher. Started even with nothing marked yet: the fd - // already exists and an unwatched inotify instance just parks in read(2) — - // one thread for the process, however many panes come and go. - if (inotify_fd >= 0) if (std.Thread.spawn(.{}, watchThread, .{ inotify_fd, &queue })) |th| th.detach() else |_| {}; - // ...and the nested-instance listener, detached like every other blocking - // worker here - if (sock_fd >= 0) if (std.Thread.spawn(.{}, lookThread, .{ gpa, sock_fd, &queue })) |th| th.detach() else |_| {}; - // ...and the /dev/fuse poller, which is the same kind of thread again — - // except joined by `Fs.deinit` rather than detached, because fuse.zig gives - // it a control pipe that CAN wake it out of poll(). - fs_service.wake(fs, &queue, wakeFs); + for (&ptys, 0..) |*slot, id| if (slot.*) |*pt| + try spawnReader(gpa, pt, @intCast(id), gens[id], &queue); + if (inotify_fd >= 0) { + watch_stop = try stopPipe(); + watch_reader = try std.Thread.spawn(.{}, watchThread, .{ inotify_fd, watch_stop[0], &queue }); + } + if (fs) |f| try f.wakeThread(&queue, wakeFs); _ = c.SDL_StartTextInput(g.window); if (test_mode) setStdinRaw() catch {}; shell.threads_ok = true; - // Server state narration: reader threads → queue → drainQueue → the - // message row. Unset before the queue closes (see the defer above it). pardes.lsp.setStatusSink(&queue, lspStatusSink); defer pardes.lsp.setStatusSink(null, null); - // The core owns the loop. This owns the two things a pump cannot do from - // inside itself, because both replace the whole session and are only safe - // BETWEEN iterations: Restore swaps the `Pardes`, and Attach retires it. while (!core.quit) { try core.pump(host); if (core.quit) break; // a session that ended does not restore into one - // Attach builtin: hand this window's screen to a detached session. - // - // GREET FIRST, SWAP SECOND, and that order IS the feature. - // `detached_client.attempt` resolves, connects AND waits for the - // `welcome`, and closes whatever it opened on every other outcome — so - // when this returns anything but `.greeted`, nothing below has run and - // this instance is exactly as it was: every pane, every shell, every - // unsaved buffer, the whole undo history. It says why on the row of the - // pane that ran the word and the session goes on. A half-torn-down - // editor is the one outcome an attach must never have, and `open` alone - // cannot rule it out — a `refuse .version` from a session built by the - // last `zig build` arrives AFTER the connect. if (core.takeAttach()) |req| { const geom = windowCells(g); const outcome = detached_client.attempt(gpa, req.name, geom.cols, geom.rows); switch (outcome) { .greeted => |client| { - // Greeted, so this session is over: the `break` runs the - // filesystem unmount below and then every defer above, and - // `runNative` picks the client up on the far side. attached.* = client; break; }, @@ -2124,24 +2381,19 @@ fn localSession( }, } } - // Restore builtin: swap in a core rebuilt from the dump; kill the live - // shells (their detached readers wake on child death; gens bumped so - // the stale eofs close the old fds without touching the replay panes) if (core.takeRestore()) |rp| blk: { - const bytes = look.readFile(gpa, rp) catch break :blk; + const bytes = filesystem.readFile(gpa, rp) catch |err| { + core.reportError(core.active, "Restore", err); + break :blk; + }; defer gpa.free(bytes); - 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(allocs.pardes, o, bytes) catch break :blk; - for (&ptys) |*slot| if (slot.*) |pt| { - _ = libc.kill(pt.pid, libc.SIG.KILL); - slot.* = null; + const nc = core.restore(bytes) catch |err| { + core.reportError(core.active, "Restore", err); + break :blk; }; - for (&gens) |*g2| g2.* +%= 1; - // the replay core's pane ids mean new things, and the dying core's - // `watch off` effects go into a queue nobody drains — drop the lot - // here. The new core emits its own `on`s as it builds its panes. + pipe_tasks.cancelAll(io); + queue.discardCompletions(); + shell.stopPtys(); for (0..pardes.MAX_PANES) |wid| file_watch.watchPane( inotify_fd, &watches, @@ -2156,6 +2408,7 @@ fn localSession( g.prepared_images.clearRetainingCapacity(); nc.native_images = true; nc.host = host; + if (fs) |f| f.reset(core); core.deinit(); core = nc; shell.core = nc; @@ -2165,32 +2418,15 @@ fn localSession( } } - // THE FILESYSTEM GOES FIRST, ahead of every deferred teardown below: a - // session that has decided to exit must not spend its teardown holding a - // mount nobody is serving, so a client blocked on `<id>/event` when the - // last pane is deleted through `ctl` gets ENOTCONN at once. if (fs) |f| { - f.deinit(); + f.deinit(gpa); fs = null; shell.fs = null; } } -// ===================================================================== -// --attach: a window, a socket, and no core -// ===================================================================== - -/// What to say when an attach did not happen. One function for both callers -/// because it is one set of outcomes: `--attach` logs it to a terminal it has -/// not drawn over yet, the `Attach` word puts it on the pane's message row, and -/// neither should be inventing its own wording for `refuse .version`. -/// -/// `requested` is the word a person typed, empty for "the session that is -/// there" — which is the whole difference between "no session called work" and -/// "nothing is detached". fn attachFailure(buf: []u8, outcome: detached_client.Attempt, requested: []const u8) []const u8 { return switch (outcome) { - // The caller took this one and never asks. .greeted => unreachable, .no_session => if (requested.len != 0) std.fmt.bufPrint(buf, "Attach: no detached session called '{s}'", .{requested}) catch @@ -2210,12 +2446,6 @@ fn attachFailure(buf: []u8, outcome: detached_client.Attempt, requested: []const }; } -/// `--attach[=<name>]`: this window is a frontend from its first frame. Split -/// from `attachedLoop` because the two arrive with different evidence — a -/// command line has a person at a terminal to tell when there is nothing to -/// attach to and a process exit status to carry it, while an `Attach` inside a -/// session has a pane's message row and a live editor to leave standing. Both -/// reach `attachedLoop` with a GREETED client and never with less. fn attachRequested(gpa: std.mem.Allocator, g: *Gui, requested: []const u8) !void { const geom = windowCells(g); const outcome = detached_client.attempt(gpa, requested, geom.cols, geom.rows); @@ -2225,13 +2455,8 @@ fn attachRequested(gpa: std.mem.Allocator, g: *Gui, requested: []const u8) !void return attachedLoop(gpa, g, &client); }, else => { - // The window exists but has drawn nothing, so stderr is still the - // only place a person is looking; the wording is the message row's, - // because it is the same set of outcomes. var mbuf: [256]u8 = undefined; log.err("{s}", .{attachFailure(&mbuf, outcome, requested)}); - // ...and the exit status keeps the distinction the sentence makes, - // for whatever launched this window. return switch (outcome) { .no_session => error.NoSession, .ambiguous => error.AmbiguousSession, @@ -2244,69 +2469,27 @@ fn attachRequested(gpa: std.mem.Allocator, g: *Gui, requested: []const u8) !void } } -/// The whole of an attached window: input and screen, and nothing else. SDL -/// events become `pardes.Event`s on the socket through the same `dispatch` a -/// local session uses; frames come back and go through the same `renderFrame`. -/// It forks no shell, writes no file and watches no path — the session process -/// does all of that now — so the only effects still arriving here are the three -/// that need a human's own display. fn attachedLoop(gpa: std.mem.Allocator, g: *Gui, client: *detached_client.Client) !void { - // The `bye` is a courtesy: the session survives a frontend that simply - // dies, but seven bytes turn "the peer vanished" into "the peer left" in - // its log. defer client.detach(); - // The window may have arrived here from `localSession`, where this was - // already called; SDL_StartTextInput is idempotent, and calling it is what - // makes the `--attach`-from-startup path receive SDL_EVENT_TEXT_INPUT at - // all. _ = c.SDL_StartTextInput(g.window); var in: Input = .{ .client = client }; - // A frame is the only thing that makes this window redraw. There is no - // animation clock and no core asking for a tick — the session spends both - // and sends the result — so a pass that saw nothing new presents nothing. var dirty = false; var geom = windowCells(g); while (true) { const link = client.wait(detached_client.poll_ms); - // DECODE BEFORE REACTING TO THE HANGUP. `wait` reports the close in the - // same call that read the last bytes, and the last bytes are the - // session's `quit`: `fill` appends every chunk and only then sees the - // zero-length read. client.zig prefers POLLIN over POLLHUP for exactly - // this reason, and honouring it is what makes an ordinary `Kill` close - // every attached window by the front door instead of leaving whichever - // one lost the race reporting a broken link. while (true) { const msg = (try client.next()) orelse break; switch (msg) { - // A greeting cannot arrive twice and a refusal cannot follow - // one at all — `detached_client.attempt` consumed the welcome - // before this loop was entered, and the union is exhaustive, so - // these two arms exist to say that rather than to do anything. - // A session that sent either here is not speaking this protocol. .welcome => {}, .refuse => |why| { log.err("session refused an already-greeted frontend: {t}", .{why}); return error.Refused; }, - // Applied too — `grid` and `cursor` are current by the time - // this lands, so all that is left is putting them on screen. .frame => dirty = true, - // THE SESSION ENDED (`Kill`): every frontend goes with it. .quit => return, - // ...and `Detach`: THIS frontend was asked to leave and the - // session is carrying on without it, panes and shells and undo - // history intact, with whatever other frontends are attached - // still looking at it. Leaving because a person asked is a - // SUCCESS — hence a plain return and not the `error.Refused` - // above — and the deferred `client.detach()` still sends the - // `bye`, so the session logs a peer that left rather than one - // that vanished. The window closes because `runNative` returns. .detach => return, .set_clipboard => |text| putClipboard(gpa, text), - // The answer is not a reply message: it is an ordinary paste - // event on the way back, which is the same asynchronous shape - // `pull_read_clipboard` already has in process. .read_clipboard => if (takeClipboard()) |text| { defer c.SDL_free(text.ptr); in.post(.{ .paste = text }); @@ -2319,15 +2502,9 @@ fn attachedLoop(gpa: std.mem.Allocator, g: *Gui, client: *detached_client.Client var sev = std.mem.zeroes(c.SDL_Event); while (c.SDL_PollEvent(&sev)) dispatch(g, &in, &sev); pollGamepad(g, &in); - // One check for the whole burst rather than one per event: `Input.post` - // stops sending at the first failure, so this is where a dead link is - // reported and there is nothing left in flight to lose. if (in.lost) |err| return err; if (in.quit) return; - // What this WINDOW can show, which is not a promise about the next - // frame: with several frontends attached the session grid is the - // smallest common one (client.zig GEOMETRY). const now = windowCells(g); if (now.cols != geom.cols or now.rows != geom.rows) { geom = now; @@ -2339,17 +2516,6 @@ fn attachedLoop(gpa: std.mem.Allocator, g: *Gui, client: *detached_client.Client } } -/// The frame the session sent, through the renderer this window already has. -/// The `Surface` is built OVER the client's grid rather than copied into one: -/// `renderFrame` reads cells and never writes them, and a full frame of a large -/// grid is 1.6 MiB. -/// -/// Three of a session's own surface fields are absent here and each absence is -/// load-bearing. No panel tracks: a pane transition is composed by the process -/// that owns the panes and what arrives is the composed result, so `makePaintPlan` -/// builds its single static batch. No pixel attachments: this wire carries no -/// images. No previous cells: `hasPanelDiff` is therefore false and the whole -/// old/new layer machinery stays out of the plan. fn paintAttached(g: *Gui, gpa: std.mem.Allocator, client: *detached_client.Client) void { var surface: pardes.Surface = .{ .cols = client.cols, @@ -2357,28 +2523,15 @@ fn paintAttached(g: *Gui, gpa: std.mem.Allocator, client: *detached_client.Clien .cells = client.grid.items, .cursor = if (client.cursor) |cu| .{ .x = cu.x, .y = cu.y, .bar = cu.bar } else null, }; - // Two of `renderFrame`'s arguments are chrome colours the session resolved - // off a theme that is not on the wire. Both want `chromeTheme().tag_bg`, - // and the frame carries it exactly: row zero IS a full-width band that - // pardes.zig fills with that colour unconditionally, which is what - // `frameChromeBg` reads. The topbar rule then wears the band's own colour, - // joining the two bands directly the way - // `config.gui_topbar_pane_border_px = 0` does — a rule whose colour we - // would have to invent is worse than no rule. const chrome = frameChromeBg(&surface); _ = renderFrame( g, gpa, null, &surface, - // No theme background either, so every cell the session left at its - // default wears this window's own ground — the same answer a terminal - // frontend gives by writing a default cell. null, chrome, chrome, - // Crt/Ripple/Glitch are core settings and the core is elsewhere; so is - // Debug, which is what the touch overlay hangs off. .{}, false, ) catch |err| blk: { @@ -2387,19 +2540,6 @@ fn paintAttached(g: *Gui, gpa: std.mem.Allocator, client: *detached_client.Clien }; } -/// The tagline background this frame was painted with, read off the frame. An -/// attached window has no core to ask for `chromeTheme().tag_bg`, and -/// `renderFrame` wants it twice: as the chrome band under the topbar's compact -/// cells, and as the sub-cell strip `buildOverlay` extends below a bottom -/// tagline band when the window is not a whole number of cells tall. -/// -/// ROW ZERO is where it is read, and that is not a guess: the topbar is filled -/// edge to edge with `chrome.tag_bg` at `font_role = .tagline` on every frame -/// (pardes.zig `renderTopbar`), so its first tagline cell IS the colour. The -/// last row was the wrong place to look and cost a visibly dark band — a -/// session whose bottom row is pane BODY has no tagline cell there at all, so -/// the scan fell through to `bg_default` and painted the topbar's remainder -/// and every tag-cell gap near-black. fn frameChromeBg(surface: *const pardes.Surface) [3]u8 { if (surface.rows == 0 or surface.cols == 0) return bg_default; for (surface.cells[0..surface.cols]) |cell| { @@ -2413,15 +2553,13 @@ fn frameChromeBg(surface: *const pardes.Surface) [3]u8 { return bg_default; } -/// PARDES_TEST_GRID=1: headless. No SDL at all — stdin escape sequences in, -/// the rendered Surface out as text frames (same framing as the prototype). fn runGrid(init: std.process.Init, opts_in: pardes.Options) !void { const io = init.io; const gpa = init.gpa; const env = init.environ_map; - const allocs = pardes.allocators.init(gpa); - defer pardes.allocators.deinit(); + const allocs = pardes.memory.init(gpa); + defer pardes.memory.deinit(); pardes.image.start(io, allocs.image); if (comptime pardes.pdf_enabled) pardes.pdf.start(allocs.pdf); pardes.syntax.start(allocs.tree_sitter); @@ -2443,26 +2581,19 @@ fn runGrid(init: std.process.Init, opts_in: pardes.Options) !void { opts.rows = grid_rows; } const core = if (opts.load_path) |lp| blk: { - const bytes = try @import("../look.zig").readFile(gpa, lp); + const bytes = try @import("../fs.zig").readFile(gpa, lp); defer gpa.free(bytes); 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 it; an integrated shell releases its greeting at OSC 133 B. if (opts.load_path == null) core.update(.{ .resize = .{ .cols = grid_cols, .rows = grid_rows } }); - var prompt_rcs = shell_bin.prepareForFork(); + var prompt_rcs = host_io.Shell.prepare(); defer prompt_rcs.deinit(); var ptys: [pardes.MAX_PANES]?Pty = @splat(null); - // per-slot spawn generation: drops a dead shell's late output/eof when its - // pane id has been respawned (see the host's spawnPane) var gens: [pardes.MAX_PANES]u32 = @splat(0); - defer for (&ptys) |*slot| if (slot.*) |pt| { - _ = libc.close(pt.fd); - }; var lsp_workers: LspWorkers = .{}; var queue: Queue = .{ .gpa = gpa, @@ -2472,19 +2603,13 @@ fn runGrid(init: std.process.Init, opts_in: pardes.Options) !void { }; defer { lsp_workers.wait(); - queue.close(&ptys, &gens); + queue.deinit(); } 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. var watches: file_watch.Table = @splat(null); - // The filesystem IS served here, unlike the file watcher above: `--fs=<dir>` - // names a predictable mount point precisely so a snapshot can drive this - // mode through it. No poll thread though — see gridPollFrame. - const fs = fs_service.start(gpa, core); - defer if (fs) |f| f.deinit(); + const fs = ninep_io.start(gpa, core); + defer if (fs) |f| f.deinit(gpa); var shell: Shell = .{ .core = core, .io = io, @@ -2499,36 +2624,26 @@ fn runGrid(init: std.process.Init, opts_in: pardes.Options) !void { .watches = &watches, .fs = fs, }; + defer shell.shutdownPtys(); const host = shell.host(); - // The core owns the loop here too, but not the scripted stdin: EOF ends - // the session and nothing may be drawn after it, so this reads its own - // input and hands `pump` a pass that has already been fed. core.host = host; while (core.nextEffect()) |e| core.perform(e); - for (&ptys, 0..) |*slot, id| if (slot.*) |pt| spawnReader(gpa, pt, @intCast(id), gens[id], &queue); + for (&ptys, 0..) |*slot, id| if (slot.*) |*pt| + try spawnReader(gpa, pt, @intCast(id), gens[id], &queue); shell.threads_ok = true; pardes.lsp.setStatusSink(&queue, lspStatusSink); defer pardes.lsp.setStatusSink(null, null); setStdinRaw() catch {}; // stdin may be a pipe, not a pty — best effort - // First frame before touching stdin, so `printf '' | pardes` still shows - // one. Its arena is released before the core's own ever allocates: both - // draw from the one stack-fallback buffer, and a live arena on top of it - // would push every later frame out to the heap. { var first: std.heap.ArenaAllocator = .init(allocs.frame); defer first.deinit(); const surface = try core.render(first.allocator()); try dumpGrid(gpa, surface); - // The grid protocol writes canonical cells; panel tracks are metadata - // for a compositor it deliberately does not run. core.acknowledgePanelPresentation(&.{}); } while (!core.quit) { - // The two halves of a pass's input, in the order the flat loop had - // them: the scripted feed, then whatever the reader threads handed - // over. `pump` has no `wait_input` to do it in — see `grid_vtable`. var in: Input = .{ .core = core }; const r = try shell.feed.pump(gpa, &in, null); if (r.eof) break; @@ -2537,11 +2652,6 @@ fn runGrid(init: std.process.Init, opts_in: pardes.Options) !void { try core.pump(host); if (shell.dump_err) |err| return err; } - // The last frame is outside `pump` for the same reason the first one is: - // `pump` returns before drawing a quitting pass, and this stream records - // the empty grid a closed session leaves behind. Same three host methods - // in the same order, so the idle rule and the acknowledgement stay in one - // place — only the render is out here. if (core.quit) { gridPollFrame(&shell); var last: std.heap.ArenaAllocator = .init(allocs.frame); @@ -2552,12 +2662,6 @@ fn runGrid(init: std.process.Init, opts_in: pardes.Options) !void { } } -// ===================================================================== -// test-mode stdin: terminal escape sequences (vaxis.Parser) → core events, -// plus the private synthetic finger OSC: -// ESC ] 777;finger;<type>;<id>;<x>;<y>;<pressure> BEL (normalized 0..1) -// ===================================================================== - const StdinFeed = struct { parser: vaxis.Parser = .{}, cache: vaxis.GraphemeCache = .{}, @@ -2568,10 +2672,8 @@ const StdinFeed = struct { const Result = struct { eof: bool = false, n_events: usize = 0 }; - /// Poll stdin briefly and translate what arrived. `g` is null in grid mode. fn pump(f: *StdinFeed, gpa: std.mem.Allocator, in: *Input, g: ?*Gui) !Result { var out: Result = .{}; - // a pty stdin also carries the winsize; poll it in place of SIGWINCH var ws: posix.winsize = std.mem.zeroes(posix.winsize); if (posix.system.ioctl(0, posix.T.IOCGWINSZ, @intFromPtr(&ws)) == 0 and ws.col > 0 and ws.row > 0 and ws.col <= 1000 and ws.row <= 1000 and @@ -2600,7 +2702,6 @@ const StdinFeed = struct { var seq_start: usize = 0; while (seq_start < len) { - // private finger OSC first (vaxis would swallow it as unknown OSC) const prefix = "\x1b]777;finger;"; if (std.mem.startsWith(u8, f.buf[seq_start..len], prefix)) { const body = f.buf[seq_start + prefix.len .. len]; @@ -2707,7 +2808,6 @@ const StdinFeed = struct { fn applyResize(f: *StdinFeed, in: *Input, g: ?*Gui, cols: u16, rows: u16) void { _ = f; if (g) |gp| { - // capture mode: resize the window; the frame loop resizes the core _ = c.SDL_SetWindowSize(gp.window, @intCast(cols * gp.cell_w), @intCast(rows * gp.cell_h)); _ = c.SDL_SyncWindow(gp.window); } else { @@ -2762,7 +2862,6 @@ fn setStdinRaw() !void { try posix.tcsetattr(0, .NOW, term); } -// 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; const cur_x: u16 = if (surface.cursor) |cu| cu.x else 0; @@ -2809,55 +2908,22 @@ fn dumpGrid(gpa: std.mem.Allocator, surface: *pardes.Surface) !void { _ = host_io.writeFd(1, frame); } -// ===================================================================== -// SDL event dispatch -// ===================================================================== - -/// Where a translated SDL event goes, and the only thing the input path knows -/// about the session it belongs to. The local shell hands events to the -/// `Pardes` in this process; an attached window puts them on a socket, because -/// the core is in the detached one. Everything between an SDL_Event and a -/// `pardes.Event` — the keycode table, the pointer/cell mapping, the touch -/// machine, the Steam Deck mapping — is ONE translation serving both, and this -/// is what keeps it from becoming two. const Input = struct { - /// Null in an attached window, and this is also the flag the renderer- and - /// pointer-side functions test: no core means no pane rects and no theme, - /// and each of those has a documented body-grid fallback. core: ?*pardes.Pardes = null, - /// Null in a local session. Exactly one of the two is ever set. client: ?*detached_client.Client = null, - /// Attached only: the window was closed. A local session says the same - /// thing by writing `core.quit`, which the core owns and this must not - /// shadow. quit: bool = false, - /// Attached only: a send failed, which means this window has lost its - /// session. Recorded rather than returned because `dispatch` is called from - /// inside an SDL drain with no error path, and a dead link does not need - /// reporting once per event in the burst. lost: ?anyerror = null, - /// One translated event on its way to the core, wherever the core is. fn post(in: *Input, ev: pardes.Event) void { if (in.core) |core| return core.update(ev); const client = in.client orelse return; - // Nothing more goes out after the first failure: the rest of this - // burst would each fail the same way, and the loop is about to leave. if (in.lost != null) return; client.send(.{ .event = ev }) catch |err| switch (err) { - // A message this protocol cannot carry is not a link that has - // died. The one event here that can reach `wire.max_payload` is a - // paste of a 16 MiB clipboard, and dropping it beats ending a - // session over it. error.Overlong, error.NoSpace => {}, else => in.lost = err, }; } - /// The window asked to close. In a session that ends the session; in an - /// attached window it ends this frontend and nothing else — the panes, the - /// shells and the undo history are in the other process and outlive it, - /// which is the whole point of `--detach`. fn close(in: *Input) void { if (in.core) |core| core.quit = true; in.quit = true; @@ -2872,42 +2938,9 @@ fn dispatch(g: *Gui, in: *Input, sev: *const c.SDL_Event) void { g.pointer_mapped = false; in.post(.pointer_leave); }, - // window resizes are picked up by the per-frame grid check c.SDL_EVENT_KEY_DOWN => { g.live_ctrl = (sev.key.mod & c.SDL_KMOD_CTRL) != 0; g.live_alt = (sev.key.mod & c.SDL_KMOD_ALT) != 0; - // Ctrl+ / Ctrl-: the font size. Here rather than in keyDown - // because it is the shell's business and not the core's — the - // core has no font, and an attached window has no core at all yet - // still resizes its own text — and because this is the only side - // of the wall where `g` is in scope anyway. - // - // SIX keycodes for two keys, and every one of them is a key - // somebody actually presses: - // - `+` on a US layout IS Shift-`=`, and SDL reports the - // UNSHIFTED keycode, so Ctrl-+ arrives as SDLK_EQUALS. Binding - // only SDLK_PLUS is the usual way to ship this dead. - // - SDLK_PLUS is nonetheless real: on the German/Nordic layouts - // `+` is its own unshifted key. Same for `_` under `-`. - // - the numpad is separate. SDL_HINT_KEYCODE_OPTIONS defaults to - // "french_numbers,latin_letters" — no "hide_numpad" — so KP_+ - // stays SDLK_KP_PLUS (0x40000057) forever and never reaches - // keyDown's `sym < 128` line at all. - // - // Nothing is taken away from anyone by claiming these. forwardKey - // encodes Ctrl only for a-z, A-Z, `@` and `[`..`_`, and both `=` - // (0x3d) and `-` (0x2d) fall below that last range, so a pane in - // tty mode already sent the pty NO bytes for either — including - // Ctrl-Shift-minus, which arrives here as SDLK_MINUS and reached - // the core as `-`, never as the `_` that would have been 0x1f. No - // chord in config.zig pairs ctrl with any of these codepoints - // either (`=` is Format and `_` is trim_sels, both unmodified; - // Alt-- and Alt-_ are the selection merges). And the numpad pair - // did nothing at all: keyDown drops every sym above 128. - // - // Returning here is the whole interception, with no TEXT_INPUT - // twin to also swallow: SDL only sends text when neither ctrl nor - // alt is down (SDL_x11events.c, `!(SDL_GetModState() & (CTRL|ALT))`). const step: f32 = if (!g.live_ctrl) 0 else switch (sev.key.key) { c.SDLK_EQUALS, c.SDLK_PLUS, c.SDLK_KP_PLUS => font_px_step, c.SDLK_MINUS, c.SDLK_UNDERSCORE, c.SDLK_KP_MINUS => -font_px_step, @@ -2915,14 +2948,9 @@ fn dispatch(g: *Gui, in: *Input, sev: *const c.SDL_Event) void { }; if (step != 0) { const want = std.math.clamp(g.px + step, font_px_min, font_px_max); - // at either end the key is inert rather than a re-raster of - // the size already on screen if (want != g.px) { g.px = want; refitFont(g, in.core); - // Attached, the new grid reaches the session as the - // ordinary window-geometry check on the next pass, and - // there is no local Font state to observe either. if (in.core) |core| observeGuiFont(g, core); } return; @@ -2939,13 +2967,6 @@ fn dispatch(g: *Gui, in: *Input, sev: *const c.SDL_Event) void { while (tptr[tlen] != 0) : (tlen += 1) {} if (tlen == 0) return; const text: []const u8 = tptr[0..tlen]; - // ONE event is not one codepoint. An IME commit arrives whole — - // the entire phrase the candidate window was holding — and so does - // anything composed (dead keys, `Ctrl-Shift-u`, a compose-key - // sequence that resolves to more than one scalar). Decoding only - // text[0] dropped the rest on the floor, silently. Validate the - // whole string first so a truncated or malformed sequence costs - // nothing rather than half a phrase already forwarded. const view = std.unicode.Utf8View.init(text) catch return; var it = view.iterator(); var at: usize = 0; @@ -2966,8 +2987,6 @@ fn dispatch(g: *Gui, in: *Input, sev: *const c.SDL_Event) void { g.pointer_present = true; const mc = mouseCell(g, in.core, b.x, b.y) orelse { g.pointer_mapped = false; - // A release outside the visible CRT tube still ends a drag at - // its last real cell; a press on black margin is inert. if (!b.down) if (g.pointer_cell) |last| in.post(.{ .mouse = .{ .button = button, .kind = .release, @@ -2985,20 +3004,15 @@ fn dispatch(g: *Gui, in: *Input, sev: *const c.SDL_Event) void { .kind = if (b.down) .press else .release, .col = mc.col, .row = mc.row, - // asked of SDL directly rather than read off g.live_ctrl: - // that one is bookkeeping from KEY events, and a ctrl-click - // with no key pressed since startup would miss it .ctrl = (c.SDL_GetModState() & c.SDL_KMOD_CTRL) != 0, }, }); }, c.SDL_EVENT_MOUSE_MOTION => { const m = sev.motion; - // pad cursor continues from wherever the pointer last was g.pad_x = m.x; g.pad_y = m.y; g.pointer_present = true; - // drag = motion with a button held (selection extension keys off it) const held: ?pardes.Mouse.Button = if ((m.state & c.SDL_BUTTON_LMASK) != 0) .left else if ((m.state & c.SDL_BUTTON_MMASK) != 0) @@ -3036,20 +3050,12 @@ fn dispatch(g: *Gui, in: *Input, sev: *const c.SDL_Event) void { g.pointer_cell = mc; if (w.y != 0 and std.math.isFinite(w.y)) { if (in.core) |core| { - // Preserve SDL's floating-point distance. Input events drained - // in this loop naturally form one render batch; stepScroll - // applies their exact sum and tells the core only about whole - // row boundaries. One accumulator belongs to one pane, so a - // wheel event over another pane first retires the old offset. const hit: ?usize = for (core.panes, 0..) |slot, i| { if (slot == null) continue; const r = core.rects[i]; if (mc.col >= r.x and mc.col < r.x + r.w and mc.row >= r.y and mc.row < r.y + r.h) break i; } else null; if (g.scroll_pane) |old| if (hit == null or hit.? != old) { - // There is one fractional overlay, not one per pane. Retire - // the old one at its already boundary-rounded core state; - // carrying its lag into `hit` would move the wrong pane. resetScroll(g); }; if (hit) |id| { @@ -3058,9 +3064,6 @@ fn dispatch(g: *Gui, in: *Input, sev: *const c.SDL_Event) void { else false; if (pdf_target) { - // PDF placements live in physical document space, so - // preserve SDL's raw magnitude directly instead of - // quantizing through synthetic wheel buttons/rows. resetScroll(g); in.post(.{ .pdf_scroll = .{ .pane = @intCast(id), @@ -3074,13 +3077,6 @@ fn dispatch(g: *Gui, in: *Input, sev: *const c.SDL_Event) void { } } } else { - // ATTACHED: no pane rect ever reaches this window, so there - // is nothing to slide a fractional row against — the - // session owns the panes and composes what is painted here. - // Accumulate SDL's exact distance (a precision touchpad - // sends fractions of a row) in the same field `stepScroll` - // would have drained, and hand the session the whole rows, - // which is all `Event.mouse` has ever been able to say. g.scroll_delta = accumulateWheelDelta(g.scroll_delta, w.y); while (g.scroll_delta >= 1) : (g.scroll_delta -= 1) in.post(.{ .mouse = .{ .button = .wheel_down, .kind = .press, .col = mc.col, .row = mc.row } }); @@ -3127,8 +3123,6 @@ fn dispatch(g: *Gui, in: *Input, sev: *const c.SDL_Event) void { } }, c.SDL_EVENT_PINCH_BEGIN, c.SDL_EVENT_PINCH_UPDATE => in.post(.{ .pinch = sev.pinch.scale }), - // ---- steamdeck: first gamepad drives a virtual mouse (buttons here, - // axes polled per frame in pollGamepad) ---- c.SDL_EVENT_GAMEPAD_ADDED => { if (g.gamepad == null) g.gamepad = c.SDL_OpenGamepad(sev.gdevice.which); }, @@ -3138,8 +3132,6 @@ fn dispatch(g: *Gui, in: *Input, sev: *const c.SDL_Event) void { g.gamepad = null; } }, - // deck.zig maps buttons/triggers/trackpads to pointer, clicks, - // wheel, keys and rumble; this arm just applies its actions c.SDL_EVENT_GAMEPAD_BUTTON_DOWN, c.SDL_EVENT_GAMEPAD_BUTTON_UP, c.SDL_EVENT_GAMEPAD_AXIS_MOTION, @@ -3147,8 +3139,6 @@ fn dispatch(g: *Gui, in: *Input, sev: *const c.SDL_Event) void { c.SDL_EVENT_GAMEPAD_TOUCHPAD_MOTION, c.SDL_EVENT_GAMEPAD_TOUCHPAD_UP, => { - // `pad_input` and not `in`: this file's `Input` is the event sink - // above, and deck.Input is a controller reading. const pad_input: deck.Input = switch (sev.type) { c.SDL_EVENT_GAMEPAD_BUTTON_DOWN, c.SDL_EVENT_GAMEPAD_BUTTON_UP => .{ .button = .{ .idx = sev.gbutton.button, @@ -3175,14 +3165,10 @@ fn dispatch(g: *Gui, in: *Input, sev: *const c.SDL_Event) void { .move => |mv| { const geometry = windowGeometry(g.window); const old = mouseCellWithGeometry(g, in.core, g.pad_x, g.pad_y, geometry); - // deck.Action.move is in physical screen pixels. Keep the - // stored/warped cursor in SDL window coordinates. const dx = mv.dx * geometry.window_w / geometry.pixel_w; const dy = mv.dy * geometry.window_h / geometry.pixel_h; g.pad_x = std.math.clamp(g.pad_x + dx, 0, geometry.window_w - 1); g.pad_y = std.math.clamp(g.pad_y + dy, 0, geometry.window_h - 1); - // the SDL cursor (plan9 arrow) rides along, so the pad - // cursor and a hardware mouse are one visible pointer c.SDL_WarpMouseInWindow(g.window, g.pad_x, g.pad_y); g.pointer_present = true; const mc = mouseCellWithGeometry(g, in.core, g.pad_x, g.pad_y, geometry) orelse { @@ -3194,8 +3180,6 @@ fn dispatch(g: *Gui, in: *Input, sev: *const c.SDL_Event) void { g.pointer_cell = mc; if (old) |previous| if (mc.col == previous.col and mc.row == previous.row) continue; - // moving with a click held drags, so selections stretch - // (a firm right-pad press drags-selects like a laptop pad) const held = heldPointerButton(g); in.post(.{ .mouse = .{ .button = held orelse .none, @@ -3213,9 +3197,6 @@ fn dispatch(g: *Gui, in: *Input, sev: *const c.SDL_Event) void { g.pointer_present = true; const mc = mouseCell(g, in.core, g.pad_x, g.pad_y) orelse { g.pointer_mapped = false; - // Mirror hardware mouse releases: black CRT margins - // are inert for presses, but cannot strand a drag whose - // button was pressed over the visible tube. if (!ck.down) if (g.pointer_cell) |last| in.post(.{ .mouse = .{ .button = button, .kind = .release, @@ -3260,20 +3241,12 @@ fn dispatch(g: *Gui, in: *Input, sev: *const c.SDL_Event) void { .cap_n => .{ .cp = 'N', .text = "N" }, .enter => .{ .cp = pardes.Key.enter }, .tab => .{ .cp = pardes.Key.tab }, - // back paddle: flip tty mode. `--tty-toggle` moves the - // ctrl chord and is the SESSION's option, so an - // attached window — which cannot know it and has no - // core to ask — sends the spelling that cannot be - // reconfigured instead: `config.tty_toggle_alt`, which - // pardes.zig honours beside the ctrl chord for exactly - // the hosts that can express it. .tty_toggle => if (in.core) |core| .{ .cp = core.opts.tty_toggle, .ctrl = true } else .{ .cp = config.tty_toggle_alt[0].cp, .shift = true }, }, }), - // a brief gentle ack for execute/look, not a buzz .rumble => if (g.gamepad) |pad| { _ = c.SDL_RumbleGamepad(pad, 0x4000, 0x4000, 80); }, @@ -3283,8 +3256,6 @@ fn dispatch(g: *Gui, in: *Input, sev: *const c.SDL_Event) void { } } -/// Special keys + ctrl/alt shortcuts. Plain printable keys arrive as -/// SDL_EVENT_TEXT_INPUT instead (so shift/layout map correctly). fn keyDown(in: *Input, sym: c.SDL_Keycode, mod: c.SDL_Keymod) void { const ctrl = (mod & c.SDL_KMOD_CTRL) != 0; const alt = (mod & c.SDL_KMOD_ALT) != 0; @@ -3305,8 +3276,6 @@ fn keyDown(in: *Input, sym: c.SDL_Keycode, mod: c.SDL_Keymod) void { c.SDLK_PAGEDOWN => pardes.Key.page_down, c.SDLK_DELETE => pardes.Key.delete, else => blk: { - // letters / digits / punctuation only as a modifier shortcut; - // plain printable (incl. space) goes via TEXT_INPUT if (!(ctrl or alt or gui_mod)) break :blk 0; if (sym < 128 and sym >= ' ') break :blk @intCast(sym); break :blk 0; @@ -3321,13 +3290,6 @@ fn pixelCell(px: f32, cell: u32) u16 { return @intFromFloat(@min(idx, 10_000)); } -/// Which grid cell a physical point is in. The COLUMN rule is -/// `pardes.gridColAt` — the inverse of the compact tagline layout, and in the -/// core beside it so the two cannot be compacted independently. `core` is null -/// in an attached window, and then the pane loop inside it is skipped and the -/// body grid answers: the same fallback `taglineLayoutForCell` takes for the -/// same missing fact, which is what makes a click on an attached tagline land -/// on the glyph it was aimed at. fn gridCellAtDimensions( core: ?*const pardes.Pardes, x: f32, @@ -3385,8 +3347,6 @@ fn mouseCellWithGeometry(g: *const Gui, core: ?*const pardes.Pardes, x: f32, y: return gridCellAtPixels(g, core, mapped.x, mapped.y); } -/// SDL window coords → the scene cell displayed at that physical point. -/// Mouse, touch and the Deck pointer all share the same CRT/ripple/glitch map. fn mouseCell(g: *const Gui, core: ?*const pardes.Pardes, x: f32, y: f32) ?MouseCell { return mouseCellWithGeometry(g, core, x, y, windowGeometry(g.window)); } @@ -3399,9 +3359,6 @@ fn heldPointerButton(g: *const Gui) ?pardes.Mouse.Button { return null; } -/// Reconcile the retained physical point with the exact scene image the GPU -/// just accepted. Same-cell frames are deliberately silent: otherwise a 60 Hz -/// scene would continuously reset the core's hover debounce. fn refreshPresentedPointer(g: *Gui, core: *pardes.Pardes) void { if (!g.pointer_present) return; const mc = mouseCell(g, core, g.pad_x, g.pad_y) orelse { @@ -3423,28 +3380,19 @@ fn refreshPresentedPointer(g: *Gui, core: *pardes.Pardes) void { } }); } -/// steamdeck support: poll the sticks each frame, mirroring the trackpads — -/// RIGHT stick moves the virtual cursor at ~cell granularity (emits -/// button-less motion so hover works), LEFT stick accumulates into wheel -/// ticks (both axes: vertical + horizontal) at the cursor position. fn pollGamepad(g: *Gui, in: *Input) void { const pad = g.gamepad orelse return; const geometry = windowGeometry(g.window); const deadzone: f32 = 8000; - // right stick = pointer const ax: f32 = @floatFromInt(c.SDL_GetGamepadAxis(pad, c.SDL_GAMEPAD_AXIS_RIGHTX)); const ay: f32 = @floatFromInt(c.SDL_GetGamepadAxis(pad, c.SDL_GAMEPAD_AXIS_RIGHTY)); const old = mouseCellWithGeometry(g, in.core, g.pad_x, g.pad_y, geometry); - // full tilt ≈ 0.4 cell-heights per frame: a gentle, aimable glide (the - // mouse-move sensitivity knob — raise for a faster pointer) const speed: f32 = @as(f32, @floatFromInt(g.cell_h)) * 0.4; const speed_x = speed * geometry.window_w / geometry.pixel_w; const speed_y = speed * geometry.window_h / geometry.pixel_h; const pointer_moved = @abs(ax) > deadzone or @abs(ay) > deadzone; if (@abs(ax) > deadzone) g.pad_x = std.math.clamp(g.pad_x + ax / 32767.0 * speed_x, 0, geometry.window_w - 1); if (@abs(ay) > deadzone) g.pad_y = std.math.clamp(g.pad_y + ay / 32767.0 * speed_y, 0, geometry.window_h - 1); - // only on actual stick movement — an unconditional per-frame warp would - // pin the pointer and fight any hardware mouse if (pointer_moved) { g.pointer_present = true; c.SDL_WarpMouseInWindow(g.window, g.pad_x, g.pad_y); @@ -3467,7 +3415,6 @@ fn pollGamepad(g: *Gui, in: *Input) void { } }); } } - // left stick = scroll (both axes) const lx: f32 = @floatFromInt(c.SDL_GetGamepadAxis(pad, c.SDL_GAMEPAD_AXIS_LEFTX)); const ly: f32 = @floatFromInt(c.SDL_GetGamepadAxis(pad, c.SDL_GAMEPAD_AXIS_LEFTY)); if (@abs(ly) > deadzone) g.pad_scroll += ly / 32767.0 * 0.15; @@ -3484,110 +3431,140 @@ fn pollGamepad(g: *Gui, in: *Input) void { } } -// ===================================================================== -// the host seam — everything the core cannot do itself: ptys, files, the -// desktop, pixels, and the one place this process is allowed to sleep. -// ===================================================================== - -/// The state the host methods below need. `gui` is null in grid test mode: -/// no SDL, so no clipboard and no pixels, and the frames go out as text. const Shell = struct { - /// Reassigned by Restore, which is why the loop pumps rather than runs: - /// a swap is only safe BETWEEN iterations. core: *pardes.Pardes, gui: ?*Gui = null, io: std.Io, gpa: std.mem.Allocator, - /// acme's control filesystem for this session, or null when `--fs` was not - /// given (or its mount failed, or this is the headless grid harness, which - /// serves nothing). Owned by `run`. - fs: ?*fuse.Fs = null, + fs: ?*ninep_io.Listener = null, lsp_allocator: std.mem.Allocator, - prompt_rcs: *const shell_bin.PromptRcs, + prompt_rcs: *const host_io.Shell.PromptFiles, ptys: *[pardes.MAX_PANES]?Pty, gens: *[pardes.MAX_PANES]u32, queue: *Queue, pipe_tasks: *PipeTasks, inotify_fd: c_int, watches: *file_watch.Table, - /// worker threads exist. The pre-loop drain forks before any of them do: - /// forkpty from a multithreaded process can wedge the child before exec. threads_ok: bool = false, - /// PARDES_TEST: input is stdin escape sequences, not SDL events test_mode: bool = false, feed: StdinFeed = .{}, - /// what the last present actually put on screen, and the frame it drew: - /// post_present may only acknowledge a frame the user has seen. presented: bool = false, surface: ?*pardes.Surface = null, - /// `pump` spends no animation time; this is where the display clock does. animation_clock: AnimationClock = .{}, - /// Whether this pass observed any input. Only the grid harness reads it: - /// its contract is one frame per scripted input event, so a pass that saw - /// nothing writes nothing. saw_event: bool = false, - /// Grid mode only: a failed write to the frame stream. Kept rather than - /// swallowed because a `present` cannot fail and the harness must. dump_err: ?anyerror = null, + retired_shells: [pardes.MAX_PANES]RetiredShell = @splat(.{}), + + fn reap(s: *Shell) void { + const now = shellClock(); + for (&s.retired_shells) |*child| reapShell(&child.pid, &child.kill_at, now); + for (s.ptys) |*slot| if (slot.*) |*pt| if (pt.fd < 0) { + reapShell(&pt.pid, &pt.kill_at, now); + if (pt.pid == 0) slot.* = null; + }; + } + + fn closePty(s: *Shell, pane: u8) void { + const pt = if (s.ptys[pane]) |*pt| pt else return; + if (pt.fd < 0) return; + s.queue.cancelReader(pane, s.gens[pane]); + if (pt.reader) |thread| { + _ = host_io.writeFd(pt.stop[1], "x"); + thread.join(); + pt.reader = null; + for (pt.stop) |fd| _ = libc.close(fd); + pt.stop = .{ -1, -1 }; + } + _ = libc.close(pt.fd); + pt.fd = -1; + const now = shellClock(); + reapShell(&pt.pid, &pt.kill_at, now); + if (pt.pid == 0) { + s.ptys[pane] = null; + return; + } + _ = libc.kill(pt.pid, libc.SIG.HUP); + pt.kill_at = now + 100; + for (&s.retired_shells) |*child| if (child.pid == 0) { + child.* = .{ .pid = pt.pid, .kill_at = pt.kill_at }; + s.ptys[pane] = null; + return; + }; + } + + fn stopPtys(s: *Shell) void { + for (0..s.ptys.len) |pane| s.closePty(@intCast(pane)); + for (s.gens) |*gen| gen.* +%= 1; + s.reap(); + } + + fn shutdownPtys(s: *Shell) void { + s.stopPtys(); + for (s.retired_shells) |child| if (child.pid > 0) { + _ = libc.kill(child.pid, libc.SIG.KILL); + }; + for (s.ptys) |slot| if (slot) |pt| if (pt.pid > 0) { + _ = libc.kill(pt.pid, libc.SIG.KILL); + }; + for (&s.retired_shells) |*child| if (child.pid > 0) { + while (libc.waitpid(child.pid, null, 0) < 0 and libc.errno(-1) == .INTR) {} + child.* = .{}; + }; + for (s.ptys) |*slot| if (slot.*) |pt| { + while (libc.waitpid(pt.pid, null, 0) < 0 and libc.errno(-1) == .INTR) {} + slot.* = null; + }; + } + + fn reconcilePtys(s: *Shell) void { + s.reap(); + for (s.ptys, 0..) |slot, pane| if (slot) |pt| { + if (pt.fd < 0) continue; + const current = s.core.panes[pane]; + if (current == null or current.?.serial != pt.serial or !current.?.isTerminal()) + s.closePty(@intCast(pane)); + }; + } fn host(s: *Shell) pardes.Host { return .{ .ctx = s, .vtable = if (s.gui == null) &grid_vtable else &vtable }; } const vtable: pardes.Host.VTable = .{ - .pull_wait_input = waitInput, - .push_present = present, - .push_post_present = postPresent, - .push_poll_frame = pollFrame, - .push_spawn = spawnPane, - .push_pty_write = ptyWrite, - .push_pty_resize = ptyResize, - .push_pty_signal = ptySignal, - .pull_tty_taken = ttyTaken, - .push_write_file = writeFile, - .push_write_dump = writeDump, - .push_watch_file = watchFile, - .push_watch_theme = watchTheme, - .push_dump_themes = dumpThemes, - .push_set_clipboard = setClipboard, - .pull_read_clipboard = readClipboard, - .push_open_link = openLink, - .pull_lsp = lsp, - .pull_pipe = pipe, - .push_fs_reply = fsReply, + .wait_input = waitInput, + .present = present, + .post_present = postPresent, + .poll_frame = pollFrame, + .spawn = spawnPane, + .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 = lsp, + .pipe = pipe, }; - /// The headless grid harness. It reads its scripted stdin itself, because - /// EOF ends the session and nothing may be drawn after it — so there is no - /// `wait_input` here, and this process never sleeps in grid mode. It starts - /// neither the watcher nor the nested listener, so `drainQueue`'s - /// `files_changed` and `command` arms cannot fire behind it. - /// - /// And it has NO SDL: `SDL_Init` is never called on this path, so the two - /// desktop-clipboard methods are nulled rather than left pointing at - /// functions that cannot answer. A null `pull_read_clipboard` is not a - /// missing feature, it is the core's OWN clipboard (host.zig: "Null - /// answers immediately from the in-process clipboard instead, so a request - /// never goes unanswered") — the same one `pardes-isolate` runs on. With - /// the methods present and returning on `s.gui == null`, `SPC y` went - /// nowhere and `SPC p` was answered by nobody, so paste was dead in the - /// one mode of this shell a test can drive. const grid_vtable: pardes.Host.VTable = vt: { var v = vtable; - v.pull_wait_input = null; - v.push_poll_frame = gridPollFrame; - v.push_present = gridPresent; - v.push_post_present = gridPostPresent; - v.push_set_clipboard = null; - v.pull_read_clipboard = null; + v.wait_input = null; + v.poll_frame = gridPollFrame; + v.present = gridPresent; + v.post_present = gridPostPresent; + v.set_clipboard = null; + v.read_clipboard = null; break :vt v; }; - /// What the detached workers handed this thread since the last pass. - /// Their bytes are borrowed for exactly one `update` call each. Each - /// message is something this pass observed — the grid harness draws a - /// frame only for a pass that observed something. fn drainQueue(s: *Shell) void { + s.reconcilePtys(); var msgs = s.queue.take(); var check_files = false; for (msgs.slice()) |m| switch (m) { @@ -3598,20 +3575,22 @@ const Shell = struct { s.saw_event = true; }, .eof => |e| { - _ = libc.close(e.fd); // the dead reader's master — stale or current if (s.gens[e.pane] == e.gen) { - s.ptys[e.pane] = null; - s.core.update(.{ .eof = .{ .pane = e.pane } }); + s.closePty(e.pane); + if (e.failure) |err| { + if (s.core.panes[e.pane]) |pane| { + pane.mode = .normal; + s.core.reportError(e.pane, "terminal reader", err); + } + } else s.core.update(.{ .eof = .{ .pane = e.pane } }); } s.saw_event = true; }, .lsp => |l| { s.core.update(.{ .lsp_resp = .{ .id = l.id, .rows = l.rows } }); - s.lsp_allocator.free(l.rows); + if (l.rows) |rows| s.lsp_allocator.free(rows); s.saw_event = true; }, - // Server state on the transient message row of the ACTIVE pane — - // session news, same row and same stamp a completed save uses. .lsp_status => |text| { var mbuf: [256]u8 = undefined; s.core.setStatus(s.core.active, message.stamp(&mbuf, "lsp", text)); @@ -3630,17 +3609,7 @@ const Shell = struct { s.saw_event = true; s.pipe_tasks.finish(s.io, response_value.id); }, - .command => |line| { - s.core.update(.{ .command = line }); - s.gpa.free(line); - }, - // Coalesced on purpose: a burst of writes (a formatter, a build, a - // `git checkout`) collapses into ONE pass below, so it cannot queue - // a reload — or an undo entry — per write. .files_changed => check_files = true, - // A wake and nothing more; the requests behind it are drained in - // pollFrame, which is where a whole batch can be answered against - // one render instead of one render per request. .fs_ready => {}, }; if (check_files and file_watch.reloadChanged(s.core, s.io, s.gpa, s.watches)) @@ -3648,19 +3617,6 @@ const Shell = struct { } }; -/// The core's answer to one filesystem request, handed straight back to the -/// transport holding it. `bytes` was resolved by `pardes.fsPayload` inside -/// `perform` and is borrowed only for this call, so a megabyte body read copies -/// nothing. `.again` needs no case here: `Fs.reply` reads the status and -/// re-parks the request itself. -fn fsReply(ctx: ?*anyopaque, reply: *const pardes.acmefs.Reply, bytes: []const u8) void { - const s = shellOf(ctx); - if (s.fs) |f| f.reply(reply, bytes); -} - -/// The /dev/fuse poller's wake. `Queue.push` is the thread-safe door and -/// already raises the SDL user event that ends a blocking WaitEventTimeout, so -/// this is the whole callback — the same shape as watchThread's. fn wakeFs(ctx: ?*anyopaque) void { const q: *Queue = @ptrCast(@alignCast(ctx.?)); q.push(.fs_ready); @@ -3670,16 +3626,9 @@ fn shellOf(ctx: ?*anyopaque) *Shell { return @ptrCast(@alignCast(ctx.?)); } -/// SDL first (blocking briefly for one event, then draining the burst), then -/// the scripted stdin feed, then the worker inbox, then the sticks. Never -/// blocks indefinitely even when the core offers to: cwd polling, the gamepad -/// and the test feed have no SDL event to wake them. fn waitInput(ctx: ?*anyopaque, timeout_ms: u32) void { const s = shellOf(ctx); const core = s.core; - // The one place a local session builds the sink: everything downstream of - // here — `dispatch`, the scripted feed, the sticks — is the same code an - // attached window runs with `client` set instead. var in: Input = .{ .core = core }; if (s.gui) |g| { var sev = std.mem.zeroes(c.SDL_Event); @@ -3690,9 +3639,6 @@ fn waitInput(ctx: ?*anyopaque, timeout_ms: u32) void { } } if (s.test_mode) { - // A dead scripted feed ends the session HERE, before the inbox, the - // sticks and the frame: the pre-pump loop broke at this line, and a - // capture written after EOF is a frame no script asked for. const r = s.feed.pump(s.gpa, &in, s.gui) catch { core.quit = true; return; @@ -3706,35 +3652,19 @@ fn waitInput(ctx: ?*anyopaque, timeout_ms: u32) void { if (s.gui) |g| pollGamepad(g, &in); } -/// Per-frame host bookkeeping with no event of its own, in the order the flat -/// loop had it: the tagline face, a font the core asked for, live cwds, the -/// grid following the window, and the wheel batch — applied LAST before the -/// render, while the previous frame is still the one on screen. fn pollFrame(ctx: ?*anyopaque) void { const s = shellOf(ctx); + s.reconcilePtys(); const core = s.core; - // acme's filesystem: one batch per frame, answered before anything else in - // the pass, so an edit a script just made through `body` is in the surface - // this frame composes. Ahead of the `s.gui orelse return` below because it - // has nothing to do with pixels. Hitting the cap means no ack reached the - // poll thread, so nothing else will wake us — re-arm the loop ourselves; - // `Queue.push` is lossy for this variant, which is correct, because a queue - // too full to take a wake is already holding one. - if (s.fs) |f| if (fs_service.drain(f.transport(), core).pending) s.queue.push(.fs_ready); + if (s.fs) |f| if (f.tick(core).pending) s.queue.push(.fs_ready); const g = s.gui orelse return; - // TaglineSize is pure renderer state: update the smaller face and its - // visual band immediately, without changing the body metrics or grid. syncTaglineFont(g, core); - // Font builtin: the core resolved a name to a path and asked for it — it - // cannot load a font itself, having no rasterizer, no atlas and no window. if (core.takeFontRequest()) |path| blk: { - const bytes = look.readFile(s.gpa, path) catch { + const bytes = filesystem.readFile(s.gpa, path) catch { core.rejectFont(); break :blk; }; const nf = c.ui_font_new(bytes.ptr, @intCast(bytes.len)) orelse { - // FreeType turned it down. Keep wearing the one that works: a font - // pardes cannot rasterize is a blank window with no way back out. log.err("ui_font_new failed: {s}", .{path}); s.gpa.free(bytes); core.rejectFont(); @@ -3749,8 +3679,6 @@ fn pollFrame(ctx: ?*anyopaque) void { acknowledgeGuiFont(g, core); } pollCwds(core, s.ptys); - // Off g.cell_w/h, not the startup metrics: a font change moves them, and - // this is the line that would go on dividing by the old cell. const geom = windowCells(g); if (updateCoreResize(core, geom.cols, geom.rows, g.cell_w, g.cell_h)) resetScroll(g); stepScroll(g, core, s.gpa); @@ -3760,8 +3688,6 @@ fn present(ctx: ?*anyopaque, surface: *const pardes.Surface) void { const s = shellOf(ctx); const core = s.core; const g = s.gui orelse return; - // renderFrame consumes the frame the core just built; nothing here writes - // to it, and post_present needs the same one to acknowledge. const frame = @constCast(surface); s.surface = frame; const scene_requested = core.settings.scene_effects.crt or @@ -3791,16 +3717,11 @@ fn present(ctx: ?*anyopaque, surface: *const pardes.Surface) void { } else if (scene_requested and s.presented) g.scene_failures = 0; } -/// A tick is spent only on a frame that was actually PRESENTED: a failed -/// renderFrame must not advance samples nobody saw. fn postPresent(ctx: ?*anyopaque) void { const s = shellOf(ctx); const g = s.gui orelse return; if (!s.presented) return; const frame = s.surface orelse return; - // Ripple/glitch move source cells under a stationary physical pointer. - // Re-feed only when that accepted scene maps to a new cell, using the same - // core mouse path a real motion event uses. refreshPresentedPointer(g, s.core); finishPresentedAnimationFrame( &s.animation_clock, @@ -3810,26 +3731,13 @@ fn postPresent(ctx: ?*anyopaque) void { ); } -/// The grid harness's own three seams. It has no window, no pointer and no -/// compositor, so what is left of a frame is the cwds a tagline draws, one -/// animation step, and the text of the grid itself. fn gridPollFrame(ctx: ?*anyopaque) void { const s = shellOf(ctx); - // The filesystem, drained on the pass rather than woken by a thread: this - // mode's contract is one frame per scripted event, and a poller posting on - // its own clock would put frames in the stream nothing asked for. fuse.zig - // supports exactly this — skip `wakeThread` and drain from the frame poll — - // and here it is not a degradation but the point. A request that changed - // something IS an event, so the pass renders: that is what lets a snapshot - // `wait` for text a script wrote through the mount. + s.reconcilePtys(); if (s.fs) |f| { - if (fs_service.drain(f.transport(), s.core).count != 0) s.saw_event = true; + if (f.tick(s.core).count != 0) s.saw_event = true; } pollCwds(s.core, s.ptys); - // The harness polls stdin at the same 16 ms cadence as native SDL, so a - // pass IS a frame interval and the tick is due here rather than behind a - // display clock. Advancing lets `stable` wait for exact endpoint colors; - // once inactive it resumes the event-only frame contract. if (s.core.animationActive()) { s.core.update(.tick); s.saw_event = true; @@ -3838,8 +3746,6 @@ fn gridPollFrame(ctx: ?*anyopaque) void { fn gridPresent(ctx: ?*anyopaque, surface: *const pardes.Surface) void { const s = shellOf(ctx); - // Idle pass: nothing changed, so no frame. The stream is one frame per - // scripted input event and a repeat of the last grid would be read as one. s.presented = s.saw_event; s.saw_event = false; if (!s.presented) return; @@ -3852,80 +3758,58 @@ fn gridPresent(ctx: ?*anyopaque, surface: *const pardes.Surface) void { fn gridPostPresent(ctx: ?*anyopaque) void { const s = shellOf(ctx); if (!s.presented) return; - // The grid protocol writes canonical cells; panel tracks are metadata for - // a compositor it deliberately does not run. s.core.acknowledgePanelPresentation(&.{}); } fn spawnPane(ctx: ?*anyopaque, pane: u8, cwd: []const u8) void { const s = shellOf(ctx); - // the core reuses pane ids and there is no close effect: a deleted pane's - // shell lives in its slot until a respawn lands here. Kill it; its - // detached reader wakes on child death and the gen-guarded eof closes the - // old fd (not here — the reader still reads it). - if (s.ptys[pane]) |old| { - _ = libc.kill(old.pid, libc.SIG.KILL); - s.ptys[pane] = null; - } + s.reap(); + s.closePty(pane); + if (s.ptys[pane] != null) return s.core.reportError(pane, "shell", error.WorkersBusy); s.gens[pane] +%= 1; - var cwd_buf: [256:0]u8 = undefined; - var cwd_z: ?[*:0]const u8 = null; - if (cwd.len > 0 and cwd.len < cwd_buf.len) { - @memcpy(cwd_buf[0..cwd.len], cwd); - cwd_buf[cwd.len] = 0; - cwd_z = @ptrCast(&cwd_buf); - } - // The machine-local half is host_io.zig's, not this file's: the same fork - // the tty shell and the detached daemon do, including the CLOEXEC on the - // master that this copy used to be missing (a master a later shell inherits - // is never closed, so a deleted pane's shell never hangs up). - const child = host_io.forkShell(s.core, pane, s.prompt_rcs, s.core.shellBin(), cwd_z, s.core.screen_h, s.core.screen_w, s.fs); - const pt: Pty = .{ .fd = child.file.handle, .pid = child.pid }; + const child = host_io.forkShell(s.core, pane, s.prompt_rcs, s.core.shellBin(), cwd, s.core.screen_h, s.core.screen_w, s.fs) catch |err| return s.core.reportError(pane, "shell", err); + const pt: Pty = .{ .fd = child.file.handle, .pid = child.pid, .serial = s.core.panes[pane].?.serial }; s.ptys[pane] = pt; - // report the pane's starting directory back to the core (tags); the slot - // needs no occupancy reset, nothing about it is remembered - var lbuf: [1024]u8 = undefined; - if (look.shellCwd(pt.pid, &lbuf)) |wd| s.core.setCwd(pane, wd); - if (s.threads_ok) spawnReader(s.gpa, pt, pane, s.gens[pane], s.queue); + var lbuf: [pardes.memory.limits.host_path_cap + 1]u8 = undefined; + if (host_io.shellCwd(pt.pid, &lbuf)) |wd| s.core.setCwd(pane, wd); + if (s.threads_ok) spawnReader(s.gpa, &s.ptys[pane].?, pane, s.gens[pane], s.queue) catch |err| { + s.closePty(pane); + s.core.reportError(pane, "terminal reader", err); + }; } fn ptyWrite(ctx: ?*anyopaque, pane: u8, bytes: []const u8) void { const s = shellOf(ctx); - if (s.ptys[pane]) |pt| _ = host_io.writeFd(pt.fd, bytes); + if (s.ptys[pane]) |pt| if (pt.fd >= 0) { + _ = host_io.writeFd(pt.fd, bytes); + }; } fn ptyResize(ctx: ?*anyopaque, pane: u8, cols: u16, rows: u16) void { const s = shellOf(ctx); if (s.ptys[pane]) |pt| { + if (pt.fd < 0) return; const ws: posix.winsize = .{ .row = rows, .col = cols, .xpixel = 0, .ypixel = 0 }; _ = posix.system.ioctl(pt.fd, TIOCSWINSZ, @intFromPtr(&ws)); } } -/// `pty/ctl`'s `sig`. A pane with no pty of ours has nothing to signal, which -/// is the same silence `ptyWrite` above gives it. fn ptySignal(ctx: ?*anyopaque, pane: u8, sig: pardes.PtySignal) void { const s = shellOf(ctx); - if (s.ptys[pane]) |pt| look.signalTty(pt.pid, pt.fd, sig); + if (s.ptys[pane]) |pt| if (pt.fd >= 0) host_io.signalTty(pt.pid, pt.fd, sig); } -/// Is a program (vim, a pager, an agent) holding this pane's tty instead of -/// the shell we forked? Asked by the core only where it is about to type a -/// command line, which is why the /proc walk behind it is not in pollCwds: -/// nothing draws this answer, and an Exec is a rare frame. fn ttyTaken(ctx: ?*anyopaque, pane: u8) bool { const s = shellOf(ctx); const pt = s.ptys[pane] orelse return false; - return look.ttyTaken(pt.pid, pt.fd); + if (pt.fd < 0) return false; + return host_io.ttyTaken(pt.pid, pt.fd); } fn writeFile(ctx: ?*anyopaque, pane: u8, path: []const u8, bytes: []const u8) void { const s = shellOf(ctx); - host_io.writeFileBytes(path, bytes) catch |err| + filesystem.write(s.core, path, bytes) catch |err| return s.core.saveFailed(pane, "save", err); - // our own write is about to come back as a watch event: restamp from the - // bytes we just put there so it reads as "no change". Only for the pane's - // OWN file — a `Put` elsewhere is a change like any other. if (s.core.panes[pane]) |pane_state| if (pane_state.file) |f| { if (std.mem.eql(u8, f.path, path)) if (s.watches[pane]) |*w| if (w.serial == pane_state.serial) switch (w.generation) { @@ -3933,8 +3817,6 @@ fn writeFile(ctx: ?*anyopaque, pane: u8, path: []const u8, bytes: []const u8) vo .pdf => {}, }; }; - // ...and say so on the pane's message row. AFTER the write, not beside it: - // a save that did not happen must not be reported as one. var mbuf: [256]u8 = undefined; s.core.setMessage(pane, message.stamp(&mbuf, "saved", path)); } @@ -3943,14 +3825,13 @@ fn writeDump(ctx: ?*anyopaque, bytes: []const u8) void { const s = shellOf(ctx); var pbuf: [1024:0]u8 = undefined; const path = pardes.dump.outPath(&pbuf) orelse return; - host_io.writeFileBytes(path, bytes) catch |err| return s.core.reportError(0, "dump", err); + filesystem.write(s.core, path, bytes) catch |err| return s.core.reportError(0, "dump", err); s.core.setLastDump(path); } -fn watchFile(ctx: ?*anyopaque, pane: u8, path: []const u8, on: bool) void { +fn watchFile(ctx: ?*anyopaque, pane: u8, _: []const u8, on: bool, mode: pardes.WatchMode) void { const s = shellOf(ctx); - _ = path; // file_watch resolves it (and a PDF's) from the pane itself - if (file_watch.applyEffect(s.core, s.io, s.gpa, s.inotify_fd, s.watches, pane, on)) + if (file_watch.applyEffect(s.core, s.io, s.inotify_fd, s.watches, pane, on, mode)) s.queue.push(.files_changed); } @@ -3970,7 +3851,7 @@ fn watchTheme(ctx: ?*anyopaque, generation: u32, on: bool) void { fn dumpThemes(ctx: ?*anyopaque, pane: u8) void { const s = shellOf(ctx); const config_dir = s.core.opts.config_dir orelse return; - const out_dir = user_config.dumpThemes(s.io, s.gpa, config_dir, pardes.themes) catch |err| { + const out_dir = config.User.dumpThemes(s.io, s.gpa, config_dir, pardes.themes) catch |err| { s.core.reportError(pane, "dump themes", err); return; }; @@ -3979,21 +3860,12 @@ fn dumpThemes(ctx: ?*anyopaque, pane: u8) void { s.core.setMessage(pane, message.stamp(&mbuf, "dumped themes", out_dir)); } -/// Put `text` on THIS display's clipboard. The one place that copy happens: -/// SDL wants a sentinel-terminated string and a run of core cells is not one. -/// Shared, because the in-process host and an attached window answering a -/// `set_clipboard` off the wire are the same desktop action. fn putClipboard(gpa: std.mem.Allocator, text: []const u8) void { const z = gpa.dupeZ(u8, text) catch return; defer gpa.free(z); _ = c.SDL_SetClipboardText(z.ptr); } -/// THIS display's clipboard, or null when it holds nothing. SDL3 hands over an -/// OWNED copy that is the caller's to `SDL_free`, and reports "no text" as an -/// EMPTY string rather than null — so the length check is what actually -/// rejects a miss. Shared with the attached loop for the `putClipboard` -/// reason, turned round. fn takeClipboard() ?[:0]u8 { const raw = c.SDL_GetClipboardText() orelse return null; const text = std.mem.span(raw); @@ -4004,38 +3876,22 @@ fn takeClipboard() ?[:0]u8 { return text; } -/// Both of these are only ever reached through `Shell.vtable`, which -/// `Shell.host` installs only when there IS a window: the headless grid -/// harness nulls them and keeps the core's own clipboard. So neither needs a -/// `s.gui == null` guard, and neither may have one — a method that returns -/// without answering is exactly what left `SPC p` unanswered in grid mode. fn setClipboard(ctx: ?*anyopaque, text: []const u8) void { putClipboard(shellOf(ctx).gpa, text); } fn readClipboard(ctx: ?*anyopaque) void { - // SDL answers synchronously, so the paste the core is waiting on lands - // inside this same drain — nothing to remember, no reply path to plumb. const text = takeClipboard() orelse return; defer c.SDL_free(text.ptr); shellOf(ctx).core.update(.{ .paste = text }); } test "the headless grid host round-trips a yank back as a paste" { - // The GUI shell's testable mode, driven through the SAME host the grid - // harness installs — `Shell.host()` picks `grid_vtable` off `gui == null`, - // so this is the real seam and not a hand-built one. Before the two - // clipboard methods were nulled, `SPC y` reached a function that returned - // on `gui == null` and `SPC p` was answered by nobody: the content below - // never changed, in the one mode of this file a test can run. const gpa = std.testing.allocator; const core = try pardes.Pardes.init(gpa, .{ .cols = 80, .rows = 24, .file = "mise.toml" }); defer core.deinit(); core.update(.{ .resize = .{ .cols = 80, .rows = 24 } }); - // Everything a Shell needs that this path never touches, at its zero - // value; `io` alone is undefined, because a clipboard is not IO the - // std.Io interface knows about and no arm reached here reads it. var ptys: [pardes.MAX_PANES]?Pty = @splat(null); var gens: [pardes.MAX_PANES]u32 = @splat(0); var lsp_workers: LspWorkers = .{}; @@ -4045,10 +3901,10 @@ test "the headless grid host round-trips a yank back as a paste" { .lsp_workers = &lsp_workers, .sdl_wake = false, }; + defer queue.deinit(); var pipe_tasks: PipeTasks = .{}; var watches: file_watch.Table = @splat(null); - shell_bin.adoptSystemPath(); - var prompt_rcs = shell_bin.PromptRcs.init(); + var prompt_rcs = host_io.Shell.prepare(); defer prompt_rcs.deinit(); var shell: Shell = .{ .core = core, @@ -4067,8 +3923,6 @@ test "the headless grid host round-trips a yank back as a paste" { core.host = shell.host(); try std.testing.expect(core.host.vtable == &Shell.grid_vtable); - // `SPC y`: the selection to the system clipboard. Headless, "the system" - // is the core's in-process one. const pane = core.panes[0].?; core.update(.{ .key = .{ .cp = ' ' } }); core.update(.{ .key = .{ .cp = 'y' } }); @@ -4076,8 +3930,6 @@ test "the headless grid host round-trips a yank back as a paste" { const yanked = core.yank orelse return error.MissingYank; try std.testing.expect(yanked.len > 0); - // ...and `SPC p` gets it back, as an ordinary paste event, inside the - // drain. A host that cannot answer leaves the file exactly as it was. const before = pane.file.?.content.len; core.update(.{ .key = .{ .cp = ' ' } }); core.update(.{ .key = .{ .cp = 'p' } }); @@ -4092,55 +3944,57 @@ fn openLink(ctx: ?*anyopaque, url: []const u8) void { look.openLink(url); // desktop browser } -fn lsp(ctx: ?*anyopaque, req: host_api.LspRequest) void { +fn lsp(ctx: ?*anyopaque, req: host_io.Lsp.Request) void { const s = shellOf(ctx); - if (s.threads_ok) spawnLsp(s.core, s.queue, req); + if (s.threads_ok) return spawnLsp(s.core, s.queue, req); + s.core.update(.{ .lsp_resp = .{ .id = req.id, .rows = null } }); + s.core.reportError(req.pane, "lsp", error.WorkersUnavailable); } fn pipe(ctx: ?*anyopaque, id: u32) void { const s = shellOf(ctx); - if (s.threads_ok) spawnPipe(s.core, s.io, s.gpa, s.queue, s.pipe_tasks, id); + if (s.threads_ok) return spawnPipe(s.core, s.io, s.gpa, s.queue, s.pipe_tasks, id); + s.core.update(.{ .pipe_resp = .{ .id = id, .success = false, .outputs = &.{} } }); + s.core.reportError(s.core.active, "pipe", error.WorkersUnavailable); } -/// Live cwd for tags/look: a cheap per-pane process lookup, polled every frame -/// because a tagline draws it. Whether a pane's tty still belongs to the prompt -/// pardes forked is deliberately NOT polled with it — see `ttyTaken`. fn pollCwds(core: *pardes.Pardes, ptys: *[pardes.MAX_PANES]?Pty) void { for (ptys, 0..) |slot, id| if (slot) |pt| { - var lbuf: [1024]u8 = undefined; - if (look.shellCwd(pt.pid, &lbuf)) |cwd| core.setCwd(id, cwd); + if (pt.fd < 0) continue; + var lbuf: [pardes.memory.limits.host_path_cap + 1]u8 = undefined; + if (host_io.shellCwd(pt.pid, &lbuf)) |cwd| core.setCwd(id, cwd); }; } -// ===================================================================== -// fractional scroll: whole rows for the core, sub-row offsets for the picture -// ===================================================================== +fn shellClock() i64 { + var ts: libc.timespec = undefined; + if (libc.clock_gettime(.MONOTONIC, &ts) != 0) return 0; + return @as(i64, ts.sec) * std.time.ms_per_s + @divTrunc(ts.nsec, std.time.ns_per_ms); +} + +fn reapShell(pid: *libc.pid_t, kill_at: *i64, now: i64) void { + if (pid.* <= 0) return; + const result = libc.waitpid(pid.*, null, libc.W.NOHANG); + if (result > 0 or (result < 0 and libc.errno(result) == .CHILD)) { + pid.* = 0; + kill_at.* = 0; + } else if (kill_at.* != 0 and now >= kill_at.*) { + _ = libc.kill(pid.*, libc.SIG.KILL); + kill_at.* = 0; + } +} -/// Add one raw SDL vertical-wheel value to this render batch. SDL calls up -/// positive; the picture coordinate below calls down positive. Non-finite -/// input, including an addition that overflows, cannot enter persistent state. fn accumulateWheelDelta(pending: f32, raw_y: f32) f32 { if (!std.math.isFinite(raw_y)) return pending; const next = pending - raw_y; return if (std.math.isFinite(next)) next else pending; } -/// The old surface can supply at most one body-height of historical rows. -/// Clamp only pathological per-frame batches to that renderable range; normal -/// SDL deltas pass through unchanged. Including lag in the bound guarantees -/// applyScrollDelta cannot cross more than `body_rows` core boundaries. fn boundScrollDelta(lag: f32, delta: f32, body_rows: u16) f32 { const limit: f32 = @floatFromInt(body_rows); return std.math.clamp(lag + delta, -limit, limit) - lag; } -/// Apply an exact picture displacement and return the whole core rows it -/// crosses (positive = down) plus the retained sub-row picture/core offset. -/// -/// Crossing in the direction of travel rounds the core one row ahead of the -/// picture and leaves lag pointing back at it. Therefore the exposed strip is -/// always the one historical edge row which the previous surface still owns; -/// the renderer never needs a row from the future. fn applyScrollDelta(lag: f32, delta: f32) struct { lag: f32, rows: i32 } { var l = lag + delta; var rows: i32 = 0; @@ -4187,8 +4041,6 @@ test "wheel magnitude is preserved without quantization" { try std.testing.expectEqual(@as(f32, -0.25), s.lag); try std.testing.expectEqual(@as(f32, 0), accumulateWheelDelta(0, std.math.inf(f32))); - // A finite but nonsensical device value is bounded before the reducer, - // avoiding an unbounded loop while keeping the largest renderable move. const bounded = boundScrollDelta(0, 3.0e38, 24); const safe = applyScrollDelta(0, bounded); try std.testing.expectEqual(@as(f32, 24), bounded); @@ -4215,10 +4067,6 @@ fn resetScroll(g: *Gui) void { g.scroll_edge_len = 0; } -/// Apply this frame's exact wheel batch, hand the core every whole row it -/// crossed, and retain the one historical row exposed by the residual offset. -/// Called immediately before core.render(), while core.surface still holds -/// what the previous frame drew. fn stepScroll(g: *Gui, core: *pardes.Pardes, gpa: std.mem.Allocator) void { const id = g.scroll_pane orelse return; const pane = core.panes[id] orelse { @@ -4231,8 +4079,6 @@ fn stepScroll(g: *Gui, core: *pardes.Pardes, gpa: std.mem.Allocator) void { if (g.scroll_lag == 0) resetScroll(g); return; } - // the rect the last frame was painted through — what the snapshot below - // indexes. Nothing between here and render() moves it. const r = core.rects[id]; if (r.h <= pardes.BOX_H) { resetScroll(g); @@ -4251,9 +4097,6 @@ fn stepScroll(g: *Gui, core: *pardes.Pardes, gpa: std.mem.Allocator) void { .col = g.scroll_col, .row = g.scroll_row, } }); - // the document ran out under us (top of a file, bottom of a live - // terminal): there is no travel left to draw, so stop dead rather - // than slide the pane against a view that is not moving if (pane.scroll() == was) { resetScroll(g); return; @@ -4261,16 +4104,9 @@ fn stepScroll(g: *Gui, core: *pardes.Pardes, gpa: std.mem.Allocator) void { } g.scroll_lag = st.lag; g.scroll_rect = r; - // the body origin travels with the rect: the two draw sites below have no - // core to ask, and with Tagbottom the body starts at r.y, not r.y + BOX_H const body_y = if (core.settings.tag_bottom) r.y else r.y + pardes.BOX_H; g.scroll_body_y = body_y; if (st.rows != 0) { - // The offset opens a gap at the trailing edge of the travel, and what - // belongs in it is the row that just left the pane: already gone from - // the surface the core is about to paint, still in the one it painted - // last frame. k rows in, that row is the k-1'th body row from the top - // going down, the k'th from the bottom going up. const s = &core.surface; g.scroll_edge_len = 0; if (r.w > config.GUTTER and r.h > pardes.BOX_H and r.x + r.w <= s.cols) { @@ -4293,12 +4129,6 @@ fn stepScroll(g: *Gui, core: *pardes.Pardes, gpa: std.mem.Allocator) void { if (g.scroll_lag == 0) resetScroll(g); } -/// The fractional pane body, emitted a SECOND time at its sub-row offset, -/// plus the one row of history that fills the gap the offset opens. Writes -/// instances at `base` and returns how many; the caller draws them scissored -/// to the body, which is the whole of the clipping — the shell draws the grid -/// in one flat pass, so without it the overhanging rows would land on the -/// pane's own tag and on whatever is below it. fn emitScrollRows(g: *Gui, instances: [*]CellInstance, base: u32, surface: *pardes.Surface, layout: CellLayout, win_w: f32, win_h: f32, page: Ground) u32 { const scroll_pane = g.scroll_pane orelse return 0; if (g.scroll_lag == 0) return 0; @@ -4311,7 +4141,6 @@ fn emitScrollRows(g: *Gui, instances: [*]CellInstance, base: u32, surface: *pard const bw = r.w - config.GUTTER; const bh = r.h - pardes.BOX_H; if (x0 + bw > surface.cols or y0 + bh > surface.rows) return 0; // resized under us - // the same layout, one sub-row up: emitInstance needs to know nothing var shifted = layout; shifted.y_off -= g.scroll_lag * layout.h; const cursor_idx: u32 = if (surface.cursor) |cu| @as(u32, cu.y) * surface.cols + cu.x else std.math.maxInt(u32); @@ -4326,8 +4155,6 @@ fn emitScrollRows(g: *Gui, instances: [*]CellInstance, base: u32, surface: *pard n += 1; } } - // ...and the row that just left, one row outside the body on the side the - // travel came from. The scissor keeps all of it but the exposed strip. if (g.scroll_edge_len >= bw) { const erow: u16 = if (g.scroll_lag > 0) y0 + bh else y0 - 1; var i: u16 = 0; @@ -4339,8 +4166,6 @@ fn emitScrollRows(g: *Gui, instances: [*]CellInstance, base: u32, surface: *pard return n; } -/// That body rect in target pixels, clamped to the target — the scissor both -/// shells set around the draw above. fn scrollScissor(g: *const Gui, layout: CellLayout, sw: u32, sh: u32) c.SDL_Rect { const r = g.scroll_rect; const px = layout.x_off + @as(f32, @floatFromInt(r.x + config.GUTTER)) * layout.w; @@ -4354,10 +4179,6 @@ fn scrollScissor(g: *const Gui, layout: CellLayout, sw: u32, sh: u32) c.SDL_Rect return .{ .x = x0, .y = y0, .w = x1 - x0, .h = y1 - y0 }; } -// ===================================================================== -// render: Surface → instanced quads → SDL GPU -// ===================================================================== - fn releaseNativeImage(g: *Gui, key: pardes.ImageCacheKey) void { if (g.native_images.fetchRemove(key)) |removed| c.SDL_ReleaseGPUTexture(g.device, removed.value); @@ -4414,7 +4235,7 @@ fn snapshotContainsPlacement(g: *const Gui, current: pardes.ImagePlace) bool { return false; } -fn placeIntersectsBox(place: SavedImagePlace, box: pardes.panel_animation.Box) bool { +fn placeIntersectsBox(place: SavedImagePlace, box: pardes.layout.Box) bool { const x0: f32 = @floatFromInt(place.x); const y0: f32 = @floatFromInt(place.y); const x1: f32 = @floatFromInt(@as(u32, place.x) + place.w); @@ -4550,21 +4371,15 @@ fn uploadNativeTexture( }; c.SDL_UploadToGPUTexture(copy, &src, &dst, false); c.SDL_EndGPUCopyPass(copy); - // SDL defers destruction until the submitted copy is done; the staging - // allocation is never needed again, so do not retain a second full image - // beside the texture for the life of the pane. try g.native_images.put(gpa, key, texture); } -/// Upload new pixel generations and the small per-frame placement buffer. -/// Returns the number of image instances drawNativeImagesGpu will consume. -// EFFECT_CODE_NATIVE_PANEL_BEGIN fn appendPreparedImage( g: *Gui, gpa: std.mem.Allocator, place: SavedImagePlace, - track: ?pardes.panel_animation.Track, - clip: ?pardes.panel_animation.Box, + track: ?pardes.layout.Track, + clip: ?pardes.layout.Box, old_layer: bool, ) void { const texture = g.native_images.get(place.key) orelse return; @@ -4676,8 +4491,6 @@ fn prepareNativeImages( appendPreparedImage(g, gpa, saved, active, null, false); } } else if (active.effect == .dissolve) { - // Old layer first. Its shader half disappears at the same - // per-cell threshold at which the current half appears. for (g.presented_images.items) |saved| { if (!placeIntersectsBox(saved, active.contentBox())) continue; appendPreparedImage(g, gpa, saved, active, active.contentBox(), true); @@ -4692,18 +4505,12 @@ fn prepareNativeImages( paintBatchForSerial(plan, current.serial) != batch_index) continue; const saved = SavedImagePlace.from(current); const current_track = if (track) |active| switch (active.effect) { - // An identical cached placement is semantically unchanged - // and must bypass a data effect exactly like an unchanged - // cell in Surface.cell_diffs. .dissolve => if (snapshotContainsPlacement(g, current)) null else active, else => active, } else null; appendPreparedImage(g, gpa, saved, current_track, null, false); } } - // Frozen visual under an incoming lifecycle pane. Append it after - // canonical static placements so it restores the old target pixels; - // the translated opening batch is painted later over this fixed clip. if (batch_index == 0) for (plan.batches[1..plan.len]) |tracked| { const active = tracked.track.?; if (active.effect != .vertical or active.phase != .opening) continue; @@ -4731,8 +4538,6 @@ fn prepareNativeImages( idx += 1; } c.SDL_UnmapGPUTransferBuffer(g.device, g.image_vxfer); - // The planning pass already rejected undrawable placements, so a mismatch - // here would desynchronize batch offsets from texture bindings. std.debug.assert(idx == capacity); const copy = c.SDL_BeginGPUCopyPass(cmd); @@ -4751,8 +4556,6 @@ fn imageScissor( ) c.SDL_Rect { if (prepared.clip) |box| return panelBoxScissor(g, box, max_w, max_h); if (prepared.track) |active| { - // Vertical travel is visible only through the pane's fixed lifecycle - // box. Other effects retain their historical visual-box clipping. const box = if (active.effect == .vertical) active.contentBox() else active.visualBox(); return panelBoxScissor(g, box, max_w, max_h); } @@ -4765,7 +4568,7 @@ fn imageScissor( }; } -fn panelBoxScissor(g: *const Gui, box: pardes.panel_animation.Box, max_w: u32, max_h: u32) c.SDL_Rect { +fn panelBoxScissor(g: *const Gui, box: pardes.layout.Box, max_w: u32, max_h: u32) c.SDL_Rect { const x0: i32 = @intFromFloat(@floor(box.x * @as(f32, @floatFromInt(g.cell_w)))); const y0: i32 = @intFromFloat(@floor(box.y * @as(f32, @floatFromInt(g.cell_h)))); const x1: i32 = @intFromFloat(@ceil((box.x + box.w) * @as(f32, @floatFromInt(g.cell_w)))); @@ -4817,9 +4620,7 @@ fn drawNativeImagesGpu( }; c.SDL_SetGPUScissor(pass, &whole); } -// EFFECT_CODE_NATIVE_PANEL_END -// EFFECT_CODE_FRAME_SUBMISSION_BEGIN fn renderFrame( g: *Gui, gpa: std.mem.Allocator, @@ -4828,27 +4629,19 @@ fn renderFrame( theme_bg: ?[3]u8, topbar_pane_border_rgb: [3]u8, tagline_rgb: [3]u8, - scene_effects: pardes.panel_animation.SceneEffect, + scene_effects: pardes.layout.SceneEffect, debug_on: bool, ) !bool { - // Per-attempt, not per-last-success: an early swapchain/capture return - // after one real target failure must not count as another failed retry. g.scene_target_failed = false; const cmd = c.SDL_AcquireGPUCommandBuffer(g.device) orelse return false; var command_consumed = false; defer if (!command_consumed) { - // CPU caches become authoritative as uploads are enqueued (glyphs lose - // atlas_dirty; native textures enter the map). Submit every abandoned - // buffer so those transactions remain true. Submission is valid with - // or without a swapchain texture; cancellation is not valid after one - // is acquired and would discard capture-mode uploads before it. _ = c.SDL_SubmitGPUCommandBuffer(cmd); }; var sw: u32 = 0; var sh: u32 = 0; var target: *c.SDL_GPUTexture = undefined; if (g.capture) { - // capture: render offscreen (the window may never present), dump PPM sw = @as(u32, surface.cols) * g.cell_w; sh = @as(u32, surface.rows) * g.cell_h; if (sw == 0 or sh == 0) { @@ -4859,8 +4652,6 @@ fn renderFrame( try ensureCaptureTexture(g, sw, sh); target = g.capture_tex.?; } else if (g.soft_present) { - // No swapchain to acquire: render the window-sized frame offscreen and - // blit it in softPresentFrame below. var pw: c_int = 0; var ph: c_int = 0; if (!c.SDL_GetWindowSizeInPixels(g.window, &pw, &ph) or pw <= 0 or ph <= 0) { @@ -4888,13 +4679,8 @@ fn renderFrame( const win_w: f32 = @floatFromInt(sw); const win_h: f32 = @floatFromInt(sh); - // All full-window effects share one offscreen scene and one composable - // shader pass. With no bits set the ordinary render remains direct. var scene_on = scene_effects.crt or scene_effects.ripple or scene_effects.glitch; if (scene_on) ensureSceneTexture(g, sw, sh) catch { - // The optional postprocess target does not own canonical rendering. - // Use this already-acquired command/swapchain directly for the frame; - // the loop bounds retries and clears the public bits after three. g.scene_target_failed = true; scene_on = false; }; @@ -4902,11 +4688,6 @@ fn renderFrame( const layout = fixedCellLayout(g); var paint_plan = makePaintPlan(surface.panelTracks(), surface.hasPanelDiff()); - // Cursors/debug overlays do not carry pane ownership. Hide those for the - // short interval in which panel geometry differs from logical geometry; - // otherwise a cursor could remain pinned at the final cell, or paint over - // a later opening pane, while its own pane moves underneath it. The - // topbar/pane rule is anchored window chrome and remains present. var overlay_count = buildOverlay( g, core, @@ -4922,8 +4703,6 @@ fn renderFrame( if (overlay_count != 0 and !uploadOverlayGpu(g, cmd, overlay_count)) overlay_count = 0; if (!prepareNativeImages(g, gpa, cmd, surface, &paint_plan, sw, sh)) { - // Planning wrote offsets before buffer growth/map could fail. Never - // let those counts index the previous frame's smaller/stale buffer. g.prepared_images.clearRetainingCapacity(); for (paint_plan.batches[0..paint_plan.len]) |*batch| { batch.image_start = 0; @@ -4940,11 +4719,6 @@ fn renderFrame( }; var color_target = std.mem.zeroes(c.SDL_GPUColorTargetInfo); color_target.texture = scene; - // Premultiplied, because that is what both a wl_surface and an X11 ARGB - // visual are composited as, and the glyph pass writes premultiplied for - // the same reason. A see-through ground is therefore all four channels - // zero and not `page.rgb` at alpha zero — the leftover colour would tint - // every glyph edge that blends against it. color_target.clear_color = if (page.clear) .{ .r = 0, .g = 0, .b = 0, .a = 0 } else .{ .r = @as(f32, @floatFromInt(page.rgb[0])) / 255.0, .g = @as(f32, @floatFromInt(page.rgb[1])) / 255.0, @@ -4978,9 +4752,6 @@ fn renderFrame( ) catch return error.GpuCapacity; } } - // Vertical opening retains the frozen grid below the incoming pane; - // closing is a presentation-only copy above the new canonical grid. Only - // those pane boxes are duplicated, so surviving panes never get tracks. for (paint_plan.batches[1..paint_plan.len]) |*batch| { const track = batch.track.?; const duplicate_under = track.effect == .vertical and track.phase == .opening; @@ -5008,14 +4779,9 @@ fn renderFrame( cell_total = std.math.add(u32, cell_total, batch.cell_count) catch return error.GpuCapacity; } if (cell_total != 0) { - // Reserve one further grid for the fractional-scroll duplicate. Its - // path suppresses itself for an animated pane, but another static pane - // may still be scrolling while a closing tombstone is visible. const capacity = std.math.add(u32, cell_total, cells) catch return error.GpuCapacity; try ensureVbuf(g, capacity); - // ponytail: full re-upload every frame; the prototype's dirty-range - // diffing (cell_keys + coalesced ranges) is skipped for now. const vptr: [*]u8 = @ptrCast(c.SDL_MapGPUTransferBuffer(g.device, g.vxfer.?, false) orelse { command_consumed = true; _ = c.SDL_SubmitGPUCommandBuffer(cmd); @@ -5110,8 +4876,6 @@ fn renderFrame( shifted = emitScrollRows(g, instances, cell_total, surface, layout, win_w, win_h, page); c.SDL_UnmapGPUTransferBuffer(g.device, g.vxfer.?); - // emitInstance may have rasterized new glyphs into the staging atlas; - // upload after vertex generation so this frame has what it references if (g.atlas_dirty) uploadAtlas(g, cmd); const copy = c.SDL_BeginGPUCopyPass(cmd); @@ -5121,10 +4885,6 @@ fn renderFrame( c.SDL_EndGPUCopyPass(copy); } - // Paint one complete panel before the next: its opaque cells followed by - // its native attachments. A phase-wide image tail would let an earlier - // pane's PDF/image cover a later pane's cells when their moving boxes - // overlap. PaintPlan is static, then moving slots, then opening slots. const rp = c.SDL_BeginGPURenderPass(cmd, &color_target, 1, null); const whole = c.SDL_Rect{ .x = 0, .y = 0, .w = @intCast(sw), .h = @intCast(sh) }; for (paint_plan.batches[0..paint_plan.len], 0..) |batch, batch_index| { @@ -5148,8 +4908,6 @@ fn renderFrame( if (panel_clipped) c.SDL_SetGPUScissor(rp, &whole); } if (has_shifted) { - // The fractional duplicate is static content and remains - // below the static pane's native attachments. const clip = scrollScissor(g, layout, sw, sh); c.SDL_SetGPUScissor(rp, &clip); const binding = c.SDL_GPUBufferBinding{ @@ -5205,9 +4963,6 @@ fn renderFrame( c.SDL_EndGPURenderPass(crt_pass); } if (g.capture) { - // captureFrame consumes `cmd` on every success and error path: its - // allocation failures submit directly, and the ordinary path submits - // while acquiring the readback fence. command_consumed = true; try captureFrame(g, gpa, cmd, target, sw, sh); g.presented_scene = rendered_scene; @@ -5215,7 +4970,6 @@ fn renderFrame( return true; } if (g.soft_present) { - // Same contract as captureFrame: the readback submits `cmd` itself. command_consumed = true; try softPresentFrame(g, cmd, target, sw, sh); g.presented_scene = rendered_scene; @@ -5230,21 +4984,15 @@ fn renderFrame( } return submitted; } -// EFFECT_CODE_FRAME_SUBMISSION_END -// EFFECT_CODE_CELL_INSTANCE_BEGIN const ResolvedCell = struct { slot: Slot, fg: [3]u8, bg: [3]u8, role: pardes.FontRole, - /// `bg` is the see-through ground rather than a colour: paint the glyph - /// and leave the rest of the cell to the compositor. clear_bg: bool = false, }; -// Both moved to the core so the AppKit shell can call the SAME rule over the C -// ABI instead of keeping a second copy of it — see pardes.taglineBandOffset. const topbarPaneBorderPixels = pardes.topbarPaneBorderPixels; const taglineBandOffset = pardes.taglineBandOffset; @@ -5281,14 +5029,8 @@ test "tagline bands face the topbar rule and Tagbottom faces the window edge" { fn resolveCell(g: *Gui, cell: *const pardes.Cell, role: pardes.FontRole, is_cursor: bool, page: Ground) ResolvedCell { var fg = fg_default; var bg = page.rgb; - // Only an UNREVERSED default background is the ground; every branch below - // that names a real colour clears this, and the reverse at the end clears - // it because a reverse puts the TEXT colour there. var clear_bg = page.clear; var reverse = is_cursor; - // An invisible cell over a clear ground has nothing left to draw: `fg = bg` - // hides a glyph by painting it in the background, and a background that is - // not painted at all would let the ink through as a coloured silhouette. var blank = false; if (!cell.default) { const st = cell.style; @@ -5332,35 +5074,10 @@ fn resolveCell(g: *Gui, cell: *const pardes.Cell, role: pardes.FontRole, is_curs }; } -/// What the CORE said this cell's face is. fn cellFontRole(cell: *const pardes.Cell) pardes.FontRole { return if (cell.default) .body else cell.style.font_role; } -/// ...and the face it is actually DRAWN in, which differs in exactly one case -/// and that case is the whole of what an attached window renders differently. -/// -/// A compact tagline band is anchored at its pane's LEFT EDGE — that is what -/// `compactTaglineLayout`'s `origin_col` is — and a pane's left edge is a pane -/// RECT, which this wire does not carry (it carries cells, not the layout that -/// placed them). Anchoring per cell instead is not a near-miss, it is a picket -/// fence: `x_off = col * (body_w - tag_w)` puts every cell back on BODY pitch -/// while the quad stays `tag_w` wide, so the chrome band shows through between -/// every pair of cells and the text tracks visibly loose. Widening the quad -/// does not close it either — `emitInstance` samples exactly `tagline_width` -/// atlas texels for a tagline cell, so a wider quad stretches the glyph. -/// -/// So a pane tag row with no core to ask goes on the BODY grid, face and all: -/// one quad per cell at body pitch and body size, tiling exactly and tracking -/// exactly. The visible difference from a local window is that those rows wear -/// the body face rather than the 82% one, and that is the price of the pane -/// rects not being on the wire. It is also the grid `gridCellAtDimensions` -/// already hit-tests an attached tag row against, so a click still lands on the -/// glyph it was aimed at. -/// -/// ROW ZERO is exempt, and that exemption is why the topbar was never striped: -/// its origin is not a pane rect but column zero, always, so its compact band -/// is right with or without a core. fn drawnFontRole(core: ?*const pardes.Pardes, cell: *const pardes.Cell, row: u16) pardes.FontRole { const role = cellFontRole(cell); if (role != .tagline or core != null or row < pardes.TOPBAR_H) return role; @@ -5371,10 +5088,6 @@ fn cellInstanceCount(core: ?*const pardes.Pardes, cell: *const pardes.Cell, row: return if (drawnFontRole(core, cell, row) == .tagline) 2 else 1; } -/// A tagline cell has two quads. The first preserves the pane-wide chrome -/// band on the body grid; the second draws the real cell on the smaller text -/// grid. Emitting them together in increasing column order is sufficient: -/// the compact cell never reaches the next body's cell origin. fn emitSurfaceCell( g: *Gui, core: ?*const pardes.Pardes, @@ -5385,7 +5098,7 @@ fn emitSurfaceCell( body_layout: CellLayout, win_w: f32, win_h: f32, - track: ?pardes.panel_animation.Track, + track: ?pardes.layout.Track, cell: *const pardes.Cell, tagline_base: *const pardes.Cell, old_layer: bool, @@ -5414,25 +5127,15 @@ fn emitInstance( layout: CellLayout, win_w: f32, win_h: f32, - track: ?pardes.panel_animation.Track, - /// The face this quad draws in, decided once per cell by `drawnFontRole` - /// rather than re-derived here: an attached window demotes a pane tag row - /// to the body face, and the quad geometry, the atlas slot and the uv span - /// all have to agree about that in one place. + track: ?pardes.layout.Track, role: pardes.FontRole, cell: *const pardes.Cell, - /// Old and new data layers carry their own quad geometry. The shader - /// discards exactly one at every reveal state, so a body/tagline role - /// change retains the correct band height on both sides of the diff. old_layer: bool, is_cursor: bool, - /// the ground this frame: what a default background resolves to, and - /// whether that is a colour at all page: Ground, ) void { const resolved = resolveCell(g, cell, role, is_cursor, page); - // Cell pixel rect (top-left origin) → NDC (y up). const px0 = layout.x_off + @as(f32, @floatFromInt(col)) * layout.w; const visual_h: f32 = if (resolved.role == .tagline) @floatFromInt(g.tagline_height) @@ -5491,7 +5194,6 @@ fn emitInstance( if (old_layer) instances[idx].effect |= old_layer_bit; if (resolved.clear_bg) instances[idx].effect |= clear_bg_bit; } -// EFFECT_CODE_CELL_INSTANCE_END fn cellCodepoint(cell: *const pardes.Cell) u32 { const grapheme = cell.grapheme(); @@ -5519,8 +5221,6 @@ test "insert cursor overlays without replacing the character beneath it" { .h = 20, }); try std.testing.expectEqual(@as(usize, 6), builder.len); - // A tagline caret uses the same centered visual band as its glyph and - // background, while its logical row remains the body-sized grid row. try std.testing.expectApproxEqAbs(@as(f32, -0.32), vertices[0].y, 0.0001); try std.testing.expectApproxEqAbs(@as(f32, -0.48), vertices[2].y, 0.0001); } @@ -5530,7 +5230,6 @@ fn palColor(idx: u8) [3]u8 { return .{ p.r, p.g, p.b }; } -// first codepoint of a UTF-8 grapheme; space on failure/empty fn firstCp(s: []const u8) u32 { if (s.len == 0) return ' '; const n = std.unicode.utf8ByteSequenceLength(s[0]) catch return ' '; @@ -5538,24 +5237,6 @@ fn firstCp(s: []const u8) u32 { return std.unicode.utf8Decode(s[0..n]) catch ' '; } -/// Re-measure the cell, throw the glyph atlas away, and re-fit the grid to the -/// window. THE path for any change to what a cell LOOKS like: point g.font at -/// a different face (the Font builtin, above) or write a different g.px (the -/// Ctrl+/Ctrl- in dispatch) and call this — those are one line each, and -/// everything that has to follow from them is here. -/// -/// The atlas is the part that must not be skipped, and the reason the whole -/// thing is a function rather than three lines at a call site. It is keyed by -/// codepoint and font role — two raster sizes sharing a pen of cell_w×cell_h -/// slots — so after a change every slot in it holds the wrong picture at the -/// wrong metrics, and every key already in the map would keep being -/// drawn from that slot forever, because ensureGlyph's first line is a cache -/// hit. Clearing the map, zeroing the staging bitmap and rewinding the pen put -/// it back to exactly what init built, and ensureGlyph refills it as the next -/// frame draws. The zeroing is not tidiness: the upload is the WHOLE texture, -/// the new cell size is a different grid over the same 2048², and a leftover -/// bitmap no slot points at any more would still be sampled by whatever new -/// slot overlaps it. fn refitFont(g: *Gui, core: ?*pardes.Pardes) void { g.scale = c.ui_font_scale_for_height(g.font, g.px); var cw: c_int = 10; @@ -5573,38 +5254,17 @@ fn refitFont(g: *Gui, core: ?*pardes.Pardes) void { resetGlyphAtlas(g); - // ...and the grid: the same window is a different number of cells now. The - // shells re-derive this every frame anyway, so this is only the frame the - // change happens on — but it is the frame the surface is about to be - // rendered for, and a stale screen_w here is a row of cells drawn off the - // right edge of the window. An attached window has no core to tell: its - // loop compares `windowCells` against the last geometry it sent and puts a - // resize on the wire from there. if (core) |p| { const geom = windowCells(g); _ = updateCoreResize(p, geom.cols, geom.rows, g.cell_w, g.cell_h); } - // ...and a fractional scroll is measured in the OLD grid: scroll_rect - // is a rect of the pane the last frame drew, and scroll_edge is a saved row - // of exactly that rect's body WIDTH. The resize above moves both under it, - // and emitScrollRows only checks that the old rect still FITS inside the new - // surface — which it does whenever the font got smaller — so the next frame - // would paint last frame's strip over cells that are no longer the same - // text. Retire the offset instead; the next wheel event starts in the new - // grid. resetScroll(g); } -/// Rewind the shared atlas without touching body metrics. TaglineSize uses -/// this path: body glyphs are lazily reinserted at the same scale, tagline -/// glyphs at their new scale and band height. Repacking everything avoids an -/// ever-growing graveyard of old tagline slots when a config file experiments -/// with several sizes in one session. fn resetGlyphAtlas(g: *Gui) void { g.glyphs.clearRetainingCapacity(); @memset(g.atlas_stage, 0); - // slot (0,0) is the space glyph, exactly as init lays it out _ = c.ui_font_raster(g.font, g.scale, ' ', g.atlas_stage.ptr, @intCast(atlas_w), @intCast(g.cell_w), @intCast(g.cell_h), g.ascent); g.space_slot = .{ .u = 0, .v = 0 }; g.pen_x = g.cell_w; @@ -5676,9 +5336,6 @@ fn ensureVbuf(g: *Gui, cells: u32) !void { var xf_info = c.SDL_GPUTransferBufferCreateInfo{ .usage = c.SDL_GPU_TRANSFERBUFFERUSAGE_UPLOAD, .size = size, .props = 0 }; const next_vxfer = c.SDL_CreateGPUTransferBuffer(g.device, &xf_info) orelse return error.GpuCreate; - // Allocate the pair before retiring either old half. A failed transfer - // allocation must not leave a new vertex buffer paired with null (or a - // stale vbuf_cells value that makes the next call accept that pair). if (g.vbuf) |buffer| c.SDL_ReleaseGPUBuffer(g.device, buffer); if (g.vxfer) |transfer| c.SDL_ReleaseGPUTransferBuffer(g.device, transfer); g.vbuf = next_vbuf; @@ -5813,8 +5470,6 @@ fn makeImagePipeline(device: *c.SDL_GPUDevice, color_format: c.SDL_GPUTextureFor return c.SDL_CreateGPUGraphicsPipeline(device, &info) orelse error.GpuCreate; } -// the CRT pass: a fullscreen triangle sampling the scene texture, no vertex -// buffers at all (positions from gl_VertexIndex) fn makeCrtPipeline(device: *c.SDL_GPUDevice, color_format: c.SDL_GPUTextureFormat) !*c.SDL_GPUGraphicsPipeline { const vs = try makeShader(device, crt_vert_spv, c.SDL_GPU_SHADERSTAGE_VERTEX, 0, 0); defer c.SDL_ReleaseGPUShader(device, vs); @@ -5845,10 +5500,6 @@ fn makeShader(device: *c.SDL_GPUDevice, code: []const u8, stage: c.SDL_GPUShader return c.SDL_CreateGPUShader(device, &info) orelse error.GpuCreate; } -// ===================================================================== -// PARDES_TEST frame capture: download the render target, write latest.ppm -// ===================================================================== - fn ensureSceneTexture(g: *Gui, width: u32, height: u32) !void { if (g.scene_tex != null and g.scene_tex_w == width and g.scene_tex_h == height) return; var info = std.mem.zeroes(c.SDL_GPUTextureCreateInfo); @@ -5885,13 +5536,6 @@ fn ensureCaptureTexture(g: *Gui, width: u32, height: u32) !void { g.capture_tex_h = height; } -// ===================================================================== -// Software present: readback + SDL_Renderer blit, for compositors that -// cannot back a Vulkan swapchain (no linux-dmabuf; p9wl, remote stacks) -// ===================================================================== - -/// A colour-target format the device supports and writeCapturePpm/softPresentFrame -/// can both interpret. BGRA first because it is the usual swapchain layout. fn softTargetFormat(device: *c.SDL_GPUDevice) c.SDL_GPUTextureFormat { const candidates = [_]c.SDL_GPUTextureFormat{ c.SDL_GPU_TEXTUREFORMAT_B8G8R8A8_UNORM, @@ -5908,7 +5552,6 @@ fn softTargetFormat(device: *c.SDL_GPUDevice) c.SDL_GPUTextureFormat { return c.SDL_GPU_TEXTUREFORMAT_B8G8R8A8_UNORM; } -/// SDL pixel format matching the byte order of a 32-bit GPU format. fn softPixelFormat(format: c.SDL_GPUTextureFormat) ?c.SDL_PixelFormat { return switch (format) { c.SDL_GPU_TEXTUREFORMAT_B8G8R8A8_UNORM, @@ -5937,10 +5580,6 @@ fn ensureSoftTexture(g: *Gui, width: u32, height: u32) !*c.SDL_Texture { @intCast(height), ) orelse return error.GpuCreate; _ = c.SDL_SetTextureScaleMode(next, c.SDL_SCALEMODE_NEAREST); - // The readback IS the frame, alpha included and already premultiplied. - // SDL's default for an alpha format is BLENDMODE_BLEND, which would blend - // it a second time against the cleared window and darken every glyph edge - // over a see-through ground. _ = c.SDL_SetTextureBlendMode(next, c.SDL_BLENDMODE_NONE); g.soft_texture = next; g.soft_tex_w = width; @@ -5948,8 +5587,6 @@ fn ensureSoftTexture(g: *Gui, width: u32, height: u32) !*c.SDL_Texture { return next; } -/// Download the finished frame and blit it with SDL_Renderer. Consumes `cmd` -/// on every path, exactly like captureFrame. fn softPresentFrame(g: *Gui, cmd: *c.SDL_GPUCommandBuffer, target: *c.SDL_GPUTexture, sw: u32, sh: u32) !void { const bpp = c.SDL_GPUTextureFormatTexelBlockSize(g.swapchain_format); const size = c.SDL_CalculateGPUTextureFormatSize(g.swapchain_format, sw, sh, 1); @@ -5985,9 +5622,6 @@ fn softPresentFrame(g: *Gui, cmd: *c.SDL_GPUCommandBuffer, target: *c.SDL_GPUTex const texture = try ensureSoftTexture(g, sw, sh); const renderer = g.soft_renderer orelse return error.GpuCreate; if (!c.SDL_UpdateTexture(texture, null, mapped, @intCast(sw * bpp))) return error.GpuMap; - // Clear to nothing rather than to opaque black: on a transparent window - // this is the pixel the compositor keeps wherever the frame does not - // cover, and BLENDMODE_NONE below writes the frame over it verbatim. _ = c.SDL_SetRenderDrawBlendMode(renderer, c.SDL_BLENDMODE_NONE); _ = c.SDL_SetRenderDrawColor(renderer, 0, 0, 0, if (g.transparent) 0 else 255); _ = c.SDL_RenderClear(renderer); @@ -6068,11 +5702,6 @@ fn writeCapturePpm(g: *Gui, gpa: std.mem.Allocator, pixels: []const u8, width: u if (libc.rename(tmp_path, final_path) != 0) return error.CaptureWriteFailed; } -// ===================================================================== -// touch debug overlay: per-finger colored circles + trails + a click-action -// flash HUD, alpha-blended over the grid only while the Debug builtin is on. -// ===================================================================== - fn addCursorBar( builder: *OverlayBuilder, x: u16, @@ -6126,9 +5755,6 @@ fn buildOverlay( .a = 1.0, }); } - // The core grid contains only complete cells. Extend a bottommost - // Tagbottom band through the swapchain remainder so an arbitrary window - // height cannot reintroduce a page-colored strip below the final row. const grid_bottom = @as(f32, @floatFromInt(surface.rows)) * layout.h; if (grid_bottom < win_h and bottomTaglinePresent(surface)) { const rgb = tagline_rgb; @@ -6395,12 +6021,6 @@ fn miniGlyph(ch: u8) [5]u8 { }; } -// ===================================================================== -// shared plumbing (same shapes as tty.zig) -// ===================================================================== - -/// The effective codepoint the way vaxis Key.matches sees it: a single-char -/// text wins (shift resolved by the terminal), else the shifted codepoint. fn effCp(key: vaxis.Key) u21 { if (key.text) |t| { const view = std.unicode.Utf8View.init(t) catch return key.codepoint; @@ -6412,8 +6032,6 @@ fn effCp(key: vaxis.Key) u21 { return key.shifted_codepoint orelse key.codepoint; } -/// vaxis functional-key codepoints -> core Key constants (ASCII ones already -/// coincide: enter/tab/escape/backspace pass through). fn mapKey(cp: u21) u21 { return switch (cp) { vaxis.Key.up => pardes.Key.up, |
