diff options
Diffstat (limited to 'src/image_pane.zig')
| -rw-r--r-- | src/image_pane.zig | 262 |
1 files changed, 262 insertions, 0 deletions
diff --git a/src/image_pane.zig b/src/image_pane.zig new file mode 100644 index 00000000..71ef2228 --- /dev/null +++ b/src/image_pane.zig @@ -0,0 +1,262 @@ +//! 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); +} |
