summaryrefslogtreecommitdiff
path: root/src/gui/gui.zig
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-08-16 15:49:12 -0300
committerGabriel Schneider <[email protected]>2026-08-18 23:44:42 -0300
commit1551e409c31992437cb2fa864f576d45c8433801 (patch)
treee2fae8451f87b735a1360c7c2e383fdc40165789 /src/gui/gui.zig
parentbe2a9957708cbf0c478ca861c4a1f0f227bbfe10 (diff)
downloadpardes-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.zig2750
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) {