// 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). When the terminal has no kitty graphics — // or the user toggles "Petscii" on the pane — we instead render the kept RGBA as // PETSCII art (see petscii.zig). Call `start` once before any pane loads. const std = @import("std"); const vaxis = @import("ui"); const zstbi = @import("zstbi"); const ghostty_vt = @import("ghostty-vt"); const petscii = @import("petscii.zig"); // The terminal's own 16 ANSI colors (ghostty's default palette) — what the PETSCII // matcher scores against; cells are then painted as indexed colors so the real // terminal resolves the displayed RGB (the art recolors with the terminal theme). fn ansiPalette() [16][3]u8 { var p: [16][3]u8 = undefined; for (0..16) |i| { const c = ghostty_vt.color.default[i]; p[i] = .{ c.r, c.g, c.b }; } return p; } // 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; // Which 16-color palette the PETSCII art matches + paints with. `commodore` (the // default) is the fixed C64 palette drawn as truecolor; `terminal` scores against // and paints with the terminal's own ANSI palette (recolors with the terminal theme). pub const PaletteMode = enum { commodore, terminal }; pub const View = struct { path: []const u8, // gpa-owned absolute path; doubles as the tag label tried: bool = false, // decode/transmit attempted — try exactly once handle: ?vaxis.Image = null, // kitty image handle once transmitted (null = none) // PETSCII fallback/toggle: when set we render the kept pixels as C64 glyph art // instead of placing the kitty image. Defaults to true on no-kitty terminals. petscii: bool = false, pmode: PaletteMode = .commodore, // color palette; terminal colors behind the toggle ascii: bool = true, // include ASCII glyphs in the matcher (max detail) rgba: []u8 = &.{}, // gpa-owned decoded+downscaled pixels, kept for PETSCII re-render iw: usize = 0, ih: usize = 0, grid: []petscii.Cell = &.{}, // gpa-owned cached PETSCII grid gw: usize = 0, gh: usize = 0, grid_w: u16 = 0, // body cols the grid was built for (recompute on change) grid_h: u16 = 0, grid_mode: PaletteMode = .commodore, // palette the grid was built for grid_ascii: bool = true, // ascii setting the grid was built for // 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(); } // free the gpa-owned buffers a View holds (RGBA + PETSCII grid). Call on pane close. pub fn deinitView(view: *View, gpa: std.mem.Allocator) void { if (view.rgba.len > 0) gpa.free(view.rgba); if (view.grid.len > 0) gpa.free(view.grid); } // Extensions stb_image can decode. Right-clicking one of these opens an image pane // (kitty if supported, else PETSCII) 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; } fn keepDecodedImage(view: *View, vx: *vaxis.Vaxis, gpa: std.mem.Allocator, writer: *std.Io.Writer, img: *const zstbi.Image) void { // 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; // keep a gpa-owned copy of the pixels for the PETSCII renderer. view.rgba = gpa.dupe(u8, src.data) catch &.{}; view.iw = src.width; view.ih = src.height; if (!vx.caps.kitty_graphics) return; // PETSCII-only terminal: nothing to transmit // 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; } // Decode (RGBA), downscale to MAX_DIM, keep the pixels for PETSCII, and — when the // terminal supports kitty graphics — transmit them once. Runs from the render loop, // which owns the tty writer. On failure `handle` stays null (PETSCII still works). 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(); keepDecodedImage(view, vx, gpa, writer, &img); } pub fn ensureLoadedFromMemory(view: *View, bytes: []const u8, vx: *vaxis.Vaxis, gpa: std.mem.Allocator, writer: *std.Io.Writer) void { if (view.tried) return; view.tried = true; if (bytes.len == 0) return; var img = zstbi.Image.loadFromMemory(bytes, 4) catch return; // force 4 components = RGBA defer img.deinit(); keepDecodedImage(view, vx, gpa, writer, &img); } // (Re)build the cached PETSCII grid for the current body size, if PETSCII drawing // is active and the size changed. Runs from the render loop (which has gpa). pub fn ensureGrid(view: *View, gpa: std.mem.Allocator, body_cols: u16, body_rows: u16) void { if (!petsciiActive(view) or view.rgba.len == 0 or body_cols == 0 or body_rows == 0) return; if (view.grid.len != 0 and view.grid_w == body_cols and view.grid_h == body_rows and view.grid_mode == view.pmode and view.grid_ascii == view.ascii) return; if (view.grid.len > 0) gpa.free(view.grid); const pal = switch (view.pmode) { .commodore => petscii.commodore, .terminal => ansiPalette(), }; const g = petscii.render(gpa, view.rgba, view.iw, view.ih, body_cols, body_rows, pal, view.ascii) catch petscii.Grid{ .cells = &.{}, .gw = 0, .gh = 0 }; view.grid = g.cells; view.gw = g.gw; view.gh = g.gh; view.grid_w = body_cols; view.grid_h = body_rows; view.grid_mode = view.pmode; view.grid_ascii = view.ascii; } // PETSCII draws when toggled on, or whenever there's no kitty handle to place. fn petsciiActive(view: *const View) bool { return view.petscii or view.handle == null; } // Draw the image into `win` (the pane body). PETSCII blits the cached glyph grid, // centered; otherwise the kitty placement is re-issued (it isn't persistent). pub fn draw(view: *const View, win: vaxis.Window, body_cols: u16, body_rows: u16) void { if (petsciiActive(view) and view.grid.len != 0) { const offx = if (body_cols > view.gw) (@as(usize, body_cols) - view.gw) / 2 else 0; const offy = if (body_rows > view.gh) (@as(usize, body_rows) - view.gh) / 2 else 0; var cy: usize = 0; while (cy < view.gh) : (cy += 1) { var cx: usize = 0; while (cx < view.gw) : (cx += 1) { // pointer into the retained grid — NOT a copy: vaxis keeps the // grapheme slice alive until vx.render(), so it must point at the // persistent grid, not a stack-local Cell. const c = &view.grid[cy * view.gw + cx]; // commodore: paint the fixed C64 colors as truecolor. terminal: // paint indexed colors so the real terminal resolves the RGB. const fg: vaxis.Color = switch (view.pmode) { .commodore => .{ .rgb = petscii.commodore[c.fg] }, .terminal => .{ .index = c.fg }, }; const bg: vaxis.Color = switch (view.pmode) { .commodore => .{ .rgb = petscii.commodore[c.bg] }, .terminal => .{ .index = c.bg }, }; win.writeCell(@intCast(offx + cx), @intCast(offy + cy), .{ .char = .{ .grapheme = c.glyph[0..c.glen] }, .style = .{ .fg = fg, .bg = bg }, }); } } return; } const h = view.handle orelse return; h.draw(win, .{ .scale = .contain }) catch {}; }