summaryrefslogtreecommitdiff
path: root/image.zig
blob: 56085f5a1fe43b3edbdd7ff468f0d427ed534c23 (plain) (blame)
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 {};
}