summaryrefslogtreecommitdiff
path: root/src/gui/gui.zig
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-09-06 18:11:36 -0300
committerGabriel Schneider <[email protected]>2026-09-07 13:59:12 -0300
commit60367d8fe23f6af98ec28e3cf6c2094dfe332df0 (patch)
tree310fc734173cf771881f4691c71909135fadde97 /src/gui/gui.zig
parentfa82cac885cb4738fe36d1e49b4749b5a3e31a4a (diff)
downloadpardes-60367d8fe23f6af98ec28e3cf6c2094dfe332df0.tar.gz
pardes-60367d8fe23f6af98ec28e3cf6c2094dfe332df0.zip
Refactor panes and filesystem; replace FUSE with 9P
Consolidate pane, layout, memory and host code. Serve 9P by default over Unix sockets, with runtime mounts and optional TCP/QUIC transports. Remove FUSE and obsolete proof-of-concept examples. Fix highlighting and terminal-history performance, expand differential and stress-test infrastructure, sort navigation results while preserving the next occurrence, add syntax-colored Braille minimaps, remove SPC-k, and document 9P interaction as a repository skill.
Diffstat (limited to 'src/gui/gui.zig')
-rw-r--r--src/gui/gui.zig2432
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,