diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/gui/gui.zig | 265 | ||||
| -rw-r--r-- | src/image.zig | 46 | ||||
| -rw-r--r-- | src/pardes.zig | 32 | ||||
| -rw-r--r-- | src/tty/tty.zig | 43 |
4 files changed, 362 insertions, 24 deletions
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 |
