1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
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 {};
}
|