//! 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 std = @import("std"); const builtin = @import("builtin"); const posix = std.posix; 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 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"); // 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({ @cDefine("SDL_DISABLE_OLD_NAMES", "1"); @cInclude("SDL3/SDL.h"); @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); const font_ttf = @embedFile("AdwaitaMono-Regular.ttf"); const vert_spv = @embedFile("ui.vert.spv"); const frag_spv = @embedFile("ui.frag.spv"); const overlay_vert_spv = @embedFile("overlay.vert.spv"); const overlay_frag_spv = @embedFile("overlay.frag.spv"); const image_vert_spv = @embedFile("image.vert.spv"); 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, fn opaqueRgb(rgb: [3]u8) Ground { return .{ .rgb = rgb, .clear = false }; } }; /// 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, 0x80, 0x02, 0x80, 0x01, 0x80, 0x02, 0x8C, 0x04, 0x92, 0x08, 0x91, 0x10, 0xA0, 0xA0, 0xC0, 0x40, }; const p9_arrow_set = [32]u8{ 0x00, 0x00, 0x7F, 0xFE, 0x7F, 0xFC, 0x7F, 0xF0, 0x7F, 0xE0, 0x7F, 0xE0, 0x7F, 0xF0, 0x7F, 0xF8, 0x7F, 0xFC, 0x7F, 0xFE, 0x7F, 0xFC, 0x73, 0xF8, 0x61, 0xF0, 0x60, 0xE0, 0x40, 0x40, 0x00, 0x00, }; const p9_arrow_mask = blk: { var m: [32]u8 = undefined; for (&m, p9_arrow_set, p9_arrow_clr) |*b, s, cl| b.* = s | cl; 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 }; const GlyphKey = struct { codepoint: u32, 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, baseline: i32, height: u32, }; fn taglineRaster(font: *c.UIFont, body_px: f32, body_cell_w: u32, body_cell_h: u32, percent: u8) TaglineRaster { const px = body_px * @as(f32, @floatFromInt(percent)) / 100.0; const scale = c.ui_font_scale_for_height(font, px); var own_w: c_int = 1; var own_h: c_int = 1; var own_ascent: c_int = 1; c.ui_font_cell_metrics(font, scale, &own_w, &own_h, &own_ascent); const fixed_w: c_int = @intCast(body_cell_w); const fixed_h: c_int = @intCast(body_cell_h); const band_h = std.math.clamp(own_h, 1, fixed_h); 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), }; } 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 = &.{}, }; const max_touch_points: usize = 16; const max_touch_trail_points: usize = 8; const overlay_circle_vertices: usize = 16 * 3; 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, fn due(clock: *AnimationClock, active: bool, now_ns: u64) bool { if (!active) { clock.next_ns = 0; return false; } if (clock.next_ns == 0) { clock.next_ns = now_ns +| pardes.animation.frame_ns; return false; } if (now_ns < clock.next_ns) return false; clock.next_ns = now_ns +| pardes.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, now_ns: u64, ) void { core.acknowledgePanelPresentation(tracks); if (clock.due(core.animationActive(), now_ns)) core.update(.tick); } 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.expectEqual(@as(u64, 0), clock.next_ns); } test "a persistent scene presents a new panel's frame zero before advancing it" { const core = try pardes.Pardes.init(std.testing.allocator, .{ .tty_only = true }); defer core.deinit(); var arena: std.heap.ArenaAllocator = .init(std.testing.allocator); 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); core.settings.panel_transition = .slide; core.update(.{ .command = "Newcol" }); _ = arena.reset(.retain_capacity); const first = try core.render(arena.allocator()); const track_count = first.panelTracks().len; 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, ); _ = arena.reset(.retain_capacity); const second = try core.render(arena.allocator()); try std.testing.expectEqual(track_count, second.panelTracks().len); for (second.panelTracks()) |track| try std.testing.expectEqual(@as(u16, 1), track.frame); } 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; 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; var ch: c_int = 0; var asc: c_int = 0; var pw: c_int = 0; var ph: c_int = 0; c.ui_font_cell_metrics(font, c.ui_font_scale_for_height(font, px), &cw, &ch, &asc); 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)); } test "FreeType atlas raster is grayscale, bounded, and clears blank cells" { const font = c.ui_font_new(font_ttf.ptr, @intCast(font_ttf.len)).?; defer c.ui_font_free(font); try std.testing.expectEqual(@as(c_int, 1), c.ui_font_has_glyph(font, 'H')); try std.testing.expectEqual(@as(c_int, 0), c.ui_font_has_glyph(font, 0x10ffff)); const size = c.ui_font_scale_for_height(font, 13.0); var cell_w: c_int = 0; var cell_h: c_int = 0; var ascent: c_int = 0; c.ui_font_cell_metrics(font, size, &cell_w, &cell_h, &ascent); try std.testing.expect(cell_w > 0 and cell_w <= 128); try std.testing.expect(cell_h > 0 and cell_h <= 128); const stride: c_int = 128; var bitmap: [128 * 128]u8 = @splat(0xaa); try std.testing.expectEqual( @as(c_int, 1), c.ui_font_raster(font, size, 'H', &bitmap, stride, cell_w, cell_h, ascent), ); var ink: usize = 0; var soft: usize = 0; for (0..@intCast(cell_h)) |y| { for (0..@intCast(cell_w)) |x| { const coverage = bitmap[y * @as(usize, @intCast(stride)) + x]; if (coverage > 0) ink += 1; if (coverage > 0 and coverage < 255) soft += 1; } } try std.testing.expect(ink > 0); try std.testing.expect(soft > 0); try std.testing.expectEqual( @as(c_int, 0), c.ui_font_raster(font, size, ' ', &bitmap, stride, cell_w, cell_h, ascent), ); for (0..@intCast(cell_h)) |y| for (0..@intCast(cell_w)) |x| try std.testing.expectEqual( @as(u8, 0), bitmap[y * @as(usize, @intCast(stride)) + x], ); } const InkBounds = struct { min_x: usize, min_y: usize, max_x: usize, max_y: usize }; fn inkBounds(bitmap: []const u8, stride: usize, w: usize, h: usize) ?InkBounds { var bounds: ?InkBounds = null; for (0..h) |y| for (0..w) |x| { if (bitmap[y * stride + x] == 0) continue; if (bounds) |*b| { b.min_x = @min(b.min_x, x); b.min_y = @min(b.min_y, y); b.max_x = @max(b.max_x, x); b.max_y = @max(b.max_y, y); } else bounds = .{ .min_x = x, .min_y = y, .max_x = x, .max_y = y }; }; return bounds; } test "tagline glyph shrinks into its own cell and stays vertically centered" { const font = c.ui_font_new(font_ttf.ptr, @intCast(font_ttf.len)).?; defer c.ui_font_free(font); const body_scale = c.ui_font_scale_for_height(font, 27.0); var cell_w: c_int = 1; var cell_h: c_int = 1; var body_ascent: c_int = 1; c.ui_font_cell_metrics(font, body_scale, &cell_w, &cell_h, &body_ascent); const tag = taglineRaster(font, 27.0, @intCast(cell_w), @intCast(cell_h), config.gui_tagline_font_percent); try std.testing.expect(tag.scale < body_scale); try std.testing.expect(tag.width < @as(u32, @intCast(cell_w))); const stride: c_int = 128; var body: [128 * 128]u8 = @splat(0); var tagline: [128 * 128]u8 = @splat(0); try std.testing.expectEqual(@as(c_int, 1), c.ui_font_raster( font, body_scale, 'H', &body, stride, cell_w, cell_h, body_ascent, )); try std.testing.expectEqual(@as(c_int, 1), c.ui_font_raster( font, tag.scale, 'H', &tagline, stride, @intCast(tag.width), @intCast(tag.height), tag.baseline, )); const b = inkBounds(&body, @intCast(stride), @intCast(cell_w), @intCast(cell_h)).?; 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); try std.testing.expect(@abs(tag_center_y - body_center_y) <= 4); try std.testing.expect(tag.height < @as(u32, @intCast(cell_h))); } test "tagline percentage changes measured band height without body metrics" { const font = c.ui_font_new(font_ttf.ptr, @intCast(font_ttf.len)).?; defer c.ui_font_free(font); const body_scale = c.ui_font_scale_for_height(font, 27.0); var cell_w: c_int = 1; var cell_h: c_int = 1; var ascent: c_int = 1; c.ui_font_cell_metrics(font, body_scale, &cell_w, &cell_h, &ascent); const small = taglineRaster(font, 27.0, @intCast(cell_w), @intCast(cell_h), 40); const configured = taglineRaster(font, 27.0, @intCast(cell_w), @intCast(cell_h), 82); const full = taglineRaster(font, 27.0, @intCast(cell_w), @intCast(cell_h), 100); try std.testing.expect(small.width < configured.width); try std.testing.expect(configured.width <= full.width); try std.testing.expectEqual(@as(u32, @intCast(cell_w)), full.width); 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)); } test "glyph atlas cache separates body and tagline roles" { var glyphs = std.AutoHashMap(GlyphKey, Slot).init(std.testing.allocator); defer glyphs.deinit(); try glyphs.put(.{ .codepoint = 'A', .role = .body }, .{ .u = 0, .v = 0 }); try glyphs.put(.{ .codepoint = 'A', .role = .tagline }, .{ .u = 10, .v = 0 }); try std.testing.expectEqual(@as(usize, 2), glyphs.count()); try std.testing.expectEqual(@as(u32, 0), glyphs.get(.{ .codepoint = 'A', .role = .body }).?.u); try std.testing.expectEqual(@as(u32, 10), glyphs.get(.{ .codepoint = 'A', .role = .tagline }).?.u); } test "installed Nerd Symbols fallback covers Yazi directory icons" { try std.testing.expectEqualStrings("SymbolsNerdFont-Regular", fonts.fallback_names[2]); var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena_state.deinit(); const candidate = for (fonts.fallbacks(arena_state.allocator())) |font| { if (std.mem.eql(u8, font.name, "SymbolsNerdFont-Regular")) break font; } else return; const bytes = try look.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, x1: f32, y1: f32, u0: f32, v0: f32, u1: f32, v1: f32, fr: f32, fg: f32, fb: f32, br: f32, bg: f32, bb: f32, panel_x0: f32 = 0, panel_y0: f32 = 0, panel_x1: f32 = 0, panel_y1: f32 = 0, present_x0: f32 = 0, present_y0: f32 = 0, present_x1: f32 = 0, present_y1: f32 = 0, effect: u32 = 0, progress_bits: u32 = @bitCast(@as(f32, 1)), serial: u32 = 0, cell_coord: u32 = 0, }; const OverlayVertex = extern struct { x: f32, y: f32, r: f32, g: f32, b: f32, a: f32 }; const OverlayColor = struct { r: f32, g: f32, b: f32, a: f32 }; const ImageInstance = extern struct { x0: f32, y0: f32, x1: f32, y1: f32, u0: f32, v0: f32, u1: f32, v1: f32, panel_x0: f32 = 0, panel_y0: f32 = 0, panel_x1: f32 = 0, panel_y1: f32 = 0, present_x0: f32 = 0, present_y0: f32 = 0, present_x1: f32 = 0, present_y1: f32 = 0, effect: u32 = 0, progress_bits: u32 = @bitCast(@as(f32, 1)), serial: u32 = 0, cell_coord: u32 = 0, }; 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, serial: u32, native: pardes.NativePlacement, x: u16, y: u16, w: u16, h: u16, iw: usize, ih: usize, fn from(place: pardes.ImagePlace) SavedImagePlace { return .{ .key = place.cacheKey(), .pane = place.pane, .serial = place.serial, .native = place.native, .x = place.x, .y = place.y, .w = place.w, .h = place.h, .iw = place.iw, .ih = place.ih, }; } fn cacheKey(place: SavedImagePlace) pardes.ImageCacheKey { return place.key; } }; const PreparedImage = struct { place: SavedImagePlace, texture: *c.SDL_GPUTexture, track: ?pardes.panel_animation.Track = null, clip: ?pardes.panel_animation.Box = null, old_layer: bool = false, }; test "saved native placement retains identity and geometry but no producer bytes" { try std.testing.expect(!@hasField(SavedImagePlace, "rgba")); const current: pardes.ImagePlace = .{ .pane = 3, .serial = 91, .x = 4, .y = 5, .w = 6, .h = 7, .rgba = &.{ 1, 2, 3, 4 }, .iw = 1, .ih = 1, }; const saved = SavedImagePlace.from(current); try std.testing.expect(sameSavedPlacement(saved, current)); try std.testing.expect(placeIntersectsBox(saved, .{ .x = 8, .y = 8, .w = 4, .h = 4 })); try std.testing.expect(!placeIntersectsBox(saved, .{ .x = 20, .y = 20, .w = 2, .h = 2 })); } test "shader instance ABI carries aligned transition vectors" { try std.testing.expectEqual(@as(usize, 104), @sizeOf(CellInstance)); try std.testing.expectEqual(@as(usize, 56), @offsetOf(CellInstance, "panel_x0")); try std.testing.expectEqual(@as(usize, 72), @offsetOf(CellInstance, "present_x0")); try std.testing.expectEqual(@as(usize, 88), @offsetOf(CellInstance, "effect")); try std.testing.expectEqual(@as(usize, 80), @sizeOf(ImageInstance)); try std.testing.expectEqual(@as(usize, 32), @offsetOf(ImageInstance, "panel_x0")); try std.testing.expectEqual(@as(usize, 48), @offsetOf(ImageInstance, "present_x0")); 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{ .{ .pane = 0, .phase = .opening, .effect = .slide }, .{ .pane = 1, .phase = .moving, .effect = .slide }, .{ .pane = 2, .phase = .moving, .effect = .slide }, }; const plan = makePaintPlan(&tracks, true); try std.testing.expectEqual(@as(usize, 4), plan.len); try std.testing.expect(plan.batches[0].track == null); try std.testing.expectEqual(@as(u8, 1), plan.batches[1].track.?.pane); try std.testing.expectEqual(@as(u8, 2), plan.batches[2].track.?.pane); try std.testing.expectEqual(@as(u8, 0), plan.batches[3].track.?.pane); } test "GUI paint plan snaps history effects without a frozen grid" { const tracks = [_]pardes.panel_animation.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); } test "closing tombstone overlays but never owns canonical cells" { const closing: pardes.panel_animation.Track = .{ .pane = 0, .phase = .closing, .effect = .vertical, .from = .{ .x = 2, .y = 3, .w = 10, .h = 4 }, .to = .{ .x = 2, .y = -1, .w = 10, .h = 4 }, }; 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); } fn updateCoreResize(core: *pardes.Pardes, cols: u16, rows: u16, cell_w: u32, cell_h: u32) bool { if (comptime pardes.pdf_enabled) { const cell_px_w: u16 = @intCast(@min(@max(1, cell_w), std.math.maxInt(u16))); const cell_px_h: u16 = @intCast(@min(@max(1, cell_h), std.math.maxInt(u16))); if (cols == core.screen_w and rows == core.screen_h and cell_px_w == core.cell_pixels.w and cell_px_h == core.cell_pixels.h) return false; core.update(.{ .resize = .{ .cols = cols, .rows = rows, .cell_pixels = .{ .w = cell_px_w, .h = cell_px_h }, } }); } else { if (cols == core.screen_w and rows == core.screen_h) return false; core.update(.{ .resize = .{ .cols = cols, .rows = rows } }); } 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 { active: bool = false, id: u64 = 0, x: f32 = 0, y: f32 = 0, pressure: f32 = 1, trail: [max_touch_trail_points]TouchSample = @splat(.{}), trail_len: usize = 0, trail_next: usize = 0, }; 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 { points: [max_touch_points]TouchPoint = @splat(.{}), scroll: struct { active: bool = false, ids: [2]u64 = .{ 0, 0 }, accum_y: f32 = 0, scrolled: bool = false, last_center: TouchNormPoint = .{}, } = .{}, click_count: u32 = 0, click_button: pardes.Mouse.Button = .middle, click_flash_frames: u8 = 0, fn findSlot(t: *const Touch, id: u64) ?usize { for (&t.points, 0..) |*tp, i| if (tp.active and tp.id == id) return i; return null; } fn updatePoint(t: *Touch, f: Finger) void { switch (f.kind) { .up, .cancel => if (t.findSlot(f.id)) |i| { t.points[i].active = false; }, .down, .motion => { const idx = t.findSlot(f.id) orelse blk: { for (&t.points, 0..) |*tp, i| if (!tp.active) break :blk i; break :blk @as(usize, @intCast(f.id % max_touch_points)); }; const tp = &t.points[idx]; if (!tp.active or tp.id != f.id) tp.* = .{ .active = true, .id = f.id }; tp.x = f.x; tp.y = f.y; tp.pressure = f.pressure; tp.trail[tp.trail_next] = .{ .x = f.x, .y = f.y, .pressure = f.pressure }; tp.trail_next = (tp.trail_next + 1) % max_touch_trail_points; if (tp.trail_len < max_touch_trail_points) tp.trail_len += 1; }, } } fn pairCenter(t: *const Touch, ids: [2]u64) ?TouchNormPoint { var sum: TouchNormPoint = .{}; for (ids) |id| { const i = t.findSlot(id) orelse return null; sum.x += t.points[i].x; sum.y += t.points[i].y; } 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; for (&t.points) |*tp| { if (!tp.active) continue; if (count >= 2) { t.scroll = .{}; return; } ids[count] = tp.id; count += 1; } if (count != 2) { t.scroll = .{}; return; } if (ids[0] > ids[1]) std.mem.swap(u64, &ids[0], &ids[1]); if (t.scroll.active and t.scroll.ids[0] == ids[0] and t.scroll.ids[1] == ids[1]) return; 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; var center: ?TouchNormPoint = null; if (tap) { var sum: TouchNormPoint = .{}; var ok = true; for (t.scroll.ids) |id| { if (id == f.id) { sum.x += f.x; sum.y += f.y; } else if (t.findSlot(id)) |i| { sum.x += t.points[i].x; sum.y += t.points[i].y; } else ok = false; } center = if (ok) .{ .x = sum.x * 0.5, .y = sum.y * 0.5 } else t.scroll.last_center; } t.scroll = .{}; return center; } fn noteMotion(t: *Touch, f: Finger) ?struct { center: TouchNormPoint, ticks: i32, delta_y: f32 } { if (!t.scroll.active or (t.scroll.ids[0] != f.id and t.scroll.ids[1] != f.id)) return null; const center = t.pairCenter(t.scroll.ids) orelse t.scroll.last_center; const delta_y = center.y - t.scroll.last_center.y; t.scroll.last_center = center; t.scroll.accum_y += delta_y; const ticks = takeScrollTicks(&t.scroll.accum_y); if (ticks == 0) return null; t.scroll.scrolled = true; return .{ .center = center, .ticks = ticks, .delta_y = @as(f32, @floatFromInt(ticks)) * touch_scroll_tick }; } }; fn takeScrollTicks(accum: *f32) i32 { var ticks: i32 = 0; while (accum.* >= touch_scroll_tick) : (ticks += 1) accum.* -= touch_scroll_tick; while (accum.* <= -touch_scroll_tick) : (ticks -= 1) accum.* += touch_scroll_tick; 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); } fn handlePairFinger(t: *Touch, in: *Input, f: Finger, win_w: f32, win_h: f32, cell_w: f32, cell_h: f32, tagline_w: f32) void { switch (f.kind) { .down => { t.updatePoint(f); t.syncPair(); }, .motion => { const had_pair = t.scroll.active; t.updatePoint(f); t.syncPair(); if (!had_pair) return; const res = t.noteMotion(f) orelse return; emitTouchScroll(in, res.center, res.ticks, res.delta_y, win_w, win_h, cell_w, cell_h, tagline_w); }, .up, .cancel => { const tap_center = t.finishPair(f); t.updatePoint(f); t.syncPair(); if (tap_center) |center| emitTouchClick(t, in, center, .middle, win_w, win_h, cell_w, cell_h, tagline_w); }, } } fn emitTouchScroll(in: *Input, center: TouchNormPoint, ticks: i32, delta_y: f32, win_w: f32, win_h: f32, cell_w: f32, cell_h: f32, tagline_w: f32) void { const cell = gridCellAtDimensions(in.core, center.x * win_w, center.y * win_h, cell_w, cell_h, tagline_w); const button: pardes.Mouse.Button = if (ticks > 0) .wheel_up else .wheel_down; var remaining: u32 = @abs(ticks); while (remaining > 0) : (remaining -= 1) in.post(.{ .mouse = .{ .button = button, .kind = .press, .col = cell.col, .row = cell.row } }); in.post(.{ .touch_scroll = delta_y }); } fn emitTouchClick(t: *Touch, in: *Input, point: TouchNormPoint, button: pardes.Mouse.Button, win_w: f32, win_h: f32, cell_w: f32, cell_h: f32, tagline_w: f32) void { t.click_count +%= 1; t.click_button = button; t.click_flash_frames = touch_click_flash_max_frames; const cell = gridCellAtDimensions(in.core, point.x * win_w, point.y * win_h, cell_w, cell_h, tagline_w); in.post(.{ .mouse = .{ .button = button, .kind = .press, .col = cell.col, .row = cell.row } }); in.post(.{ .mouse = .{ .button = button, .kind = .release, .col = cell.col, .row = cell.row } }); } fn normCell(norm: f32, win: f32, cell: f32) u16 { const px = std.math.clamp(norm, 0.0, 1.0) * win; 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 }; 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 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_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 { active: std.atomic.Value(usize) = .init(0), fn start(workers: *LspWorkers) void { _ = workers.active.fetchAdd(1, .monotonic); } fn finish(workers: *LspWorkers) void { _ = workers.active.fetchSub(1, .release); } fn wait(workers: *LspWorkers) void { var spins: u8 = 0; while (workers.active.load(.acquire) != 0) { spins +%= 1; if (spins == 0) std.Thread.yield() catch {} else std.atomic.spinLoopHint(); } } }; const max_pipe_tasks = 16; /// 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 MessageBatch = struct { items: [queue_capacity]Msg = undefined, len: usize = 0, fn slice(batch: *MessageBatch) []Msg { return batch.items[0..batch.len]; } }; const Queue = struct { gpa: std.mem.Allocator, lsp_allocator: std.mem.Allocator, lsp_workers: *LspWorkers, sdl_wake: bool, // wake a blocking SDL_WaitEventTimeout on cross-thread push // 0.16 has no std.Thread.Mutex; critical sections here are a few // instructions, so spinning on the lock-free std.atomic.Mutex is enough. mutex: std.atomic.Mutex = .unlocked, items: [queue_capacity]Msg = undefined, head: usize = 0, len: usize = 0, closed: bool = false, fn lock(q: *Queue) void { while (!q.mutex.tryLock()) std.atomic.spinLoopHint(); } fn removeAt(q: *Queue, offset: usize) Msg { const removed = q.items[(q.head + offset) % q.items.len]; var i = offset; while (i + 1 < q.len) : (i += 1) q.items[(q.head + i) % q.items.len] = q.items[(q.head + i + 1) % q.items.len]; q.len -= 1; return removed; } /// Output and other refreshable work are lossy under sustained /// backpressure. EOF and pipe completions are admitted by evicting queued /// non-critical messages, so descriptors and futures reach the loop. fn push(q: *Queue, m: Msg) void { q.lock(); if (q.closed) { q.mutex.unlock(); 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); } } fn take(q: *Queue) MessageBatch { q.lock(); defer q.mutex.unlock(); var batch: MessageBatch = .{}; while (q.len > 0) { batch.items[batch.len] = q.items[q.head]; batch.len += 1; q.head = (q.head + 1) % q.items.len; q.len -= 1; } q.head = 0; return batch; } fn close(q: *Queue, ptys: *[pardes.MAX_PANES]?Pty, gens: *[pardes.MAX_PANES]u32) void { q.lock(); defer q.mutex.unlock(); q.closed = true; while (q.len > 0) { const m = q.items[q.head]; switch (m) { .eof => |e| { _ = libc.close(e.fd); if (gens[e.pane] == e.gen) { if (ptys[e.pane]) |pt| if (pt.fd == e.fd) { ptys[e.pane] = null; }; } }, else => m.deinit(q.gpa, q.lsp_allocator), } q.head = (q.head + 1) % q.items.len; q.len -= 1; } q.head = 0; } }; /// 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. 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 { var buf: [0x10000]u8 = undefined; while (true) { 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 (n == 0) break; const bytes = gpa.dupe(u8, buf[0..@intCast(n)]) catch break; q.push(.{ .output = .{ .pane = pane, .gen = gen, .bytes = bytes } }); } q.push(.{ .eof = .{ .pane = pane, .gen = gen, .fd = fd } }); } 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(); } /// 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); } } /// 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 }); } } /// 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 { defer workers.finish(); lsp_host.work(lsp_allocator, job, q, pushLspRows); } 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 { 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.lsp_workers.finish(); job.free(lsp_allocator); return; }; th.detach(); } 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, gpa: std.mem.Allocator, q: *Queue, tasks: *PipeTasks, id: u32, ) void { if (tasks.len == tasks.items.len) { core.update(.{ .pipe_resp = .{ .id = id, .success = false, .outputs = &.{} } }); 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 { job.deinit(gpa); return; }; std.debug.assert(tasks.add(.{ .id = id, .future = future })); } // ---- the renderer state ---- const Gui = struct { window: *c.SDL_Window, device: *c.SDL_GPUDevice, swapchain_format: c.SDL_GPUTextureFormat, pipeline: *c.SDL_GPUGraphicsPipeline, overlay_pipeline: *c.SDL_GPUGraphicsPipeline, image_pipeline: *c.SDL_GPUGraphicsPipeline, crt_pipeline: *c.SDL_GPUGraphicsPipeline, atlas_tex: *c.SDL_GPUTexture, atlas_sampler: *c.SDL_GPUSampler, linear_sampler: *c.SDL_GPUSampler, atlas_xfer: *c.SDL_GPUTransferBuffer, vbuf: ?*c.SDL_GPUBuffer = null, vxfer: ?*c.SDL_GPUTransferBuffer = null, vbuf_cells: u32 = 0, overlay_vbuf: *c.SDL_GPUBuffer, overlay_vxfer: *c.SDL_GPUTransferBuffer, overlay_vertices: []OverlayVertex, image_vbuf: *c.SDL_GPUBuffer, image_vxfer: *c.SDL_GPUTransferBuffer, image_capacity: u32 = 0, native_images: std.AutoHashMapUnmanaged(pardes.ImageCacheKey, *c.SDL_GPUTexture) = .empty, presented_images: std.ArrayListUnmanaged(SavedImagePlace) = .empty, 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, tagline_percent: u8, tagline_width: u32, tagline_height: u32, cell_w: u32, cell_h: u32, 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, pen_y: u32 = 0, atlas_dirty: bool = true, space_slot: Slot = .{ .u = 0, .v = 0 }, touch: Touch = .{}, 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) scroll_col: u16 = 0, // where the wheel turned: crossed rows are delivered scroll_row: u16 = 0, // THERE, not wherever the pointer has drifted to since scroll_delta: f32 = 0, // finite raw distance waiting for the render batch scroll_lag: f32 = 0, // picture position - core position, in rows 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, capture_tex_w: u32 = 0, capture_tex_h: u32 = 0, 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, pad_scroll: f32 = 0, // fractional vertical wheel ticks (left stick) pad_scroll_h: f32 = 0, // fractional horizontal wheel ticks (left stick) deck: deck.Deck = .{}, }; 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]); g.font_name_len = @intCast(len); } fn guiFontName(g: *const Gui) []const u8 { return g.font_name[0..g.font_name_len]; } fn observeGuiFont(g: *const Gui, core: *pardes.Pardes) void { const size: u16 = @intFromFloat(@round(g.px * 100.0)); _ = core.observeFont(guiFontName(g), size, .pixels); } fn acknowledgeGuiFont(g: *const Gui, core: *pardes.Pardes) void { const size: u16 = @intFromFloat(@round(g.px * 100.0)); _ = core.acknowledgeFont(guiFontName(g), size, .pixels); } 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; } var arena_state = std.heap.ArenaAllocator.init(gpa); 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 len = std.math.cast(c_int, bytes.len) orelse { gpa.free(bytes); continue; }; const face = c.ui_font_new(bytes.ptr, len) orelse { gpa.free(bytes); continue; }; g.fallbacks[g.fallback_count] = .{ .face = face, .bytes = bytes }; g.fallback_count += 1; } } fn deinitFallbackFonts(g: *Gui, gpa: std.mem.Allocator) void { for (g.fallbacks[0..g.fallback_count]) |loaded| { const fallback = loaded.?; c.ui_font_free(fallback.face); if (fallback.bytes.len != 0) gpa.free(fallback.bytes); } } fn fontForCodepoint(g: *const Gui, cp: u32) *c.UIFont { if (c.ui_font_has_glyph(g.font, @intCast(cp)) != 0) return g.font; for (g.fallbacks[0..g.fallback_count]) |loaded| { 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 { window_w: f32, window_h: f32, pixel_w: f32, pixel_h: f32, }; fn windowGeometry(window: *c.SDL_Window) WindowGeometry { var ww: c_int = 0; var wh: c_int = 0; var pw: c_int = 0; var ph: c_int = 0; _ = c.SDL_GetWindowSize(window, &ww, &wh); _ = c.SDL_GetWindowSizeInPixels(window, &pw, &ph); return .{ .window_w = @floatFromInt(@max(ww, 1)), .window_h = @floatFromInt(@max(wh, 1)), .pixel_w = @floatFromInt(@max(pw, 1)), .pixel_h = @floatFromInt(@max(ph, 1)), }; } /// 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 { var pw: c_int = 0; var ph: c_int = 0; _ = c.SDL_GetWindowSizeInPixels(g.window, &pw, &ph); return .{ .cols = @intCast(@max(1, @divTrunc(@as(u32, @intCast(@max(pw, 1))), g.cell_w))), .rows = @intCast(@max(1, @divTrunc(@as(u32, @intCast(@max(ph, 1))), g.cell_h))), }; } fn windowPointToPixels(geometry: WindowGeometry, x: f32, y: f32) crt.Point { return .{ .x = x * geometry.pixel_w / geometry.window_w, .y = y * geometry.pixel_h / geometry.window_h, }; } test "high-density window coordinates map once to render pixels" { const geometry: WindowGeometry = .{ .window_w = 560, .window_h = 360, .pixel_w = 1120, .pixel_h = 720, }; try std.testing.expectEqual(crt.Point{ .x = 700, .y = 240 }, windowPointToPixels(geometry, 350, 120)); } fn fixedCellLayout(g: *const Gui) CellLayout { return .{ .w = @floatFromInt(g.cell_w), .h = @floatFromInt(g.cell_h), .x_off = 0, .y_off = 0 }; } fn compactTaglineLayout(g: *const Gui, origin_col: f32) CellLayout { const body_w: f32 = @floatFromInt(g.cell_w); const tag_w: f32 = @floatFromInt(g.tagline_width); 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, ) 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; // EFFECT_CODE_PANEL_HOST_BEGIN const PaintBatch = struct { track: ?pardes.panel_animation.Track = null, cell_start: u32 = 0, cell_count: u32 = 0, image_start: u32 = 0, image_count: u32 = 0, }; 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 { var plan: PaintPlan = .{}; for ([_]pardes.panel_animation.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; plan.len += 1; } } return plan; } fn paintBatchAt(plan: *const PaintPlan, col: u16, row: u16) usize { for (plan.batches[1..plan.len], 1..) |batch, index| { if (batch.track.?.phase == .closing) continue; if (boxContains(batch.track.?.to, col, row)) return index; } return 0; } fn paintBatchForSerial(plan: *const PaintPlan, serial: u32) usize { for (plan.batches[1..plan.len], 1..) |batch, index| if (batch.track.?.serial == serial) return index; return 0; } fn panelCellCoord(track: pardes.panel_animation.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 { 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 { 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; const py1 = py0 + box.h * layout.h; return .{ px0 / win_w * 2.0 - 1.0, 1.0 - py0 / win_h * 2.0, px1 / win_w * 2.0 - 1.0, 1.0 - py1 / win_h * 2.0, }; } fn setTransitionFields( instance: anytype, track: ?pardes.panel_animation.Track, layout: CellLayout, win_w: f32, win_h: f32, cell_coord: u32, ) void { const active = track orelse { instance.panel_x0 = 0; instance.panel_y0 = 0; instance.panel_x1 = 0; instance.panel_y1 = 0; instance.present_x0 = 0; instance.present_y0 = 0; instance.present_x1 = 0; instance.present_y1 = 0; instance.effect = 0; instance.progress_bits = @bitCast(@as(f32, 1)); instance.serial = 0; instance.cell_coord = cell_coord; return; }; const final = ndcBox(active.contentBox(), layout, win_w, win_h); const presented = ndcBox(active.visualBox(), layout, win_w, win_h); instance.panel_x0 = final[0]; instance.panel_y0 = final[1]; instance.panel_x1 = final[2]; instance.panel_y1 = final[3]; instance.present_x0 = presented[0]; instance.present_y0 = presented[1]; instance.present_x1 = presented[2]; instance.present_y1 = presented[3]; instance.effect = @intFromEnum(active.effect); instance.progress_bits = @bitCast(active.amount()); instance.serial = active.serial; instance.cell_coord = cell_coord; } // EFFECT_CODE_PANEL_HOST_END // ===================================================================== // entry // ===================================================================== pub const run = runNative; /// `attach` is `--attach[=]`: 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", .{}); return error.AttachNeedsWindow; } return runGrid(init, opts_in); } const test_mode = env.get("PARDES_TEST") != null; const capture_dir = env.get("PARDES_TEST_CAPTURE_DIR"); if (test_mode and capture_dir == null) { log.err("PARDES_TEST requires PARDES_TEST_CAPTURE_DIR", .{}); 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)) { log.err("SDL_Init: {s}", .{c.SDL_GetError()}); return error.SdlInit; } 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()}); return error.SdlInit; }; 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| !(std.mem.eql(u8, raw, "0") or raw.len == 0) else false; if (config.gui_transparent or force_soft or !c.SDL_ClaimWindowForGPUDevice(device, window)) { if (config.gui_transparent) log.info("transparent window: the GPU API will not claim one, presenting through SDL_Renderer", .{}) else if (force_soft) log.info("PARDES_SOFT_PRESENT set: presenting in software", .{}) else log.warn("ClaimWindowForGPUDevice: {s}; presenting in software", .{c.SDL_GetError()}); soft_renderer = c.SDL_CreateRenderer(window, null) orelse { log.err("SDL_CreateRenderer: {s}", .{c.SDL_GetError()}); return error.SdlInit; }; 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| { const want: ?c.SDL_GPUPresentMode = if (std.ascii.eqlIgnoreCase(raw, "immediate")) c.SDL_GPU_PRESENTMODE_IMMEDIATE else if (std.ascii.eqlIgnoreCase(raw, "mailbox")) c.SDL_GPU_PRESENTMODE_MAILBOX else if (std.ascii.eqlIgnoreCase(raw, "vsync")) c.SDL_GPU_PRESENTMODE_VSYNC else null; if (want) |mode| if (c.SDL_WindowSupportsGPUPresentMode(device, window, mode)) break :blk mode; } for ([_]c.SDL_GPUPresentMode{ c.SDL_GPU_PRESENTMODE_IMMEDIATE, c.SDL_GPU_PRESENTMODE_MAILBOX, c.SDL_GPU_PRESENTMODE_VSYNC }) |mode| { if (c.SDL_WindowSupportsGPUPresentMode(device, window, mode)) break :blk mode; } break :blk c.SDL_GPU_PRESENTMODE_VSYNC; }; if (!test_mode) _ = c.SDL_SetGPUAllowedFramesInFlight(device, 1); 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; }; const px: f32 = 27.0; const scale = c.ui_font_scale_for_height(font, px); var cw: c_int = 10; var chh: c_int = 20; var asc: c_int = 16; c.ui_font_cell_metrics(font, scale, &cw, &chh, &asc); const cell_w: u32 = @intCast(@max(cw, 1)); const cell_h: u32 = @intCast(@max(chh, 1)); const tagline = taglineRaster(font, px, cell_w, cell_h, config.gui_tagline_font_percent); if (test_mode) { const cols = envU16(env, "PARDES_TEST_COLS") orelse 80; const rows = envU16(env, "PARDES_TEST_ROWS") orelse 24; _ = c.SDL_SetWindowSize(window, @intCast(cols * cell_w), @intCast(rows * cell_h)); _ = 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; tex_info.usage = c.SDL_GPU_TEXTUREUSAGE_SAMPLER; tex_info.width = atlas_w; tex_info.height = atlas_h; tex_info.layer_count_or_depth = 1; tex_info.num_levels = 1; tex_info.sample_count = c.SDL_GPU_SAMPLECOUNT_1; const atlas_tex = c.SDL_CreateGPUTexture(device, &tex_info) orelse return error.GpuCreate; var samp_info = std.mem.zeroes(c.SDL_GPUSamplerCreateInfo); samp_info.min_filter = c.SDL_GPU_FILTER_NEAREST; samp_info.mag_filter = c.SDL_GPU_FILTER_NEAREST; samp_info.mipmap_mode = c.SDL_GPU_FILTER_NEAREST; samp_info.address_mode_u = c.SDL_GPU_SAMPLERADDRESSMODE_CLAMP_TO_EDGE; samp_info.address_mode_v = c.SDL_GPU_SAMPLERADDRESSMODE_CLAMP_TO_EDGE; samp_info.address_mode_w = c.SDL_GPU_SAMPLERADDRESSMODE_CLAMP_TO_EDGE; const atlas_sampler = c.SDL_CreateGPUSampler(device, &samp_info) orelse return error.GpuCreate; samp_info.min_filter = c.SDL_GPU_FILTER_LINEAR; samp_info.mag_filter = c.SDL_GPU_FILTER_LINEAR; samp_info.mipmap_mode = c.SDL_GPU_FILTER_LINEAR; const linear_sampler = c.SDL_CreateGPUSampler(device, &samp_info) orelse return error.GpuCreate; var ax_info = c.SDL_GPUTransferBufferCreateInfo{ .usage = c.SDL_GPU_TRANSFERBUFFERUSAGE_UPLOAD, .size = atlas_w * atlas_h, .props = 0 }; const atlas_xfer = c.SDL_CreateGPUTransferBuffer(device, &ax_info) orelse return error.GpuCreate; const pipeline = try makePipeline(device, swapchain_format); const overlay_pipeline = try makeOverlayPipeline(device, swapchain_format); const image_pipeline = try makeImagePipeline(device, swapchain_format); const crt_pipeline = try makeCrtPipeline(device, swapchain_format); const overlay_buffer_size: u32 = @intCast(max_overlay_vertices * @sizeOf(OverlayVertex)); var ovb_info = c.SDL_GPUBufferCreateInfo{ .usage = c.SDL_GPU_BUFFERUSAGE_VERTEX, .size = overlay_buffer_size, .props = 0 }; const overlay_vbuf = c.SDL_CreateGPUBuffer(device, &ovb_info) orelse return error.GpuCreate; var ovx_info = c.SDL_GPUTransferBufferCreateInfo{ .usage = c.SDL_GPU_TRANSFERBUFFERUSAGE_UPLOAD, .size = overlay_buffer_size, .props = 0 }; const overlay_vxfer = c.SDL_CreateGPUTransferBuffer(device, &ovx_info) orelse return error.GpuCreate; const image_buffer_size: u32 = initial_image_capacity * @sizeOf(ImageInstance); var ivb_info = c.SDL_GPUBufferCreateInfo{ .usage = c.SDL_GPU_BUFFERUSAGE_VERTEX, .size = image_buffer_size, .props = 0 }; const image_vbuf = c.SDL_CreateGPUBuffer(device, &ivb_info) orelse return error.GpuCreate; var ivx_info = c.SDL_GPUTransferBufferCreateInfo{ .usage = c.SDL_GPU_TRANSFERBUFFERUSAGE_UPLOAD, .size = image_buffer_size, .props = 0 }; const image_vxfer = c.SDL_CreateGPUTransferBuffer(device, &ivx_info) orelse return error.GpuCreate; const atlas_stage = try gpa.alloc(u8, atlas_w * atlas_h); defer gpa.free(atlas_stage); @memset(atlas_stage, 0); const overlay_vertices = try gpa.alloc(OverlayVertex, max_overlay_vertices); defer gpa.free(overlay_vertices); var g: Gui = .{ .window = window, .device = device, .swapchain_format = swapchain_format, .pipeline = pipeline, .overlay_pipeline = overlay_pipeline, .image_pipeline = image_pipeline, .crt_pipeline = crt_pipeline, .atlas_tex = atlas_tex, .atlas_sampler = atlas_sampler, .linear_sampler = linear_sampler, .atlas_xfer = atlas_xfer, .overlay_vbuf = overlay_vbuf, .overlay_vxfer = overlay_vxfer, .overlay_vertices = overlay_vertices, .image_vbuf = image_vbuf, .image_vxfer = image_vxfer, .image_capacity = initial_image_capacity, .font = font, .px = px, .scale = scale, .tagline_scale = tagline.scale, .tagline_percent = config.gui_tagline_font_percent, .tagline_width = tagline.width, .tagline_height = tagline.height, .cell_w = cell_w, .cell_h = cell_h, .ascent = asc, .tagline_baseline = tagline.baseline, .atlas_stage = atlas_stage, .glyphs = std.AutoHashMap(GlyphKey, Slot).init(gpa), .capture = test_mode, .capture_dir = capture_dir orelse "", .soft_present = soft_present, .transparent = config.gui_transparent, .soft_renderer = soft_renderer, }; setGuiFontName(&g, "Adwaita Mono"); loadFallbackFonts(&g, gpa); defer deinitFallbackFonts(&g, gpa); defer g.glyphs.deinit(); defer { clearNativeImages(&g); g.native_images.deinit(gpa); g.presented_images.deinit(gpa); g.prepared_images.deinit(gpa); } defer c.SDL_ReleaseGPUTransferBuffer(device, g.image_vxfer); defer c.SDL_ReleaseGPUBuffer(device, g.image_vbuf); defer c.SDL_ReleaseGPUGraphicsPipeline(device, image_pipeline); 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, opts_in: pardes.Options, test_mode: bool, attached: *?detached_client.Client, ) !void { const io = init.io; const gpa = init.gpa; const allocs = pardes.allocators.init(gpa); defer pardes.allocators.deinit(); pardes.image.start(io, allocs.image); // stb_image allocator for image panes if (comptime pardes.pdf_enabled) pardes.pdf.start(allocs.pdf); pardes.syntax.start(allocs.tree_sitter); defer { pardes.image.stop(); if (comptime pardes.pdf_enabled) pardes.pdf.stop(); pardes.syntax.stop(); } // Live sessions initialize at the default 80x24 grid; the real window size // arrives as a resize EVENT on the first loop pass. The core defers the // shell greeting until after the first resize (so `ls` wraps to the real // 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; opts.tree_sitter_allocator = allocs.tree_sitter; opts.frame_allocator = allocs.frame; var pw: c_int = 0; var ph: c_int = 0; if (opts.load_path != null) { _ = c.SDL_GetWindowSizeInPixels(g.window, &pw, &ph); opts.cols = @intCast(@max(1, @divTrunc(@as(u32, @intCast(@max(pw, 1))), g.cell_w))); 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); 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(); 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, .lsp_allocator = allocs.lsp, .lsp_workers = &lsp_workers, .sdl_wake = true, }; defer { lsp_workers.wait(); queue.close(&ptys, &gens); } 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); 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. 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 `/`; 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 shell: Shell = .{ .core = core, .gui = g, .io = io, .gpa = gpa, .lsp_allocator = allocs.lsp, .prompt_rcs = &prompt_rcs, .ptys = &ptys, .gens = &gens, .queue = &queue, .pipe_tasks = &pipe_tasks, .inotify_fd = inotify_fd, .watches = &watches, .fs = fs, .test_mode = test_mode, }; 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); _ = 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; }, else => { var mbuf: [256]u8 = undefined; core.setMessage(req.pane, attachFailure(&mbuf, outcome, req.name)); }, } } // 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; 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; }; 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. for (0..pardes.MAX_PANES) |wid| file_watch.watchPane( inotify_fd, &watches, @intCast(wid), null, 0, .{ .text = 0 }, ); _ = file_watch.applyThemeEffect(core, gpa, inotify_fd, &watches, 0, false, false); clearNativeImages(g); g.presented_images.clearRetainingCapacity(); g.prepared_images.clearRetainingCapacity(); nc.native_images = true; nc.host = host; core.deinit(); core = nc; shell.core = nc; shell.surface = null; observeGuiFont(g, core); syncTaglineFont(g, core); } } // 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 `/event` when the // last pane is deleted through `ctl` gets ENOTCONN at once. if (fs) |f| { f.deinit(); 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 "Attach: no detached session under that name" else "Attach: nothing is detached (start one with `pardes --detach`)", .ambiguous => |n| std.fmt.bufPrint(buf, "Attach: {d} detached sessions; say which one", .{n}) catch "Attach: several detached sessions; say which one", .refused => |why| switch (why) { .version => "Attach: that session speaks a different wire version — it is another build of pardes", .full => "Attach: that session already has every frontend slot taken", .quitting => "Attach: that session is ending", }, .silent => "Attach: that session took the connection and never greeted us", .lost => |err| std.fmt.bufPrint(buf, "Attach: lost the connection ({t})", .{err}) catch "Attach: lost the connection", }; } /// `--attach[=]`: 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); switch (outcome) { .greeted => |greeted| { var client = greeted; 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, .refused => error.Refused, .silent => error.NoGreeting, .lost => |err| err, .greeted => unreachable, }; }, } } /// 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 }); }, .open_link => |url| look.openLink(url), } } try link; 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; try client.resize(now.cols, now.rows); } if (!dirty) continue; dirty = false; paintAttached(g, gpa, 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, .rows = client.rows, .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: { log.err("render: {t}", .{err}); break :blk false; }; } /// 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| { if (cell.default or cell.style.font_role != .tagline) continue; return switch (cell.style.bg) { .default => bg_default, .index => |i| palColor(i), .rgb => |rgb| rgb, }; } 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(); pardes.image.start(io, allocs.image); if (comptime pardes.pdf_enabled) pardes.pdf.start(allocs.pdf); pardes.syntax.start(allocs.tree_sitter); defer { pardes.image.stop(); if (comptime pardes.pdf_enabled) pardes.pdf.stop(); pardes.syntax.stop(); } const grid_cols = envU16(env, "PARDES_TEST_COLS") orelse 80; const grid_rows = envU16(env, "PARDES_TEST_ROWS") orelse 24; var opts = opts_in; opts.image_allocator = allocs.image; opts.pdf_allocator = allocs.pdf; opts.tree_sitter_allocator = allocs.tree_sitter; opts.frame_allocator = allocs.frame; if (opts.load_path != null) { opts.cols = grid_cols; opts.rows = grid_rows; } const core = if (opts.load_path) |lp| blk: { const bytes = try @import("../look.zig").readFile(gpa, lp); defer gpa.free(bytes); break :blk try pardes.Pardes.initFromDump(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(); 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, .lsp_allocator = allocs.lsp, .lsp_workers = &lsp_workers, .sdl_wake = false, }; defer { lsp_workers.wait(); queue.close(&ptys, &gens); } var pipe_tasks: PipeTasks = .{}; defer pipe_tasks.cancelAll(io); // no inotify here on purpose: this mode's whole contract is one frame per // scripted input event, and a reload that arrives on its own clock would // put a frame in the stream nothing asked for. -1 makes watchPane a no-op. var watches: file_watch.Table = @splat(null); // The filesystem IS served here, unlike the file watcher above: `--fs=` // 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(); var shell: Shell = .{ .core = core, .io = io, .gpa = gpa, .lsp_allocator = allocs.lsp, .prompt_rcs = &prompt_rcs, .ptys = &ptys, .gens = &gens, .queue = &queue, .pipe_tasks = &pipe_tasks, .inotify_fd = -1, .watches = &watches, .fs = fs, }; 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); 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; if (r.n_events != 0) shell.saw_event = true; shell.drainQueue(); 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); defer last.deinit(); gridPresent(&shell, try core.render(last.allocator())); gridPostPresent(&shell); if (shell.dump_err) |err| return err; } } // ===================================================================== // test-mode stdin: terminal escape sequences (vaxis.Parser) → core events, // plus the private synthetic finger OSC: // ESC ] 777;finger;;;;; BEL (normalized 0..1) // ===================================================================== const StdinFeed = struct { parser: vaxis.Parser = .{}, cache: vaxis.GraphemeCache = .{}, buf: [1024]u8 = undefined, fill: usize = 0, last_cols: u16 = 0, last_rows: u16 = 0, 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 (ws.col != f.last_cols or ws.row != f.last_rows)) { f.last_cols = ws.col; f.last_rows = ws.row; f.applyResize(in, g, ws.col, ws.row); out.n_events += 1; } var fds = [_]posix.pollfd{.{ .fd = 0, .events = posix.POLL.IN, .revents = 0 }}; _ = posix.poll(&fds, 16) catch return out; if ((fds[0].revents & posix.POLL.IN) == 0) { if ((fds[0].revents & (posix.POLL.HUP | posix.POLL.ERR)) != 0) out.eof = true; return out; } const rn = libc.read(0, f.buf[f.fill..].ptr, f.buf.len - f.fill); if (rn < 0) return out; if (rn == 0) { out.eof = true; return out; } const len = f.fill + @as(usize, @intCast(rn)); f.fill = 0; 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]; const end = std.mem.indexOfScalar(u8, body, 0x07) orelse { std.mem.copyForwards(u8, f.buf[0 .. len - seq_start], f.buf[seq_start..len]); f.fill = len - seq_start; break; }; if (parseFinger(body[0..end])) |finger_in| { var finger = finger_in; if (g) |gp| { var wpw: c_int = 0; var wph: c_int = 0; _ = c.SDL_GetWindowSizeInPixels(gp.window, &wpw, &wph); const win_w: f32 = @floatFromInt(@max(wpw, 1)); const win_h: f32 = @floatFromInt(@max(wph, 1)); if (mapScenePoint(gp, finger.x * win_w, finger.y * win_h, win_w, win_h)) |m| { finger.x = m.x / win_w; finger.y = m.y / win_h; handleFinger(&gp.touch, in, finger, win_w, win_h, @floatFromInt(gp.cell_w), @floatFromInt(gp.cell_h), @floatFromInt(gp.tagline_width)); } else { finger.kind = .cancel; handleFinger(&gp.touch, in, finger, win_w, win_h, @floatFromInt(gp.cell_w), @floatFromInt(gp.cell_h), @floatFromInt(gp.tagline_width)); in.post(.pointer_leave); } } else if (in.core) |core| { handleFinger(&grid_touch, in, finger, @floatFromInt(core.screen_w), @floatFromInt(core.screen_h), 1, 1, 1); } out.n_events += 1; } seq_start += prefix.len + end + 1; continue; } if (len - seq_start < prefix.len and std.mem.startsWith(u8, prefix, f.buf[seq_start..len])) { std.mem.copyForwards(u8, f.buf[0 .. len - seq_start], f.buf[seq_start..len]); f.fill = len - seq_start; break; } const result = f.parser.parse(f.buf[seq_start..len], gpa) catch break; if (result.n == 0) { // partial sequence: shift to front, read more std.mem.copyForwards(u8, f.buf[0 .. len - seq_start], f.buf[seq_start..len]); f.fill = len - seq_start; break; } seq_start += result.n; const event = result.event orelse continue; switch (event) { .key_press => |key| { var text: []const u8 = ""; if (key.text) |t| text = f.cache.put(t); in.post(.{ .key = .{ .cp = mapKey(effCp(key)), .text = text, .ctrl = key.mods.ctrl, .alt = key.mods.alt, .shift = key.mods.shift, } }); out.n_events += 1; }, .mouse => |m| { const button: ?pardes.Mouse.Button = switch (m.button) { .left => .left, .middle => .middle, .right => .right, .wheel_up => .wheel_up, .wheel_down => .wheel_down, .wheel_left => .wheel_left, .wheel_right => .wheel_right, .none => .none, else => null, }; if (button) |b| { in.post(.{ .mouse = .{ .button = b, .kind = switch (m.type) { .press => .press, .release => .release, .motion => .motion, .drag => .drag, }, .col = @intCast(@max(m.col, 0)), .row = @intCast(@max(m.row, 0)), .ctrl = m.mods.ctrl, } }); out.n_events += 1; } }, .winsize => |vw| { f.applyResize(in, g, @intCast(vw.cols), @intCast(vw.rows)); out.n_events += 1; }, .paste => |text| { in.post(.{ .paste = text }); gpa.free(@constCast(text)); out.n_events += 1; }, else => {}, } } return out; } 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 { in.post(.{ .resize = .{ .cols = cols, .rows = rows } }); } } }; var grid_touch: Touch = .{}; // grid test mode has no Gui to hang it off fn parseFinger(payload: []const u8) ?Finger { var parts = std.mem.splitScalar(u8, payload, ';'); const type_name = parts.next() orelse return null; const id_s = parts.next() orelse return null; const x_s = parts.next() orelse return null; const y_s = parts.next() orelse return null; const pressure_s = parts.next() orelse return null; if (parts.next() != null) return null; const kind: @FieldType(Finger, "kind") = if (std.mem.eql(u8, type_name, "SDL_EVENT_FINGER_DOWN") or std.mem.eql(u8, type_name, "down")) .down else if (std.mem.eql(u8, type_name, "SDL_EVENT_FINGER_MOTION") or std.mem.eql(u8, type_name, "motion")) .motion else if (std.mem.eql(u8, type_name, "SDL_EVENT_FINGER_UP") or std.mem.eql(u8, type_name, "up")) .up else if (std.mem.eql(u8, type_name, "SDL_EVENT_FINGER_CANCELED") or std.mem.eql(u8, type_name, "cancel")) .cancel else return null; return .{ .kind = kind, .id = std.fmt.parseInt(u64, id_s, 10) catch return null, .x = std.fmt.parseFloat(f32, x_s) catch return null, .y = std.fmt.parseFloat(f32, y_s) catch return null, .pressure = std.fmt.parseFloat(f32, pressure_s) catch return null, }; } fn setStdinRaw() !void { var term = try posix.tcgetattr(0); term.iflag.BRKINT = false; term.iflag.ICRNL = false; term.iflag.INPCK = false; term.iflag.ISTRIP = false; term.iflag.IXON = false; term.oflag.OPOST = false; term.lflag.ECHO = false; term.lflag.ICANON = false; term.lflag.IEXTEN = false; term.lflag.ISIG = false; term.cc[@intFromEnum(posix.V.MIN)] = 1; term.cc[@intFromEnum(posix.V.TIME)] = 0; 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; const cur_y: u16 = if (surface.cursor) |cu| cu.y else 0; var hdr: [128]u8 = undefined; const header = std.fmt.bufPrint(&hdr, "\n---FRAME:{d}:{d}:{d}:{d}:{}---\n", .{ surface.rows, surface.cols, cur_x, cur_y, surface.cursor != null, }) catch return; const footer = "---ENDFRAME---\n"; var total = try std.math.add(usize, header.len, footer.len); total = try std.math.add(usize, total, surface.rows); var y: u16 = 0; while (y < surface.rows) : (y += 1) { var x: u16 = 0; while (x < surface.cols) : (x += 1) { const grapheme = surface.at(x, y).grapheme(); total = try std.math.add(usize, total, if (grapheme.len > 0) grapheme.len else 1); } } const frame = try gpa.alloc(u8, total); defer gpa.free(frame); var at: usize = 0; @memcpy(frame[at..][0..header.len], header); at += header.len; y = 0; while (y < surface.rows) : (y += 1) { var x: u16 = 0; while (x < surface.cols) : (x += 1) { const grapheme = surface.at(x, y).grapheme(); if (grapheme.len > 0) { @memcpy(frame[at..][0..grapheme.len], grapheme); at += grapheme.len; } else { frame[at] = ' '; at += 1; } } frame[at] = '\n'; at += 1; } @memcpy(frame[at..][0..footer.len], footer); at += footer.len; std.debug.assert(at == frame.len); _ = 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; } }; fn dispatch(g: *Gui, in: *Input, sev: *const c.SDL_Event) void { switch (sev.type) { c.SDL_EVENT_QUIT, c.SDL_EVENT_WINDOW_CLOSE_REQUESTED => in.close(), c.SDL_EVENT_WINDOW_MOUSE_LEAVE => { g.pointer_present = false; 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, else => 0, }; 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; } keyDown(in, sev.key.key, sev.key.mod); }, c.SDL_EVENT_KEY_UP => { g.live_ctrl = (sev.key.mod & c.SDL_KMOD_CTRL) != 0; g.live_alt = (sev.key.mod & c.SDL_KMOD_ALT) != 0; }, c.SDL_EVENT_TEXT_INPUT => { const tptr = sev.text.text orelse return; var tlen: usize = 0; 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; while (it.nextCodepoint()) |cp| : (at = it.i) { in.post(.{ .key = .{ .cp = cp, .text = text[at..it.i], .ctrl = g.live_ctrl, .alt = g.live_alt } }); } }, c.SDL_EVENT_MOUSE_BUTTON_DOWN, c.SDL_EVENT_MOUSE_BUTTON_UP => { const b = sev.button; const button: pardes.Mouse.Button = switch (b.button) { 1 => .left, 2 => .middle, 3 => .right, else => return, }; g.pad_x = b.x; g.pad_y = b.y; 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, .col = last.col, .row = last.row, } }); in.post(.pointer_leave); return; }; g.pointer_mapped = true; g.pointer_cell = mc; in.post(.{ .mouse = .{ .button = button, .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) .middle else if ((m.state & c.SDL_BUTTON_RMASK) != 0) .right else null; const mc = mouseCell(g, in.core, m.x, m.y) orelse { g.pointer_mapped = false; in.post(.pointer_leave); return; }; g.pointer_mapped = true; g.pointer_cell = mc; in.post(.{ .mouse = .{ .button = held orelse .none, .kind = if (held != null) .drag else .motion, .col = mc.col, .row = mc.row, } }); }, c.SDL_EVENT_MOUSE_WHEEL => { const w = sev.wheel; if (w.y == 0 and w.x == 0) return; g.pad_x = w.mouse_x; g.pad_y = w.mouse_y; g.pointer_present = true; const mc = mouseCell(g, in.core, w.mouse_x, w.mouse_y) orelse { g.pointer_mapped = false; in.post(.pointer_leave); return; }; g.pointer_mapped = true; 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| { const pdf_target = if (comptime pardes.pdf_enabled) core.native_images and core.panes[id].?.pdfPage() != null 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), .delta_pixels = -w.y * @as(f32, @floatFromInt(g.cell_h)), } }); } else { g.scroll_pane = id; g.scroll_col = mc.col; g.scroll_row = mc.row; g.scroll_delta = accumulateWheelDelta(g.scroll_delta, w.y); } } } 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 } }); while (g.scroll_delta <= -1) : (g.scroll_delta += 1) in.post(.{ .mouse = .{ .button = .wheel_up, .kind = .press, .col = mc.col, .row = mc.row } }); } } if (w.x != 0) in.post(.{ .mouse = .{ .button = if (w.x > 0) .wheel_right else .wheel_left, .kind = .press, .col = mc.col, .row = mc.row, } }); }, c.SDL_EVENT_FINGER_DOWN, c.SDL_EVENT_FINGER_MOTION, c.SDL_EVENT_FINGER_UP, c.SDL_EVENT_FINGER_CANCELED => { const f = sev.tfinger; var finger: Finger = .{ .kind = switch (sev.type) { c.SDL_EVENT_FINGER_DOWN => .down, c.SDL_EVENT_FINGER_MOTION => .motion, c.SDL_EVENT_FINGER_UP => .up, else => .cancel, }, .id = @intCast(f.fingerID), .x = f.x, .y = f.y, .pressure = f.pressure, }; var pw: c_int = 0; var ph: c_int = 0; _ = c.SDL_GetWindowSizeInPixels(g.window, &pw, &ph); const win_w: f32 = @floatFromInt(@max(pw, 1)); const win_h: f32 = @floatFromInt(@max(ph, 1)); const fcw: f32 = @floatFromInt(g.cell_w); const fch: f32 = @floatFromInt(g.cell_h); if (mapScenePoint(g, finger.x * win_w, finger.y * win_h, win_w, win_h)) |m| { finger.x = m.x / win_w; finger.y = m.y / win_h; handleFinger(&g.touch, in, finger, win_w, win_h, fcw, fch, @floatFromInt(g.tagline_width)); } else { finger.kind = .cancel; handleFinger(&g.touch, in, finger, win_w, win_h, fcw, fch, @floatFromInt(g.tagline_width)); in.post(.pointer_leave); } }, 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); }, c.SDL_EVENT_GAMEPAD_REMOVED => { if (g.gamepad) |pad| { c.SDL_CloseGamepad(pad); 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, c.SDL_EVENT_GAMEPAD_TOUCHPAD_DOWN, 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, .down = sev.gbutton.down, } }, c.SDL_EVENT_GAMEPAD_AXIS_MOTION => .{ .axis = .{ .idx = sev.gaxis.axis, .value = sev.gaxis.value, } }, else => .{ .touch = .{ .pad = @intCast(std.math.clamp(sev.gtouchpad.touchpad, 0, 255)), .phase = switch (sev.type) { c.SDL_EVENT_GAMEPAD_TOUCHPAD_DOWN => .down, c.SDL_EVENT_GAMEPAD_TOUCHPAD_UP => .up, else => .motion, }, .x = sev.gtouchpad.x, .y = sev.gtouchpad.y, .pressure = sev.gtouchpad.pressure, } }, }; var acts: [3]deck.Action = undefined; for (acts[0..g.deck.feed(pad_input, &acts)]) |act| switch (act) { .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 { g.pointer_mapped = false; in.post(.pointer_leave); continue; }; g.pointer_mapped = true; 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, .kind = if (held != null) .drag else .motion, .col = mc.col, .row = mc.row, } }); }, .click => |ck| { const button: pardes.Mouse.Button = switch (ck.which) { .select => .left, .execute => .middle, .look => .right, }; 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, .col = last.col, .row = last.row, } }); in.post(.pointer_leave); continue; }; g.pointer_mapped = true; g.pointer_cell = mc; in.post(.{ .mouse = .{ .button = button, .kind = if (ck.down) .press else .release, .col = mc.col, .row = mc.row, } }); }, .wheel => |ticks| { g.pointer_present = true; const mc = mouseCell(g, in.core, g.pad_x, g.pad_y) orelse { g.pointer_mapped = false; in.post(.pointer_leave); continue; }; g.pointer_mapped = true; g.pointer_cell = mc; var left = ticks; while (left != 0) { left += if (ticks > 0) -1 else 1; in.post(.{ .mouse = .{ .button = if (ticks > 0) .wheel_down else .wheel_up, .kind = .press, .col = mc.col, .row = mc.row, } }); } }, .key => |k| in.post(.{ .key = switch (k) { .n => .{ .cp = 'n', .text = "n" }, .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); }, }; }, else => {}, } } /// 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; const shift = (mod & c.SDL_KMOD_SHIFT) != 0; const gui_mod = (mod & c.SDL_KMOD_GUI) != 0; const cp: u21 = switch (sym) { c.SDLK_RETURN, c.SDLK_KP_ENTER => pardes.Key.enter, c.SDLK_BACKSPACE => pardes.Key.backspace, c.SDLK_TAB => pardes.Key.tab, c.SDLK_ESCAPE => pardes.Key.escape, c.SDLK_LEFT => pardes.Key.left, c.SDLK_RIGHT => pardes.Key.right, c.SDLK_UP => pardes.Key.up, c.SDLK_DOWN => pardes.Key.down, c.SDLK_HOME => pardes.Key.home, c.SDLK_END => pardes.Key.end, c.SDLK_PAGEUP => pardes.Key.page_up, 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; }, }; if (cp == 0) return; in.post(.{ .key = .{ .cp = cp, .ctrl = ctrl, .alt = alt, .shift = shift } }); } fn pixelCell(px: f32, cell: u32) u16 { const idx = @floor(@max(px, 0) / @as(f32, @floatFromInt(@max(cell, 1)))); 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, y: f32, body_w: f32, body_h: f32, tagline_w: f32, ) MouseCell { const row: u16 = @intFromFloat(@min(@floor(@max(y, 0) / @max(body_h, 1)), 10_000)); return .{ .col = pardes.gridColAt(core, x, row, body_w, tagline_w), .row = row }; } fn gridCellAtPixels(g: *const Gui, core: ?*const pardes.Pardes, x: f32, y: f32) MouseCell { return gridCellAtDimensions( core, x, y, @floatFromInt(g.cell_w), @floatFromInt(g.cell_h), @floatFromInt(g.tagline_width), ); } test "tagline pointer mapping follows the smaller pane-anchored grid" { const core = try pardes.Pardes.init(std.testing.allocator, .{ .tty_only = true }); defer core.deinit(); core.update(.{ .resize = .{ .cols = 60, .rows = 12 } }); core.update(.{ .command = "Newcol" }); const r = core.rects[core.active]; const body_w: f32 = 10; const body_h: f32 = 20; const tag_w: f32 = 8; const tag_y = r.y; const x = @as(f32, @floatFromInt(r.x)) * body_w + 3.5 * tag_w; const tag = gridCellAtDimensions(core, x, (@as(f32, @floatFromInt(tag_y)) + 0.5) * body_h, body_w, body_h, tag_w); try std.testing.expectEqual(MouseCell{ .col = r.x + 3, .row = tag_y }, tag); const body = gridCellAtDimensions(core, x, (@as(f32, @floatFromInt(tag_y + 1)) + 0.5) * body_h, body_w, body_h, tag_w); try std.testing.expectEqual(pixelCell(x, 10), body.col); try std.testing.expectEqual(tag_y + 1, body.row); const topbar = gridCellAtDimensions(core, 3.5 * tag_w, 0.5 * body_h, body_w, body_h, tag_w); try std.testing.expectEqual(MouseCell{ .col = 3, .row = 0 }, topbar); } fn mapScenePoint(g: *const Gui, x: f32, y: f32, w: f32, h: f32) ?crt.Point { return crt.mapScene(x, y, w, h, g.presented_scene.effects, g.presented_scene.time_seconds); } fn mouseCellWithGeometry(g: *const Gui, core: ?*const pardes.Pardes, x: f32, y: f32, geometry: WindowGeometry) ?MouseCell { const physical = windowPointToPixels(geometry, x, y); const mapped = mapScenePoint(g, physical.x, physical.y, geometry.pixel_w, geometry.pixel_h) orelse return null; 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)); } fn heldPointerButton(g: *const Gui) ?pardes.Mouse.Button { const buttons = c.SDL_GetMouseState(null, null); if ((buttons & c.SDL_BUTTON_LMASK) != 0 or g.deck.r2_held or g.deck.rpad_click) return .left; if ((buttons & c.SDL_BUTTON_MMASK) != 0 or g.deck.l2_held) return .middle; if ((buttons & c.SDL_BUTTON_RMASK) != 0 or g.deck.look_held) return .right; 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 { if (g.pointer_mapped) core.update(.pointer_leave); g.pointer_mapped = false; return; }; const moved = !g.pointer_mapped or g.pointer_cell == null or g.pointer_cell.?.col != mc.col or g.pointer_cell.?.row != mc.row; g.pointer_mapped = true; g.pointer_cell = mc; if (!moved) return; const held = heldPointerButton(g); core.update(.{ .mouse = .{ .button = held orelse .none, .kind = if (held != null) .drag else .motion, .col = mc.col, .row = mc.row, } }); } /// 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); } const mc = mouseCellWithGeometry(g, in.core, g.pad_x, g.pad_y, geometry) orelse { if (g.pointer_present and g.pointer_mapped) in.post(.pointer_leave); if (g.pointer_present) g.pointer_mapped = false; return; }; if (g.pointer_present) { g.pointer_mapped = true; g.pointer_cell = mc; if (old == null or mc.col != old.?.col or mc.row != old.?.row) { const held = heldPointerButton(g); in.post(.{ .mouse = .{ .button = held orelse .none, .kind = if (held != null) .drag else .motion, .col = mc.col, .row = mc.row, } }); } } // 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; if (@abs(lx) > deadzone) g.pad_scroll_h += lx / 32767.0 * 0.15; while (@abs(g.pad_scroll) >= 1.0) { const button: pardes.Mouse.Button = if (g.pad_scroll < 0) .wheel_up else .wheel_down; g.pad_scroll += if (g.pad_scroll < 0) 1.0 else -1.0; in.post(.{ .mouse = .{ .button = button, .kind = .press, .col = mc.col, .row = mc.row } }); } while (@abs(g.pad_scroll_h) >= 1.0) { const button: pardes.Mouse.Button = if (g.pad_scroll_h < 0) .wheel_left else .wheel_right; g.pad_scroll_h += if (g.pad_scroll_h < 0) 1.0 else -1.0; in.post(.{ .mouse = .{ .button = button, .kind = .press, .col = mc.col, .row = mc.row } }); } } // ===================================================================== // 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, lsp_allocator: std.mem.Allocator, prompt_rcs: *const shell_bin.PromptRcs, 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, 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, }; /// 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; 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 { var msgs = s.queue.take(); var check_files = false; for (msgs.slice()) |m| switch (m) { .output => |o| { if (s.gens[o.pane] == o.gen) s.core.update(.{ .output = .{ .pane = o.pane, .bytes = o.bytes } }); s.gpa.free(o.bytes); 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.saw_event = true; }, .lsp => |l| { s.core.update(.{ .lsp_resp = .{ .id = l.id, .rows = l.rows } }); s.lsp_allocator.free(l.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.setMessage(s.core.active, message.stamp(&mbuf, "lsp", text)); s.lsp_allocator.free(text); s.saw_event = true; }, .pipe => |response_value| { var response = response_value; s.core.update(.{ .pipe_resp = .{ .id = response.id, .success = response.success, .outputs = response.outputs, .failure = response.failure, } }); response.deinit(s.gpa); 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)) s.queue.push(.files_changed); } }; /// 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); } 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); const ms: c_int = if (timeout_ms != 0) @intCast(timeout_ms) else 16; if (c.SDL_WaitEventTimeout(&sev, ms)) { dispatch(g, &in, &sev); while (c.SDL_PollEvent(&sev)) dispatch(g, &in, &sev); } } 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; }; if (r.eof) { core.quit = true; return; } } s.drainQueue(); 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); 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); 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 { 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(); break :blk; }; c.ui_font_free(g.font); if (g.font_bytes.len != 0) s.gpa.free(g.font_bytes); g.font = nf; g.font_bytes = bytes; // FreeType borrows them for the face lifetime setGuiFontName(g, core.settings.font.requested_name.get()); refitFont(g, core); 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); } 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 core.settings.scene_effects.ripple or core.settings.scene_effects.glitch; if (!scene_requested) g.scene_failures = 0; s.presented = renderFrame( g, s.gpa, core, frame, core.theme().bg, config.gui_topbar_pane_border_rgb orelse core.chromeTheme().scroll_track, core.chromeTheme().tag_bg, core.settings.scene_effects, core.settings.debug, ) catch |err| blk: { log.err("render: {t}", .{err}); break :blk false; }; if (g.scene_target_failed) { g.scene_failures +|= 1; log.err("scene target unavailable (attempt {d}/3)", .{g.scene_failures}); if (g.scene_failures >= 3) { core.disableSceneEffects(); g.scene_failures = 0; } } 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, s.core, frame.panelTracks(), c.SDL_GetTicksNS(), ); } /// 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. if (s.fs) |f| { if (fs_service.drain(f.transport(), 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; } } 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; dumpGrid(s.gpa, @constCast(surface)) catch |err| { s.dump_err = err; s.presented = false; }; } 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.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 }; 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); } 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); } fn ptyResize(ctx: ?*anyopaque, pane: u8, cols: u16, rows: u16) void { const s = shellOf(ctx); if (s.ptys[pane]) |pt| { 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); } /// 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); } fn writeFile(ctx: ?*anyopaque, pane: u8, path: []const u8, bytes: []const u8) void { const s = shellOf(ctx); host_io.writeFileBytes(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) { .text => w.generation = .{ .text = std.hash.Wyhash.hash(0, bytes) }, .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)); } 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); s.core.setLastDump(path); } fn watchFile(ctx: ?*anyopaque, pane: u8, path: []const u8, on: bool) 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)) s.queue.push(.files_changed); } fn watchTheme(ctx: ?*anyopaque, generation: u32, on: bool) void { const s = shellOf(ctx); if (file_watch.applyThemeEffect( s.core, s.gpa, s.inotify_fd, s.watches, generation, on, s.threads_ok, )) s.queue.push(.files_changed); } 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| { s.core.reportError(pane, "dump themes", err); return; }; defer s.gpa.free(out_dir); var mbuf: [256]u8 = undefined; 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); if (text.len == 0) { c.SDL_free(raw); return null; } 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 = .{}; var queue: Queue = .{ .gpa = gpa, .lsp_allocator = gpa, .lsp_workers = &lsp_workers, .sdl_wake = false, }; var pipe_tasks: PipeTasks = .{}; var watches: file_watch.Table = @splat(null); shell_bin.adoptSystemPath(); var prompt_rcs = shell_bin.PromptRcs.init(); defer prompt_rcs.deinit(); var shell: Shell = .{ .core = core, .gui = null, .io = undefined, .gpa = gpa, .lsp_allocator = gpa, .prompt_rcs = &prompt_rcs, .ptys = &ptys, .gens = &gens, .queue = &queue, .pipe_tasks = &pipe_tasks, .inotify_fd = -1, .watches = &watches, }; 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' } }); while (core.nextEffect()) |e| core.perform(e); 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' } }); try std.testing.expect(core.clip_pending != null); while (core.nextEffect()) |e| core.perform(e); try std.testing.expect(core.clip_pending == null); try std.testing.expectEqual(before + yanked.len, pane.file.?.content.len); } fn openLink(ctx: ?*anyopaque, url: []const u8) void { _ = ctx; look.openLink(url); // desktop browser } fn lsp(ctx: ?*anyopaque, req: host_api.LspRequest) void { const s = shellOf(ctx); if (s.threads_ok) spawnLsp(s.core, s.queue, req); } 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); } /// 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); }; } // ===================================================================== // fractional scroll: whole rows for the core, sub-row offsets for the picture // ===================================================================== /// 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; while ((delta > 0 and l > 0) or (delta < 0 and l < 0)) { const down = l > 0; l += if (down) -1 else 1; rows += if (down) 1 else -1; } return .{ .lag = l, .rows = rows }; } test "fractional wheel delta moves the picture by the same fraction" { const delta = accumulateWheelDelta(0, -0.125); const s = applyScrollDelta(0, delta); const picture = @as(f32, @floatFromInt(s.rows)) + s.lag; try std.testing.expectEqual(@as(f32, 0.125), delta); try std.testing.expectEqual(@as(f32, 0.125), picture); try std.testing.expectEqual(@as(i32, 1), s.rows); try std.testing.expectEqual(@as(f32, -0.875), s.lag); } test "repeated fractional deltas cross exactly one core row" { var core_row: i32 = 0; var lag: f32 = 0; var crossed: u32 = 0; for (0..8) |_| { const s = applyScrollDelta(lag, 0.125); core_row += s.rows; crossed += @intCast(@abs(s.rows)); lag = s.lag; } try std.testing.expectEqual(@as(u32, 1), crossed); try std.testing.expectEqual(@as(i32, 1), core_row); try std.testing.expectEqual(@as(f32, 0), lag); } test "wheel magnitude is preserved without quantization" { const delta = accumulateWheelDelta(0, -2.75); const s = applyScrollDelta(0, delta); const picture = @as(f32, @floatFromInt(s.rows)) + s.lag; try std.testing.expectEqual(@as(f32, 2.75), delta); try std.testing.expectEqual(@as(f32, 2.75), picture); try std.testing.expectEqual(@as(i32, 3), s.rows); 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); try std.testing.expectEqual(@as(i32, 24), safe.rows); } test "fractional scroll reversals preserve signed distance" { var core_row: i32 = 0; const forward = applyScrollDelta(0, 0.375); core_row += forward.rows; try std.testing.expectEqual(@as(f32, 0.375), @as(f32, @floatFromInt(core_row)) + forward.lag); const reverse = applyScrollDelta(forward.lag, -0.125); core_row += reverse.rows; try std.testing.expectEqual(@as(i32, -1), reverse.rows); try std.testing.expectEqual(@as(f32, 0.25), @as(f32, @floatFromInt(core_row)) + reverse.lag); try std.testing.expect(reverse.lag >= 0 and reverse.lag < 1); } fn resetScroll(g: *Gui) void { g.scroll_pane = null; g.scroll_delta = 0; g.scroll_lag = 0; 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 { resetScroll(g); return; }; const queued_delta = g.scroll_delta; g.scroll_delta = 0; if (queued_delta == 0) { 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); return; } const delta = boundScrollDelta(g.scroll_lag, queued_delta, r.h - pardes.BOX_H); const st = applyScrollDelta(g.scroll_lag, delta); var left = st.rows; while (left != 0) { const down = left > 0; left += if (down) -1 else 1; const was = pane.scroll(); core.update(.{ .mouse = .{ .button = if (down) .wheel_down else .wheel_up, .kind = .press, .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; } } 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) { const bw = r.w - config.GUTTER; const bh = r.h - pardes.BOX_H; const k: u16 = @intCast(@min(@abs(st.rows), @as(i32, bh))); const srow = body_y + (if (st.rows > 0) k - 1 else bh - k); if (srow < s.rows) { if (g.scroll_edge.len < bw) { gpa.free(g.scroll_edge); g.scroll_edge = gpa.alloc(pardes.Cell, bw) catch &.{}; } if (g.scroll_edge.len >= bw) { @memcpy(g.scroll_edge[0..bw], s.cells[@as(usize, srow) * s.cols + r.x + config.GUTTER ..][0..bw]); g.scroll_edge_len = bw; } } } } 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; for (surface.panelTracks()) |track| if (track.active() and track.pane == scroll_pane) return 0; const r = g.scroll_rect; if (r.w <= config.GUTTER or r.h <= pardes.BOX_H) return 0; const x0 = r.x + config.GUTTER; const y0 = g.scroll_body_y; 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); const cursor_bar = if (surface.cursor) |cu| cu.bar else false; var n: u32 = 0; var row = y0; while (row < y0 + bh) : (row += 1) { var col = x0; while (col < x0 + bw) : (col += 1) { const sidx: u32 = @as(u32, row) * surface.cols + col; emitInstance(g, instances, base + n, col, row, shifted, win_w, win_h, null, cellFontRole(surface.at(col, row)), surface.at(col, row), false, sidx == cursor_idx and !cursor_bar, page); 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; while (i < bw) : (i += 1) { emitInstance(g, instances, base + n, x0 + i, erow, shifted, win_w, win_h, null, cellFontRole(&g.scroll_edge[i]), &g.scroll_edge[i], false, false, page); n += 1; } } 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; const py = layout.y_off + @as(f32, @floatFromInt(g.scroll_body_y)) * layout.h; const pw = @as(f32, @floatFromInt(r.w - config.GUTTER)) * layout.w; const ph = @as(f32, @floatFromInt(r.h - pardes.BOX_H)) * layout.h; const x0 = std.math.clamp(@as(i32, @intFromFloat(@floor(px))), 0, @as(i32, @intCast(sw))); const y0 = std.math.clamp(@as(i32, @intFromFloat(@floor(py))), 0, @as(i32, @intCast(sh))); const x1 = std.math.clamp(@as(i32, @intFromFloat(@ceil(px + pw))), x0, @as(i32, @intCast(sw))); const y1 = std.math.clamp(@as(i32, @intFromFloat(@ceil(py + ph))), y0, @as(i32, @intCast(sh))); 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); } fn clearNativeImages(g: *Gui) void { var iterator = g.native_images.valueIterator(); while (iterator.next()) |texture| c.SDL_ReleaseGPUTexture(g.device, texture.*); g.native_images.clearRetainingCapacity(); } fn surfaceHasNativeKey(surface: *const pardes.Surface, key: pardes.ImageCacheKey) bool { for (surface.images[0..surface.nimages]) |maybe| { const place = maybe orelse continue; if (validImageBytes(place) != null and place.cacheKey().eql(key)) return true; } return false; } fn snapshotHasNativeKey(g: *const Gui, key: pardes.ImageCacheKey) bool { for (g.presented_images.items) |place| if (place.key.eql(key)) return true; return false; } fn rememberPresentedImages(g: *Gui, gpa: std.mem.Allocator, surface: *const pardes.Surface) void { var next: std.ArrayListUnmanaged(SavedImagePlace) = .empty; var adopted = false; defer if (!adopted) next.deinit(gpa); next.ensureTotalCapacity(gpa, surface.nimages) catch return; for (surface.images[0..surface.nimages]) |maybe| { const place = maybe orelse continue; if (validImageBytes(place) == null or !g.native_images.contains(place.cacheKey())) continue; next.appendAssumeCapacity(SavedImagePlace.from(place)); } var old = g.presented_images; g.presented_images = next; adopted = true; old.deinit(gpa); } fn sameSavedPlacement(saved: SavedImagePlace, current: pardes.ImagePlace) bool { return saved.key.eql(current.cacheKey()) and saved.pane == current.pane and saved.serial == current.serial and saved.x == current.x and saved.y == current.y and saved.w == current.w and saved.h == current.h and saved.iw == current.iw and saved.ih == current.ih and std.meta.eql(saved.native, current.native); } fn snapshotContainsPlacement(g: *const Gui, current: pardes.ImagePlace) bool { for (g.presented_images.items) |saved| if (sameSavedPlacement(saved, current)) return true; return false; } fn placeIntersectsBox(place: SavedImagePlace, box: pardes.panel_animation.Box) bool { const x0: f32 = @floatFromInt(place.x); const y0: f32 = @floatFromInt(place.y); const x1: f32 = @floatFromInt(@as(u32, place.x) + place.w); const y1: f32 = @floatFromInt(@as(u32, place.y) + place.h); return x0 < box.x + box.w and x1 > box.x and y0 < box.y + box.h and y1 > box.y; } fn ensureImageBuffers(g: *Gui, needed: u32) bool { if (needed <= g.image_capacity) return true; var capacity = @max(@as(u32, 1), g.image_capacity); while (capacity < needed) capacity = std.math.mul(u32, capacity, 2) catch return false; const buffer_size = std.math.mul( u32, capacity, @as(u32, @intCast(@sizeOf(ImageInstance))), ) catch return false; var vb_info = c.SDL_GPUBufferCreateInfo{ .usage = c.SDL_GPU_BUFFERUSAGE_VERTEX, .size = buffer_size, .props = 0, }; const vbuf = c.SDL_CreateGPUBuffer(g.device, &vb_info) orelse return false; var vx_info = c.SDL_GPUTransferBufferCreateInfo{ .usage = c.SDL_GPU_TRANSFERBUFFERUSAGE_UPLOAD, .size = buffer_size, .props = 0, }; const vxfer = c.SDL_CreateGPUTransferBuffer(g.device, &vx_info) orelse { c.SDL_ReleaseGPUBuffer(g.device, vbuf); return false; }; c.SDL_ReleaseGPUTransferBuffer(g.device, g.image_vxfer); c.SDL_ReleaseGPUBuffer(g.device, g.image_vbuf); g.image_vbuf = vbuf; g.image_vxfer = vxfer; g.image_capacity = capacity; return true; } fn validImageBytes(place: pardes.ImagePlace) ?u32 { if (place.iw == 0 or place.ih == 0 or place.iw > std.math.maxInt(u32) or place.ih > std.math.maxInt(u32)) return null; const pixels = std.math.mul(usize, place.iw, place.ih) catch return null; const bytes = std.math.mul(usize, pixels, 4) catch return null; if (place.rgba.len != bytes or bytes > std.math.maxInt(u32)) return null; return @intCast(bytes); } const NativeBounds = struct { w: u32, h: u32 }; fn nativeBounds(g: *const Gui, place: anytype) ?NativeBounds { const bound_w = std.math.mul(u32, place.w, g.cell_w) catch return null; const bound_h = std.math.mul(u32, place.h, g.cell_h) catch return null; return .{ .w = bound_w, .h = bound_h }; } fn nativePlaceGeometry(g: *const Gui, place: anytype) ?pardes.image.NativeGeometry { if (comptime pardes.pdf_enabled) { if (place.native.geometry) |geometry| return geometry; const bounds = nativeBounds(g, place) orelse return null; return pardes.image.nativeGeometry( place.iw, place.ih, bounds.w, bounds.h, place.native.fit, place.native.pan_x, place.native.pan_y, ); } return null; } fn nativePlaceDrawable(g: *const Gui, place: anytype) bool { if (comptime pardes.pdf_enabled) return nativePlaceGeometry(g, place) != null; const bounds = nativeBounds(g, place) orelse return false; const fit = pardes.image.contain(place.iw, place.ih, bounds.w, bounds.h); return fit.w != 0 and fit.h != 0; } fn uploadNativeTexture( g: *Gui, gpa: std.mem.Allocator, cmd: *c.SDL_GPUCommandBuffer, place: pardes.ImagePlace, ) !void { const byte_len = validImageBytes(place) orelse return error.BadImage; const key = place.cacheKey(); if (g.native_images.contains(key)) return; var tex_info = std.mem.zeroes(c.SDL_GPUTextureCreateInfo); tex_info.type = c.SDL_GPU_TEXTURETYPE_2D; tex_info.format = c.SDL_GPU_TEXTUREFORMAT_R8G8B8A8_UNORM; tex_info.usage = c.SDL_GPU_TEXTUREUSAGE_SAMPLER; tex_info.width = @intCast(place.iw); tex_info.height = @intCast(place.ih); tex_info.layer_count_or_depth = 1; tex_info.num_levels = 1; tex_info.sample_count = c.SDL_GPU_SAMPLECOUNT_1; const texture = c.SDL_CreateGPUTexture(g.device, &tex_info) orelse return error.GpuCreate; errdefer c.SDL_ReleaseGPUTexture(g.device, texture); var xf_info = c.SDL_GPUTransferBufferCreateInfo{ .usage = c.SDL_GPU_TRANSFERBUFFERUSAGE_UPLOAD, .size = byte_len, .props = 0, }; const transfer = c.SDL_CreateGPUTransferBuffer(g.device, &xf_info) orelse return error.GpuCreate; defer c.SDL_ReleaseGPUTransferBuffer(g.device, transfer); const mapped: [*]u8 = @ptrCast(c.SDL_MapGPUTransferBuffer(g.device, transfer, false) orelse return error.GpuMap); @memcpy(mapped[0..byte_len], place.rgba); c.SDL_UnmapGPUTransferBuffer(g.device, transfer); const copy = c.SDL_BeginGPUCopyPass(cmd); const src = c.SDL_GPUTextureTransferInfo{ .transfer_buffer = transfer, .offset = 0, .pixels_per_row = @intCast(place.iw), .rows_per_layer = @intCast(place.ih), }; const dst = c.SDL_GPUTextureRegion{ .texture = texture, .mip_level = 0, .layer = 0, .x = 0, .y = 0, .z = 0, .w = @intCast(place.iw), .h = @intCast(place.ih), .d = 1, }; 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, old_layer: bool, ) void { const texture = g.native_images.get(place.key) orelse return; if (!nativePlaceDrawable(g, place)) return; g.prepared_images.append(gpa, .{ .place = place, .texture = texture, .track = track, .clip = clip, .old_layer = old_layer, }) catch {}; } fn emitPreparedImage( g: *Gui, instance: *ImageInstance, prepared: PreparedImage, layout: CellLayout, win_w: f32, win_h: f32, ) bool { const place = prepared.place; const body_x = @as(u32, place.x) * g.cell_w; const body_y = @as(u32, place.y) * g.cell_h; if (comptime pardes.pdf_enabled) { const geometry = nativePlaceGeometry(g, place) orelse return false; const px0 = @as(f32, @floatFromInt(body_x + geometry.dst.x)); const py0 = @as(f32, @floatFromInt(body_y + geometry.dst.y)) + place.native.pixel_offset_y; const px1 = px0 + @as(f32, @floatFromInt(geometry.dst.w)); const py1 = py0 + @as(f32, @floatFromInt(geometry.dst.h)); const image_w: f32 = @floatFromInt(place.iw); const image_h: f32 = @floatFromInt(place.ih); instance.* = .{ .x0 = (px0 / win_w) * 2.0 - 1.0, .y0 = 1.0 - (py0 / win_h) * 2.0, .x1 = (px1 / win_w) * 2.0 - 1.0, .y1 = 1.0 - (py1 / win_h) * 2.0, .u0 = @as(f32, @floatFromInt(geometry.src.x)) / image_w, .v0 = @as(f32, @floatFromInt(geometry.src.y)) / image_h, .u1 = @as(f32, @floatFromInt(geometry.src.x + geometry.src.w)) / image_w, .v1 = @as(f32, @floatFromInt(geometry.src.y + geometry.src.h)) / image_h, }; } else { const bounds = nativeBounds(g, place) orelse return false; const fit = pardes.image.contain(place.iw, place.ih, bounds.w, bounds.h); if (fit.w == 0 or fit.h == 0) return false; instance.* = .{ .x0 = (@as(f32, @floatFromInt(body_x)) / win_w) * 2.0 - 1.0, .y0 = 1.0 - (@as(f32, @floatFromInt(body_y)) / win_h) * 2.0, .x1 = (@as(f32, @floatFromInt(body_x + fit.w)) / win_w) * 2.0 - 1.0, .y1 = 1.0 - (@as(f32, @floatFromInt(body_y + fit.h)) / win_h) * 2.0, .u0 = 0, .v0 = 0, .u1 = 1, .v1 = 1, }; } setTransitionFields( instance, prepared.track, layout, win_w, win_h, if (prepared.track) |active| panelGridSize(active) else 0, ); if (prepared.old_layer) instance.effect |= old_layer_bit; return true; } fn prepareNativeImages( g: *Gui, gpa: std.mem.Allocator, cmd: *c.SDL_GPUCommandBuffer, surface: *pardes.Surface, plan: *PaintPlan, sw: u32, sh: u32, ) bool { for (surface.images[0..surface.nimages]) |maybe| { const place = maybe orelse continue; if (validImageBytes(place) == null) continue; uploadNativeTexture(g, gpa, cmd, place) catch continue; } while (true) { var stale: [pardes.MAX_PANES]pardes.ImageCacheKey = undefined; var stale_len: usize = 0; var iterator = g.native_images.iterator(); while (iterator.next()) |entry| { if (surfaceHasNativeKey(surface, entry.key_ptr.*) or snapshotHasNativeKey(g, entry.key_ptr.*)) continue; stale[stale_len] = entry.key_ptr.*; stale_len += 1; if (stale_len == stale.len) break; } for (stale[0..stale_len]) |key| releaseNativeImage(g, key); if (stale_len < stale.len) break; } g.prepared_images.clearRetainingCapacity(); for (plan.batches[0..plan.len], 0..) |*batch, batch_index| { batch.image_start = std.math.cast(u32, g.prepared_images.items.len) orelse return false; const track = batch.track; if (track) |active| { if (active.phase == .closing) { for (g.presented_images.items) |saved| { if (saved.serial != active.serial) continue; 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); } } } if (track == null or track.?.phase != .closing) { for (surface.images[0..surface.nimages]) |maybe| { const current = maybe orelse continue; if (validImageBytes(current) == null or 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; for (g.presented_images.items) |saved| { if (!placeIntersectsBox(saved, active.contentBox())) continue; appendPreparedImage(g, gpa, saved, null, active.contentBox(), false); } }; const image_end = std.math.cast(u32, g.prepared_images.items.len) orelse return false; batch.image_count = image_end - batch.image_start; } const capacity = std.math.cast(u32, g.prepared_images.items.len) orelse return false; if (capacity == 0) return true; if (!ensureImageBuffers(g, capacity)) return false; const ptr: [*]u8 = @ptrCast(c.SDL_MapGPUTransferBuffer(g.device, g.image_vxfer, false) orelse return false); const instances: [*]ImageInstance = @ptrCast(@alignCast(ptr)); const win_w: f32 = @floatFromInt(sw); const win_h: f32 = @floatFromInt(sh); const layout = fixedCellLayout(g); var idx: u32 = 0; for (g.prepared_images.items) |prepared| { if (!emitPreparedImage(g, &instances[idx], prepared, layout, win_w, win_h)) continue; 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); const src = c.SDL_GPUTransferBufferLocation{ .transfer_buffer = g.image_vxfer, .offset = 0 }; const dst = c.SDL_GPUBufferRegion{ .buffer = g.image_vbuf, .offset = 0, .size = idx * @sizeOf(ImageInstance) }; c.SDL_UploadToGPUBuffer(copy, &src, &dst, false); c.SDL_EndGPUCopyPass(copy); return true; } fn imageScissor( g: *const Gui, prepared: PreparedImage, max_w: u32, max_h: u32, ) 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); } const place = prepared.place; return .{ .x = @intCast(@as(u32, place.x) * g.cell_w), .y = @intCast(@as(u32, place.y) * g.cell_h), .w = @intCast(@as(u32, place.w) * g.cell_w), .h = @intCast(@as(u32, place.h) * g.cell_h), }; } fn panelBoxScissor(g: *const Gui, box: pardes.panel_animation.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)))); const y1: i32 = @intFromFloat(@ceil((box.y + box.h) * @as(f32, @floatFromInt(g.cell_h)))); const clipped_x0 = std.math.clamp(x0, 0, @as(i32, @intCast(max_w))); const clipped_y0 = std.math.clamp(y0, 0, @as(i32, @intCast(max_h))); const clipped_x1 = std.math.clamp(x1, clipped_x0, @as(i32, @intCast(max_w))); const clipped_y1 = std.math.clamp(y1, clipped_y0, @as(i32, @intCast(max_h))); return .{ .x = clipped_x0, .y = clipped_y0, .w = clipped_x1 - clipped_x0, .h = clipped_y1 - clipped_y0, }; } fn drawNativeImagesGpu( g: *Gui, rp: ?*c.SDL_GPURenderPass, surface: *pardes.Surface, plan: *const PaintPlan, batch_index: usize, ) void { const pass = rp orelse return; const batch = plan.batches[batch_index]; c.SDL_BindGPUGraphicsPipeline(pass, g.image_pipeline); const max_w = @as(u32, surface.cols) * g.cell_w; const max_h = @as(u32, surface.rows) * g.cell_h; var idx = batch.image_start; const end = batch.image_start + batch.image_count; for (g.prepared_images.items[batch.image_start..end]) |prepared| { const clip = imageScissor(g, prepared, max_w, max_h); if (clip.w > 0 and clip.h > 0) { c.SDL_SetGPUScissor(pass, &clip); const sampler = c.SDL_GPUTextureSamplerBinding{ .texture = prepared.texture, .sampler = g.linear_sampler }; c.SDL_BindGPUFragmentSamplers(pass, 0, &sampler, 1); const binding = c.SDL_GPUBufferBinding{ .buffer = g.image_vbuf, .offset = idx * @sizeOf(ImageInstance) }; c.SDL_BindGPUVertexBuffers(pass, 0, &binding, 1); c.SDL_DrawGPUPrimitives(pass, 6, 1, 0, 0); } idx += 1; } std.debug.assert(idx == batch.image_start + batch.image_count); const whole = c.SDL_Rect{ .x = 0, .y = 0, .w = @intCast(@as(u32, surface.cols) * g.cell_w), .h = @intCast(@as(u32, surface.rows) * g.cell_h), }; c.SDL_SetGPUScissor(pass, &whole); } // EFFECT_CODE_NATIVE_PANEL_END // EFFECT_CODE_FRAME_SUBMISSION_BEGIN fn renderFrame( g: *Gui, gpa: std.mem.Allocator, core: ?*const pardes.Pardes, surface: *pardes.Surface, theme_bg: ?[3]u8, topbar_pane_border_rgb: [3]u8, tagline_rgb: [3]u8, scene_effects: pardes.panel_animation.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) { command_consumed = true; _ = c.SDL_SubmitGPUCommandBuffer(cmd); return false; } 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) { command_consumed = true; _ = c.SDL_SubmitGPUCommandBuffer(cmd); return false; } sw = @intCast(pw); sh = @intCast(ph); try ensureCaptureTexture(g, sw, sh); target = g.capture_tex.?; } else { var swap_tex: ?*c.SDL_GPUTexture = null; if (!c.SDL_AcquireGPUSwapchainTexture(cmd, g.window, &swap_tex, &sw, &sh)) { command_consumed = true; _ = c.SDL_SubmitGPUCommandBuffer(cmd); return false; } target = swap_tex orelse { // swapchain busy: skip the frame command_consumed = true; _ = c.SDL_SubmitGPUCommandBuffer(cmd); return false; }; } 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; }; const scene: *c.SDL_GPUTexture = if (scene_on) g.scene_tex.? else target; 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, surface, layout, sw, sh, topbar_pane_border_rgb, tagline_rgb, paint_plan.len == 1, debug_on, ); 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; batch.image_count = 0; } } const cells: u32 = @as(u32, surface.cols) * surface.rows; const page = ground(theme_bg, g.transparent); const tagline_base = pardes.Cell{ .style = .{ .bg = .{ .rgb = tagline_rgb }, .font_role = .tagline }, .default = false, }; 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, .b = @as(f32, @floatFromInt(page.rgb[2])) / 255.0, .a = 1.0, }; color_target.load_op = c.SDL_GPU_LOADOP_CLEAR; color_target.store_op = c.SDL_GPU_STOREOP_STORE; var shifted: u32 = 0; var row: u16 = 0; while (row < surface.rows) : (row += 1) { var col: u16 = 0; while (col < surface.cols) : (col += 1) { const batch_index = paintBatchAt(&paint_plan, col, row); const track = paint_plan.batches[batch_index].track; const data_effect = if (track) |active| active.effect == .dissolve else false; const data_diff = data_effect and surface.panelCellChanged(col, row); const logical_idx: u32 = @as(u32, row) * surface.cols + col; const instance_count = if (data_diff) cellInstanceCount(core, &surface.previous_cells[logical_idx], row) + cellInstanceCount(core, surface.at(col, row), row) else cellInstanceCount(core, surface.at(col, row), row); paint_plan.batches[batch_index].cell_count = std.math.add( u32, paint_plan.batches[batch_index].cell_count, instance_count, ) 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; if (!duplicate_under and track.phase != .closing) continue; row = 0; while (row < surface.rows) : (row += 1) { var col: u16 = 0; while (col < surface.cols) : (col += 1) { if (!boxContains(track.contentBox(), col, row)) continue; const destination = if (duplicate_under) &paint_plan.batches[0] else batch; const logical_idx: u32 = @as(u32, row) * surface.cols + col; destination.cell_count = std.math.add( u32, destination.cell_count, cellInstanceCount(core, &surface.previous_cells[logical_idx], row), ) catch return error.GpuCapacity; } } } var cell_next: [pardes.MAX_PANES * 2 + 1]u32 = @splat(0); var cell_total: u32 = 0; for (paint_plan.batches[0..paint_plan.len], 0..) |*batch, batch_index| { batch.cell_start = cell_total; cell_next[batch_index] = cell_total; 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); return false; }); const instances: [*]CellInstance = @ptrCast(@alignCast(vptr)); const cursor_idx: u32 = if (surface.cursor) |cu| @as(u32, cu.y) * surface.cols + cu.x else cells; const cursor_bar = if (surface.cursor) |cu| cu.bar else false; row = 0; while (row < surface.rows) : (row += 1) { var col: u16 = 0; while (col < surface.cols) : (col += 1) { const batch_index = paintBatchAt(&paint_plan, col, row); const track = paint_plan.batches[batch_index].track; const logical_idx: u32 = @as(u32, row) * surface.cols + col; const data_effect = if (track) |active| active.effect == .dissolve else false; const data_diff = data_effect and surface.panelCellChanged(col, row); if (data_diff) { emitSurfaceCell( g, core, instances, &cell_next[batch_index], col, row, layout, win_w, win_h, track, &surface.previous_cells[logical_idx], &tagline_base, true, false, page, ); } emitSurfaceCell( g, core, instances, &cell_next[batch_index], col, row, layout, win_w, win_h, if (data_effect and !data_diff) null else track, surface.at(col, row), &tagline_base, false, paint_plan.len == 1 and logical_idx == cursor_idx and !cursor_bar, page, ); } } for (paint_plan.batches[1..paint_plan.len], 1..) |batch, batch_index| { const track = batch.track.?; const duplicate_under = track.effect == .vertical and track.phase == .opening; if (!duplicate_under and track.phase != .closing) continue; row = 0; while (row < surface.rows) : (row += 1) { var col: u16 = 0; while (col < surface.cols) : (col += 1) { if (!boxContains(track.contentBox(), col, row)) continue; const logical_idx: u32 = @as(u32, row) * surface.cols + col; const destination: usize = if (duplicate_under) 0 else batch_index; emitSurfaceCell( g, core, instances, &cell_next[destination], col, row, layout, win_w, win_h, if (duplicate_under) null else track, &surface.previous_cells[logical_idx], &tagline_base, false, false, page, ); } } } for (paint_plan.batches[0..paint_plan.len], 0..) |batch, batch_index| std.debug.assert(cell_next[batch_index] == batch.cell_start + batch.cell_count); 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); const src = c.SDL_GPUTransferBufferLocation{ .transfer_buffer = g.vxfer.?, .offset = 0 }; const dst = c.SDL_GPUBufferRegion{ .buffer = g.vbuf.?, .offset = 0, .size = (cell_total + shifted) * @sizeOf(CellInstance) }; c.SDL_UploadToGPUBuffer(copy, &src, &dst, false); 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| { const has_shifted = batch_index == 0 and shifted != 0; if ((batch.cell_count != 0 or has_shifted) and g.vbuf != null) { c.SDL_BindGPUGraphicsPipeline(rp, g.pipeline); const samp_binding = c.SDL_GPUTextureSamplerBinding{ .texture = g.atlas_tex, .sampler = g.atlas_sampler }; c.SDL_BindGPUFragmentSamplers(rp, 0, &samp_binding, 1); if (batch.cell_count != 0) { const panel_clipped = if (batch.track) |active| active.effect == .vertical else false; if (panel_clipped) { const clip = panelBoxScissor(g, batch.track.?.contentBox(), sw, sh); c.SDL_SetGPUScissor(rp, &clip); } const binding = c.SDL_GPUBufferBinding{ .buffer = g.vbuf.?, .offset = batch.cell_start * @sizeOf(CellInstance), }; c.SDL_BindGPUVertexBuffers(rp, 0, &binding, 1); c.SDL_DrawGPUPrimitives(rp, 6, batch.cell_count, 0, 0); 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{ .buffer = g.vbuf.?, .offset = cell_total * @sizeOf(CellInstance), }; c.SDL_BindGPUVertexBuffers(rp, 0, &binding, 1); c.SDL_DrawGPUPrimitives(rp, 6, shifted, 0, 0); c.SDL_SetGPUScissor(rp, &whole); } } if (batch.image_count != 0) drawNativeImagesGpu(g, rp, surface, &paint_plan, batch_index); } drawOverlayGpu(g, rp, overlay_count); c.SDL_EndGPURenderPass(rp); var rendered_scene: crt.Frame = .{}; if (scene_on) { var crt_target = std.mem.zeroes(c.SDL_GPUColorTargetInfo); crt_target.texture = target; crt_target.load_op = c.SDL_GPU_LOADOP_DONT_CARE; crt_target.store_op = c.SDL_GPU_STOREOP_STORE; const crt_pass = c.SDL_BeginGPURenderPass(cmd, &crt_target, 1, null); c.SDL_BindGPUGraphicsPipeline(crt_pass, g.crt_pipeline); const samp_binding = c.SDL_GPUTextureSamplerBinding{ .texture = g.scene_tex.?, .sampler = g.linear_sampler }; c.SDL_BindGPUFragmentSamplers(crt_pass, 0, &samp_binding, 1); const now_ns = c.SDL_GetTicksNS(); const time_wrap_ns: u64 = 4096 * std.time.ns_per_s; const time_seconds: f32 = @floatCast( @as(f64, @floatFromInt(now_ns % time_wrap_ns)) / @as(f64, std.time.ns_per_s), ); rendered_scene = .{ .effects = .{ .crt = scene_effects.crt, .ripple = scene_effects.ripple, .glitch = scene_effects.glitch, }, .time_seconds = time_seconds, }; const crt_uniforms = [8]f32{ time_seconds, @floatFromInt((now_ns / (std.time.ns_per_s / 60)) % 65_536), @floatFromInt(sw), @floatFromInt(sh), @floatFromInt(@intFromBool(scene_effects.crt)), @floatFromInt(@intFromBool(scene_effects.ripple)), @floatFromInt(@intFromBool(scene_effects.glitch)), 0, }; c.SDL_PushGPUFragmentUniformData(cmd, 0, &crt_uniforms, @sizeOf(@TypeOf(crt_uniforms))); c.SDL_DrawGPUPrimitives(crt_pass, 3, 1, 0, 0); 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; if (paint_plan.len == 1) rememberPresentedImages(g, gpa, surface); 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; if (paint_plan.len == 1) rememberPresentedImages(g, gpa, surface); return true; } command_consumed = true; const submitted = c.SDL_SubmitGPUCommandBuffer(cmd); if (submitted) { g.presented_scene = rendered_scene; if (paint_plan.len == 1) rememberPresentedImages(g, gpa, surface); } 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; fn topbarPaneBorderHeight(surface: *const pardes.Surface, cell_h: u32, tagline_h: u32) ?u32 { if (surface.rows <= pardes.TOPBAR_H) return null; const height = topbarPaneBorderPixels(cell_h, tagline_h); if (height == 0) return null; const base = @as(usize, pardes.TOPBAR_H) * surface.cols; for (surface.cells[base..][0..surface.cols]) |cell| if (!cell.default and cell.style.font_role == .tagline) return height; return null; } fn bottomTaglinePresent(surface: *const pardes.Surface) bool { if (surface.rows == 0) return false; const base = @as(usize, surface.rows - 1) * surface.cols; for (surface.cells[base..][0..surface.cols]) |cell| if (!cell.default and cell.style.font_role == .tagline) return true; return false; } test "tagline bands face the topbar rule and Tagbottom faces the window edge" { const cell_h: u32 = 20; const tagline_h: u32 = 16; const canvas_h: f32 = 200; const border = topbarPaneBorderPixels(cell_h, tagline_h); try std.testing.expectEqual(@as(u32, 1), border); try std.testing.expectEqual(@as(u32, 4), taglineBandOffset(0, canvas_h, cell_h, tagline_h)); try std.testing.expectEqual(@as(u32, 1), taglineBandOffset(1, canvas_h, cell_h, tagline_h)); try std.testing.expectEqual(@as(u32, 2), taglineBandOffset(5, canvas_h, cell_h, tagline_h)); try std.testing.expectEqual(@as(u32, 4), taglineBandOffset(9, canvas_h, cell_h, tagline_h)); } 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; fg = switch (st.fg) { .default => fg_default, .index => |i| palColor(i), .rgb => |rgb| rgb, }; switch (st.bg) { .default => {}, .index => |i| { bg = palColor(i); clear_bg = false; }, .rgb => |rgb| { bg = rgb; clear_bg = false; }, } if (st.reverse) reverse = !reverse; if (st.invisible) { fg = bg; blank = clear_bg; } if (st.dim) for (&fg) |*ch| { ch.* = @intCast(@as(u16, ch.*) * 6 / 10); }; } if (reverse) { std.mem.swap([3]u8, &fg, &bg); clear_bg = false; } const cp = if (blank) ' ' else cellCodepoint(cell); return .{ .slot = if (cp == ' ') g.space_slot else ensureGlyph(g, cp, role), .fg = fg, .bg = bg, .role = role, .clear_bg = clear_bg, }; } /// 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; return .body; } fn cellInstanceCount(core: ?*const pardes.Pardes, cell: *const pardes.Cell, row: u16) u32 { 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, instances: [*]CellInstance, next: *u32, col: u16, row: u16, body_layout: CellLayout, win_w: f32, win_h: f32, track: ?pardes.panel_animation.Track, cell: *const pardes.Cell, tagline_base: *const pardes.Cell, old_layer: bool, is_cursor: bool, page: Ground, ) void { const role = drawnFontRole(core, cell, row); if (role == .tagline) { emitInstance(g, instances, next.*, col, row, body_layout, win_w, win_h, track, .tagline, tagline_base, old_layer, false, page); next.* += 1; const tag_layout = taglineLayoutForCell(g, core, col, row, track); emitInstance(g, instances, next.*, col, row, tag_layout, win_w, win_h, track, .tagline, cell, old_layer, is_cursor, page); next.* += 1; return; } emitInstance(g, instances, next.*, col, row, body_layout, win_w, win_h, track, role, cell, old_layer, is_cursor, page); next.* += 1; } fn emitInstance( g: *Gui, instances: [*]CellInstance, idx: u32, col: u16, row: u16, 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. 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) else layout.h; const band_offset: f32 = if (resolved.role == .tagline) @floatFromInt(taglineBandOffset(row, win_h, g.cell_h, g.tagline_height)) else 0; const py0 = layout.y_off + @as(f32, @floatFromInt(row)) * layout.h + band_offset; const x0 = (px0 / win_w) * 2.0 - 1.0; const x1 = ((px0 + layout.w) / win_w) * 2.0 - 1.0; const y0 = 1.0 - (py0 / win_h) * 2.0; const y1 = 1.0 - ((py0 + visual_h) / win_h) * 2.0; const aw_f: f32 = @floatFromInt(atlas_w); const ah_f: f32 = @floatFromInt(atlas_h); const uv_w: u32 = if (resolved.role == .tagline) g.tagline_width else g.cell_w; const uv_h: u32 = if (resolved.role == .tagline) g.tagline_height else g.cell_h; instances[idx] = .{ .x0 = x0, .y0 = y0, .x1 = x1, .y1 = y1, .u0 = @as(f32, @floatFromInt(resolved.slot.u)) / aw_f, .v0 = @as(f32, @floatFromInt(resolved.slot.v)) / ah_f, .u1 = @as(f32, @floatFromInt(resolved.slot.u + uv_w)) / aw_f, .v1 = @as(f32, @floatFromInt(resolved.slot.v + uv_h)) / ah_f, .fr = @as(f32, @floatFromInt(resolved.fg[0])) / 255.0, .fg = @as(f32, @floatFromInt(resolved.fg[1])) / 255.0, .fb = @as(f32, @floatFromInt(resolved.fg[2])) / 255.0, .br = @as(f32, @floatFromInt(resolved.bg[0])) / 255.0, .bg = @as(f32, @floatFromInt(resolved.bg[1])) / 255.0, .bb = @as(f32, @floatFromInt(resolved.bg[2])) / 255.0, .panel_x0 = 0, .panel_y0 = 0, .panel_x1 = 0, .panel_y1 = 0, .present_x0 = 0, .present_y0 = 0, .present_x1 = 0, .present_y1 = 0, .effect = 0, .progress_bits = @bitCast(@as(f32, 1)), .serial = 0, .cell_coord = 0, }; setTransitionFields( &instances[idx], track, fixedCellLayout(g), win_w, win_h, if (track) |active| panelCellCoord(active, col, row) else 0, ); 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(); return if (cell.default or grapheme.len == 0) ' ' else firstCp(grapheme); } test "insert cursor overlays without replacing the character beneath it" { const cell: pardes.Cell = .{ .text = .{ 'x', ' ', ' ', ' ', ' ', ' ', ' ' }, .len = 1, .default = false, }; try std.testing.expectEqual(@as(u32, 'x'), cellCodepoint(&cell)); var vertices: [6]OverlayVertex = undefined; var builder: OverlayBuilder = .{ .vertices = &vertices, .win_w = 100, .win_h = 100, }; addCursorBar(&builder, 2, 3, true, 8, .{ .x_off = 0, .y_off = 0, .w = 10, .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); } fn palColor(idx: u8) [3]u8 { const p = ghostty_vt.color.default[idx]; 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 ' '; if (n > s.len) return ' '; 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; var chh: c_int = 20; var asc: c_int = 16; c.ui_font_cell_metrics(g.font, g.scale, &cw, &chh, &asc); g.cell_w = @intCast(@max(cw, 1)); g.cell_h = @intCast(@max(chh, 1)); g.ascent = @intCast(@max(asc, 1)); const tagline = taglineRaster(g.font, g.px, g.cell_w, g.cell_h, g.tagline_percent); g.tagline_scale = tagline.scale; g.tagline_width = tagline.width; g.tagline_baseline = tagline.baseline; g.tagline_height = tagline.height; 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; g.pen_y = 0; g.atlas_dirty = true; } fn syncTaglineFont(g: *Gui, core: *const pardes.Pardes) void { const percent = core.settings.font.tagline_percent; if (percent == g.tagline_percent) return; g.tagline_percent = percent; const tagline = taglineRaster(g.font, g.px, g.cell_w, g.cell_h, percent); g.tagline_scale = tagline.scale; g.tagline_width = tagline.width; g.tagline_baseline = tagline.baseline; g.tagline_height = tagline.height; resetGlyphAtlas(g); } fn ensureGlyph(g: *Gui, cp: u32, role: pardes.FontRole) Slot { const key: GlyphKey = .{ .codepoint = cp, .role = role }; if (g.glyphs.get(key)) |s| return s; if (g.pen_x + g.cell_w > atlas_w) { g.pen_x = 0; g.pen_y += g.cell_h; } if (g.pen_y + g.cell_h > atlas_h) return g.space_slot; // atlas full: fall back const s = Slot{ .u = g.pen_x, .v = g.pen_y }; const out = g.atlas_stage.ptr + @as(usize, s.v) * atlas_w + s.u; const face = fontForCodepoint(g, cp); const scale = if (role == .tagline) g.tagline_scale else g.scale; const baseline = if (role == .tagline) g.tagline_baseline else g.ascent; const raster_w = if (role == .tagline) g.tagline_width else g.cell_w; const raster_h = if (role == .tagline) g.tagline_height else g.cell_h; _ = c.ui_font_raster(face, scale, @intCast(cp), out, @intCast(atlas_w), @intCast(raster_w), @intCast(raster_h), baseline); g.glyphs.put(key, s) catch return g.space_slot; g.pen_x += g.cell_w; g.atlas_dirty = true; return s; } fn uploadAtlas(g: *Gui, cmd: *c.SDL_GPUCommandBuffer) void { const ptr = c.SDL_MapGPUTransferBuffer(g.device, g.atlas_xfer, false) orelse return; const dst: [*]u8 = @ptrCast(ptr); @memcpy(dst[0 .. atlas_w * atlas_h], g.atlas_stage); c.SDL_UnmapGPUTransferBuffer(g.device, g.atlas_xfer); const copy = c.SDL_BeginGPUCopyPass(cmd); const src = c.SDL_GPUTextureTransferInfo{ .transfer_buffer = g.atlas_xfer, .offset = 0, .pixels_per_row = atlas_w, .rows_per_layer = atlas_h }; const dstregion = c.SDL_GPUTextureRegion{ .texture = g.atlas_tex, .mip_level = 0, .layer = 0, .x = 0, .y = 0, .z = 0, .w = atlas_w, .h = atlas_h, .d = 1 }; c.SDL_UploadToGPUTexture(copy, &src, &dstregion, false); c.SDL_EndGPUCopyPass(copy); g.atlas_dirty = false; } fn ensureVbuf(g: *Gui, cells: u32) !void { if (cells <= g.vbuf_cells and g.vbuf != null and g.vxfer != null) return; var capacity = @max(@as(u32, 1), g.vbuf_cells); while (capacity < cells) capacity = std.math.mul(u32, capacity, 2) catch return error.GpuCapacity; const size = std.math.mul( u32, capacity, @as(u32, @intCast(@sizeOf(CellInstance))), ) catch return error.GpuCapacity; var vb_info = c.SDL_GPUBufferCreateInfo{ .usage = c.SDL_GPU_BUFFERUSAGE_VERTEX, .size = size, .props = 0 }; const next_vbuf = c.SDL_CreateGPUBuffer(g.device, &vb_info) orelse return error.GpuCreate; errdefer c.SDL_ReleaseGPUBuffer(g.device, next_vbuf); 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; g.vxfer = next_vxfer; g.vbuf_cells = capacity; } fn makePipeline(device: *c.SDL_GPUDevice, color_format: c.SDL_GPUTextureFormat) !*c.SDL_GPUGraphicsPipeline { const vs = try makeShader(device, vert_spv, c.SDL_GPU_SHADERSTAGE_VERTEX, 0, 0); defer c.SDL_ReleaseGPUShader(device, vs); const fs = try makeShader(device, frag_spv, c.SDL_GPU_SHADERSTAGE_FRAGMENT, 1, 0); defer c.SDL_ReleaseGPUShader(device, fs); var vbuf_desc = c.SDL_GPUVertexBufferDescription{ .slot = 0, .pitch = @sizeOf(CellInstance), .input_rate = c.SDL_GPU_VERTEXINPUTRATE_INSTANCE, .instance_step_rate = 0, }; var attrs = [_]c.SDL_GPUVertexAttribute{ .{ .location = 0, .buffer_slot = 0, .format = c.SDL_GPU_VERTEXELEMENTFORMAT_FLOAT4, .offset = @offsetOf(CellInstance, "x0") }, .{ .location = 1, .buffer_slot = 0, .format = c.SDL_GPU_VERTEXELEMENTFORMAT_FLOAT4, .offset = @offsetOf(CellInstance, "u0") }, .{ .location = 2, .buffer_slot = 0, .format = c.SDL_GPU_VERTEXELEMENTFORMAT_FLOAT3, .offset = @offsetOf(CellInstance, "fr") }, .{ .location = 3, .buffer_slot = 0, .format = c.SDL_GPU_VERTEXELEMENTFORMAT_FLOAT3, .offset = @offsetOf(CellInstance, "br") }, .{ .location = 4, .buffer_slot = 0, .format = c.SDL_GPU_VERTEXELEMENTFORMAT_FLOAT4, .offset = @offsetOf(CellInstance, "panel_x0") }, .{ .location = 5, .buffer_slot = 0, .format = c.SDL_GPU_VERTEXELEMENTFORMAT_FLOAT4, .offset = @offsetOf(CellInstance, "present_x0") }, .{ .location = 6, .buffer_slot = 0, .format = c.SDL_GPU_VERTEXELEMENTFORMAT_UINT4, .offset = @offsetOf(CellInstance, "effect") }, }; var col_desc = c.SDL_GPUColorTargetDescription{ .format = color_format, .blend_state = std.mem.zeroes(c.SDL_GPUColorTargetBlendState) }; var info = std.mem.zeroes(c.SDL_GPUGraphicsPipelineCreateInfo); info.vertex_shader = vs; info.fragment_shader = fs; info.primitive_type = c.SDL_GPU_PRIMITIVETYPE_TRIANGLELIST; info.vertex_input_state = .{ .vertex_buffer_descriptions = &vbuf_desc, .num_vertex_buffers = 1, .vertex_attributes = &attrs, .num_vertex_attributes = attrs.len, }; info.rasterizer_state.fill_mode = c.SDL_GPU_FILLMODE_FILL; info.rasterizer_state.cull_mode = c.SDL_GPU_CULLMODE_NONE; info.multisample_state.sample_count = c.SDL_GPU_SAMPLECOUNT_1; info.target_info = .{ .color_target_descriptions = &col_desc, .num_color_targets = 1 }; return c.SDL_CreateGPUGraphicsPipeline(device, &info) orelse error.GpuCreate; } fn makeOverlayPipeline(device: *c.SDL_GPUDevice, color_format: c.SDL_GPUTextureFormat) !*c.SDL_GPUGraphicsPipeline { const vs = try makeShader(device, overlay_vert_spv, c.SDL_GPU_SHADERSTAGE_VERTEX, 0, 0); defer c.SDL_ReleaseGPUShader(device, vs); const fs = try makeShader(device, overlay_frag_spv, c.SDL_GPU_SHADERSTAGE_FRAGMENT, 0, 0); defer c.SDL_ReleaseGPUShader(device, fs); var vbuf_desc = c.SDL_GPUVertexBufferDescription{ .slot = 0, .pitch = @sizeOf(OverlayVertex), .input_rate = c.SDL_GPU_VERTEXINPUTRATE_VERTEX, .instance_step_rate = 0, }; var attrs = [_]c.SDL_GPUVertexAttribute{ .{ .location = 0, .buffer_slot = 0, .format = c.SDL_GPU_VERTEXELEMENTFORMAT_FLOAT2, .offset = @offsetOf(OverlayVertex, "x") }, .{ .location = 1, .buffer_slot = 0, .format = c.SDL_GPU_VERTEXELEMENTFORMAT_FLOAT4, .offset = @offsetOf(OverlayVertex, "r") }, }; var blend = std.mem.zeroes(c.SDL_GPUColorTargetBlendState); blend.src_color_blendfactor = c.SDL_GPU_BLENDFACTOR_SRC_ALPHA; blend.dst_color_blendfactor = c.SDL_GPU_BLENDFACTOR_ONE_MINUS_SRC_ALPHA; blend.color_blend_op = c.SDL_GPU_BLENDOP_ADD; blend.src_alpha_blendfactor = c.SDL_GPU_BLENDFACTOR_ONE; blend.dst_alpha_blendfactor = c.SDL_GPU_BLENDFACTOR_ONE_MINUS_SRC_ALPHA; blend.alpha_blend_op = c.SDL_GPU_BLENDOP_ADD; blend.enable_blend = true; var col_desc = c.SDL_GPUColorTargetDescription{ .format = color_format, .blend_state = blend }; var info = std.mem.zeroes(c.SDL_GPUGraphicsPipelineCreateInfo); info.vertex_shader = vs; info.fragment_shader = fs; info.primitive_type = c.SDL_GPU_PRIMITIVETYPE_TRIANGLELIST; info.vertex_input_state = .{ .vertex_buffer_descriptions = &vbuf_desc, .num_vertex_buffers = 1, .vertex_attributes = &attrs, .num_vertex_attributes = attrs.len, }; info.rasterizer_state.fill_mode = c.SDL_GPU_FILLMODE_FILL; info.rasterizer_state.cull_mode = c.SDL_GPU_CULLMODE_NONE; info.multisample_state.sample_count = c.SDL_GPU_SAMPLECOUNT_1; info.target_info = .{ .color_target_descriptions = &col_desc, .num_color_targets = 1 }; return c.SDL_CreateGPUGraphicsPipeline(device, &info) orelse error.GpuCreate; } fn makeImagePipeline(device: *c.SDL_GPUDevice, color_format: c.SDL_GPUTextureFormat) !*c.SDL_GPUGraphicsPipeline { const vs = try makeShader(device, image_vert_spv, c.SDL_GPU_SHADERSTAGE_VERTEX, 0, 0); defer c.SDL_ReleaseGPUShader(device, vs); const fs = try makeShader(device, image_frag_spv, c.SDL_GPU_SHADERSTAGE_FRAGMENT, 1, 0); defer c.SDL_ReleaseGPUShader(device, fs); var vbuf_desc = c.SDL_GPUVertexBufferDescription{ .slot = 0, .pitch = @sizeOf(ImageInstance), .input_rate = c.SDL_GPU_VERTEXINPUTRATE_INSTANCE, .instance_step_rate = 0, }; var attrs = [_]c.SDL_GPUVertexAttribute{ .{ .location = 0, .buffer_slot = 0, .format = c.SDL_GPU_VERTEXELEMENTFORMAT_FLOAT4, .offset = @offsetOf(ImageInstance, "x0") }, .{ .location = 1, .buffer_slot = 0, .format = c.SDL_GPU_VERTEXELEMENTFORMAT_FLOAT4, .offset = @offsetOf(ImageInstance, "u0") }, .{ .location = 2, .buffer_slot = 0, .format = c.SDL_GPU_VERTEXELEMENTFORMAT_FLOAT4, .offset = @offsetOf(ImageInstance, "panel_x0") }, .{ .location = 3, .buffer_slot = 0, .format = c.SDL_GPU_VERTEXELEMENTFORMAT_FLOAT4, .offset = @offsetOf(ImageInstance, "present_x0") }, .{ .location = 4, .buffer_slot = 0, .format = c.SDL_GPU_VERTEXELEMENTFORMAT_UINT4, .offset = @offsetOf(ImageInstance, "effect") }, }; var blend = std.mem.zeroes(c.SDL_GPUColorTargetBlendState); blend.src_color_blendfactor = c.SDL_GPU_BLENDFACTOR_SRC_ALPHA; blend.dst_color_blendfactor = c.SDL_GPU_BLENDFACTOR_ONE_MINUS_SRC_ALPHA; blend.color_blend_op = c.SDL_GPU_BLENDOP_ADD; blend.src_alpha_blendfactor = c.SDL_GPU_BLENDFACTOR_ONE; blend.dst_alpha_blendfactor = c.SDL_GPU_BLENDFACTOR_ONE_MINUS_SRC_ALPHA; blend.alpha_blend_op = c.SDL_GPU_BLENDOP_ADD; blend.enable_blend = true; var col_desc = c.SDL_GPUColorTargetDescription{ .format = color_format, .blend_state = blend }; var info = std.mem.zeroes(c.SDL_GPUGraphicsPipelineCreateInfo); info.vertex_shader = vs; info.fragment_shader = fs; info.primitive_type = c.SDL_GPU_PRIMITIVETYPE_TRIANGLELIST; info.vertex_input_state.vertex_buffer_descriptions = &vbuf_desc; info.vertex_input_state.num_vertex_buffers = 1; info.vertex_input_state.vertex_attributes = &attrs; info.vertex_input_state.num_vertex_attributes = attrs.len; info.target_info.color_target_descriptions = &col_desc; info.target_info.num_color_targets = 1; info.rasterizer_state.fill_mode = c.SDL_GPU_FILLMODE_FILL; info.rasterizer_state.cull_mode = c.SDL_GPU_CULLMODE_NONE; info.multisample_state.sample_count = c.SDL_GPU_SAMPLECOUNT_1; 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); const fs = try makeShader(device, crt_frag_spv, c.SDL_GPU_SHADERSTAGE_FRAGMENT, 1, 1); defer c.SDL_ReleaseGPUShader(device, fs); var col_desc = c.SDL_GPUColorTargetDescription{ .format = color_format, .blend_state = std.mem.zeroes(c.SDL_GPUColorTargetBlendState) }; var info = std.mem.zeroes(c.SDL_GPUGraphicsPipelineCreateInfo); info.vertex_shader = vs; info.fragment_shader = fs; info.primitive_type = c.SDL_GPU_PRIMITIVETYPE_TRIANGLELIST; info.rasterizer_state.fill_mode = c.SDL_GPU_FILLMODE_FILL; info.rasterizer_state.cull_mode = c.SDL_GPU_CULLMODE_NONE; info.multisample_state.sample_count = c.SDL_GPU_SAMPLECOUNT_1; info.target_info = .{ .color_target_descriptions = &col_desc, .num_color_targets = 1 }; return c.SDL_CreateGPUGraphicsPipeline(device, &info) orelse error.GpuCreate; } fn makeShader(device: *c.SDL_GPUDevice, code: []const u8, stage: c.SDL_GPUShaderStage, n_samplers: u32, n_uniform_buffers: u32) !*c.SDL_GPUShader { var info = std.mem.zeroes(c.SDL_GPUShaderCreateInfo); info.code = code.ptr; info.code_size = code.len; info.entrypoint = "main"; info.format = c.SDL_GPU_SHADERFORMAT_SPIRV; info.stage = stage; info.num_samplers = n_samplers; info.num_uniform_buffers = n_uniform_buffers; 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); info.type = c.SDL_GPU_TEXTURETYPE_2D; info.format = g.swapchain_format; info.usage = c.SDL_GPU_TEXTUREUSAGE_COLOR_TARGET | c.SDL_GPU_TEXTUREUSAGE_SAMPLER; info.width = width; info.height = height; info.layer_count_or_depth = 1; info.num_levels = 1; info.sample_count = c.SDL_GPU_SAMPLECOUNT_1; const next = c.SDL_CreateGPUTexture(g.device, &info) orelse return error.GpuCreate; if (g.scene_tex) |texture| c.SDL_ReleaseGPUTexture(g.device, texture); g.scene_tex = next; g.scene_tex_w = width; g.scene_tex_h = height; } fn ensureCaptureTexture(g: *Gui, width: u32, height: u32) !void { if (g.capture_tex != null and g.capture_tex_w == width and g.capture_tex_h == height) return; var info = std.mem.zeroes(c.SDL_GPUTextureCreateInfo); info.type = c.SDL_GPU_TEXTURETYPE_2D; info.format = g.swapchain_format; info.usage = c.SDL_GPU_TEXTUREUSAGE_COLOR_TARGET; info.width = width; info.height = height; info.layer_count_or_depth = 1; info.num_levels = 1; info.sample_count = c.SDL_GPU_SAMPLECOUNT_1; const next = c.SDL_CreateGPUTexture(g.device, &info) orelse return error.GpuCreate; if (g.capture_tex) |texture| c.SDL_ReleaseGPUTexture(g.device, texture); g.capture_tex = next; g.capture_tex_w = width; 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, c.SDL_GPU_TEXTUREFORMAT_R8G8B8A8_UNORM, }; for (candidates) |format| { if (c.SDL_GPUTextureSupportsFormat( device, format, c.SDL_GPU_TEXTURETYPE_2D, c.SDL_GPU_TEXTUREUSAGE_COLOR_TARGET | c.SDL_GPU_TEXTUREUSAGE_SAMPLER, )) return format; } 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, c.SDL_GPU_TEXTUREFORMAT_B8G8R8A8_UNORM_SRGB, => c.SDL_PIXELFORMAT_ARGB8888, c.SDL_GPU_TEXTUREFORMAT_R8G8B8A8_UNORM, c.SDL_GPU_TEXTUREFORMAT_R8G8B8A8_UNORM_SRGB, => c.SDL_PIXELFORMAT_ABGR8888, else => null, }; } fn ensureSoftTexture(g: *Gui, width: u32, height: u32) !*c.SDL_Texture { if (g.soft_texture) |texture| { if (g.soft_tex_w == width and g.soft_tex_h == height) return texture; c.SDL_DestroyTexture(texture); g.soft_texture = null; } const renderer = g.soft_renderer orelse return error.GpuCreate; const pixel_format = softPixelFormat(g.swapchain_format) orelse return error.UnsupportedCaptureFormat; const next = c.SDL_CreateTexture( renderer, pixel_format, c.SDL_TEXTUREACCESS_STREAMING, @intCast(width), @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; g.soft_tex_h = height; 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); if (size == 0 or bpp != 4) { _ = c.SDL_SubmitGPUCommandBuffer(cmd); return error.UnsupportedCaptureFormat; } if (g.capture_xfer == null or g.capture_xfer_size < size) { var info = c.SDL_GPUTransferBufferCreateInfo{ .usage = c.SDL_GPU_TRANSFERBUFFERUSAGE_DOWNLOAD, .size = size, .props = 0 }; const next = c.SDL_CreateGPUTransferBuffer(g.device, &info) orelse { _ = c.SDL_SubmitGPUCommandBuffer(cmd); return error.GpuCreate; }; if (g.capture_xfer) |transfer| c.SDL_ReleaseGPUTransferBuffer(g.device, transfer); g.capture_xfer = next; g.capture_xfer_size = size; } const xfer = g.capture_xfer.?; const copy = c.SDL_BeginGPUCopyPass(cmd); const src = c.SDL_GPUTextureRegion{ .texture = target, .mip_level = 0, .layer = 0, .x = 0, .y = 0, .z = 0, .w = sw, .h = sh, .d = 1 }; const dst = c.SDL_GPUTextureTransferInfo{ .transfer_buffer = xfer, .offset = 0, .pixels_per_row = sw, .rows_per_layer = sh }; c.SDL_DownloadFromGPUTexture(copy, &src, &dst); c.SDL_EndGPUCopyPass(copy); const fence = c.SDL_SubmitGPUCommandBufferAndAcquireFence(cmd) orelse return error.GpuSubmit; defer c.SDL_ReleaseGPUFence(g.device, fence); var fences = [_]*c.SDL_GPUFence{fence}; if (!c.SDL_WaitForGPUFences(g.device, true, &fences, 1)) return error.GpuSubmit; const mapped = c.SDL_MapGPUTransferBuffer(g.device, xfer, false) orelse return error.GpuMap; defer c.SDL_UnmapGPUTransferBuffer(g.device, xfer); 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); _ = c.SDL_RenderTexture(renderer, texture, null, null); _ = c.SDL_RenderPresent(renderer); } fn captureFrame(g: *Gui, gpa: std.mem.Allocator, cmd: *c.SDL_GPUCommandBuffer, target: *c.SDL_GPUTexture, sw: u32, sh: u32) !void { const size = c.SDL_CalculateGPUTextureFormatSize(g.swapchain_format, sw, sh, 1); if (size == 0) { _ = c.SDL_SubmitGPUCommandBuffer(cmd); return error.UnsupportedCaptureFormat; } if (g.capture_xfer == null or g.capture_xfer_size < size) { var info = c.SDL_GPUTransferBufferCreateInfo{ .usage = c.SDL_GPU_TRANSFERBUFFERUSAGE_DOWNLOAD, .size = size, .props = 0 }; const next = c.SDL_CreateGPUTransferBuffer(g.device, &info) orelse { _ = c.SDL_SubmitGPUCommandBuffer(cmd); return error.GpuCreate; }; if (g.capture_xfer) |transfer| c.SDL_ReleaseGPUTransferBuffer(g.device, transfer); g.capture_xfer = next; g.capture_xfer_size = size; } const xfer = g.capture_xfer.?; const copy = c.SDL_BeginGPUCopyPass(cmd); const src = c.SDL_GPUTextureRegion{ .texture = target, .mip_level = 0, .layer = 0, .x = 0, .y = 0, .z = 0, .w = sw, .h = sh, .d = 1 }; const dst = c.SDL_GPUTextureTransferInfo{ .transfer_buffer = xfer, .offset = 0, .pixels_per_row = sw, .rows_per_layer = sh }; c.SDL_DownloadFromGPUTexture(copy, &src, &dst); c.SDL_EndGPUCopyPass(copy); const fence = c.SDL_SubmitGPUCommandBufferAndAcquireFence(cmd) orelse return error.GpuSubmit; defer c.SDL_ReleaseGPUFence(g.device, fence); var fences = [_]*c.SDL_GPUFence{fence}; if (!c.SDL_WaitForGPUFences(g.device, true, &fences, 1)) return error.GpuSubmit; const mapped = c.SDL_MapGPUTransferBuffer(g.device, xfer, false) orelse return error.GpuMap; defer c.SDL_UnmapGPUTransferBuffer(g.device, xfer); const pixels: [*]const u8 = @ptrCast(mapped); try writeCapturePpm(g, gpa, pixels[0..size], sw, sh); } fn writeCapturePpm(g: *Gui, gpa: std.mem.Allocator, pixels: []const u8, width: u32, height: u32) !void { if (g.capture_dir.len == 0) return; const bpp = c.SDL_GPUTextureFormatTexelBlockSize(g.swapchain_format); if (bpp != 4) return error.UnsupportedCaptureFormat; const bgr = switch (g.swapchain_format) { c.SDL_GPU_TEXTUREFORMAT_R8G8B8A8_UNORM, c.SDL_GPU_TEXTUREFORMAT_R8G8B8A8_UNORM_SRGB => false, c.SDL_GPU_TEXTUREFORMAT_B8G8R8A8_UNORM, c.SDL_GPU_TEXTUREFORMAT_B8G8R8A8_UNORM_SRGB => true, else => return error.UnsupportedCaptureFormat, }; var tmp_buf: [4096]u8 = undefined; var final_buf: [4096]u8 = undefined; const tmp_path = std.fmt.bufPrintSentinel(&tmp_buf, "{s}/latest.ppm.tmp", .{g.capture_dir}, 0) catch return error.CapturePathTooLong; const final_path = std.fmt.bufPrintSentinel(&final_buf, "{s}/latest.ppm", .{g.capture_dir}, 0) catch return error.CapturePathTooLong; const fd = libc.open(tmp_path, .{ .ACCMODE = .WRONLY, .CREAT = true, .TRUNC = true }, @as(libc.mode_t, 0o644)); if (fd < 0) return error.CaptureWriteFailed; defer _ = libc.close(fd); var header: [64]u8 = undefined; const hdr = std.fmt.bufPrint(&header, "P6\n{d} {d}\n255\n", .{ width, height }) catch return error.CaptureWriteFailed; _ = host_io.writeFd(fd, hdr); const row_rgb = try gpa.alloc(u8, @as(usize, width) * 3); defer gpa.free(row_rgb); for (0..height) |row| { const src = pixels[row * width * 4 ..][0 .. @as(usize, width) * 4]; for (0..width) |x| { const si = x * 4; const di = x * 3; row_rgb[di + 0] = src[si + if (bgr) @as(usize, 2) else 0]; row_rgb[di + 1] = src[si + 1]; row_rgb[di + 2] = src[si + if (bgr) @as(usize, 0) else 2]; } _ = host_io.writeFd(fd, row_rgb); } 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, y: u16, bar: bool, visual_height: f32, layout: CellLayout, ) void { if (!bar) return; const x0 = layout.x_off + @as(f32, @floatFromInt(x)) * layout.w; const height = std.math.clamp(visual_height, 1.0, layout.h); const y0 = layout.y_off + @as(f32, @floatFromInt(y)) * layout.h + @as(f32, @floatFromInt(taglineBandOffset(y, builder.win_h, @intFromFloat(layout.h), @intFromFloat(height)))); builder.addRect( x0, y0, x0 + @max(1.0, layout.w / 8.0), y0 + height, .{ .r = @as(f32, @floatFromInt(fg_default[0])) / 255.0, .g = @as(f32, @floatFromInt(fg_default[1])) / 255.0, .b = @as(f32, @floatFromInt(fg_default[2])) / 255.0, .a = 1.0, }, ); } fn buildOverlay( g: *Gui, core: ?*const pardes.Pardes, surface: *const pardes.Surface, layout: CellLayout, sw: u32, sh: u32, topbar_pane_border_rgb: [3]u8, tagline_rgb: [3]u8, transient_on: bool, debug_on: bool, ) u32 { if (g.overlay_vertices.len == 0 or sw == 0 or sh == 0) return 0; const win_w: f32 = @floatFromInt(sw); const win_h: f32 = @floatFromInt(sh); var builder = OverlayBuilder{ .vertices = g.overlay_vertices, .win_w = win_w, .win_h = win_h }; if (topbarPaneBorderHeight(surface, g.cell_h, g.tagline_height)) |height| { const y0: f32 = @floatFromInt(g.cell_h - height / 2); const rgb = topbar_pane_border_rgb; builder.addRect(0, y0, win_w, y0 + @as(f32, @floatFromInt(height)), .{ .r = @as(f32, @floatFromInt(rgb[0])) / 255.0, .g = @as(f32, @floatFromInt(rgb[1])) / 255.0, .b = @as(f32, @floatFromInt(rgb[2])) / 255.0, .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; builder.addRect(0, grid_bottom, win_w, win_h, .{ .r = @as(f32, @floatFromInt(rgb[0])) / 255.0, .g = @as(f32, @floatFromInt(rgb[1])) / 255.0, .b = @as(f32, @floatFromInt(rgb[2])) / 255.0, .a = 1.0, }); } if (transient_on) { if (surface.cursor) |cursor| { if (cursor.x < surface.cols and cursor.y < surface.rows) { const cell = surface.cells[@as(usize, cursor.y) * surface.cols + cursor.x]; const role = drawnFontRole(core, &cell, cursor.y); const visual_height: f32 = if (role == .tagline) @floatFromInt(g.tagline_height) else layout.h; const cursor_layout = if (role == .tagline) taglineLayoutForCell(g, core, cursor.x, cursor.y, null) else layout; addCursorBar(&builder, cursor.x, cursor.y, cursor.bar, visual_height, cursor_layout); } } } if (transient_on) appendTouchOverlay(g, &builder, debug_on); return @intCast(builder.len); } fn appendTouchOverlay(g: *Gui, builder: *OverlayBuilder, debug_on: bool) void { if (!debug_on) { g.touch.click_flash_frames = 0; return; } const t = &g.touch; if (t.click_flash_frames != 0) { const flash = @as(f32, @floatFromInt(t.click_flash_frames)) / @as(f32, @floatFromInt(touch_click_flash_max_frames)); const label = touchClickLabel(t.click_count, t.click_button); const glyph_scale = 3.0; const char_step = 4.0 * glyph_scale; const pad = 5.0; const stripe_w = 4.0; const label_w = pad * 2.0 + stripe_w + 3.0 + @as(f32, @floatFromInt(label.len)) * char_step - glyph_scale; const label_h = pad * 2.0 + 5.0 * glyph_scale; builder.addRect(0.0, 0.0, builder.win_w, builder.win_h, .{ .r = 1.0, .g = 0.78, .b = 0.18, .a = 0.055 * flash }); builder.addRect(8.0, 8.0, 8.0 + label_w, 8.0 + label_h, .{ .r = 0.0, .g = 0.0, .b = 0.0, .a = 0.54 + 0.22 * flash }); builder.addRect(8.0 + pad, 8.0 + pad, 8.0 + pad + stripe_w, 8.0 + label_h - pad, .{ .r = 1.0, .g = 0.78, .b = 0.18, .a = 0.96 }); builder.addMiniText(label[0..], 8.0 + pad + stripe_w + 3.0, 8.0 + pad, glyph_scale, .{ .r = 1.0, .g = 1.0, .b = 1.0, .a = 0.96 }); t.click_flash_frames -= 1; } for (&t.points) |*tp| { if (!tp.active) continue; const cx = std.math.clamp(tp.x, 0.0, 1.0) * builder.win_w; const cy = std.math.clamp(tp.y, 0.0, 1.0) * builder.win_h; const radius = (12.0 + std.math.clamp(tp.pressure, 0.0, 1.0) * 4.0) * 4.0; const color = touchColor(tp.id); if (tp.trail_len > 1) { const first = if (tp.trail_len == max_touch_trail_points) tp.trail_next else 0; for (0..tp.trail_len - 1) |trail_i| { const sample = tp.trail[(first + trail_i) % max_touch_trail_points]; const fade = @as(f32, @floatFromInt(trail_i + 1)) / @as(f32, @floatFromInt(tp.trail_len)); var trail_color = color; trail_color.a *= 0.42 * fade; const tx = std.math.clamp(sample.x, 0.0, 1.0) * builder.win_w; const ty = std.math.clamp(sample.y, 0.0, 1.0) * builder.win_h; const trail_radius = (12.0 + std.math.clamp(sample.pressure, 0.0, 1.0) * 4.0) * 2.8; builder.addCircle(tx, ty, trail_radius, trail_color); } } builder.addCircle(cx, cy, radius + 4.0, .{ .r = 0.0, .g = 0.0, .b = 0.0, .a = 0.62 }); builder.addCircle(cx, cy, radius, color); builder.addCircle(cx, cy, 2.4, .{ .r = 1.0, .g = 1.0, .b = 1.0, .a = 0.92 }); builder.addIdTag(tp.id, cx, cy, color); } } fn uploadOverlayGpu(g: *Gui, cmd: *c.SDL_GPUCommandBuffer, vertex_count: u32) bool { const byte_len: u32 = vertex_count * @sizeOf(OverlayVertex); const ptr = c.SDL_MapGPUTransferBuffer(g.device, g.overlay_vxfer, false) orelse return false; const dst: [*]u8 = @ptrCast(ptr); const src: [*]const u8 = @ptrCast(g.overlay_vertices.ptr); @memcpy(dst[0..byte_len], src[0..byte_len]); c.SDL_UnmapGPUTransferBuffer(g.device, g.overlay_vxfer); const copy = c.SDL_BeginGPUCopyPass(cmd); const src_loc = c.SDL_GPUTransferBufferLocation{ .transfer_buffer = g.overlay_vxfer, .offset = 0 }; const dst_region = c.SDL_GPUBufferRegion{ .buffer = g.overlay_vbuf, .offset = 0, .size = byte_len }; c.SDL_UploadToGPUBuffer(copy, &src_loc, &dst_region, false); c.SDL_EndGPUCopyPass(copy); return true; } fn drawOverlayGpu(g: *Gui, rp: ?*c.SDL_GPURenderPass, vertex_count: u32) void { if (vertex_count == 0) return; const pass = rp orelse return; c.SDL_BindGPUGraphicsPipeline(pass, g.overlay_pipeline); const binding = c.SDL_GPUBufferBinding{ .buffer = g.overlay_vbuf, .offset = 0 }; c.SDL_BindGPUVertexBuffers(pass, 0, &binding, 1); c.SDL_DrawGPUPrimitives(pass, vertex_count, 1, 0, 0); } fn touchColor(id: u64) OverlayColor { const palette = [_]OverlayColor{ .{ .r = 0.10, .g = 0.72, .b = 1.00, .a = 0.88 }, .{ .r = 1.00, .g = 0.32, .b = 0.50, .a = 0.88 }, .{ .r = 0.35, .g = 0.90, .b = 0.38, .a = 0.88 }, .{ .r = 1.00, .g = 0.78, .b = 0.18, .a = 0.88 }, .{ .r = 0.78, .g = 0.52, .b = 1.00, .a = 0.88 }, .{ .r = 0.10, .g = 0.88, .b = 0.75, .a = 0.88 }, .{ .r = 1.00, .g = 0.48, .b = 0.18, .a = 0.88 }, .{ .r = 0.78, .g = 0.90, .b = 1.00, .a = 0.88 }, }; return palette[@intCast(id % palette.len)]; } const OverlayBuilder = struct { vertices: []OverlayVertex, len: usize = 0, win_w: f32, win_h: f32, fn addVertex(b: *OverlayBuilder, px: f32, py: f32, color: OverlayColor) void { if (b.len >= b.vertices.len) return; b.vertices[b.len] = .{ .x = (px / b.win_w) * 2.0 - 1.0, .y = 1.0 - (py / b.win_h) * 2.0, .r = color.r, .g = color.g, .b = color.b, .a = color.a, }; b.len += 1; } fn addTri(b: *OverlayBuilder, x0: f32, y0: f32, x1: f32, y1: f32, x2: f32, y2: f32, color: OverlayColor) void { if (b.len + 3 > b.vertices.len) return; b.addVertex(x0, y0, color); b.addVertex(x1, y1, color); b.addVertex(x2, y2, color); } fn addRect(b: *OverlayBuilder, x0_in: f32, y0_in: f32, x1_in: f32, y1_in: f32, color: OverlayColor) void { if (b.len + 6 > b.vertices.len) return; const x0 = std.math.clamp(@min(x0_in, x1_in), 0.0, b.win_w); const x1 = std.math.clamp(@max(x0_in, x1_in), 0.0, b.win_w); const y0 = std.math.clamp(@min(y0_in, y1_in), 0.0, b.win_h); const y1 = std.math.clamp(@max(y0_in, y1_in), 0.0, b.win_h); if (x1 <= x0 or y1 <= y0) return; b.addTri(x0, y0, x1, y0, x1, y1, color); b.addTri(x0, y0, x1, y1, x0, y1, color); } fn addCircle(b: *OverlayBuilder, cx: f32, cy: f32, radius: f32, color: OverlayColor) void { var i: usize = 0; while (i + 1 < circle_points.len) : (i += 1) { const p0 = circle_points[i]; const p1 = circle_points[i + 1]; b.addTri(cx, cy, cx + p0[0] * radius, cy + p0[1] * radius, cx + p1[0] * radius, cy + p1[1] * radius, color); } } fn addIdTag(b: *OverlayBuilder, id: u64, cx: f32, cy: f32, color: OverlayColor) void { const label = touchIdLabel(id); const glyph_scale = 2.0; const char_step = 4.0 * glyph_scale; const pad = 4.0; const stripe_w = 3.0; const label_w = pad * 2.0 + stripe_w + 3.0 + @as(f32, @floatFromInt(label.len)) * char_step - glyph_scale; const label_h = pad * 2.0 + 5.0 * glyph_scale; var x = cx + 20.0; if (x + label_w > b.win_w - 2.0) x = cx - label_w - 20.0; x = std.math.clamp(x, 2.0, @max(2.0, b.win_w - label_w - 2.0)); var y = cy - label_h - 10.0; if (y < 2.0) y = cy + 20.0; y = std.math.clamp(y, 2.0, @max(2.0, b.win_h - label_h - 2.0)); b.addRect(x, y, x + label_w, y + label_h, .{ .r = 0.0, .g = 0.0, .b = 0.0, .a = 0.72 }); b.addRect(x + pad, y + pad, x + pad + stripe_w, y + label_h - pad, color); b.addMiniText(label[0..], x + pad + stripe_w + 3.0, y + pad, glyph_scale, .{ .r = 1.0, .g = 1.0, .b = 1.0, .a = 0.95 }); } fn addMiniText(b: *OverlayBuilder, text: []const u8, x: f32, y: f32, scale: f32, color: OverlayColor) void { var cx = x; for (text) |ch| { const rows = miniGlyph(ch); const masks = [_]u8{ 0b100, 0b010, 0b001 }; for (rows, 0..) |bits, row| { for (masks, 0..) |mask, colidx| { if ((bits & mask) == 0) continue; const x0 = cx + @as(f32, @floatFromInt(colidx)) * scale; const y0 = y + @as(f32, @floatFromInt(row)) * scale; b.addRect(x0, y0, x0 + scale, y0 + scale, color); } } cx += 4.0 * scale; } } }; const circle_points = [_][2]f32{ .{ 1.0000000, 0.0000000 }, .{ 0.9238795, 0.3826834 }, .{ 0.7071068, 0.7071068 }, .{ 0.3826834, 0.9238795 }, .{ 0.0000000, 1.0000000 }, .{ -0.3826834, 0.9238795 }, .{ -0.7071068, 0.7071068 }, .{ -0.9238795, 0.3826834 }, .{ -1.0000000, 0.0000000 }, .{ -0.9238795, -0.3826834 }, .{ -0.7071068, -0.7071068 }, .{ -0.3826834, -0.9238795 }, .{ 0.0000000, -1.0000000 }, .{ 0.3826834, -0.9238795 }, .{ 0.7071068, -0.7071068 }, .{ 0.9238795, -0.3826834 }, .{ 1.0000000, 0.0000000 }, }; fn touchIdLabel(id: u64) [9]u8 { var out: [9]u8 = undefined; out[0] = '#'; const low: u32 = @truncate(id); for (0..8) |i| { const shift: u5 = @intCast((7 - i) * 4); out[i + 1] = hexDigit((low >> shift) & 0xf); } return out; } fn touchClickLabel(count: u32, button: pardes.Mouse.Button) [14]u8 { var out: [14]u8 = undefined; @memcpy(out[0..6], if (button == .right) "LOOK #" else "MID #"); for (0..8) |i| { const shift: u5 = @intCast((7 - i) * 4); out[i + 6] = hexDigit((count >> shift) & 0xf); } return out; } fn hexDigit(n: u32) u8 { return if (n < 10) @intCast('0' + n) else @intCast('A' + (n - 10)); } fn miniGlyph(ch: u8) [5]u8 { return switch (ch) { '#' => .{ 0b101, 0b111, 0b101, 0b111, 0b101 }, '0' => .{ 0b111, 0b101, 0b101, 0b101, 0b111 }, '1' => .{ 0b010, 0b110, 0b010, 0b010, 0b111 }, '2' => .{ 0b111, 0b001, 0b111, 0b100, 0b111 }, '3' => .{ 0b111, 0b001, 0b111, 0b001, 0b111 }, '4' => .{ 0b101, 0b101, 0b111, 0b001, 0b001 }, '5' => .{ 0b111, 0b100, 0b111, 0b001, 0b111 }, '6' => .{ 0b111, 0b100, 0b111, 0b101, 0b111 }, '7' => .{ 0b111, 0b001, 0b010, 0b010, 0b010 }, '8' => .{ 0b111, 0b101, 0b111, 0b101, 0b111 }, '9' => .{ 0b111, 0b101, 0b111, 0b001, 0b111 }, 'A' => .{ 0b111, 0b101, 0b111, 0b101, 0b101 }, 'B' => .{ 0b110, 0b101, 0b110, 0b101, 0b110 }, 'C' => .{ 0b111, 0b100, 0b100, 0b100, 0b111 }, 'D' => .{ 0b110, 0b101, 0b101, 0b101, 0b110 }, 'E' => .{ 0b111, 0b100, 0b111, 0b100, 0b111 }, 'F' => .{ 0b111, 0b100, 0b111, 0b100, 0b100 }, 'I' => .{ 0b111, 0b010, 0b010, 0b010, 0b111 }, 'K' => .{ 0b101, 0b101, 0b110, 0b101, 0b101 }, 'L' => .{ 0b100, 0b100, 0b100, 0b100, 0b111 }, 'M' => .{ 0b101, 0b111, 0b111, 0b101, 0b101 }, 'O' => .{ 0b111, 0b101, 0b101, 0b101, 0b111 }, else => .{ 0, 0, 0, 0, 0 }, }; } // ===================================================================== // 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; var it = view.iterator(); if (it.nextCodepoint()) |cp| { if (it.nextCodepoint() == null) return cp; } } 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, vaxis.Key.down => pardes.Key.down, vaxis.Key.left => pardes.Key.left, vaxis.Key.right => pardes.Key.right, vaxis.Key.home => pardes.Key.home, vaxis.Key.end => pardes.Key.end, vaxis.Key.page_up => pardes.Key.page_up, vaxis.Key.page_down => pardes.Key.page_down, vaxis.Key.delete => pardes.Key.delete, else => cp, }; } fn envU16(env: *std.process.Environ.Map, name: []const u8) ?u16 { const raw = env.get(name) orelse return null; return std.fmt.parseInt(u16, raw, 10) catch null; }