summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--build.zig31
-rw-r--r--shaders/image.frag.glsl10
-rw-r--r--shaders/image.vert.glsl33
-rw-r--r--src/gui/gui.zig265
-rw-r--r--src/image.zig46
-rw-r--r--src/pardes.zig32
-rw-r--r--src/tty/tty.zig43
-rw-r--r--test/image_harness.zig166
8 files changed, 602 insertions, 24 deletions
diff --git a/build.zig b/build.zig
index b089a9c9..c7a42872 100644
--- a/build.zig
+++ b/build.zig
@@ -485,6 +485,12 @@ pub fn build(b: *std.Build) void {
root_mod.addAnonymousImport("overlay.frag.spv", .{
.root_source_file = compileGlsl(b, "shaders/overlay.frag.glsl", "fragment", "overlay.frag.spv"),
});
+ root_mod.addAnonymousImport("image.vert.spv", .{
+ .root_source_file = compileGlsl(b, "shaders/image.vert.glsl", "vertex", "image.vert.spv"),
+ });
+ root_mod.addAnonymousImport("image.frag.spv", .{
+ .root_source_file = compileGlsl(b, "shaders/image.frag.glsl", "fragment", "image.frag.spv"),
+ });
root_mod.addAnonymousImport("crt.vert.spv", .{
.root_source_file = compileGlsl(b, "shaders/crt.vert.glsl", "vertex", "crt.vert.spv"),
});
@@ -552,6 +558,31 @@ pub fn build(b: *std.Build) void {
.ghostty_vt = ghostty_vt_for_snap,
});
+ // Native image regression harness. TTY builds emulate Kitty over a
+ // pty and inspect the real APC stream; GUI builds drive PARDES_TEST's
+ // real SDL GPU readback and inspect source-colored pixels. It is a
+ // separate explicit step because the GUI arm needs a graphical/GPU
+ // session, while unit-test remains safe on headless builders.
+ const image_harness = b.addExecutable(.{
+ .name = "pardes-image-harness",
+ .root_module = b.createModule(.{
+ .target = target,
+ .optimize = optimize,
+ .root_source_file = b.path("test/image_harness.zig"),
+ .link_libc = true,
+ }),
+ });
+ if (ghostty_vt_for_snap) |vt| image_harness.root_module.addImport("ghostty-vt", vt);
+ switch (target.result.os.tag) {
+ .freebsd, .netbsd, .openbsd => image_harness.root_module.linkSystemLibrary("util", .{}),
+ else => {},
+ }
+ const run_image_harness = b.addRunArtifact(image_harness);
+ run_image_harness.addArtifactArg(exe);
+ run_image_harness.addArg(if (platform == .gui) "gui" else "tty");
+ run_image_harness.has_side_effects = true;
+ b.step("image-harness", "exercise native Kitty/SDL image rendering end to end").dependOn(&run_image_harness.step);
+
// helix differential harness: drive the sans-IO core headlessly over
// JSON-Lines cases, one contract result line per case on stdout —
// the pardes half of the pardes-vs-helix diff (helix's hx-harness on
diff --git a/shaders/image.frag.glsl b/shaders/image.frag.glsl
new file mode 100644
index 00000000..927d5f65
--- /dev/null
+++ b/shaders/image.frag.glsl
@@ -0,0 +1,10 @@
+#version 450
+
+layout(set = 2, binding = 0) uniform sampler2D u_image;
+
+layout(location = 0) in vec2 v_uv;
+layout(location = 0) out vec4 o_col;
+
+void main() {
+ o_col = texture(u_image, v_uv);
+}
diff --git a/shaders/image.vert.glsl b/shaders/image.vert.glsl
new file mode 100644
index 00000000..ace55a53
--- /dev/null
+++ b/shaders/image.vert.glsl
@@ -0,0 +1,33 @@
+#version 450
+
+// One instance per Surface image attachment. The CPU has already converted
+// the pane's cell rectangle and the aspect-preserving contain fit to NDC.
+layout(location = 0) in vec4 a_rect; // x0, y0, x1, y1
+layout(location = 1) in vec4 a_uv_rect; // u0, v0, u1, v1
+
+layout(location = 0) out vec2 v_uv;
+
+void main() {
+ vec2 pos;
+ int corner = gl_VertexIndex % 6;
+ if (corner == 0) {
+ pos = vec2(a_rect.x, a_rect.y);
+ v_uv = vec2(a_uv_rect.x, a_uv_rect.y);
+ } else if (corner == 1) {
+ pos = vec2(a_rect.z, a_rect.y);
+ v_uv = vec2(a_uv_rect.z, a_uv_rect.y);
+ } else if (corner == 2) {
+ pos = vec2(a_rect.z, a_rect.w);
+ v_uv = vec2(a_uv_rect.z, a_uv_rect.w);
+ } else if (corner == 3) {
+ pos = vec2(a_rect.x, a_rect.y);
+ v_uv = vec2(a_uv_rect.x, a_uv_rect.y);
+ } else if (corner == 4) {
+ pos = vec2(a_rect.z, a_rect.w);
+ v_uv = vec2(a_uv_rect.z, a_uv_rect.w);
+ } else {
+ pos = vec2(a_rect.x, a_rect.w);
+ v_uv = vec2(a_uv_rect.x, a_uv_rect.w);
+ }
+ gl_Position = vec4(pos, 0.0, 1.0);
+}
diff --git a/src/gui/gui.zig b/src/gui/gui.zig
index a26fdd1c..c08c27fe 100644
--- a/src/gui/gui.zig
+++ b/src/gui/gui.zig
@@ -54,6 +54,8 @@ 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)
@@ -184,6 +186,22 @@ const CellInstance = extern struct {
const OverlayVertex = extern struct { x: f32, y: f32, r: f32, g: f32, b: f32, a: f32 };
const OverlayColor = struct { r: f32, g: f32, b: f32, a: f32 };
+const ImageInstance = extern struct {
+ x0: f32,
+ y0: f32,
+ x1: f32,
+ y1: f32,
+ u0: f32,
+ v0: f32,
+ u1: f32,
+ v1: f32,
+};
+
+const NativeImage = struct {
+ serial: u32,
+ texture: *c.SDL_GPUTexture,
+};
+
// ---- touch: per-finger tracking + shared scroll/tap machines ----
const TouchSample = struct { x: f32 = 0, y: f32 = 0, pressure: f32 = 1 };
@@ -812,6 +830,7 @@ const Gui = struct {
swapchain_format: c.SDL_GPUTextureFormat,
pipeline: *c.SDL_GPUGraphicsPipeline,
overlay_pipeline: *c.SDL_GPUGraphicsPipeline,
+ image_pipeline: *c.SDL_GPUGraphicsPipeline,
crt_pipeline: *c.SDL_GPUGraphicsPipeline,
atlas_tex: *c.SDL_GPUTexture,
atlas_sampler: *c.SDL_GPUSampler,
@@ -822,6 +841,9 @@ const Gui = struct {
overlay_vbuf: *c.SDL_GPUBuffer,
overlay_vxfer: *c.SDL_GPUTransferBuffer,
overlay_vertices: []OverlayVertex,
+ image_vbuf: *c.SDL_GPUBuffer,
+ image_vxfer: *c.SDL_GPUTransferBuffer,
+ native_images: [pardes.MAX_PANES]?NativeImage = @splat(null),
font: *c.UIFont,
/// the file behind `font`, when it is one the Font builtin loaded. Empty
@@ -1052,6 +1074,7 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options) !void {
const pipeline = try makePipeline(device, swapchain_format);
const overlay_pipeline = try makeOverlayPipeline(device, swapchain_format);
+ const image_pipeline = try makeImagePipeline(device, swapchain_format);
const crt_pipeline = try makeCrtPipeline(device, swapchain_format);
const overlay_buffer_size: u32 = @intCast(max_overlay_vertices * @sizeOf(OverlayVertex));
@@ -1060,6 +1083,12 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options) !void {
var ovx_info = c.SDL_GPUTransferBufferCreateInfo{ .usage = c.SDL_GPU_TRANSFERBUFFERUSAGE_UPLOAD, .size = overlay_buffer_size, .props = 0 };
const overlay_vxfer = c.SDL_CreateGPUTransferBuffer(device, &ovx_info) orelse return error.GpuCreate;
+ const image_buffer_size: u32 = @intCast(pardes.MAX_PANES * @sizeOf(ImageInstance));
+ var ivb_info = c.SDL_GPUBufferCreateInfo{ .usage = c.SDL_GPU_BUFFERUSAGE_VERTEX, .size = image_buffer_size, .props = 0 };
+ const image_vbuf = c.SDL_CreateGPUBuffer(device, &ivb_info) orelse return error.GpuCreate;
+ var ivx_info = c.SDL_GPUTransferBufferCreateInfo{ .usage = c.SDL_GPU_TRANSFERBUFFERUSAGE_UPLOAD, .size = image_buffer_size, .props = 0 };
+ const image_vxfer = c.SDL_CreateGPUTransferBuffer(device, &ivx_info) orelse return error.GpuCreate;
+
const atlas_stage = try gpa.alloc(u8, atlas_w * atlas_h);
defer gpa.free(atlas_stage);
@memset(atlas_stage, 0);
@@ -1072,6 +1101,7 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options) !void {
.swapchain_format = swapchain_format,
.pipeline = pipeline,
.overlay_pipeline = overlay_pipeline,
+ .image_pipeline = image_pipeline,
.crt_pipeline = crt_pipeline,
.atlas_tex = atlas_tex,
.atlas_sampler = atlas_sampler,
@@ -1079,6 +1109,8 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options) !void {
.overlay_vbuf = overlay_vbuf,
.overlay_vxfer = overlay_vxfer,
.overlay_vertices = overlay_vertices,
+ .image_vbuf = image_vbuf,
+ .image_vxfer = image_vxfer,
.font = font,
.px = px,
.scale = scale,
@@ -1091,6 +1123,10 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options) !void {
.capture_dir = capture_dir orelse "",
};
defer g.glyphs.deinit();
+ defer clearNativeImages(&g);
+ defer c.SDL_ReleaseGPUTransferBuffer(device, image_vxfer);
+ defer c.SDL_ReleaseGPUBuffer(device, image_vbuf);
+ defer c.SDL_ReleaseGPUGraphicsPipeline(device, image_pipeline);
defer if (g.font_bytes.len != 0) gpa.free(g.font_bytes); // set by Font, if it ran
defer gpa.free(g.scroll_edge); // grown on demand by stepScroll
// slot (0,0) is the space glyph (blank cells sample alpha=0 → bg only)
@@ -1121,8 +1157,10 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options) !void {
break :blk try pardes.Pardes.initFromDump(gpa, opts, bytes);
} else try pardes.Pardes.init(gpa, opts);
defer core.deinit();
- // core.kitty_ok stays false: no kitty graphics in SDL, petscii covers images.
- // ponytail: Surface.images pixel attachments could blit as textures later.
+ // SDL is itself a native-pixel backend. This is deliberately set after
+ // construction: argv image panes no longer freeze the startup capability
+ // into their PETSCII preference, so their first render emits attachments.
+ core.native_images = true;
// shells emit OSC 133 prompt marks via this rc (prompt hiding, click-move)
writeFile(pardes.bash_rc);
@@ -1231,6 +1269,8 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options) !void {
// `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);
+ clearNativeImages(&g);
+ nc.native_images = true;
core.deinit();
core = nc;
}
@@ -1481,6 +1521,7 @@ fn runWeb(opts_in: pardes.Options) !void {
.swapchain_format = undefined,
.pipeline = undefined,
.overlay_pipeline = undefined,
+ .image_pipeline = undefined,
.crt_pipeline = undefined,
.atlas_tex = undefined,
.atlas_sampler = undefined,
@@ -1488,6 +1529,8 @@ fn runWeb(opts_in: pardes.Options) !void {
.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,
@@ -2699,6 +2742,171 @@ fn scrollScissor(g: *const Gui, layout: CellLayout, sw: u32, sh: u32) c.SDL_Rect
// render: Surface → instanced quads → SDL GPU
// =====================================================================
+fn releaseNativeImage(g: *Gui, pane: usize) void {
+ if (g.native_images[pane]) |cached| {
+ c.SDL_ReleaseGPUTexture(g.device, cached.texture);
+ g.native_images[pane] = null;
+ }
+}
+
+fn clearNativeImages(g: *Gui) void {
+ for (0..g.native_images.len) |pane| releaseNativeImage(g, pane);
+}
+
+fn validImageBytes(place: pardes.ImagePlace) ?u32 {
+ if (place.iw == 0 or place.ih == 0 or
+ place.iw > std.math.maxInt(u32) or place.ih > std.math.maxInt(u32)) return null;
+ const pixels = std.math.mul(usize, place.iw, place.ih) catch return null;
+ const bytes = std.math.mul(usize, pixels, 4) catch return null;
+ if (place.rgba.len != bytes or bytes > std.math.maxInt(u32)) return null;
+ return @intCast(bytes);
+}
+
+fn uploadNativeTexture(g: *Gui, cmd: *c.SDL_GPUCommandBuffer, place: pardes.ImagePlace) !void {
+ const byte_len = validImageBytes(place) orelse return error.BadImage;
+ const pane: usize = place.pane;
+ if (g.native_images[pane]) |cached| {
+ if (cached.serial == place.serial) return;
+ releaseNativeImage(g, pane);
+ }
+
+ var tex_info = std.mem.zeroes(c.SDL_GPUTextureCreateInfo);
+ tex_info.type = c.SDL_GPU_TEXTURETYPE_2D;
+ tex_info.format = c.SDL_GPU_TEXTUREFORMAT_R8G8B8A8_UNORM;
+ tex_info.usage = c.SDL_GPU_TEXTUREUSAGE_SAMPLER;
+ tex_info.width = @intCast(place.iw);
+ tex_info.height = @intCast(place.ih);
+ tex_info.layer_count_or_depth = 1;
+ tex_info.num_levels = 1;
+ tex_info.sample_count = c.SDL_GPU_SAMPLECOUNT_1;
+ const texture = c.SDL_CreateGPUTexture(g.device, &tex_info) orelse return error.GpuCreate;
+ errdefer c.SDL_ReleaseGPUTexture(g.device, texture);
+
+ var xf_info = c.SDL_GPUTransferBufferCreateInfo{
+ .usage = c.SDL_GPU_TRANSFERBUFFERUSAGE_UPLOAD,
+ .size = byte_len,
+ .props = 0,
+ };
+ const transfer = c.SDL_CreateGPUTransferBuffer(g.device, &xf_info) orelse return error.GpuCreate;
+ errdefer c.SDL_ReleaseGPUTransferBuffer(g.device, transfer);
+ const mapped: [*]u8 = @ptrCast(c.SDL_MapGPUTransferBuffer(g.device, transfer, false) orelse return error.GpuMap);
+ @memcpy(mapped[0..byte_len], place.rgba);
+ c.SDL_UnmapGPUTransferBuffer(g.device, transfer);
+
+ const copy = c.SDL_BeginGPUCopyPass(cmd);
+ const src = c.SDL_GPUTextureTransferInfo{
+ .transfer_buffer = transfer,
+ .offset = 0,
+ .pixels_per_row = @intCast(place.iw),
+ .rows_per_layer = @intCast(place.ih),
+ };
+ const dst = c.SDL_GPUTextureRegion{
+ .texture = texture,
+ .mip_level = 0,
+ .layer = 0,
+ .x = 0,
+ .y = 0,
+ .z = 0,
+ .w = @intCast(place.iw),
+ .h = @intCast(place.ih),
+ .d = 1,
+ };
+ c.SDL_UploadToGPUTexture(copy, &src, &dst, false);
+ c.SDL_EndGPUCopyPass(copy);
+ // SDL defers destruction until the submitted copy is done; the staging
+ // allocation is never needed again, so do not retain a second full image
+ // beside the texture for the life of the pane.
+ c.SDL_ReleaseGPUTransferBuffer(g.device, transfer);
+ g.native_images[pane] = .{ .serial = place.serial, .texture = texture };
+}
+
+/// Upload new pane generations and the small per-frame placement buffer.
+/// Returns the number of image instances drawNativeImagesGpu will consume.
+fn prepareNativeImages(
+ g: *Gui,
+ cmd: *c.SDL_GPUCommandBuffer,
+ surface: *pardes.Surface,
+ sw: u32,
+ sh: u32,
+) u32 {
+ var seen: [pardes.MAX_PANES]bool = @splat(false);
+ for (surface.images[0..surface.nimages]) |maybe| {
+ const place = maybe orelse continue;
+ if (validImageBytes(place) == null) continue;
+ seen[place.pane] = true;
+ uploadNativeTexture(g, cmd, place) catch continue;
+ }
+ for (seen, 0..) |is_seen, pane| if (!is_seen) releaseNativeImage(g, pane);
+
+ var count: u32 = 0;
+ for (surface.images[0..surface.nimages]) |maybe| {
+ const place = maybe orelse continue;
+ if (g.native_images[place.pane]) |cached| {
+ if (cached.serial == place.serial) count += 1;
+ }
+ }
+ if (count == 0) return 0;
+
+ const ptr: [*]u8 = @ptrCast(c.SDL_MapGPUTransferBuffer(g.device, g.image_vxfer, false) orelse return 0);
+ const instances: [*]ImageInstance = @ptrCast(@alignCast(ptr));
+ const win_w: f32 = @floatFromInt(sw);
+ const win_h: f32 = @floatFromInt(sh);
+ var idx: u32 = 0;
+ for (surface.images[0..surface.nimages]) |maybe| {
+ const place = maybe orelse continue;
+ const cached = g.native_images[place.pane] orelse continue;
+ if (cached.serial != place.serial) continue;
+
+ const bound_w = @as(u32, place.w) * g.cell_w;
+ const bound_h = @as(u32, place.h) * g.cell_h;
+ const fit = pardes.image.contain(place.iw, place.ih, bound_w, bound_h);
+ if (fit.w == 0 or fit.h == 0) continue;
+ const px0 = @as(u32, place.x) * g.cell_w;
+ const py0 = @as(u32, place.y) * g.cell_h;
+ const x0 = (@as(f32, @floatFromInt(px0)) / win_w) * 2.0 - 1.0;
+ const x1 = (@as(f32, @floatFromInt(px0 + fit.w)) / win_w) * 2.0 - 1.0;
+ const y0 = 1.0 - (@as(f32, @floatFromInt(py0)) / win_h) * 2.0;
+ const y1 = 1.0 - (@as(f32, @floatFromInt(py0 + fit.h)) / win_h) * 2.0;
+ instances[idx] = .{
+ .x0 = x0,
+ .y0 = y0,
+ .x1 = x1,
+ .y1 = y1,
+ .u0 = 0,
+ .v0 = 0,
+ .u1 = 1,
+ .v1 = 1,
+ };
+ idx += 1;
+ }
+ c.SDL_UnmapGPUTransferBuffer(g.device, g.image_vxfer);
+ if (idx == 0) return 0;
+
+ 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;
+}
+
+fn drawNativeImagesGpu(g: *Gui, rp: ?*c.SDL_GPURenderPass, surface: *pardes.Surface) void {
+ const pass = rp orelse return;
+ c.SDL_BindGPUGraphicsPipeline(pass, g.image_pipeline);
+ var idx: u32 = 0;
+ for (surface.images[0..surface.nimages]) |maybe| {
+ const place = maybe orelse continue;
+ const cached = g.native_images[place.pane] orelse continue;
+ if (cached.serial != place.serial) continue;
+ const sampler = c.SDL_GPUTextureSamplerBinding{ .texture = cached.texture, .sampler = g.atlas_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;
+ }
+}
+
fn renderFrame(g: *Gui, gpa: std.mem.Allocator, surface: *pardes.Surface, crt_on: bool, debug_on: bool) !void {
const cmd = c.SDL_AcquireGPUCommandBuffer(g.device) orelse return;
var sw: u32 = 0;
@@ -2735,6 +2943,7 @@ fn renderFrame(g: *Gui, gpa: std.mem.Allocator, surface: *pardes.Surface, crt_on
const overlay_count = buildTouchOverlay(g, sw, sh, debug_on);
if (overlay_count != 0) uploadOverlayGpu(g, cmd, overlay_count);
+ const image_count = prepareNativeImages(g, cmd, surface, sw, sh);
const cells: u32 = @as(u32, surface.cols) * surface.rows;
// ponytail: ×2, because the smooth-scroll pane is drawn a second time,
@@ -2805,11 +3014,20 @@ fn renderFrame(g: *Gui, gpa: std.mem.Allocator, surface: *pardes.Surface, crt_on
const whole = c.SDL_Rect{ .x = 0, .y = 0, .w = @intCast(sw), .h = @intCast(sh) };
c.SDL_SetGPUScissor(rp, &whole);
}
- drawOverlayGpu(g, rp, overlay_count);
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);
}
@@ -3114,6 +3332,47 @@ fn makeOverlayPipeline(device: *c.SDL_GPUDevice, color_format: c.SDL_GPUTextureF
return c.SDL_CreateGPUGraphicsPipeline(device, &info) orelse error.GpuCreate;
}
+fn makeImagePipeline(device: *c.SDL_GPUDevice, color_format: c.SDL_GPUTextureFormat) !*c.SDL_GPUGraphicsPipeline {
+ const vs = try makeShader(device, image_vert_spv, c.SDL_GPU_SHADERSTAGE_VERTEX, 0);
+ defer c.SDL_ReleaseGPUShader(device, vs);
+ const fs = try makeShader(device, image_frag_spv, c.SDL_GPU_SHADERSTAGE_FRAGMENT, 1);
+ defer c.SDL_ReleaseGPUShader(device, fs);
+
+ var vbuf_desc = c.SDL_GPUVertexBufferDescription{
+ .slot = 0,
+ .pitch = @sizeOf(ImageInstance),
+ .input_rate = c.SDL_GPU_VERTEXINPUTRATE_INSTANCE,
+ .instance_step_rate = 0,
+ };
+ var attrs = [_]c.SDL_GPUVertexAttribute{
+ .{ .location = 0, .buffer_slot = 0, .format = c.SDL_GPU_VERTEXELEMENTFORMAT_FLOAT4, .offset = @offsetOf(ImageInstance, "x0") },
+ .{ .location = 1, .buffer_slot = 0, .format = c.SDL_GPU_VERTEXELEMENTFORMAT_FLOAT4, .offset = @offsetOf(ImageInstance, "u0") },
+ };
+ var blend = std.mem.zeroes(c.SDL_GPUColorTargetBlendState);
+ blend.src_color_blendfactor = c.SDL_GPU_BLENDFACTOR_SRC_ALPHA;
+ blend.dst_color_blendfactor = c.SDL_GPU_BLENDFACTOR_ONE_MINUS_SRC_ALPHA;
+ blend.color_blend_op = c.SDL_GPU_BLENDOP_ADD;
+ blend.src_alpha_blendfactor = c.SDL_GPU_BLENDFACTOR_ONE;
+ blend.dst_alpha_blendfactor = c.SDL_GPU_BLENDFACTOR_ONE_MINUS_SRC_ALPHA;
+ blend.alpha_blend_op = c.SDL_GPU_BLENDOP_ADD;
+ blend.enable_blend = true;
+ var col_desc = c.SDL_GPUColorTargetDescription{ .format = color_format, .blend_state = blend };
+ var info = std.mem.zeroes(c.SDL_GPUGraphicsPipelineCreateInfo);
+ info.vertex_shader = vs;
+ info.fragment_shader = fs;
+ info.primitive_type = c.SDL_GPU_PRIMITIVETYPE_TRIANGLELIST;
+ info.vertex_input_state.vertex_buffer_descriptions = &vbuf_desc;
+ info.vertex_input_state.num_vertex_buffers = 1;
+ info.vertex_input_state.vertex_attributes = &attrs;
+ info.vertex_input_state.num_vertex_attributes = attrs.len;
+ info.target_info.color_target_descriptions = &col_desc;
+ info.target_info.num_color_targets = 1;
+ info.rasterizer_state.fill_mode = c.SDL_GPU_FILLMODE_FILL;
+ info.rasterizer_state.cull_mode = c.SDL_GPU_CULLMODE_NONE;
+ info.multisample_state.sample_count = c.SDL_GPU_SAMPLECOUNT_1;
+ return c.SDL_CreateGPUGraphicsPipeline(device, &info) orelse error.GpuCreate;
+}
+
// the CRT pass: a fullscreen triangle sampling the scene texture, no vertex
// buffers at all (positions from gl_VertexIndex)
fn makeCrtPipeline(device: *c.SDL_GPUDevice, color_format: c.SDL_GPUTextureFormat) !*c.SDL_GPUGraphicsPipeline {
diff --git a/src/image.zig b/src/image.zig
index a3415019..1e7736c5 100644
--- a/src/image.zig
+++ b/src/image.zig
@@ -1,7 +1,7 @@
//! Image panes. Decoding is zstbi (C stb_image) to RGBA, downscaled; the kept
//! pixels render as PETSCII glyph art directly into the Surface — or, when the
-//! host has kitty graphics (and Petscii isn't toggled), ride the Surface as a
-//! pixel attachment the shell transmits/places (tty: vaxis kitty; SDL: blit).
+//! host supports native images (and PETSCII isn't toggled), ride the Surface as
+//! a pixel attachment the shell transmits/places (TTY: Kitty; SDL: GPU texture).
const std = @import("std");
const zstbi = @import("zstbi");
const ghostty_vt = @import("ghostty-vt");
@@ -25,6 +25,48 @@ const MAX_DIM: u32 = 1280;
pub const PaletteMode = enum { commodore, terminal };
+/// Fit an image inside a pixel rectangle without changing its aspect ratio or
+/// enlarging it. Native SDL uses this for the same "contain" policy the kitty
+/// placement uses. Keeping the arithmetic here makes the backend geometry
+/// testable without a GPU or an image-capable terminal.
+pub const Fit = struct { w: u32, h: u32 };
+
+pub fn contain(iw: usize, ih: usize, max_w: u32, max_h: u32) Fit {
+ if (iw == 0 or ih == 0 or max_w == 0 or max_h == 0) return .{ .w = 0, .h = 0 };
+
+ const src_w: u64 = @intCast(iw);
+ const src_h: u64 = @intCast(ih);
+ const bound_w: u64 = max_w;
+ const bound_h: u64 = max_h;
+ if (src_w <= bound_w and src_h <= bound_h)
+ return .{ .w = @intCast(src_w), .h = @intCast(src_h) };
+
+ // Compare the two scale ratios without floating point. Whichever bound is
+ // tighter determines one exact dimension; the other is rounded down so it
+ // can never leak a pixel outside the pane body.
+ if (bound_w * src_h <= bound_h * src_w) {
+ return .{
+ .w = max_w,
+ .h = @intCast(@max(1, src_h * bound_w / src_w)),
+ };
+ }
+ return .{
+ .w = @intCast(@max(1, src_w * bound_h / src_h)),
+ .h = max_h,
+ };
+}
+
+test "native image contain keeps aspect, bounds, and small-image size" {
+ try std.testing.expectEqual(Fit{ .w = 40, .h = 20 }, contain(400, 200, 40, 40));
+ try std.testing.expectEqual(Fit{ .w = 20, .h = 40 }, contain(200, 400, 40, 40));
+ try std.testing.expectEqual(Fit{ .w = 17, .h = 9 }, contain(17, 9, 40, 40));
+ try std.testing.expectEqual(Fit{ .w = 0, .h = 0 }, contain(17, 9, 0, 40));
+ // Awkward ratios stay inside both bounds rather than rounding one pixel
+ // over them.
+ const odd = contain(403, 211, 101, 47);
+ try std.testing.expect(odd.w <= 101 and odd.h <= 47);
+}
+
/// call once at startup / exit (stb_image's allocator shim)
pub fn start(io: std.Io, gpa: std.mem.Allocator) void {
zstbi.init(io, gpa);
diff --git a/src/pardes.zig b/src/pardes.zig
index 0c7c0363..956b0e25 100644
--- a/src/pardes.zig
+++ b/src/pardes.zig
@@ -391,9 +391,12 @@ pub const Cell = struct {
};
/// A pixel image riding the surface: the shell transmits/places it over the
-/// given cell rect (tty: kitty graphics; SDL: texture blit).
+/// given cell rect (tty: Kitty graphics; SDL: alpha-blended GPU texture).
pub const ImagePlace = struct {
- pane: u8, // stable key for the shell's transmit-once cache
+ pane: u8, // cache slot
+ /// Pane slots are reused. This generation makes a cached GPU texture or
+ /// kitty image unambiguously belong to the pane which supplied the bytes.
+ serial: u32,
x: u16,
y: u16,
w: u16,
@@ -1172,9 +1175,10 @@ pub const Pardes = struct {
/// the Crt builtin's CRT post-process, rendered by the gui shell only
/// (the tty and web shells never read it)
crt_on: bool = false,
- /// kitty graphics support, reported by the shell (image panes fall back
- /// to the petscii matcher without it)
- kitty_ok: bool = false,
+ /// Native pixel attachments supported by the shell (Kitty graphics in a
+ /// terminal, GPU textures in SDL). Image panes dynamically fall back to
+ /// the PETSCII matcher without it.
+ native_images: bool = false,
quit: bool = false,
drag: Drag = .none,
hover_col: u16 = 0,
@@ -1260,7 +1264,7 @@ pub const Pardes = struct {
// FILE argv boot: the doc focused in the left column, a terminal
// on the launch directory in the right (files left, shells right)
_ = if (look.isImagePath(path))
- try p.openImageView(0, path, !p.kitty_ok)
+ try p.openImageView(0, path)
else
try file_pane.open(p, 0, path, opts.file_line);
const sh = try p.newPane(1);
@@ -6250,11 +6254,15 @@ pub const Pardes = struct {
return pane;
}
- fn openImageView(p: *Pardes, id: usize, path: []const u8, petscii: bool) !*Pane {
+ fn openImageView(p: *Pardes, id: usize, path: []const u8) !*Pane {
const path_copy = try p.gpa.dupe(u8, path);
errdefer p.gpa.free(path_copy);
const pane = try p.newDocPane(id);
- pane.image = .{ .path = path_copy, .petscii = petscii };
+ // Native pixels are the preference, not a capability observed at open
+ // time. drawImage dynamically falls back while a shell has no native
+ // image support; this matters because kitty detection finishes after
+ // argv images and restored panes have already been constructed.
+ pane.image = .{ .path = path_copy };
return pane;
}
@@ -6489,8 +6497,7 @@ pub const Pardes = struct {
.image => |target| {
if (p.focusPaneByPath(target.path, .{})) return;
const free = p.freeSlot() orelse return;
- // petscii by default when the host has no kitty graphics
- const nt = p.openImageView(free, target.path, !p.kitty_ok) catch return;
+ const nt = p.openImageView(free, target.path) catch return;
p.placeDoc(id, free, nt);
},
}
@@ -6866,7 +6873,7 @@ pub const Pardes = struct {
dump.decodeBytes(gpa, im.bytes_b64) catch &.{}
else
&.{};
- pane.image = .{ .path = path, .petscii = true, .raw = raw };
+ pane.image = .{ .path = path, .raw = raw };
pane.cols = @max(1, src.cols);
pane.rows = @max(1, src.rows);
},
@@ -7553,12 +7560,13 @@ pub const Pardes = struct {
if (iv.rgba.len == 0 or r.h <= BOX_H) return;
const body_cols = tw;
const body_rows = r.h - BOX_H;
- const use_petscii = iv.petscii or !p.kitty_ok;
+ const use_petscii = iv.petscii or !p.native_images;
if (!use_petscii) {
s.images[s.nimages] = .{
.pane = @intCast(for (p.panes, 0..) |slot, i| {
if (slot == pane) break i;
} else 0),
+ .serial = pane.serial,
.x = tx,
.y = r.y + BOX_H,
.w = body_cols,
diff --git a/src/tty/tty.zig b/src/tty/tty.zig
index e2fe10e8..f0013020 100644
--- a/src/tty/tty.zig
+++ b/src/tty/tty.zig
@@ -143,7 +143,12 @@ pub fn run(init: std.process.Init, opts: pardes.Options) !void {
// 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);
- var kitty_handles: [pardes.MAX_PANES]?vaxis.Image = @splat(null);
+ const KittyCached = struct { serial: u32, image: vaxis.Image };
+ var kitty_handles: [pardes.MAX_PANES]?KittyCached = @splat(null);
+ defer for (&kitty_handles) |*slot| if (slot.*) |cached| {
+ vx.freeImage(tty.writer(), cached.image.id);
+ slot.* = null;
+ };
var ptys: [pardes.MAX_PANES]?Pty = @splat(null);
// per-slot spawn generation: a reused pane id ignores the old shell's
// late pty_eof (which would otherwise close the NEW pty on that slot)
@@ -392,7 +397,11 @@ pub fn run(init: std.process.Init, opts: pardes.Options) !void {
// `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);
- kitty_handles = @splat(null);
+ for (&kitty_handles) |*slot| if (slot.*) |cached| {
+ vx.freeImage(tty.writer(), cached.image.id);
+ slot.* = null;
+ };
+ nc.native_images = vx.caps.kitty_graphics;
core.deinit();
core = nc;
}
@@ -414,6 +423,10 @@ pub fn run(init: std.process.Init, opts: pardes.Options) !void {
if (caps_pending and vx.queries_done.load(.unordered)) {
caps_pending = false;
vx.enableDetectedFeatures(tty.writer()) catch {};
+ // The core was constructed before the asynchronous handshake.
+ // Advertise native pixels only now, after every reply preceding
+ // DA1 has updated the capability set.
+ core.native_images = vx.caps.kitty_graphics;
vx.queueRefresh();
}
@@ -438,23 +451,39 @@ pub fn run(init: std.process.Init, opts: pardes.Options) !void {
}
}
tz_cells.end();
- // pixel attachments (kitty graphics): transmit once per pane, then
- // re-place every frame (placements aren't persistent)
+ // Pixel attachments (kitty graphics): transmit once per pane
+ // generation, then re-place every frame (placements aren't
+ // persistent). A slot can be closed and reused for an unrelated pane,
+ // so the pane number by itself is not a cache key.
+ var kitty_seen: [pardes.MAX_PANES]bool = @splat(false);
for (surface.images[0..surface.nimages]) |maybe| {
const place = maybe orelse continue;
+ kitty_seen[place.pane] = true;
+ if (kitty_handles[place.pane]) |cached| if (cached.serial != place.serial) {
+ vx.freeImage(tty.writer(), cached.image.id);
+ kitty_handles[place.pane] = null;
+ };
if (kitty_handles[place.pane] == null and vx.caps.kitty_graphics) {
const enc = std.base64.standard.Encoder;
if (gpa.alloc(u8, enc.calcSize(place.rgba.len))) |b64| {
defer gpa.free(b64);
_ = enc.encode(b64, place.rgba);
- kitty_handles[place.pane] = vx.transmitPreEncodedImage(tty.writer(), b64, @intCast(place.iw), @intCast(place.ih), .rgba) catch null;
+ if (vx.transmitPreEncodedImage(tty.writer(), b64, @intCast(place.iw), @intCast(place.ih), .rgba) catch null) |handle|
+ kitty_handles[place.pane] = .{ .serial = place.serial, .image = handle };
} else |_| {}
}
- if (kitty_handles[place.pane]) |h| {
+ if (kitty_handles[place.pane]) |cached| {
const child = win.child(.{ .x_off = place.x, .y_off = place.y, .width = place.w, .height = place.h });
- h.draw(child, .{ .scale = .contain }) catch {};
+ cached.image.draw(child, .{ .scale = .contain }) catch {};
}
}
+ // Toggling PETSCII or closing a pane removes its attachment from the
+ // Surface. Release the terminal-side image then, not merely when that
+ // numeric pane slot happens to be reused.
+ for (&kitty_handles, kitty_seen) |*slot, seen| if (!seen) if (slot.*) |cached| {
+ vx.freeImage(tty.writer(), cached.image.id);
+ slot.* = null;
+ };
if (surface.cursor) |cur| {
win.showCursor(cur.x, cur.y);
// insert = beam, everything else = the terminal's default shape
diff --git a/test/image_harness.zig b/test/image_harness.zig
new file mode 100644
index 00000000..5ec192c2
--- /dev/null
+++ b/test/image_harness.zig
@@ -0,0 +1,166 @@
+//! Native-image backend harness.
+//!
+//! zig build image-harness fake a Kitty terminal and check
+//! real transmit + placement APCs
+//! zig build image-harness -Dplatform=gui run the real SDL GPU renderer,
+//! capture a PPM, inspect pixels
+//!
+//! Unlike the text snapshot suite, both arms observe the thing the native
+//! backend actually emits. The TTY arm completes vaxis's asynchronous
+//! capability handshake over a PTY; the GUI arm consumes PARDES_TEST's GPU
+//! readback, so a PETSCII fallback cannot accidentally satisfy either one.
+const std = @import("std");
+const libc = std.c;
+const eh = @import("e2e_harness.zig");
+
+pub const std_options: std.Options = .{ .log_level = .err };
+
+extern "c" fn setenv(name: [*:0]const u8, value: [*:0]const u8, overwrite: c_int) c_int;
+extern "c" fn unsetenv(name: [*:0]const u8) c_int;
+
+const gpa = std.heap.page_allocator;
+const fixture_rgb = [3]u8{ 13, 77, 231 };
+
+pub fn main(init: std.process.Init) !void {
+ var arena_state: std.heap.ArenaAllocator = .init(gpa);
+ defer arena_state.deinit();
+ const arena = arena_state.allocator();
+ const args = try init.minimal.args.toSlice(arena);
+ if (args.len != 3) return error.BadArgs;
+
+ const exe = try arena.dupeZ(u8, args[1]);
+ const backend = args[2];
+ const base = try std.fmt.allocPrintSentinel(arena, "/tmp/pardes-image-harness-{d}", .{libc.getpid()}, 0);
+ if (libc.mkdir(base, 0o755) != 0 and libc.errno(-1) != .EXIST) return error.MkdirFailed;
+ defer cleanupBase(arena, base);
+ _ = setenv("HOME", base, 1);
+ _ = setenv("HISTFILE", "/dev/null", 1);
+ _ = setenv("TERM", "xterm-256color", 1);
+ _ = setenv("LC_ALL", "C", 1);
+
+ if (std.mem.eql(u8, backend, "tty")) {
+ try runKitty(arena, exe, base);
+ } else if (std.mem.eql(u8, backend, "gui")) {
+ try runGui(arena, exe, base);
+ } else return error.BadArgs;
+}
+
+fn cleanupBase(arena: std.mem.Allocator, base: [:0]const u8) void {
+ const names = [_][]const u8{ "native.ppm", "latest.ppm", "latest.ppm.tmp", ".bash_history" };
+ for (names) |name| {
+ const path = std.fmt.allocPrintSentinel(arena, "{s}/{s}", .{ base, name }, 0) catch continue;
+ _ = libc.unlink(path);
+ }
+ _ = libc.rmdir(base);
+}
+
+fn writePpm(arena: std.mem.Allocator, path: [:0]const u8, width: usize, height: usize) !void {
+ var out: std.ArrayList(u8) = .empty;
+ try out.appendSlice(arena, try std.fmt.allocPrint(arena, "P6\n{d} {d}\n255\n", .{ width, height }));
+ for (0..width * height) |_| try out.appendSlice(arena, &fixture_rgb);
+ try eh.writeFile(path, out.items);
+}
+
+fn runKitty(arena: std.mem.Allocator, exe: [:0]const u8, base: [:0]const u8) !void {
+ _ = unsetenv("PARDES_TEST");
+ _ = unsetenv("PARDES_TEST_CAPTURE_DIR");
+ const image_path = try std.fmt.allocPrintSentinel(arena, "{s}/native.ppm", .{base}, 0);
+ try writePpm(arena, image_path, 2, 2);
+
+ var h = try eh.Harness.initArgs(gpa, exe, 12, 30, image_path);
+ defer h.deinit();
+ try h.pump(150);
+ try h.expectRawContains("\x1b_Gi=1,a=q\x1b\\", "app did not issue the Kitty graphics capability query");
+
+ // APC says graphics is present; DA1 is the last capability response and
+ // closes the asynchronous handshake. Arrow-up is an ordinary queued event
+ // that wakes the main loop even on a terminal which supplied no in-band
+ // resize report, matching the documented idle-handshake edge case.
+ try h.send("\x1b_Gi=1;OK\x1b\\\x1b[?1;2c\x1b[A");
+ const deadline = eh.nowMs() + 3000;
+ while (eh.nowMs() < deadline) {
+ _ = try h.pumpOnce(50);
+ const sent = std.mem.indexOf(u8, h.raw.items, "\x1b_Gf=32,s=2,v=2,i=") != null;
+ const placed = std.mem.indexOf(u8, h.raw.items, "\x1b_Ga=p,i=") != null;
+ if (sent and placed) break;
+ }
+ try h.expectRawContains("\x1b_Gf=32,s=2,v=2,i=", "decoded RGBA was not transmitted to the Kitty terminal");
+ try h.expectRawContains("\x1b_Ga=p,i=", "transmitted Kitty image was not placed in the pane body");
+
+ // SPC t p toggles the attachment off and back on. The first transition
+ // must release terminal-side storage; the second must transmit again
+ // instead of resurrecting a stale pane-slot handle.
+ try h.send(" tp");
+ const delete_deadline = eh.nowMs() + 2000;
+ while (eh.nowMs() < delete_deadline and std.mem.indexOf(u8, h.raw.items, "\x1b_Ga=d,d=I,i=") == null)
+ _ = try h.pumpOnce(50);
+ try h.expectRawContains("\x1b_Ga=d,d=I,i=", "PETSCII toggle did not release the cached Kitty image");
+ try h.send(" tp");
+ const retransmit_deadline = eh.nowMs() + 2000;
+ while (eh.nowMs() < retransmit_deadline and std.mem.count(u8, h.raw.items, "\x1b_Gf=32,s=2,v=2,i=") < 2)
+ _ = try h.pumpOnce(50);
+ if (std.mem.count(u8, h.raw.items, "\x1b_Gf=32,s=2,v=2,i=") < 2)
+ return error.KittyRetransmitMissing;
+ std.debug.print("native image harness ok: kitty transmit + placement\n", .{});
+}
+
+fn runGui(arena: std.mem.Allocator, exe: [:0]const u8, base: [:0]const u8) !void {
+ const image_path = try std.fmt.allocPrintSentinel(arena, "{s}/native.ppm", .{base}, 0);
+ const capture_path = try std.fmt.allocPrintSentinel(arena, "{s}/latest.ppm", .{base}, 0);
+ try writePpm(arena, image_path, 32, 16);
+ _ = libc.unlink(capture_path);
+ _ = setenv("PARDES_TEST", "1", 1);
+ _ = setenv("PARDES_TEST_CAPTURE_DIR", base, 1);
+ _ = setenv("PARDES_TEST_COLS", "20", 1);
+ _ = setenv("PARDES_TEST_ROWS", "10", 1);
+
+ var h = try eh.Harness.initArgs(gpa, exe, 10, 20, image_path);
+ defer h.deinit();
+ try h.pump(1200);
+
+ const capture = readFile(gpa, capture_path) catch |err| {
+ std.debug.print("SDL image harness produced no capture ({t}); raw child output: {s}\n", .{ err, h.raw.items });
+ return err;
+ };
+ defer gpa.free(capture);
+ const pixels = ppmPixels(capture) orelse return error.BadCapture;
+ var exact: usize = 0;
+ var i: usize = 0;
+ while (i + 2 < pixels.len) : (i += 3) {
+ if (std.mem.eql(u8, pixels[i .. i + 3], &fixture_rgb)) exact += 1;
+ }
+ // The source contributes 512 aligned pixels. Leave ample room for driver
+ // filtering while still excluding glyph-art fallback and incidental UI
+ // colors.
+ if (exact < 200) {
+ std.debug.print("SDL image harness found only {d} native-color pixels in the GPU capture\n", .{exact});
+ return error.NativePixelsMissing;
+ }
+ std.debug.print("native image harness ok: SDL GPU capture ({d} source pixels)\n", .{exact});
+}
+
+fn readFile(allocator: std.mem.Allocator, path: [:0]const u8) ![]u8 {
+ const fd = libc.open(path, .{ .ACCMODE = .RDONLY }, @as(libc.mode_t, 0));
+ if (fd < 0) return error.OpenFailed;
+ defer _ = libc.close(fd);
+ var out: std.ArrayList(u8) = .empty;
+ errdefer out.deinit(allocator);
+ var buf: [8192]u8 = undefined;
+ while (true) {
+ const n = libc.read(fd, &buf, buf.len);
+ if (n < 0) return error.ReadFailed;
+ if (n == 0) break;
+ try out.appendSlice(allocator, buf[0..@intCast(n)]);
+ }
+ return out.toOwnedSlice(allocator);
+}
+
+fn ppmPixels(bytes: []const u8) ?[]const u8 {
+ if (!std.mem.startsWith(u8, bytes, "P6\n")) return null;
+ var newlines: usize = 0;
+ for (bytes, 0..) |byte, i| if (byte == '\n') {
+ newlines += 1;
+ if (newlines == 3) return bytes[i + 1 ..];
+ };
+ return null;
+}