// Image-viewer panes — the kitty-graphics side quest, kept out of the main editor // logic. A pane can carry a `View`; the render loop transmits it once (lazily, // because transmitting needs the tty writer) and re-places it every frame. // // Decoding is done by zstbi (C stb_image) — NOT zigimg, whose pure-Zig decoder // crashes the Zig 0.16 compiler when analyzed here. We decode to RGBA, downscale // large images so the transmit stays cheap, then hand the raw pixels to the // terminal via vaxis (kitty graphics). Call `start` once before any pane loads. const std = @import("std"); const vaxis = @import("vaxis"); const zstbi = @import("zstbi"); // Cap the longest side before transmitting: a terminal pane is at most a screenful // of cells, so multi-thousand-pixel photos just waste decode/transmit time. const MAX_DIM: u32 = 1280; pub const View = struct { path: []const u8, // gpa-owned absolute path; doubles as the tag label tried: bool = false, // transmit attempted (success or failure) — try exactly once handle: ?vaxis.Image = null, // kitty image handle once transmitted (null = pending/failed) // ponytail: we never vx.freeImage on close, so the terminal-side pixel data // leaks until app exit. Fine for a viewer; add freeImage in the close paths // (needs vx + the tty writer there) if you open many large images. }; // Initialize / tear down the stb_image allocator. Call once at startup / exit. pub fn start(io: std.Io, gpa: std.mem.Allocator) void { zstbi.init(io, gpa); } pub fn stop() void { zstbi.deinit(); } // Extensions stb_image can decode. Right-clicking one of these — when the terminal // reports kitty graphics — opens an image pane instead of a text view. pub fn isImagePath(path: []const u8) bool { const exts = [_][]const u8{ ".png", ".jpg", ".jpeg", ".jpe", ".bmp", ".tga", ".gif", ".psd", ".hdr", ".pic", ".pnm", ".ppm", ".pgm", }; for (exts) |ext| { if (path.len >= ext.len and std.ascii.eqlIgnoreCase(path[path.len - ext.len ..], ext)) return true; } return false; } // Decode (RGBA), downscale to MAX_DIM, and transmit to the terminal exactly once. // Runs from the render loop, which owns the tty writer. On any failure `handle` // stays null and the pane shows just its tag. pub fn ensureLoaded(view: *View, vx: *vaxis.Vaxis, gpa: std.mem.Allocator, writer: *std.Io.Writer) void { if (view.tried) return; view.tried = true; var pathbuf: [4096]u8 = undefined; const pz = std.fmt.bufPrintZ(&pathbuf, "{s}", .{view.path}) catch return; var img = zstbi.Image.loadFromFile(pz, 4) catch return; // force 4 components = RGBA defer img.deinit(); // downscale the longest side to MAX_DIM (keeping aspect) when it's bigger. var scaled: ?zstbi.Image = null; defer if (scaled) |*s| s.deinit(); const longest = @max(img.width, img.height); const src: *const zstbi.Image = if (longest > MAX_DIM) blk: { const nw = @max(1, img.width * MAX_DIM / longest); const nh = @max(1, img.height * MAX_DIM / longest); scaled = img.resize(nw, nh); break :blk &scaled.?; } else &img; // base64-encode the raw RGBA and transmit it (terminal shows the pixels as-is, // no terminal-side decode); vaxis chunks the payload over the kitty protocol. const enc = std.base64.standard.Encoder; const b64 = gpa.alloc(u8, enc.calcSize(src.data.len)) catch return; defer gpa.free(b64); _ = enc.encode(b64, src.data); view.handle = vx.transmitPreEncodedImage(writer, b64, @intCast(src.width), @intCast(src.height), .rgba) catch null; } // Re-place the image in `win`, scaled to fit while keeping aspect ratio. Kitty // placements aren't persistent (render clears them), so this must run every frame. pub fn draw(view: *const View, win: vaxis.Window) void { const h = view.handle orelse return; h.draw(win, .{ .scale = .contain }) catch {}; }