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
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
|
// 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). When the terminal has no kitty graphics —
// or the user toggles "Petscii" on the pane — we instead render the kept RGBA as
// PETSCII art (see petscii.zig). Call `start` once before any pane loads.
const std = @import("std");
const vaxis = @import("ui");
const zstbi = @import("zstbi");
const ghostty_vt = @import("ghostty-vt");
const petscii = @import("petscii.zig");
// The terminal's own 16 ANSI colors (ghostty's default palette) — what the PETSCII
// matcher scores against; cells are then painted as indexed colors so the real
// terminal resolves the displayed RGB (the art recolors with the terminal theme).
fn ansiPalette() [16][3]u8 {
var p: [16][3]u8 = undefined;
for (0..16) |i| {
const c = ghostty_vt.color.default[i];
p[i] = .{ c.r, c.g, c.b };
}
return p;
}
// 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;
// Which 16-color palette the PETSCII art matches + paints with. `commodore` (the
// default) is the fixed C64 palette drawn as truecolor; `terminal` scores against
// and paints with the terminal's own ANSI palette (recolors with the terminal theme).
pub const PaletteMode = enum { commodore, terminal };
pub const View = struct {
path: []const u8, // gpa-owned absolute path; doubles as the tag label
tried: bool = false, // decode/transmit attempted — try exactly once
handle: ?vaxis.Image = null, // kitty image handle once transmitted (null = none)
// PETSCII fallback/toggle: when set we render the kept pixels as C64 glyph art
// instead of placing the kitty image. Defaults to true on no-kitty terminals.
petscii: bool = false,
pmode: PaletteMode = .commodore, // color palette; terminal colors behind the toggle
ascii: bool = true, // include ASCII glyphs in the matcher (max detail)
rgba: []u8 = &.{}, // gpa-owned decoded+downscaled pixels, kept for PETSCII re-render
iw: usize = 0,
ih: usize = 0,
grid: []petscii.Cell = &.{}, // gpa-owned cached PETSCII grid
gw: usize = 0,
gh: usize = 0,
grid_w: u16 = 0, // body cols the grid was built for (recompute on change)
grid_h: u16 = 0,
grid_mode: PaletteMode = .commodore, // palette the grid was built for
grid_ascii: bool = true, // ascii setting the grid was built for
// 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();
}
// free the gpa-owned buffers a View holds (RGBA + PETSCII grid). Call on pane close.
pub fn deinitView(view: *View, gpa: std.mem.Allocator) void {
if (view.rgba.len > 0) gpa.free(view.rgba);
if (view.grid.len > 0) gpa.free(view.grid);
}
// Extensions stb_image can decode. Right-clicking one of these opens an image pane
// (kitty if supported, else PETSCII) 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;
}
fn keepDecodedImage(view: *View, vx: *vaxis.Vaxis, gpa: std.mem.Allocator, writer: *std.Io.Writer, img: *const zstbi.Image) void {
// 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;
// keep a gpa-owned copy of the pixels for the PETSCII renderer.
view.rgba = gpa.dupe(u8, src.data) catch &.{};
view.iw = src.width;
view.ih = src.height;
if (!vx.caps.kitty_graphics) return; // PETSCII-only terminal: nothing to transmit
// 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;
}
// Decode (RGBA), downscale to MAX_DIM, keep the pixels for PETSCII, and — when the
// terminal supports kitty graphics — transmit them once. Runs from the render loop,
// which owns the tty writer. On failure `handle` stays null (PETSCII still works).
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();
keepDecodedImage(view, vx, gpa, writer, &img);
}
pub fn ensureLoadedFromMemory(view: *View, bytes: []const u8, vx: *vaxis.Vaxis, gpa: std.mem.Allocator, writer: *std.Io.Writer) void {
if (view.tried) return;
view.tried = true;
if (bytes.len == 0) return;
var img = zstbi.Image.loadFromMemory(bytes, 4) catch return; // force 4 components = RGBA
defer img.deinit();
keepDecodedImage(view, vx, gpa, writer, &img);
}
// (Re)build the cached PETSCII grid for the current body size, if PETSCII drawing
// is active and the size changed. Runs from the render loop (which has gpa).
pub fn ensureGrid(view: *View, gpa: std.mem.Allocator, body_cols: u16, body_rows: u16) void {
if (!petsciiActive(view) or view.rgba.len == 0 or body_cols == 0 or body_rows == 0) return;
if (view.grid.len != 0 and view.grid_w == body_cols and view.grid_h == body_rows and
view.grid_mode == view.pmode and view.grid_ascii == view.ascii) return;
if (view.grid.len > 0) gpa.free(view.grid);
const pal = switch (view.pmode) {
.commodore => petscii.commodore,
.terminal => ansiPalette(),
};
const g = petscii.render(gpa, view.rgba, view.iw, view.ih, body_cols, body_rows, pal, view.ascii) catch petscii.Grid{ .cells = &.{}, .gw = 0, .gh = 0 };
view.grid = g.cells;
view.gw = g.gw;
view.gh = g.gh;
view.grid_w = body_cols;
view.grid_h = body_rows;
view.grid_mode = view.pmode;
view.grid_ascii = view.ascii;
}
// PETSCII draws when toggled on, or whenever there's no kitty handle to place.
fn petsciiActive(view: *const View) bool {
return view.petscii or view.handle == null;
}
// Draw the image into `win` (the pane body). PETSCII blits the cached glyph grid,
// centered; otherwise the kitty placement is re-issued (it isn't persistent).
pub fn draw(view: *const View, win: vaxis.Window, body_cols: u16, body_rows: u16) void {
if (petsciiActive(view) and view.grid.len != 0) {
const offx = if (body_cols > view.gw) (@as(usize, body_cols) - view.gw) / 2 else 0;
const offy = if (body_rows > view.gh) (@as(usize, body_rows) - view.gh) / 2 else 0;
var cy: usize = 0;
while (cy < view.gh) : (cy += 1) {
var cx: usize = 0;
while (cx < view.gw) : (cx += 1) {
// pointer into the retained grid — NOT a copy: vaxis keeps the
// grapheme slice alive until vx.render(), so it must point at the
// persistent grid, not a stack-local Cell.
const c = &view.grid[cy * view.gw + cx];
// commodore: paint the fixed C64 colors as truecolor. terminal:
// paint indexed colors so the real terminal resolves the RGB.
const fg: vaxis.Color = switch (view.pmode) {
.commodore => .{ .rgb = petscii.commodore[c.fg] },
.terminal => .{ .index = c.fg },
};
const bg: vaxis.Color = switch (view.pmode) {
.commodore => .{ .rgb = petscii.commodore[c.bg] },
.terminal => .{ .index = c.bg },
};
win.writeCell(@intCast(offx + cx), @intCast(offy + cy), .{
.char = .{ .grapheme = c.glyph[0..c.glen] },
.style = .{ .fg = fg, .bg = bg },
});
}
}
return;
}
const h = view.handle orelse return;
h.draw(win, .{ .scale = .contain }) catch {};
}
|