//! Image panes: their owned pixels, PETSCII cache, lifecycle, and two render //! paths. Core layout supplies the body rectangle; this module fills it or //! attaches one native image to it. const std = @import("std"); const pardes = @import("pardes.zig"); const image = @import("image.zig"); const look = @import("look.zig"); const dump = @import("dump.zig"); const config = @import("config.zig"); const GridKey = struct { cols: u16 = 0, rows: u16 = 0, palette: image.PaletteMode = .commodore, ascii: bool = true, }; /// The decoded, downscaled RGBA is retained so changing the pane size or one /// of the glyph-renderer toggles can rebuild the PETSCII projection without /// decoding the source again. All slices are owned by Pardes.image_gpa. pub const State = struct { path: []u8, petscii: bool = false, pmode: image.PaletteMode = .commodore, ascii: bool = true, tried: bool = false, rgba: []u8 = &.{}, iw: usize = 0, ih: usize = 0, /// Dump-loaded bytes, decoded lazily by the same path as a disk image. raw: []u8 = &.{}, grid: image.petscii.Grid = .{ .cells = &.{}, .gw = 0, .gh = 0 }, grid_key: GridKey = .{}, pub fn deinit(state: *State, gpa: std.mem.Allocator) void { gpa.free(state.path); if (state.rgba.len > 0) gpa.free(state.rgba); if (state.raw.len > 0) gpa.free(state.raw); if (state.grid.cells.len > 0) gpa.free(state.grid.cells); state.* = undefined; } }; /// Construct an image pane from a path and optionally transferred dump bytes. /// `raw` must be image_gpa-owned and ownership transfers only on success. pub fn create(p: *pardes.Pardes, id: usize, path: []const u8, raw: []u8) !*pardes.Pane { const path_copy = try p.image_gpa.dupe(u8, path); errdefer p.image_gpa.free(path_copy); const pane = try p.newDocPane(id); pane.image = .{ .path = path_copy, .raw = raw }; return pane; } /// Serialize the binary image record used by images and, for dump-schema /// compatibility, PDFs. Common pane metadata is supplied by the core. pub fn dumpPane( arena: std.mem.Allocator, pane: *const pardes.Pane, tag: []const u8, body: []const u8, scroll: usize, path: []const u8, raw: []const u8, ) !dump.Pane { const bytes = if (raw.len > 0) raw else look.readFile(arena, path) catch ""; return .{ .kind = .image, .tag = tag, .body = body, .scroll = scroll, .cols = pane.cols, .rows = pane.rows, .vweight = pane.vweight, .image = .{ .path = path, .bytes_b64 = if (bytes.len > 0) try dump.encodeBytes(arena, bytes) else "", .petscii = if (pane.image) |state| state.petscii else false, .palette = if (pane.image) |state| switch (state.pmode) { .commodore => .commodore, .terminal => .terminal, } else .commodore, .ascii = if (pane.image) |state| state.ascii else true, }, }; } /// Rebuild an ordinary raster image from its dump record. PDF records share /// the schema for compatibility and are deliberately dispatched by the core /// before reaching this function. pub fn restore(p: *pardes.Pardes, id: usize, src: dump.Pane) !*pardes.Pane { const saved = src.image.?; var raw: []u8 = if (saved.bytes_b64.len > 0) try dump.decodeBytes(p.image_gpa, saved.bytes_b64) else &.{}; errdefer if (raw.len > 0) p.image_gpa.free(raw); const pane = try create(p, id, saved.path, raw); raw = &.{}; pane.image.?.petscii = saved.petscii; pane.image.?.pmode = switch (saved.palette) { .commodore => .commodore, .terminal => .terminal, }; pane.image.?.ascii = saved.ascii; pane.cols = @max(1, src.cols); pane.rows = @max(1, src.rows); return pane; } pub fn togglePetscii(state: *State) void { state.petscii = !state.petscii; } pub fn togglePalette(state: *State) void { state.pmode = if (state.pmode == .commodore) .terminal else .commodore; } pub fn toggleAscii(state: *State) void { state.ascii = !state.ascii; } /// Image renderer choices are pane-local, not global Config values. Keep them /// queryable where they apply: the live tag beside the image's path. pub fn tagPrefix(arena: std.mem.Allocator, state: *const State) ![]u8 { return std.fmt.allocPrint( arena, "{s} petscii:{s} palette:{s} ascii:{s} {s}", .{ config.tag_image, if (state.petscii) "on" else "off", @tagName(state.pmode), if (state.ascii) "on" else "off", state.path, }, ); } /// Prefix written by version-1 dumps before renderer choices became visible /// in the live tag. Returned as a borrowed slice of saved_tag for one-time /// custom-tail migration. pub fn legacySavedPrefix(state: *const State, saved_tag: []const u8) ?[]const u8 { const lead = config.tag_image ++ " "; if (!std.mem.startsWith(u8, saved_tag, lead)) return null; const path_at = lead.len; if (!std.mem.startsWith(u8, saved_tag[path_at..], state.path)) return null; return saved_tag[0 .. path_at + state.path.len]; } fn ensureDecoded(p: *pardes.Pardes, state: *State) void { if (state.tried) return; state.tried = true; const bytes: []const u8 = if (state.raw.len > 0) state.raw else look.readFile(p.scratch.allocator(), state.path) catch ""; if (image.decode(p.image_gpa, bytes)) |decoded| { state.rgba = decoded.rgba; state.iw = decoded.w; state.ih = decoded.h; } } fn ensureGrid(p: *pardes.Pardes, state: *State, cols: u16, rows: u16) void { const wanted = GridKey{ .cols = cols, .rows = rows, .palette = state.pmode, .ascii = state.ascii }; if (state.grid.cells.len > 0 and std.meta.eql(state.grid_key, wanted)) return; if (state.grid.cells.len > 0) p.image_gpa.free(state.grid.cells); const palette = switch (state.pmode) { .commodore => image.petscii.commodore, .terminal => image.ansiPalette(), }; state.grid = image.petscii.render( p.image_gpa, state.rgba, state.iw, state.ih, cols, rows, palette, state.ascii, ) catch .{ .cells = &.{}, .gw = 0, .gh = 0 }; state.grid_key = wanted; } /// Render the image body supplied by core layout. Native-capable hosts receive /// a pixel attachment; other hosts and explicit PETSCII mode receive cells. pub fn draw( p: *pardes.Pardes, state: *State, pane_id: u8, serial: u32, x: u16, y: u16, cols: u16, rows: u16, ) void { ensureDecoded(p, state); if (state.rgba.len == 0 or cols == 0 or rows == 0) return; if (!state.petscii and p.native_images) { _ = p.appendImagePlace(.{ .pane = pane_id, .serial = serial, .x = x, .y = y, .w = cols, .h = rows, .rgba = state.rgba, .iw = state.iw, .ih = state.ih, }); return; } ensureGrid(p, state, cols, rows); if (state.grid.cells.len == 0) return; const offx = if (cols > state.grid.gw) (@as(usize, cols) - state.grid.gw) / 2 else 0; const offy = if (rows > state.grid.gh) (@as(usize, rows) - state.grid.gh) / 2 else 0; for (0..state.grid.gh) |cy| for (0..state.grid.gw) |cx| { const cell = &state.grid.cells[cy * state.grid.gw + cx]; const fg: pardes.Color = switch (state.pmode) { .commodore => .{ .rgb = image.petscii.commodore[cell.fg] }, .terminal => .{ .index = cell.fg }, }; const bg: pardes.Color = switch (state.pmode) { .commodore => .{ .rgb = image.petscii.commodore[cell.bg] }, .terminal => .{ .index = cell.bg }, }; const sx = x + @as(u16, @intCast(offx + cx)); const sy = y + @as(u16, @intCast(offy + cy)); if (sx < p.surface.cols and sy < p.surface.rows) p.surface.set(sx, sy, cell.glyph[0..cell.glen], .{ .fg = fg, .bg = bg }); }; } test "pane-local renderer choices are visible in the image tag" { var state: State = .{ .path = @constCast("/tmp/picture.ppm") }; togglePetscii(&state); togglePalette(&state); toggleAscii(&state); const tag = try tagPrefix(std.testing.allocator, &state); defer std.testing.allocator.free(tag); try std.testing.expectEqualStrings( config.tag_image ++ " petscii:on palette:terminal ascii:off /tmp/picture.ppm", tag, ); try std.testing.expectEqualStrings( "img /tmp/picture.ppm", legacySavedPrefix(&state, "img /tmp/picture.ppm Keep Del").?, ); } test "dump restore propagates malformed embedded image bytes" { const p = try pardes.Pardes.init(std.testing.allocator, .{ .tty_only = true }); defer p.deinit(); try std.testing.expect(p.panes[1] == null); try std.testing.expectError(error.InvalidCharacter, restore(p, 1, .{ .kind = .image, .tag = "img /tmp/bad.ppm", .body = "", .image = .{ .path = "/tmp/bad.ppm", .bytes_b64 = "A..A" }, })); try std.testing.expect(p.panes[1] == null); }