diff options
| author | Gabriel Schneider <[email protected]> | 2026-08-16 15:49:12 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-08-18 23:44:42 -0300 |
| commit | 1551e409c31992437cb2fa864f576d45c8433801 (patch) | |
| tree | e2fae8451f87b735a1360c7c2e383fdc40165789 /src/gui/gui.zig | |
| parent | be2a9957708cbf0c478ca861c4a1f0f227bbfe10 (diff) | |
| download | pardes-1551e409c31992437cb2fa864f576d45c8433801.tar.gz pardes-1551e409c31992437cb2fa864f576d45c8433801.zip | |
big slow change: prebuilt shaders (SPIR-V/Metal), core gui reflow, docs, web + snapshot refresh
Diffstat (limited to 'src/gui/gui.zig')
| -rw-r--r-- | src/gui/gui.zig | 2750 |
1 files changed, 1573 insertions, 1177 deletions
diff --git a/src/gui/gui.zig b/src/gui/gui.zig index f36de939..2c556895 100644 --- a/src/gui/gui.zig +++ b/src/gui/gui.zig @@ -5,11 +5,6 @@ //! 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. -//! -//! The same file is the BROWSER shell (-Dplatform=web, wasm32-emscripten): -//! WebGL2 instead of the GPU device, an embedded dump instead of ptys, and a -//! requestAnimationFrame callback instead of a blocking loop. Comptime -//! branches on is_emscripten, the way the stdlib branches on os.tag. const std = @import("std"); const builtin = @import("builtin"); const posix = std.posix; @@ -21,20 +16,19 @@ const pardes = @import("../pardes.zig"); const config = @import("../config.zig"); const look = @import("../look.zig"); const message = @import("../message.zig"); +const file_watch = @import("../file_watch.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 is_emscripten = builtin.os.tag == .emscripten; -const temp_file = if (is_emscripten) struct {} else @import("../temp_file.zig"); -const shell_bin = if (is_emscripten) struct {} else @import("../shell_bin.zig"); -const nested = if (is_emscripten) struct {} else @import("../nested.zig"); +const temp_file = @import("../temp_file.zig"); +const shell_bin = @import("../shell_bin.zig"); +const nested = @import("../nested.zig"); pub const c = @cImport({ @cDefine("SDL_DISABLE_OLD_NAMES", "1"); @cInclude("SDL3/SDL.h"); - if (is_emscripten) @cInclude("GLES3/gl3.h"); @cInclude("font.h"); }); @@ -43,30 +37,20 @@ extern "c" fn execv(path: [*:0]const u8, argv: [*:null]const ?[*:0]const u8) c_i extern "c" fn chdir(path: [*:0]const u8) c_int; extern "c" fn _exit(status: c_int) noreturn; extern "c" fn setenv(name: [*:0]const u8, value: [*:0]const u8, overwrite: c_int) c_int; -extern "c" fn emscripten_get_element_css_size(target: [*:0]const u8, width: *f64, height: *f64) c_int; -const EMSCRIPTEN_RESULT_SUCCESS: c_int = 0; - // 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 = if (is_emscripten) "" else @embedFile("ui.vert.spv"); -const frag_spv = if (is_emscripten) "" else @embedFile("ui.frag.spv"); -const overlay_vert_spv = if (is_emscripten) "" else @embedFile("overlay.vert.spv"); -const overlay_frag_spv = if (is_emscripten) "" else @embedFile("overlay.frag.spv"); -const image_vert_spv = if (is_emscripten) "" else @embedFile("image.vert.spv"); -const image_frag_spv = if (is_emscripten) "" else @embedFile("image.frag.spv"); -const crt_vert_spv = if (is_emscripten) "" else @embedFile("crt.vert.spv"); -const crt_frag_spv = if (is_emscripten) "" else @embedFile("crt.frag.spv"); -// web: GL ES shader SOURCES, compiled by WebGL at init (no SPIR-V there) -const vert_glsl_es = if (is_emscripten) @embedFile("ui.vert.es.glsl") else ""; -const frag_glsl_es = if (is_emscripten) @embedFile("ui.frag.es.glsl") else ""; -const overlay_vert_glsl_es = if (is_emscripten) @embedFile("overlay.vert.es.glsl") else ""; -const overlay_frag_glsl_es = if (is_emscripten) @embedFile("overlay.frag.es.glsl") else ""; -// web: no ptys — the whole state replays from a dump embedded at build time -const embedded_dump = if (is_emscripten) @embedFile("embedded.dump.zon") else ""; +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 @@ -120,15 +104,42 @@ const p9_arrow_mask = blk: { break :blk m; }; -// web: glyphs raster at 1.5x and the canvas backs at CSS×1.5 so text stays -// sharp on scaled canvases (the prototype's -Dweb-render-scale, which never -// shipped non-default — hardcoded here instead of a build option). -const web_render_scale: f32 = 1.5; - -// 2048 fits ~2500 glyphs at the 2x native cell (~20x40); web needs it too +// 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. The atlas slot remains a +/// body-sized cell: only the glyph shrinks, and its own line box is centered +/// vertically inside that fixed geometry. +const TaglineRaster = struct { + scale: f32, + baseline: i32, + height: u32, +}; + +fn taglineRaster(font: *c.UIFont, body_px: f32, 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 ignored_w: c_int = 1; + var own_h: c_int = 1; + var own_ascent: c_int = 1; + c.ui_font_cell_metrics(font, scale, &ignored_w, &own_h, &own_ascent); + const fixed_h: c_int = @intCast(body_cell_h); + const band_h = std.math.clamp(own_h, 1, fixed_h); + return .{ + .scale = scale, + // 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, @@ -146,7 +157,8 @@ 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. This monotonic gate keeps it near 60 Hz without sleeping the event -/// loop; the first frame after activation displays the exact source palette. +/// 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, @@ -165,6 +177,21 @@ const AnimationClock = struct { } }; +/// 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)); @@ -175,9 +202,43 @@ test "GUI animation clock is active-only and cadence gated" { try std.testing.expectEqual(@as(u64, 0), clock.next_ns); } -/// Web tap-vs-scroll heuristic, measured after normalized coordinates have -/// been mapped into backing pixels (12px = 8 CSS px at web_render_scale). -const touch_tap_slop_px: f32 = 12.0; +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 = 6 + 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. @@ -203,9 +264,8 @@ 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 one ladder: dispatch starts from 27 - // native and 18 web, so both the odd and the even rungs get pressed, and - // a stall on either is a dead keystroke for somebody. + // 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; @@ -268,6 +328,101 @@ test "FreeType atlas raster is grayscale, bounded, and clears blank cells" { ); } +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 and stays centered in body cell geometry" { + 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_h), config.gui_tagline_font_percent); + try std.testing.expect(tag.scale < body_scale); + + 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, + cell_w, + @intCast(tag.height), + tag.baseline, + )); + const b = inkBounds(&body, @intCast(stride), @intCast(cell_w), @intCast(cell_h)).?; + const t = inkBounds(&tagline, @intCast(stride), @intCast(cell_w), @intCast(cell_h)).?; + 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; + try std.testing.expect(@abs(tag_center_x - body_center_x) <= 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_h), 40); + const configured = taglineRaster(font, 27.0, @intCast(cell_h), 82); + const full = taglineRaster(font, 27.0, @intCast(cell_h), 100); + 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]); @@ -302,6 +457,18 @@ const CellInstance = extern struct { 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 }; @@ -316,9 +483,134 @@ const ImageInstance = extern struct { 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; +const old_layer_bit: u32 = 0x8000_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) { @@ -368,19 +660,6 @@ const Touch = struct { scrolled: bool = false, last_center: TouchNormPoint = .{}, } = .{}, - // Web policy: one primary finger is either a body tap/scroll or a latched - // left-mouse gesture when it begins on layout chrome. A second simultaneous - // finger cancels a body tap rather than producing a click. - single: struct { - active: bool = false, - id: u64 = 0, - start: TouchNormPoint = .{}, - last: TouchNormPoint = .{}, - accum_y: f32 = 0, - scrolling: bool = false, - mouse_drag: bool = false, - multitouch: bool = false, - } = .{}, click_count: u32 = 0, click_button: pardes.Mouse.Button = .middle, click_flash_frames: u8 = 0, @@ -412,12 +691,6 @@ const Touch = struct { } } - fn activeCount(t: *const Touch) usize { - var count: usize = 0; - for (&t.points) |*tp| count += @intFromBool(tp.active); - return count; - } - fn pairCenter(t: *const Touch, ids: [2]u64) ?TouchNormPoint { var sum: TouchNormPoint = .{}; for (ids) |id| { @@ -486,47 +759,6 @@ const Touch = struct { t.scroll.scrolled = true; return .{ .center = center, .ticks = ticks, .delta_y = @as(f32, @floatFromInt(ticks)) * touch_scroll_tick }; } - - fn beginSingle(t: *Touch, f: Finger, mouse_drag: bool) void { - if (t.single.active) { - if (t.single.id != f.id) t.single.multitouch = true; - return; - } - if (t.activeCount() != 1) return; - const point = TouchNormPoint{ .x = f.x, .y = f.y }; - t.single = .{ .active = true, .id = f.id, .start = point, .last = point, .mouse_drag = mouse_drag }; - } - - fn noteSingleMotion(t: *Touch, f: Finger, win_w: f32, win_h: f32) ?struct { center: TouchNormPoint, ticks: i32, delta_y: f32 } { - if (!t.single.active or t.single.id != f.id or t.single.multitouch) return null; - const center = TouchNormPoint{ .x = f.x, .y = f.y }; - var delta_y = center.y - t.single.last.y; - t.single.last = center; - if (!t.single.scrolling) { - const dx = (center.x - t.single.start.x) * win_w; - const dy = (center.y - t.single.start.y) * win_h; - if (dx * dx + dy * dy < touch_tap_slop_px * touch_tap_slop_px) return null; - // Once the pointer leaves the tap slop it can never click. Include - // all travel since finger-down so the first scroll tick is prompt. - t.single.scrolling = true; - delta_y = center.y - t.single.start.y; - } - t.single.accum_y += delta_y; - const ticks = takeScrollTicks(&t.single.accum_y); - if (ticks == 0) return null; - return .{ .center = center, .ticks = ticks, .delta_y = @as(f32, @floatFromInt(ticks)) * touch_scroll_tick }; - } - - fn finishSingle(t: *Touch, f: Finger, win_w: f32, win_h: f32) ?TouchNormPoint { - if (!t.single.active or t.single.id != f.id) return null; - const point = TouchNormPoint{ .x = f.x, .y = f.y }; - const dx = (point.x - t.single.start.x) * win_w; - const dy = (point.y - t.single.start.y) * win_h; - const left_slop = dx * dx + dy * dy >= touch_tap_slop_px * touch_tap_slop_px; - const tap = f.kind == .up and !left_slop and !t.single.scrolling and !t.single.mouse_drag and !t.single.multitouch; - t.single = .{}; - return if (tap) point else null; - } }; fn takeScrollTicks(accum: *f32) i32 { @@ -536,59 +768,12 @@ fn takeScrollTicks(accum: *f32) i32 { return ticks; } -/// The SDL boundary owns touch policy: a web body uses one-finger natural -/// scrolling and tap-as-LOOK, while layout chrome latches a left-mouse drag; -/// native keeps two-finger scroll/tap-as-execute. Both feed the same core mouse -/// events and coordinate mapping. +/// The SDL boundary owns touch policy: two-finger scroll and tap-as-execute. fn handleFinger(t: *Touch, core: *pardes.Pardes, f: Finger, win_w: f32, win_h: f32, cell_w: f32, cell_h: f32) void { std.debug.assert(cell_w > 0 and cell_h > 0); - if (is_emscripten) - return handleSingleFinger(t, core, f, win_w, win_h, cell_w, cell_h); return handlePairFinger(t, core, f, win_w, win_h, cell_w, cell_h); } -fn handleSingleFinger(t: *Touch, core: *pardes.Pardes, f: Finger, win_w: f32, win_h: f32, cell_w: f32, cell_h: f32) void { - switch (f.kind) { - .down => { - t.updatePoint(f); - const point = TouchNormPoint{ .x = f.x, .y = f.y }; - const col = normCell(point.x, win_w, cell_w); - const row = normCell(point.y, win_h, cell_h); - const chrome = core.chromeTarget(col, row); - t.beginSingle(f, chrome != null); - if (chrome) |target| - core.update(.{ .mouse = .{ .button = .left, .kind = .press, .col = target.col, .row = target.row } }); - }, - .motion => { - t.updatePoint(f); - if (t.single.active and t.single.id == f.id and t.single.mouse_drag) { - core.update(.{ .mouse = .{ - .button = .left, - .kind = .drag, - .col = normCell(f.x, win_w, cell_w), - .row = normCell(f.y, win_h, cell_h), - } }); - return; - } - const res = t.noteSingleMotion(f, win_w, win_h) orelse return; - emitTouchScroll(core, res.center, res.ticks, res.delta_y, win_w, win_h, cell_w, cell_h); - }, - .up, .cancel => { - const mouse_drag = t.single.active and t.single.id == f.id and t.single.mouse_drag; - const tap = t.finishSingle(f, win_w, win_h); - t.updatePoint(f); - if (mouse_drag) { - core.update(.{ .mouse = .{ - .button = .left, - .kind = .release, - .col = normCell(f.x, win_w, cell_w), - .row = normCell(f.y, win_h, cell_h), - } }); - } else if (tap) |point| emitTouchClick(t, core, point, .right, win_w, win_h, cell_w, cell_h); - }, - } -} - fn handlePairFinger(t: *Touch, core: *pardes.Pardes, f: Finger, win_w: f32, win_h: f32, cell_w: f32, cell_h: f32) void { switch (f.kind) { .down => { @@ -668,14 +853,6 @@ const Msg = union(enum) { } }; -/// The files on open panes, watched through ONE inotify instance. Same shape -/// and same reasoning as tty.zig's Watch, which spells it out: the mark goes on -/// the containing DIRECTORY because nothing rewrites a file in place — a -/// formatter, a checkout, an editor all rename a temp file over the target and -/// swap the inode — and `hash` is what we last saw ON DISK, so our own Save -/// never reads as an external change. -const Watch = struct { wd: c_int, hash: u64 }; - /// One language query, owned by the thread running it — the gui twin of /// tty.zig's LspJob, and copied for the same reason: the core edits on. const LspJob = struct { @@ -889,41 +1066,6 @@ fn spawnReader(gpa: std.mem.Allocator, pt: Pty, pane: u8, gen: u32, q: *Queue) v th.detach(); } -/// Mark or unmark one pane's file (`path` null = unmark). Linux only: anywhere -/// else this returns silently, the core never receives a file_changed event, -/// and the feature is simply off — which the core already has to tolerate, -/// since the browser build of this same shell has no filesystem at all. -/// ponytail: darwin wants the FSEvents half of std.Build.Watch here. -fn watchPane(fd: c_int, watches: *[pardes.MAX_PANES]?Watch, id: u8, path: ?[]const u8, hash: u64) void { - if (comptime builtin.os.tag != .linux) return; - if (fd < 0) return; - if (watches[id]) |old| { - // inotify hands out ONE descriptor per directory, so two panes on - // files in the same directory share it: drop the mark only when the - // last of them lets go, or closing one blinds the other. - var shared = false; - for (watches, 0..) |other, i| { - const o = other orelse continue; - if (i != id and o.wd == old.wd) shared = true; - } - if (!shared) _ = libc.inotify_rm_watch(fd, old.wd); - watches[id] = null; - } - const p = path orelse return; - const dir = std.fs.path.dirname(p) orelse "."; - var dbuf: [4096:0]u8 = undefined; - if (dir.len >= dbuf.len) return; - @memcpy(dbuf[0..dir.len], dir); - dbuf[dir.len] = 0; - // CLOSE_WRITE, not MODIFY: one event when a writer is DONE rather than one - // per write(2). MOVED_TO and CREATE catch the rename-over and the - // delete-then-recreate that are how files are actually replaced. - const mask = linux.IN.CLOSE_WRITE | linux.IN.MOVED_TO | linux.IN.CREATE | linux.IN.ONLYDIR; - const wd = libc.inotify_add_watch(fd, dbuf[0..dir.len :0], mask); - if (wd < 0) return; - watches[id] = .{ .wd = wd, .hash = hash }; -} - /// 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 @@ -958,31 +1100,6 @@ fn lookThread(gpa: std.mem.Allocator, fd: c_int, q: *Queue) void { } } -/// Hand the core every watched pane whose bytes moved on disk. The wake says -/// only THAT something happened, so this re-reads the lot; the hash comparison -/// is what keeps our own Save — and any write that lands on identical content -/// — out of the undo stack. Read on the loop rather than on the watcher thread -/// because the core is the only thing that knows which pane a path belongs to. -fn reloadChanged(core: *pardes.Pardes, gpa: std.mem.Allocator, watches: *[pardes.MAX_PANES]?Watch) void { - for (watches, 0..) |*slot, id| { - if (slot.* == null) continue; - const pane = core.panes[id] orelse continue; - const f = pane.file orelse continue; - const bytes = look.readFile(gpa, f.path) catch continue; - defer gpa.free(bytes); - const h = std.hash.Wyhash.hash(0, bytes); - if (h == slot.*.?.hash) continue; - slot.*.?.hash = h; - core.update(.{ .file_changed = .{ .pane = @intCast(id), .bytes = bytes } }); - // the hash moving IS "someone else wrote this file" (our own Save - // restamped it), so the pane says who did what to it — and it says it - // after the update, which cannot clear a message because only a key or - // a mouse event does - var mbuf: [256]u8 = undefined; - core.setMessage(id, message.stamp(&mbuf, "reloaded", f.path)); - } -} - /// Answer a language query off the render loop and push the rows to the queue /// — the async execution model, spelled in the plumbing this shell already has /// (a detached thread and the mutex queue the pty readers use). @@ -1105,12 +1222,16 @@ const Gui = struct { 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. @@ -1122,23 +1243,25 @@ const Gui = struct { /// Ctrl+/Ctrl- this leaves the path for) is writing here and calling that. px: f32, scale: f32, + tagline_scale: f32, + tagline_percent: u8, + tagline_height: u32, cell_w: u32, cell_h: u32, ascent: i32, + tagline_baseline: i32, - // glyph atlas: CPU staging bitmap + codepoint → texel slot, pen-walk alloc. + // 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(u32, Slot), + 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 = .{}, - mouse_x: f32 = 0, - mouse_y: f32 = 0, live_ctrl: bool = false, live_alt: bool = false, @@ -1167,6 +1290,19 @@ const Gui = struct { 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, @@ -1177,24 +1313,41 @@ const Gui = struct { capture_xfer: ?*c.SDL_GPUTransferBuffer = null, capture_xfer_size: u32 = 0, - // steamdeck: gamepad-driven virtual mouse cursor (pixel position) + // 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 = .{}, - - // web: WebGL2 handles (plain GL ints) + the CPU-side instance staging - gl_program: c_uint = 0, - gl_overlay_program: c_uint = 0, - gl_atlas_tex: c_uint = 0, - gl_vbo: c_uint = 0, - gl_overlay_vbo: c_uint = 0, - gl_u_atlas: c_int = -1, - gl_instances: []CellInstance = &.{}, }; +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. @@ -1239,37 +1392,178 @@ fn fontForCodepoint(g: *const Gui, cp: u32) *c.UIFont { return g.font; } -/// A cell's on-screen rect. Native: exactly cell_w×cell_h at (0,0). Web: the -/// canvas resize and the grid resize land on different frames, so the grid is -/// scaled to fit and centered (letterboxed) instead of clipping. +/// 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)), + }; +} + +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 cellLayout(g: *const Gui, cols: u16, rows: u16, sw: u32, sh: u32) CellLayout { - const fixed = fixedCellLayout(g); - if (!is_emscripten) return fixed; - if (cols == 0 or rows == 0) return fixed; - const grid_w = @as(f32, @floatFromInt(cols)) * fixed.w; - const grid_h = @as(f32, @floatFromInt(rows)) * fixed.h; - const win_w: f32 = @floatFromInt(sw); - const win_h: f32 = @floatFromInt(sh); - const scale = @min(win_w / grid_w, win_h / grid_h); +fn boxContains(box: pardes.panel_animation.Box, col: u16, row: u16) bool { + const x: f32 = @floatFromInt(col); + const y: f32 = @floatFromInt(row); + return x >= box.x and x < box.x + box.w and y >= box.y and y < box.y + box.h; +} + +// 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 .{ - .w = fixed.w * scale, - .h = fixed.h * scale, - .x_off = @max(0, (win_w - grid_w * scale) * 0.5), - .y_off = @max(0, (win_h - grid_h * scale) * 0.5), + 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 = if (is_emscripten) runWeb else runNative; +pub const run = runNative; fn runNative(init: std.process.Init, opts_in: pardes.Options) !void { const io = init.io; @@ -1354,6 +1648,7 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options) !void { 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_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; @@ -1433,20 +1728,27 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options) !void { .font = font, .px = px, .scale = scale, + .tagline_scale = tagline.scale, + .tagline_percent = config.gui_tagline_font_percent, + .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(u32, Slot).init(gpa), + .glyphs = std.AutoHashMap(GlyphKey, Slot).init(gpa), .capture = test_mode, .capture_dir = capture_dir orelse "", }; + 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); @@ -1497,10 +1799,13 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options) !void { // 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); - // shells emit OSC 133 prompt marks via this rc (prompt hiding, click-move) - writeFile(shell_bin.bash_rc_path, shell_bin.bash_rc); - writeFile(shell_bin.fish_rc_path, shell_bin.fish_rc); + // Private, complete before any fork and retained until the last possible + // spawn; children borrow only these stable in-struct path buffers. + var prompt_rcs = shell_bin.PromptRcs.init(); + defer prompt_rcs.deinit(); // macos: apple's bash 3.2 prints the zsh-deprecation banner into every // pane unless this is in the env BEFORE bash starts (the rc is too late) if (comptime builtin.os.tag.isDarwin()) _ = setenv("BASH_SILENCE_DEPRECATION_WARNING", "1", 1); @@ -1536,7 +1841,7 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options) !void { _ = libc.close(inotify_fd); // ends the detached watcher's read inotify_fd = -1; }; - var watches: [pardes.MAX_PANES]?Watch = @splat(null); + 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 @@ -1546,7 +1851,7 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options) !void { // initial spawns BEFORE any worker thread exists: forkpty from a // multithreaded process can wedge the child before exec (see tty.zig). - drainEffects(core, &ptys, &gens, io, gpa, &queue, &pipe_tasks, &g, inotify_fd, &watches, false); + drainEffects(core, &prompt_rcs, &ptys, &gens, io, gpa, &queue, &pipe_tasks, &g, inotify_fd, &watches, false); 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) — @@ -1616,11 +1921,12 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options) !void { // a reload — or an undo entry — per write. .files_changed => check_files = true, }; - if (check_files) reloadChanged(core, gpa, &watches); + if (check_files and file_watch.reloadChanged(core, io, gpa, &watches)) + queue.push(.files_changed); // 3. steamdeck: poll gamepad axes into virtual cursor / wheel events pollGamepad(&g, core); // 4. effects - drainEffects(core, &ptys, &gens, io, gpa, &queue, &pipe_tasks, &g, inotify_fd, &watches, true); + drainEffects(core, &prompt_rcs, &ptys, &gens, io, gpa, &queue, &pipe_tasks, &g, inotify_fd, &watches, true); if (core.quit) break; // Restore builtin: swap in a core rebuilt from the dump; kill the live // shells (their detached readers wake on child death; gens bumped so @@ -1640,32 +1946,51 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options) !void { // 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..watches.len) |wid| watchPane(inotify_fd, &watches, @intCast(wid), null, 0); + for (0..watches.len) |wid| file_watch.watchPane( + inotify_fd, + &watches, + @intCast(wid), + null, + 0, + .{ .text = 0 }, + ); clearNativeImages(&g); + g.presented_images.clearRetainingCapacity(); + g.prepared_images.clearRetainingCapacity(); nc.native_images = true; core.deinit(); core = nc; + observeGuiFont(&g, core); + syncTaglineFont(&g, core); } + // 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. Inline here beside Restore because it is the same kind of // thing and this is the flat loop. - if (fonts.want) |path| blk: { - fonts.want = null; - const bytes = look.readFile(gpa, path) catch break :blk; + if (core.takeFontRequest()) |path| blk: { + const bytes = look.readFile(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 of it. log.err("ui_font_new failed: {s}", .{path}); gpa.free(bytes); + core.rejectFont(); break :blk; }; c.ui_font_free(g.font); if (g.font_bytes.len != 0) 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); } // 5. live cwd for tags/look: cheap /proc readlink per pane, per frame pollCwds(core, &ptys); @@ -1679,336 +2004,42 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options) !void { // 7. render — apply the wheel batch LAST before it, while // core.surface still holds the frame the last pass drew stepScroll(&g, core, gpa); - if (animation_clock.due(core.themeAnimationActive(), c.SDL_GetTicksNS())) - core.update(.tick); _ = frame_arena.reset(.retain_capacity); const surface = try core.render(frame_arena.allocator()); - renderFrame(&g, gpa, surface, core.theme().bg, core.crt_on, core.show_debug) catch |err| log.err("render: {t}", .{err}); - } -} - -// ===================================================================== -// web entry: set everything up, register a rAF frame callback, return. -// EXIT_RUNTIME=0 keeps the wasm runtime (and these statics) alive after. -// ===================================================================== - -// std.process.Init provides no gpa on emscripten — one 512 MiB fixed buffer -// is the whole heap, never freed (the shell lives for the page). -var web_heap: [if (is_emscripten) 512 * 1024 * 1024 else 0]u8 align(16) = undefined; -var web_fba: std.heap.FixedBufferAllocator = undefined; - -const WebState = struct { - g: Gui, - core: *pardes.Pardes, - gpa: std.mem.Allocator, - frame_arena: std.heap.ArenaAllocator, - animation_clock: AnimationClock = .{}, -}; -var live_web_state: ?*WebState = null; - -// Browser snapshot tests drive real DOM touch input, then read the exact same -// rendered Surface that WebGL consumed. These tiny exports keep the test -// contract textual (the existing .snap convention) without adding test state -// or browser concepts to the core. -comptime { - if (is_emscripten) { - @export(&webTestCols, .{ .name = "pardes_web_test_cols" }); - @export(&webTestRows, .{ .name = "pardes_web_test_rows" }); - @export(&webTestCursorX, .{ .name = "pardes_web_test_cursor_x" }); - @export(&webTestCursorY, .{ .name = "pardes_web_test_cursor_y" }); - @export(&webTestCell, .{ .name = "pardes_web_test_cell" }); - @export(&webTestCellFg, .{ .name = "pardes_web_test_cell_fg" }); - @export(&webTestCellBg, .{ .name = "pardes_web_test_cell_bg" }); - @export(&webTestCellAttrs, .{ .name = "pardes_web_test_cell_attrs" }); - @export(&webTestCellX, .{ .name = "pardes_web_test_cell_x" }); - @export(&webTestCellY, .{ .name = "pardes_web_test_cell_y" }); - } -} - -fn webTestCols() callconv(.c) c_uint { - const st = live_web_state orelse return 0; - return st.core.surface.cols; -} - -fn webTestRows() callconv(.c) c_uint { - const st = live_web_state orelse return 0; - return st.core.surface.rows; -} - -fn webTestCursorX() callconv(.c) c_int { - const st = live_web_state orelse return -1; - return if (st.core.surface.cursor) |cursor| cursor.x else -1; -} - -fn webTestCursorY() callconv(.c) c_int { - const st = live_web_state orelse return -1; - return if (st.core.surface.cursor) |cursor| cursor.y else -1; -} - -fn webTestCell(col: c_uint, row: c_uint) callconv(.c) c_uint { - const cell = webTestCellAt(col, row) orelse return ' '; - const glyph = cell.grapheme(); - if (glyph.len == 0) return ' '; - return std.unicode.utf8Decode(glyph) catch 0xfffd; -} - -// RGB occupies 0x000000-0xffffff; the high byte distinguishes the two other -// Color tags. The browser .snap harness renders these into d/pN/#rrggbb runs. -fn webTestCellFg(col: c_uint, row: c_uint) callconv(.c) c_uint { - const cell = webTestCellAt(col, row) orelse return 0x01000000; - return webTestColor(cell.style.fg); -} - -fn webTestCellBg(col: c_uint, row: c_uint) callconv(.c) c_uint { - const cell = webTestCellAt(col, row) orelse return 0x01000000; - return webTestColor(cell.style.bg); -} - -fn webTestColor(color: pardes.Color) c_uint { - return switch (color) { - .default => 0x01000000, - .index => |idx| 0x02000000 | @as(c_uint, idx), - .rgb => |rgb| (@as(c_uint, rgb[0]) << 16) | (@as(c_uint, rgb[1]) << 8) | rgb[2], - }; -} - -fn webTestCellAttrs(col: c_uint, row: c_uint) callconv(.c) c_uint { - const cell = webTestCellAt(col, row) orelse return 0; - const style = cell.style; - var attrs: c_uint = 0; - if (style.bold) attrs |= 1 << 0; - if (style.dim) attrs |= 1 << 1; - if (style.italic) attrs |= 1 << 2; - if (style.blink) attrs |= 1 << 3; - if (style.reverse) attrs |= 1 << 4; - if (style.invisible) attrs |= 1 << 5; - if (style.strikethrough) attrs |= 1 << 6; - attrs |= @as(c_uint, @intFromEnum(style.ul)) << 8; - return attrs; -} - -fn webTestCellAt(col: c_uint, row: c_uint) ?*const pardes.Cell { - const st = live_web_state orelse return null; - const surface = &st.core.surface; - if (col >= surface.cols or row >= surface.rows) return null; - return surface.at(@intCast(col), @intCast(row)); -} - -fn webTestCellX(col: f32) callconv(.c) f32 { - return webTestCellCenter(col, true); -} - -fn webTestCellY(row: f32) callconv(.c) f32 { - return webTestCellCenter(row, false); -} - -fn webTestCellCenter(cell: f32, horizontal: bool) f32 { - const st = live_web_state orelse return 0; - var pixel_w: c_int = 0; - var pixel_h: c_int = 0; - _ = c.SDL_GetWindowSizeInPixels(st.g.window, &pixel_w, &pixel_h); - const pw: u32 = @intCast(@max(pixel_w, 1)); - const ph: u32 = @intCast(@max(pixel_h, 1)); - var css_w: f64 = 0; - var css_h: f64 = 0; - if (emscripten_get_element_css_size("#canvas", &css_w, &css_h) != EMSCRIPTEN_RESULT_SUCCESS) return 0; - const layout = cellLayout(&st.g, st.core.surface.cols, st.core.surface.rows, pw, ph); - if (horizontal) { - const backing = layout.x_off + (cell + 0.5) * layout.w; - return backing * @as(f32, @floatCast(css_w)) / @as(f32, @floatFromInt(pw)); - } - const backing = layout.y_off + (cell + 0.5) * layout.h; - return backing * @as(f32, @floatCast(css_h)) / @as(f32, @floatFromInt(ph)); -} - -fn runWeb(opts_in: pardes.Options) !void { - web_fba = .init(&web_heap); - const gpa = web_fba.allocator(); - - // Our finger machine deliberately maps touch to scroll/LOOK. Suppress - // SDL's default synthetic left mouse event for the same browser touch. - _ = c.SDL_SetHint(c.SDL_HINT_TOUCH_MOUSE_EVENTS, "0"); - if (!c.SDL_Init(c.SDL_INIT_VIDEO)) { // no gamepad in the browser - log.err("SDL_Init: {s}", .{c.SDL_GetError()}); - return error.SdlInit; - } - const window = c.SDL_CreateWindow("pardes", 1120, 720, c.SDL_WINDOW_RESIZABLE | c.SDL_WINDOW_OPENGL) orelse { - log.err("SDL_CreateWindow: {s}", .{c.SDL_GetError()}); - return error.SdlInit; - }; - _ = c.SDL_GL_SetAttribute(c.SDL_GL_CONTEXT_PROFILE_MASK, c.SDL_GL_CONTEXT_PROFILE_ES); - _ = c.SDL_GL_SetAttribute(c.SDL_GL_CONTEXT_MAJOR_VERSION, 3); - _ = c.SDL_GL_SetAttribute(c.SDL_GL_CONTEXT_MINOR_VERSION, 0); - _ = c.SDL_GL_SetAttribute(c.SDL_GL_DOUBLEBUFFER, 1); - const gl_context = c.SDL_GL_CreateContext(window) orelse { - log.err("SDL_GL_CreateContext: {s}", .{c.SDL_GetError()}); - return error.SdlInit; - }; - if (!c.SDL_GL_MakeCurrent(window, gl_context)) { - log.err("SDL_GL_MakeCurrent: {s}", .{c.SDL_GetError()}); - return error.SdlInit; - } - _ = c.SDL_GL_SetSwapInterval(1); - - // ---- font + cell metrics, rastered at web_render_scale ---- - 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 = 18.0; - const base_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, base_scale, &cw, &chh, &asc); - const scale = c.ui_font_scale_for_height(font, px * web_render_scale); - const cell_w: u32 = @intFromFloat(@ceil(@as(f32, @floatFromInt(@max(cw, 1))) * web_render_scale)); - const cell_h: u32 = @intFromFloat(@ceil(@as(f32, @floatFromInt(@max(chh, 1))) * web_render_scale)); - const ascent: i32 = @intFromFloat(@ceil(@as(f32, @floatFromInt(@max(asc, 1))) * web_render_scale)); - - // ---- WebGL2: runtime-compiled ES shaders + atlas texture + vbos ---- - const program = try makeGlProgramFrom(vert_glsl_es, frag_glsl_es); - const overlay_program = try makeGlProgramFrom(overlay_vert_glsl_es, overlay_frag_glsl_es); - var atlas_tex: c_uint = 0; - c.glGenTextures(1, &atlas_tex); - c.glBindTexture(c.GL_TEXTURE_2D, atlas_tex); - c.glTexParameteri(c.GL_TEXTURE_2D, c.GL_TEXTURE_MIN_FILTER, c.GL_LINEAR); - c.glTexParameteri(c.GL_TEXTURE_2D, c.GL_TEXTURE_MAG_FILTER, c.GL_LINEAR); - c.glTexParameteri(c.GL_TEXTURE_2D, c.GL_TEXTURE_WRAP_S, c.GL_CLAMP_TO_EDGE); - c.glTexParameteri(c.GL_TEXTURE_2D, c.GL_TEXTURE_WRAP_T, c.GL_CLAMP_TO_EDGE); - c.glPixelStorei(c.GL_UNPACK_ALIGNMENT, 1); - c.glTexImage2D(c.GL_TEXTURE_2D, 0, c.GL_R8, atlas_w, atlas_h, 0, c.GL_RED, c.GL_UNSIGNED_BYTE, null); - var vbo: c_uint = 0; - c.glGenBuffers(1, &vbo); - var overlay_vbo: c_uint = 0; - c.glGenBuffers(1, &overlay_vbo); - - const atlas_stage = try gpa.alloc(u8, atlas_w * atlas_h); - @memset(atlas_stage, 0); - const overlay_vertices = try gpa.alloc(OverlayVertex, max_overlay_vertices); - - const st = try gpa.create(WebState); - st.* = .{ - .g = .{ - .window = window, - .device = undefined, // web renders through GL, never the GPU device - .swapchain_format = undefined, - .pipeline = undefined, - .overlay_pipeline = undefined, - .image_pipeline = undefined, - .crt_pipeline = undefined, - .atlas_tex = undefined, - .atlas_sampler = undefined, - .linear_sampler = undefined, - .atlas_xfer = undefined, - .overlay_vbuf = undefined, - .overlay_vxfer = undefined, - .overlay_vertices = overlay_vertices, - .image_vbuf = undefined, - .image_vxfer = undefined, - .gl_program = program, - .gl_overlay_program = overlay_program, - .gl_atlas_tex = atlas_tex, - .gl_vbo = vbo, - .gl_overlay_vbo = overlay_vbo, - .gl_u_atlas = c.glGetUniformLocation(program, "u_atlas"), - .font = font, - .px = px, - .scale = scale, - .cell_w = cell_w, - .cell_h = cell_h, - .ascent = ascent, - .atlas_stage = atlas_stage, - .glyphs = std.AutoHashMap(u32, Slot).init(gpa), - }, - .core = undefined, - .gpa = gpa, - .frame_arena = undefined, - }; - const g = &st.g; - // 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), ascent); - g.pen_x = cell_w; - - // ---- the core: state replays from the embedded dump, no ptys ---- - const allocs = pardes.allocators.init(gpa); - errdefer pardes.allocators.deinit(); - st.frame_arena = .init(allocs.frame); - errdefer st.frame_arena.deinit(); - pardes.image.start(std.Io.failing, allocs.image); // decode is synchronous; no io on wasm - errdefer pardes.image.stop(); - if (comptime pardes.pdf_enabled) pardes.pdf.start(allocs.pdf); - errdefer if (comptime pardes.pdf_enabled) pardes.pdf.stop(); - pardes.syntax.start(allocs.tree_sitter); - errdefer pardes.syntax.stop(); - syncCanvasSize(g); // size the backing canvas before deriving the grid - var opts = opts_in; - opts.image_allocator = allocs.image; - opts.pdf_allocator = allocs.pdf; - opts.tree_sitter_allocator = allocs.tree_sitter; - var pw: c_int = 0; - var ph: c_int = 0; - _ = c.SDL_GetWindowSizeInPixels(window, &pw, &ph); - opts.cols = @intCast(@max(1, @divTrunc(@as(u32, @intCast(@max(pw, 1))), cell_w))); - opts.rows = @intCast(@max(1, @divTrunc(@as(u32, @intCast(@max(ph, 1))), cell_h))); - st.core = try pardes.Pardes.initFromDump(allocs.pardes, opts, embedded_dump); - live_web_state = st; - - _ = c.SDL_StartTextInput(window); - std.os.emscripten.emscripten_set_main_loop_arg(webFrame, st, 0, 0); -} - -fn webFrame(arg: ?*anyopaque) callconv(.c) void { - const st: *WebState = @ptrCast(@alignCast(arg.?)); - const g = &st.g; - const core = st.core; - // 1. input - var sev = std.mem.zeroes(c.SDL_Event); - while (c.SDL_PollEvent(&sev)) dispatch(g, core, &sev); - // 2. effects - drainEffectsWeb(core, st.gpa, g); - if (core.quit) { - std.os.emscripten.emscripten_cancel_main_loop(); - live_web_state = null; - core.deinit(); - st.frame_arena.deinit(); - pardes.image.stop(); - if (comptime pardes.pdf_enabled) pardes.pdf.stop(); - pardes.syntax.stop(); - pardes.allocators.deinit(); - return; - } - // 3. the backing canvas follows the page, the grid follows the canvas - syncCanvasSize(g); - var pw: c_int = 0; - var ph: c_int = 0; - _ = c.SDL_GetWindowSizeInPixels(g.window, &pw, &ph); - const cols: u16 = @intCast(@max(1, @divTrunc(@as(u32, @intCast(@max(pw, 1))), g.cell_w))); - const rows: u16 = @intCast(@max(1, @divTrunc(@as(u32, @intCast(@max(ph, 1))), g.cell_h))); - if (updateCoreResize(core, cols, rows, g.cell_w, g.cell_h)) resetScroll(g); - // 4. render — apply the wheel batch last, see runNative - stepScroll(g, core, st.gpa); - if (st.animation_clock.due(core.themeAnimationActive(), c.SDL_GetTicksNS())) - core.update(.tick); - _ = st.frame_arena.reset(.retain_capacity); - const surface = core.render(st.frame_arena.allocator()) catch return; - renderFrameGl(g, st.gpa, surface, core.theme().bg, core.show_debug) catch return; -} - -/// the canvas follows the page (CSS); the SDL window follows the canvas, -/// backed at web_render_scale (postWinsize in the prototype). -fn syncCanvasSize(g: *Gui) void { - var css_w: f64 = 0; - var css_h: f64 = 0; - if (emscripten_get_element_css_size("#canvas", &css_w, &css_h) != EMSCRIPTEN_RESULT_SUCCESS or css_w <= 0 or css_h <= 0) return; - const want_w: c_int = @intFromFloat(@round(css_w * web_render_scale)); - const want_h: c_int = @intFromFloat(@round(css_h * web_render_scale)); - var cur_w: c_int = 0; - var cur_h: c_int = 0; - _ = c.SDL_GetWindowSizeInPixels(g.window, &cur_w, &cur_h); - if (cur_w != want_w or cur_h != want_h) { - _ = c.SDL_SetWindowSize(g.window, want_w, want_h); - _ = c.SDL_SyncWindow(g.window); + 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; + const presented = renderFrame( + &g, + gpa, + surface, + core.theme().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 presented) g.scene_failures = 0; + if (presented) { + // 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, core); + finishPresentedAnimationFrame( + &animation_clock, + core, + surface.panelTracks(), + c.SDL_GetTicksNS(), + ); + } } } @@ -2046,13 +2077,13 @@ fn runGrid(init: std.process.Init, opts_in: pardes.Options) !void { break :blk try pardes.Pardes.initFromDump(allocs.pardes, opts, bytes); } else try pardes.Pardes.init(allocs.pardes, opts); defer core.deinit(); - // the requested grid arrives as a resize EVENT (not init opts) so the core - // counts a resize and releases the shell greeting on first output + // 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 } }); - writeFile(shell_bin.bash_rc_path, shell_bin.bash_rc); - writeFile(shell_bin.fish_rc_path, shell_bin.fish_rc); + var prompt_rcs = shell_bin.PromptRcs.init(); + defer prompt_rcs.deinit(); // macos: apple's bash 3.2 prints the zsh-deprecation banner into every // pane unless this is in the env BEFORE bash starts (the rc is too late) if (comptime builtin.os.tag.isDarwin()) _ = setenv("BASH_SILENCE_DEPRECATION_WARNING", "1", 1); @@ -2081,8 +2112,8 @@ fn runGrid(init: std.process.Init, opts_in: pardes.Options) !void { // 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: [pardes.MAX_PANES]?Watch = @splat(null); - drainEffects(core, &ptys, &gens, io, gpa, &queue, &pipe_tasks, null, -1, &watches, false); + var watches: file_watch.Table = @splat(null); + drainEffects(core, &prompt_rcs, &ptys, &gens, io, gpa, &queue, &pipe_tasks, null, -1, &watches, false); for (&ptys, 0..) |*slot, id| if (slot.*) |pt| spawnReader(gpa, pt, @intCast(id), gens[id], &queue); setStdinRaw() catch {}; // stdin may be a pipe, not a pty — best effort @@ -2095,6 +2126,9 @@ fn runGrid(init: std.process.Init, opts_in: pardes.Options) !void { { const surface = try core.render(frame_arena.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) { const r = try feed.pump(gpa, core, null); @@ -2136,12 +2170,12 @@ fn runGrid(init: std.process.Init, opts_in: pardes.Options) !void { .files_changed => {}, .command => |line| gpa.free(line), }; - drainEffects(core, &ptys, &gens, io, gpa, &queue, &pipe_tasks, null, -1, &watches, true); + drainEffects(core, &prompt_rcs, &ptys, &gens, io, gpa, &queue, &pipe_tasks, null, -1, &watches, true); pollCwds(core, &ptys); // The grid harness polls stdin at the same 16 ms cadence as native // SDL. Advancing here lets `stable` wait for exact endpoint colors; // once inactive it resumes the old event-only frame contract. - if (core.themeAnimationActive()) { + if (core.animationActive()) { core.update(.tick); n_events += 1; } @@ -2149,6 +2183,7 @@ fn runGrid(init: std.process.Init, opts_in: pardes.Options) !void { _ = frame_arena.reset(.retain_capacity); const surface = try core.render(frame_arena.allocator()); try dumpGrid(gpa, surface); + core.acknowledgePanelPresentation(&.{}); } } @@ -2217,13 +2252,15 @@ const StdinFeed = struct { _ = c.SDL_GetWindowSizeInPixels(gp.window, &wpw, &wph); const win_w: f32 = @floatFromInt(@max(wpw, 1)); const win_h: f32 = @floatFromInt(@max(wph, 1)); - if (core.crt_on) { - // touch points are physical: same CRT mapping as the mouse - const m = crt.map(finger.x * win_w, finger.y * win_h, win_w, win_h); + 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, core, finger, win_w, win_h, @floatFromInt(gp.cell_w), @floatFromInt(gp.cell_h)); + } else { + finger.kind = .cancel; + handleFinger(&gp.touch, core, finger, win_w, win_h, @floatFromInt(gp.cell_w), @floatFromInt(gp.cell_h)); + core.update(.pointer_leave); } - handleFinger(&gp.touch, core, finger, win_w, win_h, @floatFromInt(gp.cell_w), @floatFromInt(gp.cell_h)); } else { handleFinger(&grid_touch, core, finger, @floatFromInt(core.screen_w), @floatFromInt(core.screen_h), 1, 1); } @@ -2414,6 +2451,11 @@ fn dumpGrid(gpa: std.mem.Allocator, surface: *pardes.Surface) !void { fn dispatch(g: *Gui, core: *pardes.Pardes, sev: *const c.SDL_Event) void { switch (sev.type) { c.SDL_EVENT_QUIT, c.SDL_EVENT_WINDOW_CLOSE_REQUESTED => core.quit = true, + c.SDL_EVENT_WINDOW_MOUSE_LEAVE => { + g.pointer_present = false; + g.pointer_mapped = false; + core.update(.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; @@ -2461,6 +2503,7 @@ fn dispatch(g: *Gui, core: *pardes.Pardes, sev: *const c.SDL_Event) void { if (want != g.px) { g.px = want; refitFont(g, core); + observeGuiFont(g, core); } return; } @@ -2492,15 +2535,30 @@ fn dispatch(g: *Gui, core: *pardes.Pardes, sev: *const c.SDL_Event) void { }, c.SDL_EVENT_MOUSE_BUTTON_DOWN, c.SDL_EVENT_MOUSE_BUTTON_UP => { const b = sev.button; - g.mouse_x = b.x; - g.mouse_y = b.y; const button: pardes.Mouse.Button = switch (b.button) { 1 => .left, 2 => .middle, 3 => .right, else => return, }; - const mc = mouseCell(g, core, b.x, b.y); + g.pad_x = b.x; + g.pad_y = b.y; + g.pointer_present = true; + const mc = mouseCell(g, 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| core.update(.{ .mouse = .{ + .button = button, + .kind = .release, + .col = last.col, + .row = last.row, + } }); + core.update(.pointer_leave); + return; + }; + g.pointer_mapped = true; + g.pointer_cell = mc; core.update(.{ .mouse = .{ .button = button, @@ -2516,11 +2574,10 @@ fn dispatch(g: *Gui, core: *pardes.Pardes, sev: *const c.SDL_Event) void { }, c.SDL_EVENT_MOUSE_MOTION => { const m = sev.motion; - g.mouse_x = m.x; - g.mouse_y = m.y; // 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 @@ -2530,7 +2587,13 @@ fn dispatch(g: *Gui, core: *pardes.Pardes, sev: *const c.SDL_Event) void { .right else null; - const mc = mouseCell(g, core, m.x, m.y); + const mc = mouseCell(g, m.x, m.y) orelse { + g.pointer_mapped = false; + core.update(.pointer_leave); + return; + }; + g.pointer_mapped = true; + g.pointer_cell = mc; core.update(.{ .mouse = .{ .button = held orelse .none, .kind = if (held != null) .drag else .motion, @@ -2541,9 +2604,16 @@ fn dispatch(g: *Gui, core: *pardes.Pardes, sev: *const c.SDL_Event) void { c.SDL_EVENT_MOUSE_WHEEL => { const w = sev.wheel; if (w.y == 0 and w.x == 0) return; - g.mouse_x = w.mouse_x; - g.mouse_y = w.mouse_y; - const mc = mouseCell(g, core, w.mouse_x, w.mouse_y); + g.pad_x = w.mouse_x; + g.pad_y = w.mouse_y; + g.pointer_present = true; + const mc = mouseCell(g, w.mouse_x, w.mouse_y) orelse { + g.pointer_mapped = false; + core.update(.pointer_leave); + return; + }; + g.pointer_mapped = true; + g.pointer_cell = mc; if (w.y != 0 and std.math.isFinite(w.y)) { // Preserve SDL's floating-point distance. Input events drained // in this loop naturally form one render batch; stepScroll @@ -2607,30 +2677,19 @@ fn dispatch(g: *Gui, core: *pardes.Pardes, sev: *const c.SDL_Event) void { var pw: c_int = 0; var ph: c_int = 0; _ = c.SDL_GetWindowSizeInPixels(g.window, &pw, &ph); - var win_w: f32 = @floatFromInt(@max(pw, 1)); - var win_h: f32 = @floatFromInt(@max(ph, 1)); - var fcw: f32 = @floatFromInt(g.cell_w); - var fch: f32 = @floatFromInt(g.cell_h); - if (!is_emscripten and core.crt_on) { - // touch points are physical too: same CRT mapping as the mouse - const m = crt.map(finger.x * win_w, finger.y * win_h, win_w, win_h); + 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, core, finger, win_w, win_h, fcw, fch); + } else { + finger.kind = .cancel; + handleFinger(&g.touch, core, finger, win_w, win_h, fcw, fch); + core.update(.pointer_leave); } - if (is_emscripten) { - // remap normalized coords into the letterboxed grid so the - // shared touch machine's cell math stays exact - const layout = cellLayout(g, core.screen_w, core.screen_h, @intCast(@max(pw, 1)), @intCast(@max(ph, 1))); - const grid_w = @max(1.0, @as(f32, @floatFromInt(core.screen_w)) * layout.w); - const grid_h = @max(1.0, @as(f32, @floatFromInt(core.screen_h)) * layout.h); - finger.x = std.math.clamp((finger.x * win_w - layout.x_off) / grid_w, 0.0, 1.0); - finger.y = std.math.clamp((finger.y * win_h - layout.y_off) / grid_h, 0.0, 1.0); - win_w = grid_w; - win_h = grid_h; - fcw = layout.w; - fch = layout.h; - } - handleFinger(&g.touch, core, finger, win_w, win_h, fcw, fch); }, c.SDL_EVENT_PINCH_BEGIN, c.SDL_EVENT_PINCH_UPDATE => core.update(.{ .pinch = sev.pinch.scale }), // ---- steamdeck: first gamepad drives a virtual mouse (buttons here, @@ -2677,29 +2736,30 @@ fn dispatch(g: *Gui, core: *pardes.Pardes, sev: *const c.SDL_Event) void { var acts: [3]deck.Action = undefined; for (acts[0..g.deck.feed(in, &acts)]) |act| switch (act) { .move => |mv| { - var pw: c_int = 0; - var ph: c_int = 0; - _ = c.SDL_GetWindowSizeInPixels(g.window, &pw, &ph); - // pad_x/pad_y stay PHYSICAL; mouseCell applies the CRT - // mapping once at cell conversion, never into the pad - const old = mouseCell(g, core, g.pad_x, g.pad_y); - g.pad_x = std.math.clamp(g.pad_x + mv.dx, 0, @as(f32, @floatFromInt(@max(pw, 1))) - 1); - g.pad_y = std.math.clamp(g.pad_y + mv.dy, 0, @as(f32, @floatFromInt(@max(ph, 1))) - 1); + const geometry = windowGeometry(g.window); + const old = mouseCellWithGeometry(g, 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); - const mc = mouseCell(g, core, g.pad_x, g.pad_y); - if (mc.col == old.col and mc.row == old.row) continue; + g.pointer_present = true; + const mc = mouseCellWithGeometry(g, g.pad_x, g.pad_y, geometry) orelse { + g.pointer_mapped = false; + core.update(.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: ?pardes.Mouse.Button = if (g.deck.r2_held or g.deck.rpad_click) - .left - else if (g.deck.l2_held) - .middle - else if (g.deck.look_held) - .right - else - null; + const held = heldPointerButton(g); core.update(.{ .mouse = .{ .button = held orelse .none, .kind = if (held != null) .drag else .motion, @@ -2708,20 +2768,44 @@ fn dispatch(g: *Gui, core: *pardes.Pardes, sev: *const c.SDL_Event) void { } }); }, .click => |ck| { - const mc = mouseCell(g, core, g.pad_x, g.pad_y); + const button: pardes.Mouse.Button = switch (ck.which) { + .select => .left, + .execute => .middle, + .look => .right, + }; + g.pointer_present = true; + const mc = mouseCell(g, 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| core.update(.{ .mouse = .{ + .button = button, + .kind = .release, + .col = last.col, + .row = last.row, + } }); + core.update(.pointer_leave); + continue; + }; + g.pointer_mapped = true; + g.pointer_cell = mc; core.update(.{ .mouse = .{ - .button = switch (ck.which) { - .select => .left, - .execute => .middle, - .look => .right, - }, + .button = button, .kind = if (ck.down) .press else .release, .col = mc.col, .row = mc.row, } }); }, .wheel => |ticks| { - const mc = mouseCell(g, core, g.pad_x, g.pad_y); + g.pointer_present = true; + const mc = mouseCell(g, g.pad_x, g.pad_y) orelse { + g.pointer_mapped = false; + core.update(.pointer_leave); + continue; + }; + g.pointer_mapped = true; + g.pointer_cell = mc; var left = ticks; while (left != 0) { left += if (ticks > 0) -1 else 1; @@ -2787,46 +2871,56 @@ fn keyDown(core: *pardes.Pardes, sym: c.SDL_Keycode, mod: c.SDL_Keymod) void { } fn pixelCell(px: f32, cell: u32) u16 { - return pixelCellF(px, @floatFromInt(cell)); + const idx = @floor(@max(px, 0) / @as(f32, @floatFromInt(@max(cell, 1)))); + return @intFromFloat(@min(idx, 10_000)); } -fn pixelCellF(px: f32, cell: f32) u16 { - const idx = @floor(@max(px, 0) / @max(cell, 1)); - return @intFromFloat(@min(idx, 10_000)); +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); } -/// mouse coords → cell. Native windows are pixel-exact; web SDL reports CSS -/// points into a letterboxed canvas backed at web_render_scale. With the Crt -/// builtin on, the physical point first maps to the scene point the shader -/// displays there (crt.map) — this is the one physical→logical spot shared -/// by the real mouse and the deck's virtual cursor. -fn mouseCell(g: *const Gui, core: *const pardes.Pardes, x: f32, y: f32) struct { col: u16, row: u16 } { - if (!is_emscripten) { - var mx = x; - var my = y; - if (core.crt_on) { - var pw: c_int = 0; - var ph: c_int = 0; - _ = c.SDL_GetWindowSizeInPixels(g.window, &pw, &ph); - const m = crt.map(mx, my, @floatFromInt(@max(pw, 1)), @floatFromInt(@max(ph, 1))); - mx = m.x; - my = m.y; - } - return .{ .col = pixelCell(mx, g.cell_w), .row = pixelCell(my, g.cell_h) }; - } - var pw: c_int = 0; - var ph: c_int = 0; - _ = c.SDL_GetWindowSizeInPixels(g.window, &pw, &ph); - var ww: c_int = 0; - var wh: c_int = 0; - _ = c.SDL_GetWindowSize(g.window, &ww, &wh); - const px = x * @as(f32, @floatFromInt(@max(pw, 1))) / @as(f32, @floatFromInt(@max(ww, 1))); - const py = y * @as(f32, @floatFromInt(@max(ph, 1))) / @as(f32, @floatFromInt(@max(wh, 1))); - const layout = cellLayout(g, core.screen_w, core.screen_h, @intCast(@max(pw, 1)), @intCast(@max(ph, 1))); - return .{ - .col = pixelCellF(px - layout.x_off, layout.w), - .row = pixelCellF(py - layout.y_off, layout.h), +fn mouseCellWithGeometry(g: *const Gui, 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 .{ .col = pixelCell(mapped.x, g.cell_w), .row = pixelCell(mapped.y, g.cell_h) }; +} + +/// 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, x: f32, y: f32) ?MouseCell { + return mouseCellWithGeometry(g, 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, 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 — @@ -2835,27 +2929,44 @@ fn mouseCell(g: *const Gui, core: *const pardes.Pardes, x: f32, y: f32) struct { /// ticks (both axes: vertical + horizontal) at the cursor position. fn pollGamepad(g: *Gui, core: *pardes.Pardes) void { const pad = g.gamepad orelse return; - 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 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 = mouseCell(g, core, g.pad_x, g.pad_y); + const old = mouseCellWithGeometry(g, 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; - if (@abs(ax) > deadzone) g.pad_x = std.math.clamp(g.pad_x + ax / 32767.0 * speed, 0, win_w - 1); - if (@abs(ay) > deadzone) g.pad_y = std.math.clamp(g.pad_y + ay / 32767.0 * speed, 0, win_h - 1); + 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 (@abs(ax) > deadzone or @abs(ay) > deadzone) c.SDL_WarpMouseInWindow(g.window, g.pad_x, g.pad_y); - const mc = mouseCell(g, core, g.pad_x, g.pad_y); - if (mc.col != old.col or mc.row != old.row) - core.update(.{ .mouse = .{ .button = .none, .kind = .motion, .col = mc.col, .row = mc.row } }); + if (pointer_moved) { + g.pointer_present = true; + c.SDL_WarpMouseInWindow(g.window, g.pad_x, g.pad_y); + } + const mc = mouseCellWithGeometry(g, g.pad_x, g.pad_y, geometry) orelse { + if (g.pointer_present and g.pointer_mapped) core.update(.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); + core.update(.{ .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)); @@ -2879,6 +2990,7 @@ fn pollGamepad(g: *Gui, core: *pardes.Pardes) void { fn drainEffects( core: *pardes.Pardes, + prompt_rcs: *const shell_bin.PromptRcs, ptys: *[pardes.MAX_PANES]?Pty, gens: *[pardes.MAX_PANES]u32, io: std.Io, @@ -2887,7 +2999,7 @@ fn drainEffects( pipe_tasks: *PipeTasks, g: ?*Gui, // null in grid test mode (no SDL: clipboard effects are no-ops) inotify_fd: c_int, - watches: *[pardes.MAX_PANES]?Watch, + watches: *file_watch.Table, threads_ok: bool, ) void { while (core.nextEffect()) |effect| switch (effect) { @@ -2909,7 +3021,7 @@ fn drainEffects( cwd_buf[cwd.len] = 0; cwd_z = @ptrCast(&cwd_buf); } - const pt = forkShell(core.shellBin(), cwd_z, core.screen_h, core.screen_w); + const pt = forkShell(core, sp.pane, prompt_rcs, core.shellBin(), cwd_z, core.screen_h, core.screen_w); ptys[sp.pane] = pt; // report the pane's starting directory back to the core (tags); the // slot needs no occupancy reset, nothing about it is remembered @@ -2940,7 +3052,10 @@ fn drainEffects( _ = libc.close(fd); // 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" - if (watches[sf.pane]) |*w| w.hash = std.hash.Wyhash.hash(0, f.content); + if (watches[sf.pane]) |*w| if (w.serial == pane.serial) switch (w.generation) { + .text => w.generation = .{ .text = std.hash.Wyhash.hash(0, f.content) }, + .pdf => {}, + }; // ...and say so on the pane's message row. AFTER the write, not // beside it: every `continue` above is a save that did not happen // and must not be reported as one. @@ -2988,26 +3103,19 @@ fn drainEffects( .lsp => |e| if (threads_ok) spawnLsp(core, queue, e), .pipe => |e| if (threads_ok) spawnPipe(core, io, gpa, queue, pipe_tasks, e), .watch => |w| { - // starting, the path and the on-disk bytes are read off the core - // (same split as save_file); stopping, the pane is already gone - var path: ?[]const u8 = null; - var hash: u64 = 0; - if (w.on) if (core.panes[w.pane]) |pane| if (pane.file) |f| { - path = f.path; - hash = std.hash.Wyhash.hash(0, f.content); - }; - watchPane(inotify_fd, watches, w.pane, path, hash); + if (file_watch.applyEffect(core, io, gpa, inotify_fd, watches, w.pane, w.on)) + queue.push(.files_changed); }, .quit => {}, }; } -fn forkShell(bin: []const u8, cwd: ?[*:0]const u8, rows: u16, cols: u16) Pty { +fn forkShell(core: *pardes.Pardes, pane: usize, prompt_rcs: *const shell_bin.PromptRcs, bin: []const u8, cwd: ?[*:0]const u8, rows: u16, cols: u16) Pty { var master: c_int = undefined; // resolved BEFORE the fork, into this frame, which the child inherits: // nothing between fork and exec may allocate, and a PATH search would var path_buf: [std.fs.max_path_bytes]u8 = undefined; - const spawn = shell_bin.resolve(bin, &path_buf); + const spawn = shell_bin.resolve(bin, &path_buf, prompt_rcs); const ws = posix.winsize{ .row = rows, .col = cols, .xpixel = 0, .ypixel = 0 }; const pid = forkpty(&master, null, null, &ws); if (pid == 0) { @@ -3015,6 +3123,7 @@ fn forkShell(bin: []const u8, cwd: ?[*:0]const u8, rows: u16, cols: u16) Pty { _ = execv(spawn.path, &spawn.argv); _exit(127); } + if (pid > 0) core.acknowledgeShell(pane, std.mem.span(spawn.path), spawn.argv[1] != null); return .{ .fd = master, .pid = pid }; } @@ -3038,64 +3147,6 @@ fn ttyTakenAt(ctx: ?*anyopaque, pane: usize) bool { return look.ttyTaken(pt.pid, pt.fd); } -/// web: no ptys, no fs — panes replay from the embedded dump. Only the -/// clipboard and open_link effects have a browser meaning. -fn drainEffectsWeb(core: *pardes.Pardes, gpa: std.mem.Allocator, g: *Gui) void { - _ = g; - while (core.nextEffect()) |effect| switch (effect) { - .set_clipboard => { - const y = core.yank orelse continue; - const z = gpa.dupeZ(u8, y) catch continue; - defer gpa.free(z); - _ = c.SDL_SetClipboardText(z.ptr); - }, - .read_clipboard => { - // emscripten fills SDL's clipboard only from a real paste gesture, - // so an empty answer here is the ordinary case and not a failure. - const raw = c.SDL_GetClipboardText() orelse continue; - defer c.SDL_free(raw); - const text = std.mem.span(raw); - if (text.len == 0) continue; - core.update(.{ .paste = text }); - }, - // look on a URL → a new tab - .open_link => |url| openLinkWeb(gpa, url.slice()), - // nothing to spawn/write/resize/save/dump into, and no filesystem to - // watch — all no-ops - .spawn, .write, .resize_pty, .save_file, .new_file, .write_dump, .lsp, .pipe, .watch, .quit => {}, - }; -} - -/// window.open in a tiny generated script; single quotes and backslashes are -/// %-escaped so the url can't break out of the JS string literal. -fn openLinkWeb(gpa: std.mem.Allocator, url: []const u8) void { - const prefix = "window.open('"; - const suffix = "','_blank')"; - var escaped_len: usize = 0; - for (url) |ch| escaped_len = std.math.add(usize, escaped_len, if (ch == '\'' or ch == '\\') 3 else 1) catch return; - const script_len = std.math.add(usize, prefix.len + suffix.len, escaped_len) catch return; - const script = gpa.allocSentinel(u8, script_len, 0) catch return; - defer gpa.free(script); - @memcpy(script[0..prefix.len], prefix); - var at = prefix.len; - for (url) |ch| switch (ch) { - '\'' => { - @memcpy(script[at..][0..3], "%27"); - at += 3; - }, - '\\' => { - @memcpy(script[at..][0..3], "%5C"); - at += 3; - }, - else => { - script[at] = ch; - at += 1; - }, - }; - @memcpy(script[at..][0..suffix.len], suffix); - std.os.emscripten.emscripten_run_script(script.ptr); -} - // ===================================================================== // fractional scroll: whole rows for the core, sub-row offsets for the picture // ===================================================================== @@ -3247,7 +3298,7 @@ fn stepScroll(g: *Gui, core: *pardes.Pardes, gpa: std.mem.Allocator) void { 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.tag_bottom) r.y else r.y + pardes.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 @@ -3284,7 +3335,10 @@ fn stepScroll(g: *Gui, core: *pardes.Pardes, gpa: std.mem.Allocator) void { /// 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: [3]u8) u32 { - if (g.scroll_pane == null or g.scroll_lag == 0) return 0; + 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; @@ -3303,7 +3357,7 @@ fn emitScrollRows(g: *Gui, instances: [*]CellInstance, base: u32, surface: *pard 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, surface.at(col, row), sidx == cursor_idx and !cursor_bar, page); + emitInstance(g, instances, base + n, col, row, shifted, win_w, win_h, null, surface.at(col, row), false, sidx == cursor_idx and !cursor_bar, page); n += 1; } } @@ -3313,7 +3367,7 @@ fn emitScrollRows(g: *Gui, instances: [*]CellInstance, base: u32, surface: *pard 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, &g.scroll_edge[i], false, page); + emitInstance(g, instances, base + n, x0 + i, erow, shifted, win_w, win_h, null, &g.scroll_edge[i], false, false, page); n += 1; } } @@ -3358,6 +3412,52 @@ fn surfaceHasNativeKey(surface: *const pardes.Surface, key: pardes.ImageCacheKey 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); @@ -3402,13 +3502,13 @@ fn validImageBytes(place: pardes.ImagePlace) ?u32 { const NativeBounds = struct { w: u32, h: u32 }; -fn nativeBounds(g: *const Gui, place: pardes.ImagePlace) ?NativeBounds { +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: pardes.ImagePlace) ?pardes.image.NativeGeometry { +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; @@ -3425,7 +3525,7 @@ fn nativePlaceGeometry(g: *const Gui, place: pardes.ImagePlace) ?pardes.image.Na return null; } -fn nativePlaceDrawable(g: *const Gui, place: pardes.ImagePlace) bool { +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); @@ -3493,14 +3593,91 @@ fn uploadNativeTexture( /// 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, -) u32 { +) bool { for (surface.images[0..surface.nimages]) |maybe| { const place = maybe orelse continue; if (validImageBytes(place) == null) continue; @@ -3512,7 +3689,8 @@ fn prepareNativeImages( var stale_len: usize = 0; var iterator = g.native_images.iterator(); while (iterator.next()) |entry| { - if (surfaceHasNativeKey(surface, entry.key_ptr.*)) continue; + 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; @@ -3521,94 +3699,151 @@ fn prepareNativeImages( if (stale_len < stale.len) break; } - var count: u32 = 0; - for (surface.images[0..surface.nimages]) |maybe| { - const place = maybe orelse continue; - if (g.native_images.contains(place.cacheKey()) and nativePlaceDrawable(g, place)) count += 1; + 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 or active.effect == .ascii) { + // 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.changed_cells. + .dissolve, .ascii => 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; } - if (count == 0) return 0; - if (!ensureImageBuffers(g, count)) return 0; - const ptr: [*]u8 = @ptrCast(c.SDL_MapGPUTransferBuffer(g.device, g.image_vxfer, false) orelse return 0); + 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 (surface.images[0..surface.nimages]) |maybe| { - const place = maybe orelse continue; - if (!g.native_images.contains(place.cacheKey())) continue; - - 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 continue; - 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); - instances[idx] = .{ - .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 continue; - const fit = pardes.image.contain(place.iw, place.ih, bounds.w, bounds.h); - if (fit.w == 0 or fit.h == 0) continue; - instances[idx] = .{ - .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, - }; - } + 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); - if (idx == 0) return 0; + // 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 idx; + 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) void { +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); - var idx: u32 = 0; - for (surface.images[0..surface.nimages]) |maybe| { - const place = maybe orelse continue; - const texture = g.native_images.get(place.cacheKey()) orelse continue; - if (!nativePlaceDrawable(g, place)) continue; - const clip = c.SDL_Rect{ - .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), - }; - c.SDL_SetGPUScissor(pass, &clip); - const sampler = c.SDL_GPUTextureSamplerBinding{ .texture = 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); + 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, @@ -3617,9 +3852,30 @@ fn drawNativeImagesGpu(g: *Gui, rp: ?*c.SDL_GPURenderPass, surface: *pardes.Surf }; c.SDL_SetGPUScissor(pass, &whole); } +// EFFECT_CODE_NATIVE_PANEL_END -fn renderFrame(g: *Gui, gpa: std.mem.Allocator, surface: *pardes.Surface, theme_bg: ?[3]u8, crt_on: bool, debug_on: bool) !void { - const cmd = c.SDL_AcquireGPUCommandBuffer(g.device) orelse return; +// EFFECT_CODE_FRAME_SUBMISSION_BEGIN +fn renderFrame( + g: *Gui, + gpa: std.mem.Allocator, + surface: *pardes.Surface, + theme_bg: ?[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; @@ -3628,42 +3884,63 @@ fn renderFrame(g: *Gui, gpa: std.mem.Allocator, surface: *pardes.Surface, theme_ 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; + return false; } 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; + return false; } target = swap_tex orelse { // swapchain busy: skip the frame + command_consumed = true; _ = c.SDL_SubmitGPUCommandBuffer(cmd); - return; + return false; }; } const win_w: f32 = @floatFromInt(sw); const win_h: f32 = @floatFromInt(sh); - // Crt on: the scene pass lands in an offscreen texture, then a second - // pass draws it through the CRT shader onto the real target - if (crt_on) try ensureSceneTexture(g, sw, sh); - const scene: *c.SDL_GPUTexture = if (crt_on) g.scene_tex.? else target; + // 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); - const overlay_count = buildOverlay(g, surface, layout, sw, sh, debug_on); - if (overlay_count != 0) uploadOverlayGpu(g, cmd, overlay_count); - const image_count = prepareNativeImages(g, gpa, cmd, surface, sw, sh); + var paint_plan = makePaintPlan(surface.panelTracks(), surface.hasPanelDiff()); + // Cursors/debug overlays do not carry pane ownership. Hide them 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. + var overlay_count = if (paint_plan.len == 1) + buildOverlay(g, surface, layout, sw, sh, debug_on) + else + 0; + 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; - // ponytail: ×2, because the fractional-scroll pane is drawn a second time, - // offset, out of the tail of the same buffer — and a pane is never bigger - // than the grid. The ceiling is one grid's worth of vertex memory nobody - // uses when nothing is sliding; sizing it to the actual pane instead means - // re-creating the buffer on every frame that starts or ends a slide. - if (cells != 0) try ensureVbuf(g, cells * 2); const page = ground(theme_bg); var color_target = std.mem.zeroes(c.SDL_GPUColorTargetInfo); @@ -3677,25 +3954,147 @@ fn renderFrame(g: *Gui, gpa: std.mem.Allocator, surface: *pardes.Surface, theme_ color_target.load_op = c.SDL_GPU_LOADOP_CLEAR; color_target.store_op = c.SDL_GPU_STOREOP_STORE; - if (cells != 0 and g.vbuf != null) { + 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 or active.effect == .ascii + else + false; + const data_diff = data_effect and surface.panelCellChanged(col, row); + paint_plan.batches[batch_index].cell_count = std.math.add( + u32, + paint_plan.batches[batch_index].cell_count, + if (data_diff) 2 else 1, + ) 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; + destination.cell_count = std.math.add(u32, destination.cell_count, 1) 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; + 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; - var row: u16 = 0; + row = 0; while (row < surface.rows) : (row += 1) { var col: u16 = 0; while (col < surface.cols) : (col += 1) { - const idx: u32 = @as(u32, row) * surface.cols + col; - emitInstance(g, instances, idx, col, row, layout, win_w, win_h, surface.at(col, row), idx == cursor_idx and !cursor_bar, page); + 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 or active.effect == .ascii + else + false; + const data_diff = data_effect and surface.panelCellChanged(col, row); + if (data_diff) { + emitInstance( + g, + instances, + cell_next[batch_index], + col, + row, + layout, + win_w, + win_h, + track, + &surface.previous_cells[logical_idx], + true, + false, + page, + ); + cell_next[batch_index] += 1; + } + emitInstance( + g, + 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), + false, + paint_plan.len == 1 and logical_idx == cursor_idx and !cursor_bar, + page, + ); + cell_next[batch_index] += 1; } } - const shifted = emitScrollRows(g, instances, cells, surface, layout, win_w, win_h, 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; + emitInstance( + g, + instances, + cell_next[destination], + col, + row, + layout, + win_w, + win_h, + if (duplicate_under) null else track, + &surface.previous_cells[logical_idx], + false, + false, + page, + ); + cell_next[destination] += 1; + } + } + } + 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; @@ -3704,80 +4103,123 @@ fn renderFrame(g: *Gui, gpa: std.mem.Allocator, surface: *pardes.Surface, theme_ 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 = (cells + shifted) * @sizeOf(CellInstance) }; + 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); - - const rp = c.SDL_BeginGPURenderPass(cmd, &color_target, 1, 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); - const vbinding = c.SDL_GPUBufferBinding{ .buffer = g.vbuf.?, .offset = 0 }; - c.SDL_BindGPUVertexBuffers(rp, 0, &vbinding, 1); - c.SDL_DrawGPUPrimitives(rp, 6, cells, 0, 0); - if (shifted != 0) { - // second pass, clipped to the pane body: it overdraws the same - // pane's un-offset cells, and its overhang stops at the body edge - const clip = scrollScissor(g, layout, sw, sh); - c.SDL_SetGPUScissor(rp, &clip); - const sbinding = c.SDL_GPUBufferBinding{ .buffer = g.vbuf.?, .offset = cells * @sizeOf(CellInstance) }; - c.SDL_BindGPUVertexBuffers(rp, 0, &sbinding, 1); - c.SDL_DrawGPUPrimitives(rp, 6, shifted, 0, 0); - const whole = c.SDL_Rect{ .x = 0, .y = 0, .w = @intCast(sw), .h = @intCast(sh) }; - c.SDL_SetGPUScissor(rp, &whole); - } - c.SDL_EndGPURenderPass(rp); - } else { - // no grid yet: just clear to the page bg - const rp = c.SDL_BeginGPURenderPass(cmd, &color_target, 1, null); - c.SDL_EndGPURenderPass(rp); } - // Images belong above the opaque cell backgrounds and below interaction - // overlays. A second load pass expresses that ordering without teaching - // the canonical cell surface about alpha or backend textures. - if (image_count != 0 or overlay_count != 0) { - color_target.load_op = c.SDL_GPU_LOADOP_LOAD; - const rp = c.SDL_BeginGPURenderPass(cmd, &color_target, 1, null); - if (image_count != 0) drawNativeImagesGpu(g, rp, surface); - drawOverlayGpu(g, rp, overlay_count); - c.SDL_EndGPURenderPass(rp); + // 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); - if (crt_on) { + 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 rp = c.SDL_BeginGPURenderPass(cmd, &crt_target, 1, null); - c.SDL_BindGPUGraphicsPipeline(rp, g.crt_pipeline); + 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(rp, 0, &samp_binding, 1); - c.SDL_DrawGPUPrimitives(rp, 3, 1, 0, 0); - c.SDL_EndGPURenderPass(rp); + 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) return captureFrame(g, gpa, cmd, target, sw, sh); - _ = c.SDL_SubmitGPUCommandBuffer(cmd); + 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; + } + 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 -fn emitInstance( - g: *Gui, - instances: [*]CellInstance, - idx: u32, - col: u16, - row: u16, - layout: CellLayout, - win_w: f32, - win_h: f32, - cell: *const pardes.Cell, - is_cursor: bool, - /// the ground this frame: what a default background resolves to - page: [3]u8, -) void { - // Resolve the cell style to concrete fg/bg. A normal block cursor reverses - // the cell; the insert cursor is a separate overlay, so the glyph beneath - // it remains in this base pass. +// EFFECT_CODE_CELL_INSTANCE_BEGIN +const ResolvedCell = struct { + slot: Slot, + fg: [3]u8, + bg: [3]u8, + role: pardes.FontRole, +}; + +fn resolveCell(g: *Gui, cell: *const pardes.Cell, is_cursor: bool, page: [3]u8) ResolvedCell { var fg = fg_default; var bg = page; var reverse = is_cursor; @@ -3801,36 +4243,95 @@ fn emitInstance( } if (reverse) std.mem.swap([3]u8, &fg, &bg); - // cell pixel rect (top-left origin) → NDC (y up); the layout letterboxes on web + const role: pardes.FontRole = if (cell.default) + .body + else + cell.style.font_role; + const cp = cellCodepoint(cell); + return .{ + .slot = if (cp == ' ') g.space_slot else ensureGlyph(g, cp, role), + .fg = fg, + .bg = bg, + .role = role, + }; +} + +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, + 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 + page: [3]u8, +) void { + const resolved = resolveCell(g, cell, is_cursor, page); + + // Cell pixel rect (top-left origin) → NDC (y up). const px0 = layout.x_off + @as(f32, @floatFromInt(col)) * layout.w; - const py0 = layout.y_off + @as(f32, @floatFromInt(row)) * layout.h; + const visual_h: f32 = if (resolved.role == .tagline) + @floatFromInt(g.tagline_height) + else + layout.h; + const py0 = layout.y_off + @as(f32, @floatFromInt(row)) * layout.h + + @max(0.0, (layout.h - visual_h) * 0.5); 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 + layout.h) / win_h) * 2.0; - - const cp = cellCodepoint(cell); - const slot = if (cp == ' ') g.space_slot else ensureGlyph(g, cp); + 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_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(slot.u)) / aw_f, - .v0 = @as(f32, @floatFromInt(slot.v)) / ah_f, - .u1 = @as(f32, @floatFromInt(slot.u + g.cell_w)) / aw_f, - .v1 = @as(f32, @floatFromInt(slot.v + g.cell_h)) / ah_f, - .fr = @as(f32, @floatFromInt(fg[0])) / 255.0, - .fg = @as(f32, @floatFromInt(fg[1])) / 255.0, - .fb = @as(f32, @floatFromInt(fg[2])) / 255.0, - .br = @as(f32, @floatFromInt(bg[0])) / 255.0, - .bg = @as(f32, @floatFromInt(bg[1])) / 255.0, - .bb = @as(f32, @floatFromInt(bg[2])) / 255.0, + .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 + g.cell_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, + layout, + win_w, + win_h, + if (track) |active| panelCellCoord(active, col, row) else 0, + ); + if (old_layer) instances[idx].effect |= old_layer_bit; } +// EFFECT_CODE_CELL_INSTANCE_END fn cellCodepoint(cell: *const pardes.Cell) u32 { const grapheme = cell.grapheme(); @@ -3851,13 +4352,17 @@ test "insert cursor overlays without replacing the character beneath it" { .win_w = 100, .win_h = 100, }; - addCursorBar(&builder, 2, 3, true, .{ + 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 { @@ -3881,9 +4386,9 @@ fn firstCp(s: []const u8) u32 { /// /// 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 ALONE — one face, one size, a pen walking rows of cell_w×cell_h +/// 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 codepoint already in the map would keep being +/// 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 @@ -3892,29 +4397,20 @@ fn firstCp(s: []const u8) u32 { /// 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 { - // Native rasters at the size it measures. Web MEASURES at the CSS size and - // RASTERS web_render_scale bigger, so text stays sharp on a scaled canvas - // and cellLayout scales the quads back down — the same two scales initGl - // takes, asked again because a different face answers differently. - const measure = c.ui_font_scale_for_height(g.font, g.px); - g.scale = if (is_emscripten) c.ui_font_scale_for_height(g.font, g.px * web_render_scale) else measure; + 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, measure, &cw, &chh, &asc); - const grow: f32 = if (is_emscripten) web_render_scale else 1.0; - g.cell_w = @intFromFloat(@ceil(@as(f32, @floatFromInt(@max(cw, 1))) * grow)); - g.cell_h = @intFromFloat(@ceil(@as(f32, @floatFromInt(@max(chh, 1))) * grow)); - g.ascent = @intFromFloat(@ceil(@as(f32, @floatFromInt(@max(asc, 1))) * grow)); + 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_h, g.tagline_percent); + g.tagline_scale = tagline.scale; + g.tagline_baseline = tagline.baseline; + g.tagline_height = tagline.height; - 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; + 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 @@ -3939,8 +4435,36 @@ fn refitFont(g: *Gui, core: *pardes.Pardes) void { resetScroll(g); } -fn ensureGlyph(g: *Gui, cp: u32) Slot { - if (g.glyphs.get(cp)) |s| return s; +/// 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_h, percent); + g.tagline_scale = tagline.scale; + 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; @@ -3949,8 +4473,11 @@ fn ensureGlyph(g: *Gui, cp: u32) Slot { 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); - _ = c.ui_font_raster(face, g.scale, @intCast(cp), out, @intCast(atlas_w), @intCast(g.cell_w), @intCast(g.cell_h), g.ascent); - g.glyphs.put(cp, s) catch return g.space_slot; + const scale = if (role == .tagline) g.tagline_scale else g.scale; + const baseline = if (role == .tagline) g.tagline_baseline else g.ascent; + 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(g.cell_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; @@ -3971,23 +4498,35 @@ fn uploadAtlas(g: *Gui, cmd: *c.SDL_GPUCommandBuffer) void { } fn ensureVbuf(g: *Gui, cells: u32) !void { - if (g.vbuf_cells == cells and g.vbuf != null) return; - if (g.vbuf) |b| c.SDL_ReleaseGPUBuffer(g.device, b); - if (g.vxfer) |x| c.SDL_ReleaseGPUTransferBuffer(g.device, x); - g.vbuf = null; - g.vxfer = null; - const size = cells * @sizeOf(CellInstance); + 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 }; - g.vbuf = c.SDL_CreateGPUBuffer(g.device, &vb_info) orelse return error.GpuCreate; + 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 }; - g.vxfer = c.SDL_CreateGPUTransferBuffer(g.device, &xf_info) orelse return error.GpuCreate; - g.vbuf_cells = cells; + 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); + 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); + 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{ @@ -4001,6 +4540,9 @@ fn makePipeline(device: *c.SDL_GPUDevice, color_format: c.SDL_GPUTextureFormat) .{ .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) }; @@ -4022,9 +4564,9 @@ fn makePipeline(device: *c.SDL_GPUDevice, color_format: c.SDL_GPUTextureFormat) } 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); + 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); + 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{ @@ -4065,9 +4607,9 @@ fn makeOverlayPipeline(device: *c.SDL_GPUDevice, color_format: c.SDL_GPUTextureF } 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); + 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); + 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{ @@ -4079,6 +4621,9 @@ fn makeImagePipeline(device: *c.SDL_GPUDevice, color_format: c.SDL_GPUTextureFor 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; @@ -4108,9 +4653,9 @@ fn makeImagePipeline(device: *c.SDL_GPUDevice, color_format: c.SDL_GPUTextureFor // 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); + 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); + 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) }; @@ -4125,7 +4670,7 @@ fn makeCrtPipeline(device: *c.SDL_GPUDevice, color_format: c.SDL_GPUTextureForma 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) !*c.SDL_GPUShader { +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; @@ -4133,170 +4678,16 @@ fn makeShader(device: *c.SDL_GPUDevice, code: []const u8, stage: c.SDL_GPUShader 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; } // ===================================================================== -// web render: Surface → the same instanced quads → WebGL2. Full instance -// re-upload each frame; attribs re-specified per draw (no VAO to manage). -// ===================================================================== - -fn renderFrameGl(g: *Gui, gpa: std.mem.Allocator, surface: *pardes.Surface, theme_bg: ?[3]u8, debug_on: bool) !void { - var pw: c_int = 0; - var ph: c_int = 0; - _ = c.SDL_GetWindowSizeInPixels(g.window, &pw, &ph); - const sw: u32 = @intCast(@max(pw, 1)); - const sh: u32 = @intCast(@max(ph, 1)); - c.glViewport(0, 0, @intCast(sw), @intCast(sh)); - const page = ground(theme_bg); - c.glClearColor( - @as(f32, @floatFromInt(page[0])) / 255.0, - @as(f32, @floatFromInt(page[1])) / 255.0, - @as(f32, @floatFromInt(page[2])) / 255.0, - 1.0, - ); - c.glClear(c.GL_COLOR_BUFFER_BIT); - - const cells: u32 = @as(u32, surface.cols) * surface.rows; - if (cells == 0) { - const overlay_count = buildTouchOverlay(g, sw, sh, debug_on); - drawOverlayGl(g, overlay_count); - _ = c.SDL_GL_SwapWindow(g.window); - return; - } - // ×2 for the fractional-scroll pane's offset copy, as in renderFrame - if (g.gl_instances.len < cells * 2) { - gpa.free(g.gl_instances); - g.gl_instances = try gpa.alloc(CellInstance, cells * 2); - } - - const win_w: f32 = @floatFromInt(sw); - const win_h: f32 = @floatFromInt(sh); - const layout = cellLayout(g, surface.cols, surface.rows, sw, sh); - const overlay_count = buildOverlay(g, surface, layout, sw, sh, debug_on); - 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; - var row: u16 = 0; - while (row < surface.rows) : (row += 1) { - var col: u16 = 0; - while (col < surface.cols) : (col += 1) { - const idx: u32 = @as(u32, row) * surface.cols + col; - emitInstance(g, g.gl_instances.ptr, idx, col, row, layout, win_w, win_h, surface.at(col, row), idx == cursor_idx and !cursor_bar); - } - } - const shifted = emitScrollRows(g, g.gl_instances.ptr, cells, surface, layout, win_w, win_h); - if (g.atlas_dirty) uploadAtlasGl(g); - - c.glUseProgram(g.gl_program); - c.glActiveTexture(c.GL_TEXTURE0); - c.glBindTexture(c.GL_TEXTURE_2D, g.gl_atlas_tex); - c.glUniform1i(g.gl_u_atlas, 0); - c.glBindBuffer(c.GL_ARRAY_BUFFER, g.gl_vbo); - c.glBufferData(c.GL_ARRAY_BUFFER, @intCast((cells + shifted) * @sizeOf(CellInstance)), g.gl_instances.ptr, c.GL_STREAM_DRAW); - const stride: c_int = @intCast(@sizeOf(CellInstance)); - c.glEnableVertexAttribArray(0); - c.glVertexAttribPointer(0, 4, c.GL_FLOAT, c.GL_FALSE, stride, @ptrFromInt(@offsetOf(CellInstance, "x0"))); - c.glVertexAttribDivisor(0, 1); - c.glEnableVertexAttribArray(1); - c.glVertexAttribPointer(1, 4, c.GL_FLOAT, c.GL_FALSE, stride, @ptrFromInt(@offsetOf(CellInstance, "u0"))); - c.glVertexAttribDivisor(1, 1); - c.glEnableVertexAttribArray(2); - c.glVertexAttribPointer(2, 3, c.GL_FLOAT, c.GL_FALSE, stride, @ptrFromInt(@offsetOf(CellInstance, "fr"))); - c.glVertexAttribDivisor(2, 1); - c.glEnableVertexAttribArray(3); - c.glVertexAttribPointer(3, 3, c.GL_FLOAT, c.GL_FALSE, stride, @ptrFromInt(@offsetOf(CellInstance, "br"))); - c.glVertexAttribDivisor(3, 1); - c.glDrawArraysInstanced(c.GL_TRIANGLES, 0, 6, @intCast(cells)); - if (shifted != 0) { - // the same second pass renderFrame does: re-point the attributes at - // the tail of the buffer, clip to the pane body (GL counts scissor - // rows from the BOTTOM), draw the offset copy over the flat one - const tail: usize = cells * @sizeOf(CellInstance); - c.glVertexAttribPointer(0, 4, c.GL_FLOAT, c.GL_FALSE, stride, @ptrFromInt(tail + @offsetOf(CellInstance, "x0"))); - c.glVertexAttribPointer(1, 4, c.GL_FLOAT, c.GL_FALSE, stride, @ptrFromInt(tail + @offsetOf(CellInstance, "u0"))); - c.glVertexAttribPointer(2, 3, c.GL_FLOAT, c.GL_FALSE, stride, @ptrFromInt(tail + @offsetOf(CellInstance, "fr"))); - c.glVertexAttribPointer(3, 3, c.GL_FLOAT, c.GL_FALSE, stride, @ptrFromInt(tail + @offsetOf(CellInstance, "br"))); - const clip = scrollScissor(g, layout, sw, sh); - c.glEnable(c.GL_SCISSOR_TEST); - c.glScissor(clip.x, @as(c_int, @intCast(sh)) - (clip.y + clip.h), clip.w, clip.h); - c.glDrawArraysInstanced(c.GL_TRIANGLES, 0, 6, @intCast(shifted)); - c.glDisable(c.GL_SCISSOR_TEST); - } - drawOverlayGl(g, overlay_count); - _ = c.SDL_GL_SwapWindow(g.window); -} - -fn uploadAtlasGl(g: *Gui) void { - c.glBindTexture(c.GL_TEXTURE_2D, g.gl_atlas_tex); - c.glPixelStorei(c.GL_UNPACK_ALIGNMENT, 1); - c.glTexSubImage2D(c.GL_TEXTURE_2D, 0, 0, 0, atlas_w, atlas_h, c.GL_RED, c.GL_UNSIGNED_BYTE, g.atlas_stage.ptr); - g.atlas_dirty = false; -} - -fn drawOverlayGl(g: *Gui, vertex_count: u32) void { - if (vertex_count == 0) return; - c.glUseProgram(g.gl_overlay_program); - c.glBindBuffer(c.GL_ARRAY_BUFFER, g.gl_overlay_vbo); - c.glBufferData(c.GL_ARRAY_BUFFER, @intCast(vertex_count * @sizeOf(OverlayVertex)), g.overlay_vertices.ptr, c.GL_STREAM_DRAW); - const stride: c_int = @intCast(@sizeOf(OverlayVertex)); - c.glEnableVertexAttribArray(0); - c.glVertexAttribPointer(0, 2, c.GL_FLOAT, c.GL_FALSE, stride, @ptrFromInt(@offsetOf(OverlayVertex, "x"))); - c.glVertexAttribDivisor(0, 0); - c.glEnableVertexAttribArray(1); - c.glVertexAttribPointer(1, 4, c.GL_FLOAT, c.GL_FALSE, stride, @ptrFromInt(@offsetOf(OverlayVertex, "r"))); - c.glVertexAttribDivisor(1, 0); - c.glDisableVertexAttribArray(2); - c.glDisableVertexAttribArray(3); - c.glEnable(c.GL_BLEND); - c.glBlendFuncSeparate(c.GL_SRC_ALPHA, c.GL_ONE_MINUS_SRC_ALPHA, c.GL_ONE, c.GL_ONE_MINUS_SRC_ALPHA); - c.glDrawArrays(c.GL_TRIANGLES, 0, @intCast(vertex_count)); - c.glDisable(c.GL_BLEND); -} - -fn makeGlProgramFrom(vertex_source: []const u8, fragment_source: []const u8) !c_uint { - const vs = try makeGlShader(c.GL_VERTEX_SHADER, vertex_source); - defer c.glDeleteShader(vs); - const fs = try makeGlShader(c.GL_FRAGMENT_SHADER, fragment_source); - defer c.glDeleteShader(fs); - const program = c.glCreateProgram(); - if (program == 0) return error.GpuCreate; - c.glAttachShader(program, vs); - c.glAttachShader(program, fs); - c.glLinkProgram(program); - var ok: c_int = 0; - c.glGetProgramiv(program, c.GL_LINK_STATUS, &ok); - if (ok == 0) { - log.err("WebGL program link failed", .{}); - c.glDeleteProgram(program); - return error.GpuCreate; - } - return program; -} - -fn makeGlShader(kind: c_uint, source: []const u8) !c_uint { - const shader = c.glCreateShader(kind); - if (shader == 0) return error.GpuCreate; - var src_ptr = [_][*c]const u8{@ptrCast(source.ptr)}; - var src_len = [_]c_int{@intCast(source.len)}; - c.glShaderSource(shader, 1, &src_ptr, &src_len); - c.glCompileShader(shader); - var ok: c_int = 0; - c.glGetShaderiv(shader, c.GL_COMPILE_STATUS, &ok); - if (ok == 0) { - log.err("WebGL shader compile failed", .{}); - c.glDeleteShader(shader); - return error.GpuCreate; - } - return shader; -} - -// ===================================================================== // 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; - if (g.scene_tex) |t| c.SDL_ReleaseGPUTexture(g.device, t); var info = std.mem.zeroes(c.SDL_GPUTextureCreateInfo); info.type = c.SDL_GPU_TEXTURETYPE_2D; info.format = g.swapchain_format; @@ -4306,14 +4697,15 @@ fn ensureSceneTexture(g: *Gui, width: u32, height: u32) !void { info.layer_count_or_depth = 1; info.num_levels = 1; info.sample_count = c.SDL_GPU_SAMPLECOUNT_1; - g.scene_tex = c.SDL_CreateGPUTexture(g.device, &info) orelse return error.GpuCreate; + 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; - if (g.capture_tex) |t| c.SDL_ReleaseGPUTexture(g.device, t); var info = std.mem.zeroes(c.SDL_GPUTextureCreateInfo); info.type = c.SDL_GPU_TEXTURETYPE_2D; info.format = g.swapchain_format; @@ -4323,7 +4715,9 @@ fn ensureCaptureTexture(g: *Gui, width: u32, height: u32) !void { info.layer_count_or_depth = 1; info.num_levels = 1; info.sample_count = c.SDL_GPU_SAMPLECOUNT_1; - g.capture_tex = c.SDL_CreateGPUTexture(g.device, &info) orelse return error.GpuCreate; + 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; } @@ -4335,12 +4729,13 @@ fn captureFrame(g: *Gui, gpa: std.mem.Allocator, cmd: *c.SDL_GPUCommandBuffer, t return error.UnsupportedCaptureFormat; } if (g.capture_xfer == null or g.capture_xfer_size < size) { - if (g.capture_xfer) |x| c.SDL_ReleaseGPUTransferBuffer(g.device, x); var info = c.SDL_GPUTransferBufferCreateInfo{ .usage = c.SDL_GPU_TRANSFERBUFFERUSAGE_DOWNLOAD, .size = size, .props = 0 }; - g.capture_xfer = c.SDL_CreateGPUTransferBuffer(g.device, &info) orelse { + 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.?; @@ -4405,15 +4800,24 @@ fn writeCapturePpm(g: *Gui, gpa: std.mem.Allocator, pixels: []const u8, width: u // flash HUD, alpha-blended over the grid only while the Debug builtin is on. // ===================================================================== -fn addCursorBar(builder: *OverlayBuilder, x: u16, y: u16, bar: bool, layout: CellLayout) void { +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 y0 = layout.y_off + @as(f32, @floatFromInt(y)) * layout.h; + const height = std.math.clamp(visual_height, 1.0, layout.h); + const y0 = layout.y_off + @as(f32, @floatFromInt(y)) * layout.h + + (layout.h - height) * 0.5; builder.addRect( x0, y0, x0 + @max(1.0, layout.w / 8.0), - y0 + layout.h, + y0 + height, .{ .r = @as(f32, @floatFromInt(fg_default[0])) / 255.0, .g = @as(f32, @floatFromInt(fg_default[1])) / 255.0, @@ -4429,22 +4833,20 @@ fn buildOverlay(g: *Gui, surface: *const pardes.Surface, layout: CellLayout, sw: const win_h: f32 = @floatFromInt(sh); var builder = OverlayBuilder{ .vertices = g.overlay_vertices, .win_w = win_w, .win_h = win_h }; if (surface.cursor) |cursor| { - if (cursor.x < surface.cols and cursor.y < surface.rows) - addCursorBar(&builder, cursor.x, cursor.y, cursor.bar, layout); + if (cursor.x < surface.cols and cursor.y < surface.rows) { + const cell = surface.cells[@as(usize, cursor.y) * surface.cols + cursor.x]; + const role = cell.style.font_role; + const visual_height: f32 = if (role == .tagline) + @floatFromInt(g.tagline_height) + else + layout.h; + addCursorBar(&builder, cursor.x, cursor.y, cursor.bar, visual_height, layout); + } } appendTouchOverlay(g, &builder, debug_on); return @intCast(builder.len); } -fn buildTouchOverlay(g: *Gui, sw: u32, sh: u32, 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 }; - 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; @@ -4496,9 +4898,9 @@ fn appendTouchOverlay(g: *Gui, builder: *OverlayBuilder, debug_on: bool) void { } } -fn uploadOverlayGpu(g: *Gui, cmd: *c.SDL_GPUCommandBuffer, vertex_count: u32) void { +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; + 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]); @@ -4508,6 +4910,7 @@ fn uploadOverlayGpu(g: *Gui, cmd: *c.SDL_GPUCommandBuffer, vertex_count: u32) vo 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 { @@ -4718,13 +5121,6 @@ fn envU16(env: *std.process.Environ.Map, name: []const u8) ?u16 { return std.fmt.parseInt(u16, raw, 10) catch null; } -fn writeFile(path: [*:0]const u8, data: []const u8) void { - const fd = libc.open(path, .{ .ACCMODE = .WRONLY, .CREAT = true, .TRUNC = true }, @as(libc.mode_t, 0o644)); - if (fd < 0) return; - defer _ = libc.close(fd); - writeFd(fd, data); -} - fn writeFd(fd: c_int, data: []const u8) void { var off: usize = 0; while (off < data.len) { |
