summaryrefslogtreecommitdiff
path: root/image.zig
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-06-30 13:00:23 -0300
committerGabriel Schneider <[email protected]>2026-06-30 15:32:46 -0300
commita68564e9fb9fb8be6d997db5e6ad9148fb506d18 (patch)
tree0230a359962c2e5e67bc4875050afe8411142c9e /image.zig
parent651af1db938bcbaa039d14b65ff6bc184a3d91b4 (diff)
downloadpardes-a68564e9fb9fb8be6d997db5e6ad9148fb506d18.tar.gz
pardes-a68564e9fb9fb8be6d997db5e6ad9148fb506d18.zip
images? lol
Diffstat (limited to 'image.zig')
-rw-r--r--image.zig83
1 files changed, 83 insertions, 0 deletions
diff --git a/image.zig b/image.zig
new file mode 100644
index 00000000..56085f5a
--- /dev/null
+++ b/image.zig
@@ -0,0 +1,83 @@
+// 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 {};
+}