summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-06-30 20:29:43 -0300
committerGabriel Schneider <[email protected]>2026-06-30 22:21:44 -0300
commitf6089186cfe1517073fafc8979a7546ec773d6c1 (patch)
treecfb474b5a5cff5d7197aa5084ea6013a0c53df6f
parent8b60c2c29c016c7db0fa6ee63769cea1defd9b3c (diff)
downloadpardes-full-prototype.tar.gz
pardes-full-prototype.zip
stealing petsciinatorfull-prototype
-rw-r--r--build.zig13
-rw-r--r--image.zig125
-rw-r--r--main.zig46
-rw-r--r--petscii.zig446
-rw-r--r--term.zig3
-rw-r--r--tests.zig43
6 files changed, 655 insertions, 21 deletions
diff --git a/build.zig b/build.zig
index d5bba19a..f6647ed5 100644
--- a/build.zig
+++ b/build.zig
@@ -176,6 +176,19 @@ pub fn build(b: *std.Build) void {
unit_step.dependOn(&run_unit.step);
test_step.dependOn(&run_unit.step);
+ // petscii.zig is likewise pure (std only) — fold its matcher tests into both
+ // the unit-test and the umbrella test step.
+ const petscii_tests = b.addTest(.{
+ .root_module = b.createModule(.{
+ .target = target,
+ .optimize = optimize,
+ .root_source_file = b.path("petscii.zig"),
+ }),
+ });
+ const run_petscii = b.addRunArtifact(petscii_tests);
+ unit_step.dependOn(&run_petscii.step);
+ test_step.dependOn(&run_petscii.step);
+
// Tutor practice blocks as unit tests: a second `zig test` target over the
// generated tutor_cases.zig (which imports modal.zig). Depends on tutor-gen
// so the file is (re)generated first.
diff --git a/image.zig b/image.zig
index 56085f5a..b6ebcf71 100644
--- a/image.zig
+++ b/image.zig
@@ -5,19 +5,55 @@
// 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.
+// 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("vaxis");
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, // transmit attempted (success or failure) — try exactly once
- handle: ?vaxis.Image = null, // kitty image handle once transmitted (null = pending/failed)
+ 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.
@@ -31,8 +67,14 @@ 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.
+// 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",
@@ -44,9 +86,9 @@ pub fn isImagePath(path: []const u8) bool {
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.
+// 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;
@@ -66,6 +108,12 @@ pub fn ensureLoaded(view: *View, vx: *vaxis.Vaxis, gpa: std.mem.Allocator, write
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;
@@ -75,9 +123,64 @@ pub fn ensureLoaded(view: *View, vx: *vaxis.Vaxis, gpa: std.mem.Allocator, write
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 {
+// (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 {};
}
diff --git a/main.zig b/main.zig
index 2ed94ccb..4c82d862 100644
--- a/main.zig
+++ b/main.zig
@@ -392,7 +392,12 @@ fn isDir(path: [*:0]const u8) bool {
// selectable text (acme-style). Caller frees with `alloc`.
fn tagLine(alloc: std.mem.Allocator, t: *Term) ![]u8 {
// an image pane's tag: a label + path (+ builtins); display-only, no mode/cwd.
- if (t.image) |iv| return std.fmt.allocPrint(alloc, "img {s} {s}", .{ iv.path, pane_builtins_str });
+ if (t.image) |iv| {
+ // toggles: Petscii (glyph art on/off), the color mode (C64 = commodore /
+ // Term = terminal palette), Ascii (include ascii glyphs in the matcher).
+ const mode = if (iv.pmode == .commodore) "C64" else "Term";
+ return std.fmt.allocPrint(alloc, "img {s} Petscii {s} Ascii {s}", .{ iv.path, mode, pane_builtins_str });
+ }
// a file pane's tag: mode + path (+ builtins); no cwd. Files never reach tty.
if (t.file) |f| {
const fmode = if (t.mode == .insert) "in" else "nm";
@@ -625,7 +630,7 @@ fn openFileView(gpa: std.mem.Allocator, rp: [*:0]const u8, line: usize, cols: u1
// sharing the layout machinery. No pty (handle -1), no reader, a stub 1x1 emulator
// (only its allocator-owned bits are touched). The render loop transmits + draws
// the image (see image.zig). `rp` is the absolute path. Caller wires it in + frees.
-fn openImageView(gpa: std.mem.Allocator, rp: [*:0]const u8, cols: u16, rows: u16) !*Term {
+fn openImageView(gpa: std.mem.Allocator, rp: [*:0]const u8, cols: u16, rows: u16, petscii: bool) !*Term {
const path = try gpa.dupe(u8, std.mem.span(rp));
errdefer gpa.free(path);
const t = try gpa.create(Term);
@@ -636,7 +641,7 @@ fn openImageView(gpa: std.mem.Allocator, rp: [*:0]const u8, cols: u16, rows: u16
.term = try ghostty_vt.Terminal.init(gpa, .{ .cols = 1, .rows = 1 }),
.stream = undefined,
.reader = .{ .any_future = null, .result = {} },
- .image = .{ .path = path },
+ .image = .{ .path = path, .petscii = petscii },
.cols = cols,
.rows = rows,
};
@@ -950,7 +955,7 @@ fn renderPane(root: vaxis.Window, alloc: std.mem.Allocator, t: *Term, r: Rect, a
// gutter so it reads like any other pane. kitty placements are re-issued each
// frame, so this runs every render. Nothing else (text/cursor/selection) applies.
if (t.image) |*iv| {
- if (r.h > BOX_H) image.draw(iv, text.child(.{ .y_off = BOX_H, .height = r.h - BOX_H }));
+ if (r.h > BOX_H) image.draw(iv, text.child(.{ .y_off = BOX_H, .height = r.h - BOX_H }), r.w - GUTTER, r.h - BOX_H);
const gut = root.child(.{ .x_off = r.x, .y_off = r.y, .width = GUTTER, .height = r.h });
const box_bg = if (active) accent else accent_dim;
var gy: u16 = 0;
@@ -2167,6 +2172,23 @@ fn actOnSelection(
txt: []const u8,
) bool {
const t = terms[id] orelse return false;
+ // image-pane tagline toggles (middle-click, acme execute): Petscii on/off, the
+ // C64/Term color mode, and Ascii glyphs. Intercept before the builtin/run dispatch.
+ if (button == .middle) if (t.image) |*iv| {
+ const w = std.mem.trim(u8, txt, " \t\r\n");
+ if (std.mem.eql(u8, w, "Petscii")) {
+ iv.petscii = !iv.petscii;
+ return false;
+ }
+ if (std.mem.eql(u8, w, "C64") or std.mem.eql(u8, w, "Term")) {
+ iv.pmode = if (iv.pmode == .commodore) .terminal else .commodore;
+ return false;
+ }
+ if (std.mem.eql(u8, w, "Ascii")) {
+ iv.ascii = !iv.ascii;
+ return false;
+ }
+ };
if (button == .right) {
// acme "look": resolve the selected word (peeling a :line suffix) relative
// to the window's dir. A directory opens/focuses a terminal there + ls; a
@@ -2214,9 +2236,9 @@ fn actOnSelection(
}
} else {
const fp = std.mem.span(rp);
- // an image opens as an IMAGE pane, but only when the terminal reported
- // the kitty-graphics capability; otherwise it falls back to a text view.
- const as_image = images_ok and image.isImagePath(fp);
+ // image paths open as IMAGE panes; they render via kitty when supported,
+ // else via the PETSCII fallback (also toggleable on the pane's tagline).
+ const as_image = image.isImagePath(fp);
// focus an existing pane already showing this path (file or image)
var found: ?usize = null;
for (terms, 0..) |*slot, i| if (slot.*) |tt| {
@@ -2238,7 +2260,7 @@ fn actOnSelection(
} else null;
if (slot) |free| {
const opened: ?*Term = if (as_image)
- (openImageView(gpa, rp, sw, sh) catch null)
+ (openImageView(gpa, rp, sw, sh, !images_ok) catch null) // petscii by default when no kitty
else
(openFileView(gpa, rp, pl.line, sw, sh) catch null);
if (opened) |nt| {
@@ -2882,7 +2904,13 @@ pub fn main(init: std.process.Init) !void {
if (slot.*) |t| {
// image panes transmit lazily (needs the tty writer, which lives
// here on the render thread); renderPane then re-places the picture.
- if (t.image) |*iv| image.ensureLoaded(iv, &vx, gpa, tty.writer());
+ if (t.image) |*iv| {
+ image.ensureLoaded(iv, &vx, gpa, tty.writer());
+ const r = rects[id];
+ const bc = if (r.w > GUTTER) r.w - GUTTER else 0;
+ const br = if (r.h > BOX_H) r.h - BOX_H else 0;
+ image.ensureGrid(iv, gpa, bc, br);
+ }
try renderPane(win, frame_arena.allocator(), t, rects[id], id == active);
}
}
diff --git a/petscii.zig b/petscii.zig
new file mode 100644
index 00000000..136c4ead
--- /dev/null
+++ b/petscii.zig
@@ -0,0 +1,446 @@
+// PETSCII image rendering — a fallback when the terminal has no kitty graphics,
+// and a per-pane toggle. Ported in spirit from caioluders/petsciinator: split the
+// image into character cells and match each cell to the Unicode block/sextant glyph
+// + a C64 foreground/background color pair that best reproduces the cell's pixels
+// (minimum per-pixel color error). Pure: only std + the raw RGBA bytes (no vaxis),
+// so the matcher is unit-testable on its own.
+const std = @import("std");
+
+// The match palette is supplied by the caller (mode-dependent): the Commodore 64
+// colors below by default, or the terminal's own ANSI palette. Cells store fg/bg as
+// palette indices; image.draw paints them as C64 RGB or as indexed colors per mode.
+// This is Pepto's canonical C64 palette.
+pub const commodore = [16][3]u8{
+ .{ 0x00, 0x00, 0x00 }, // 0 black
+ .{ 0xff, 0xff, 0xff }, // 1 white
+ .{ 0x68, 0x37, 0x2b }, // 2 red
+ .{ 0x70, 0xa4, 0xb2 }, // 3 cyan
+ .{ 0x6f, 0x3d, 0x86 }, // 4 purple
+ .{ 0x58, 0x8d, 0x43 }, // 5 green
+ .{ 0x35, 0x28, 0x79 }, // 6 blue
+ .{ 0xb8, 0xc7, 0x6f }, // 7 yellow
+ .{ 0x6f, 0x4f, 0x25 }, // 8 orange
+ .{ 0x43, 0x39, 0x00 }, // 9 brown
+ .{ 0x9a, 0x67, 0x59 }, // 10 light red
+ .{ 0x44, 0x44, 0x44 }, // 11 dark grey
+ .{ 0x6c, 0x6c, 0x6c }, // 12 grey
+ .{ 0x9a, 0xd2, 0x84 }, // 13 light green
+ .{ 0x6c, 0x5e, 0xb5 }, // 14 light blue
+ .{ 0x95, 0x95, 0x95 }, // 15 light grey
+};
+
+const Palette = [16][3]u8;
+
+// One rendered character cell: the UTF-8 bytes of the chosen glyph (stored inline
+// so the slice handed to the retained vaxis screen outlives the frame) + palette
+// fg/bg indices.
+pub const Cell = struct {
+ glyph: [4]u8 = .{ ' ', 0, 0, 0 },
+ glen: u3 = 1,
+ fg: u4 = 1,
+ bg: u4 = 0,
+};
+
+pub const Grid = struct { cells: []Cell, gw: usize, gh: usize };
+
+fn dist2(a: [3]u8, b: [3]u8) u32 {
+ const dr = @as(i32, a[0]) - b[0];
+ const dg = @as(i32, a[1]) - b[1];
+ const db = @as(i32, a[2]) - b[2];
+ return @intCast(dr * dr + dg * dg + db * db);
+}
+
+fn nearest(px: [3]u8, pal: Palette) u4 {
+ var best: u4 = 0;
+ var bestd: u32 = std.math.maxInt(u32);
+ for (pal, 0..) |c, i| {
+ const d = dist2(px, c);
+ if (d < bestd) {
+ bestd = d;
+ best = @intCast(i);
+ }
+ }
+ return best;
+}
+
+// ---- glyph set: each is a codepoint + an 8x8 ink bitmap (bit y*8+x set = fg) ----
+const Glyph = struct { cp: u21, bits: u64 };
+
+// The Unicode codepoint for a 2x3 sextant pattern. Bits: 1=upper-left, 2=upper-right,
+// 4=mid-left, 8=mid-right, 16=lower-left, 32=lower-right. The four patterns that
+// coincide with existing block characters are mapped to those instead.
+fn sextantCp(p: u6) u21 {
+ return switch (p) {
+ 0 => ' ',
+ 21 => 0x258C, // left half ▌
+ 42 => 0x2590, // right half ▐
+ 63 => 0x2588, // full block █
+ else => blk: {
+ var off: u21 = @as(u21, p) - 1;
+ if (p > 21) off -= 1;
+ if (p > 42) off -= 1;
+ break :blk 0x1FB00 + off; // Symbols for Legacy Computing sextants
+ },
+ };
+}
+
+// the 8x8 ink bitmap for a sextant pattern (2 cols x 3 rows of subcells).
+fn sextantBits(p: u6) u64 {
+ var bits: u64 = 0;
+ var y: usize = 0;
+ while (y < 8) : (y += 1) {
+ const band = (y * 3) / 8; // 0,0,0,1,1,1,2,2
+ var x: usize = 0;
+ while (x < 8) : (x += 1) {
+ const col: usize = if (x < 4) 0 else 1;
+ const sub: u6 = @intCast(band * 2 + col);
+ if ((p >> sub) & 1 != 0) bits |= @as(u64, 1) << @intCast(y * 8 + x);
+ }
+ }
+ return bits;
+}
+
+// pack 8 row-bytes (bit x set, x=0 leftmost) into the 8x8 bitmap.
+fn rows(r: [8]u8) u64 {
+ var b: u64 = 0;
+ for (r, 0..) |row, y| b |= @as(u64, row) << @intCast(y * 8);
+ return b;
+}
+
+// the horizontal half blocks and the ten 2x2 quadrants — sextants are 2x3, so they
+// can't express an exact 4-row half or a quarter; these fill that gap.
+const block_glyphs = [_]Glyph{
+ .{ .cp = 0x2580, .bits = rows(.{ 0xff, 0xff, 0xff, 0xff, 0, 0, 0, 0 }) }, // ▀ top half
+ .{ .cp = 0x2584, .bits = rows(.{ 0, 0, 0, 0, 0xff, 0xff, 0xff, 0xff }) }, // ▄ bottom half
+ .{ .cp = 0x2598, .bits = rows(.{ 0x0f, 0x0f, 0x0f, 0x0f, 0, 0, 0, 0 }) }, // ▘ TL
+ .{ .cp = 0x259d, .bits = rows(.{ 0xf0, 0xf0, 0xf0, 0xf0, 0, 0, 0, 0 }) }, // ▝ TR
+ .{ .cp = 0x2596, .bits = rows(.{ 0, 0, 0, 0, 0x0f, 0x0f, 0x0f, 0x0f }) }, // ▖ BL
+ .{ .cp = 0x2597, .bits = rows(.{ 0, 0, 0, 0, 0xf0, 0xf0, 0xf0, 0xf0 }) }, // ▗ BR
+ .{ .cp = 0x259a, .bits = rows(.{ 0x0f, 0x0f, 0x0f, 0x0f, 0xf0, 0xf0, 0xf0, 0xf0 }) }, // ▚ TL+BR
+ .{ .cp = 0x259e, .bits = rows(.{ 0xf0, 0xf0, 0xf0, 0xf0, 0x0f, 0x0f, 0x0f, 0x0f }) }, // ▞ TR+BL
+ .{ .cp = 0x259b, .bits = rows(.{ 0xff, 0xff, 0xff, 0xff, 0x0f, 0x0f, 0x0f, 0x0f }) }, // ▛ ¬BR
+ .{ .cp = 0x259c, .bits = rows(.{ 0xff, 0xff, 0xff, 0xff, 0xf0, 0xf0, 0xf0, 0xf0 }) }, // ▜ ¬BL
+ .{ .cp = 0x2599, .bits = rows(.{ 0x0f, 0x0f, 0x0f, 0x0f, 0xff, 0xff, 0xff, 0xff }) }, // ▙ ¬TR
+ .{ .cp = 0x259f, .bits = rows(.{ 0xf0, 0xf0, 0xf0, 0xf0, 0xff, 0xff, 0xff, 0xff }) }, // ▟ ¬TL
+};
+
+// line/diagonal glyphs add the characteristic PETSCII "drawn" look on edges.
+const line_glyphs = [_]Glyph{
+ .{ .cp = 0x2500, .bits = rows(.{ 0, 0, 0, 0xff, 0xff, 0, 0, 0 }) }, // ─
+ .{ .cp = 0x2502, .bits = rows(.{ 0x18, 0x18, 0x18, 0x18, 0x18, 0x18, 0x18, 0x18 }) }, // │
+ .{ .cp = 0x253c, .bits = rows(.{ 0x18, 0x18, 0x18, 0xff, 0xff, 0x18, 0x18, 0x18 }) }, // ┼
+ .{ .cp = 0x2572, .bits = rows(.{ 0x01, 0x02, 0x04, 0x08, 0x10, 0x20, 0x40, 0x80 }) }, // ╲
+ .{ .cp = 0x2571, .bits = rows(.{ 0x80, 0x40, 0x20, 0x10, 0x08, 0x04, 0x02, 0x01 }) }, // ╱
+ .{ .cp = 0x2573, .bits = rows(.{ 0x81, 0x42, 0x24, 0x18, 0x18, 0x24, 0x42, 0x81 }) }, // ╳
+};
+
+// Real 8x8 font bitmaps for the printable ASCII range (extracted from the cp850-8x8
+// console font). This is how petsciinator's charset works: the FULL character set is
+// matched, each glyph scored against the cell by its actual bitmap (ink = fg), then
+// rendered by its codepoint — the terminal draws the letter in its own font. (bit
+// y*8+x set = ink; x=0 leftmost. Generated, not hand-drawn.)
+const ascii_glyphs = [_]Glyph{
+ .{ .cp = 0x21, .bits = 0x00180018183c3c18 }, // !
+ .{ .cp = 0x22, .bits = 0x0000000000246666 }, // "
+ .{ .cp = 0x23, .bits = 0x0036367f367f3636 }, // #
+ .{ .cp = 0x24, .bits = 0x00183e603c067c18 }, // $
+ .{ .cp = 0x25, .bits = 0x0063660c18336300 }, // %
+ .{ .cp = 0x26, .bits = 0x006e333b6e1c361c }, // &
+ .{ .cp = 0x27, .bits = 0x00000000000c1818 }, // '
+ .{ .cp = 0x28, .bits = 0x0030180c0c0c1830 }, // (
+ .{ .cp = 0x29, .bits = 0x000c18303030180c }, // )
+ .{ .cp = 0x2a, .bits = 0x0000663cff3c6600 }, // *
+ .{ .cp = 0x2b, .bits = 0x000018187e181800 }, // +
+ .{ .cp = 0x2c, .bits = 0x0c18180000000000 }, // ,
+ .{ .cp = 0x2d, .bits = 0x000000007e000000 }, // -
+ .{ .cp = 0x2e, .bits = 0x0018180000000000 }, // .
+ .{ .cp = 0x2f, .bits = 0x000103060c183060 }, // /
+ .{ .cp = 0x30, .bits = 0x001c36636b63361c }, // 0
+ .{ .cp = 0x31, .bits = 0x007e181818181c18 }, // 1
+ .{ .cp = 0x32, .bits = 0x007f660c3860633e }, // 2
+ .{ .cp = 0x33, .bits = 0x003e63603c60633e }, // 3
+ .{ .cp = 0x34, .bits = 0x0078307f33363c38 }, // 4
+ .{ .cp = 0x35, .bits = 0x003e63603f03037f }, // 5
+ .{ .cp = 0x36, .bits = 0x003e63633f03061c }, // 6
+ .{ .cp = 0x37, .bits = 0x000c0c0c1830637f }, // 7
+ .{ .cp = 0x38, .bits = 0x003e63633e63633e }, // 8
+ .{ .cp = 0x39, .bits = 0x001e30607e63633e }, // 9
+ .{ .cp = 0x3a, .bits = 0x0018180000181800 }, // :
+ .{ .cp = 0x3b, .bits = 0x0c18180000181800 }, // ;
+ .{ .cp = 0x3c, .bits = 0x006030180c183060 }, // <
+ .{ .cp = 0x3d, .bits = 0x00007e00007e0000 }, // =
+ .{ .cp = 0x3e, .bits = 0x00060c1830180c06 }, // >
+ .{ .cp = 0x3f, .bits = 0x001800181830633e }, // ?
+ .{ .cp = 0x40, .bits = 0x001e037b7b7b633e }, // @
+ .{ .cp = 0x41, .bits = 0x006363637f63361c }, // A
+ .{ .cp = 0x42, .bits = 0x003f66663e66663f }, // B
+ .{ .cp = 0x43, .bits = 0x003c66030303663c }, // C
+ .{ .cp = 0x44, .bits = 0x001f36666666361f }, // D
+ .{ .cp = 0x45, .bits = 0x007f46161e16467f }, // E
+ .{ .cp = 0x46, .bits = 0x000f06161e16467f }, // F
+ .{ .cp = 0x47, .bits = 0x005c66730303663c }, // G
+ .{ .cp = 0x48, .bits = 0x006363637f636363 }, // H
+ .{ .cp = 0x49, .bits = 0x003c18181818183c }, // I
+ .{ .cp = 0x4a, .bits = 0x001e333330303078 }, // J
+ .{ .cp = 0x4b, .bits = 0x006766361e366667 }, // K
+ .{ .cp = 0x4c, .bits = 0x007f66460606060f }, // L
+ .{ .cp = 0x4d, .bits = 0x0063636b7f7f7763 }, // M
+ .{ .cp = 0x4e, .bits = 0x006363737b6f6763 }, // N
+ .{ .cp = 0x4f, .bits = 0x003e63636363633e }, // O
+ .{ .cp = 0x50, .bits = 0x000f06063e66663f }, // P
+ .{ .cp = 0x51, .bits = 0x703e73636363633e }, // Q
+ .{ .cp = 0x52, .bits = 0x006766363e66663f }, // R
+ .{ .cp = 0x53, .bits = 0x003c6630180c663c }, // S
+ .{ .cp = 0x54, .bits = 0x003c1818185a7e7e }, // T
+ .{ .cp = 0x55, .bits = 0x003e636363636363 }, // U
+ .{ .cp = 0x56, .bits = 0x001c366363636363 }, // V
+ .{ .cp = 0x57, .bits = 0x00367f6b6b636363 }, // W
+ .{ .cp = 0x58, .bits = 0x006363361c366363 }, // X
+ .{ .cp = 0x59, .bits = 0x003c18183c666666 }, // Y
+ .{ .cp = 0x5a, .bits = 0x007f664c1831637f }, // Z
+ .{ .cp = 0x5b, .bits = 0x003c0c0c0c0c0c3c }, // [
+ .{ .cp = 0x5c, .bits = 0x00406030180c0603 }, // \\
+ .{ .cp = 0x5d, .bits = 0x003c30303030303c }, // ]
+ .{ .cp = 0x5e, .bits = 0x0000000063361c08 }, // ^
+ .{ .cp = 0x5f, .bits = 0xff00000000000000 }, // _
+ .{ .cp = 0x60, .bits = 0x000000000030180c }, // `
+ .{ .cp = 0x61, .bits = 0x006e333e301e0000 }, // a
+ .{ .cp = 0x62, .bits = 0x003b6666663e0607 }, // b
+ .{ .cp = 0x63, .bits = 0x003e6303633e0000 }, // c
+ .{ .cp = 0x64, .bits = 0x006e3333333e3038 }, // d
+ .{ .cp = 0x65, .bits = 0x003e037f633e0000 }, // e
+ .{ .cp = 0x66, .bits = 0x000f06061f06663c }, // f
+ .{ .cp = 0x67, .bits = 0x1f303e33336e0000 }, // g
+ .{ .cp = 0x68, .bits = 0x006766666e360607 }, // h
+ .{ .cp = 0x69, .bits = 0x003c1818181c0018 }, // i
+ .{ .cp = 0x6a, .bits = 0x3c66666060600060 }, // j
+ .{ .cp = 0x6b, .bits = 0x0067361e36660607 }, // k
+ .{ .cp = 0x6c, .bits = 0x003c18181818181c }, // l
+ .{ .cp = 0x6d, .bits = 0x006b6b6b7f370000 }, // m
+ .{ .cp = 0x6e, .bits = 0x00666666663b0000 }, // n
+ .{ .cp = 0x6f, .bits = 0x003e6363633e0000 }, // o
+ .{ .cp = 0x70, .bits = 0x0f063e66663b0000 }, // p
+ .{ .cp = 0x71, .bits = 0x78303e33336e0000 }, // q
+ .{ .cp = 0x72, .bits = 0x000f06066e3b0000 }, // r
+ .{ .cp = 0x73, .bits = 0x003f603e037e0000 }, // s
+ .{ .cp = 0x74, .bits = 0x00386c0c0c3f0c0c }, // t
+ .{ .cp = 0x75, .bits = 0x006e333333330000 }, // u
+ .{ .cp = 0x76, .bits = 0x001c366363630000 }, // v
+ .{ .cp = 0x77, .bits = 0x00367f6b6b630000 }, // w
+ .{ .cp = 0x78, .bits = 0x0063361c36630000 }, // x
+ .{ .cp = 0x79, .bits = 0x3f607e6363630000 }, // y
+ .{ .cp = 0x7a, .bits = 0x007e4c18327e0000 }, // z
+ .{ .cp = 0x7b, .bits = 0x007018180e181870 }, // {
+ .{ .cp = 0x7c, .bits = 0x0018181818181818 }, // |
+ .{ .cp = 0x7d, .bits = 0x000e18187018180e }, // }
+ .{ .cp = 0x7e, .bits = 0x0000000000003b6e }, // ~
+};
+
+// the block glyph table (sextants + half/quadrant blocks + line glyphs), built at
+// comptime, and the same set extended with the ASCII glyphs. `render(ascii=…)` picks.
+const glyphs = blk: {
+ @setEvalBranchQuota(100000);
+ var list: [64 + block_glyphs.len + line_glyphs.len]Glyph = undefined;
+ var p: usize = 0;
+ while (p < 64) : (p += 1) list[p] = .{ .cp = sextantCp(@intCast(p)), .bits = sextantBits(@intCast(p)) };
+ for (block_glyphs, 0..) |bg, i| list[64 + i] = bg;
+ for (line_glyphs, 0..) |lg, i| list[64 + block_glyphs.len + i] = lg;
+ break :blk list;
+};
+const glyphs_ascii = glyphs ++ ascii_glyphs;
+
+// match one 8x8 RGB cell to the best (glyph, fg, bg). Candidate colors are the
+// most-common palette colors among the 64 pixels; for each ordered pair the glyph
+// cost is base(all-bg) + sum over the glyph's ink bits of (dist_fg - dist_bg).
+fn matchCell(cell: *const [64][3]u8, pal: Palette, gset: []const Glyph) Cell {
+ var counts = [_]u16{0} ** 16;
+ for (cell) |px| counts[nearest(px, pal)] += 1;
+ var cand: [4]u4 = undefined;
+ var ncand: usize = 0;
+ var used = [_]bool{false} ** 16;
+ while (ncand < 4) : (ncand += 1) {
+ var best: ?usize = null;
+ for (counts, 0..) |c, i| {
+ if (used[i] or c == 0) continue;
+ if (best == null or c > counts[best.?]) best = i;
+ }
+ if (best) |bi| {
+ cand[ncand] = @intCast(bi);
+ used[bi] = true;
+ } else break;
+ }
+ if (ncand == 0) return .{}; // can't happen (64 pixels), but keep it total
+ if (ncand == 1) return encode(' ', cand[0], cand[0]); // solid color
+
+ var best_cost: i64 = std.math.maxInt(i64);
+ var best = encode(' ', cand[0], cand[0]);
+ var fi: usize = 0;
+ while (fi < ncand) : (fi += 1) {
+ var bi: usize = 0;
+ while (bi < ncand) : (bi += 1) {
+ if (fi == bi) continue;
+ const fg = cand[fi];
+ const bg = cand[bi];
+ var dfg: [64]u32 = undefined;
+ var dbg: [64]u32 = undefined;
+ var base: i64 = 0;
+ for (cell, 0..) |px, p| {
+ dfg[p] = dist2(px, pal[fg]);
+ dbg[p] = dist2(px, pal[bg]);
+ base += dbg[p];
+ }
+ for (gset) |g| {
+ var delta: i64 = 0;
+ var bits = g.bits;
+ while (bits != 0) : (bits &= bits - 1) {
+ const p: usize = @ctz(bits);
+ delta += @as(i64, dfg[p]) - @as(i64, dbg[p]);
+ }
+ const cost = base + delta;
+ if (cost < best_cost) {
+ best_cost = cost;
+ best = encode(g.cp, fg, bg);
+ }
+ }
+ }
+ }
+ return best;
+}
+
+fn encode(cp: u21, fg: u4, bg: u4) Cell {
+ var c = Cell{ .fg = fg, .bg = bg };
+ const n = std.unicode.utf8Encode(cp, &c.glyph) catch 1;
+ c.glen = @intCast(n);
+ return c;
+}
+
+// Render `rgba` (iw x ih, 4 bytes/px) into a grid of at most cols x rows cells,
+// preserving the image aspect with the terminal cell aspect (~1:2) corrected.
+// Caller owns Grid.cells (gpa).
+pub fn render(gpa: std.mem.Allocator, rgba: []const u8, iw: usize, ih: usize, cols: usize, rows_: usize, pal: Palette, ascii: bool) !Grid {
+ if (iw == 0 or ih == 0 or cols == 0 or rows_ == 0) return .{ .cells = try gpa.alloc(Cell, 0), .gw = 0, .gh = 0 };
+ const gset: []const Glyph = if (ascii) &glyphs_ascii else &glyphs;
+ // contain-fit; cells are ~twice as tall as wide, so a row spans 2 width-units.
+ var gw = cols;
+ var gh = (cols * ih) / (2 * iw);
+ if (gh > rows_) {
+ gh = rows_;
+ gw = (rows_ * 2 * iw) / ih;
+ }
+ gw = std.math.clamp(gw, 1, cols);
+ gh = std.math.clamp(gh, 1, rows_);
+
+ const cells = try gpa.alloc(Cell, gw * gh);
+ var cy: usize = 0;
+ while (cy < gh) : (cy += 1) {
+ const ry0 = cy * ih / gh;
+ const ry1 = @max(ry0 + 1, (cy + 1) * ih / gh);
+ var cx: usize = 0;
+ while (cx < gw) : (cx += 1) {
+ const rx0 = cx * iw / gw;
+ const rx1 = @max(rx0 + 1, (cx + 1) * iw / gw);
+ var cell: [64][3]u8 = undefined;
+ var sy: usize = 0;
+ while (sy < 8) : (sy += 1) {
+ const py0 = ry0 + sy * (ry1 - ry0) / 8;
+ const py1 = @max(py0 + 1, ry0 + (sy + 1) * (ry1 - ry0) / 8);
+ var sx: usize = 0;
+ while (sx < 8) : (sx += 1) {
+ const px0 = rx0 + sx * (rx1 - rx0) / 8;
+ const px1 = @max(px0 + 1, rx0 + (sx + 1) * (rx1 - rx0) / 8);
+ var rs: usize = 0;
+ var gs: usize = 0;
+ var bs: usize = 0;
+ var n: usize = 0;
+ var yy = py0;
+ while (yy < py1 and yy < ih) : (yy += 1) {
+ var xx = px0;
+ while (xx < px1 and xx < iw) : (xx += 1) {
+ const i = (yy * iw + xx) * 4;
+ rs += rgba[i];
+ gs += rgba[i + 1];
+ bs += rgba[i + 2];
+ n += 1;
+ }
+ }
+ if (n == 0) n = 1;
+ cell[sy * 8 + sx] = .{ @intCast(rs / n), @intCast(gs / n), @intCast(bs / n) };
+ }
+ }
+ cells[cy * gw + cx] = matchCell(&cell, pal, gset);
+ }
+ }
+ return .{ .cells = cells, .gw = gw, .gh = gh };
+}
+
+test "sextant codepoints: blocks + endpoints" {
+ try std.testing.expectEqual(@as(u21, ' '), sextantCp(0));
+ try std.testing.expectEqual(@as(u21, 0x2588), sextantCp(63));
+ try std.testing.expectEqual(@as(u21, 0x258C), sextantCp(21));
+ try std.testing.expectEqual(@as(u21, 0x2590), sextantCp(42));
+ try std.testing.expectEqual(@as(u21, 0x1FB00), sextantCp(1)); // first sextant
+ try std.testing.expectEqual(@as(u21, 0x1FB3B), sextantCp(62)); // last sextant
+}
+
+test "matchCell: solid color -> space on that bg" {
+ var cell: [64][3]u8 = undefined;
+ for (&cell) |*p| p.* = commodore[5]; // all green
+ const m = matchCell(&cell, commodore, &glyphs);
+ try std.testing.expectEqual(@as(u4, 5), m.bg);
+ try std.testing.expectEqual(@as(u8, ' '), m.glyph[0]);
+}
+
+test "matchCell: clean top/bottom split picks the two colors" {
+ var cell: [64][3]u8 = undefined;
+ for (0..64) |p| cell[p] = if (p < 32) commodore[1] else commodore[6]; // white over blue
+ const m = matchCell(&cell, commodore, &glyphs);
+ // both palette colors must be chosen (in some fg/bg order)
+ const a = @as(u4, @min(m.fg, m.bg));
+ const b = @as(u4, @max(m.fg, m.bg));
+ try std.testing.expectEqual(@as(u4, 1), a);
+ try std.testing.expectEqual(@as(u4, 6), b);
+ // a clean top/bottom split must resolve to a real block glyph (the top-4-rows
+ // half block ▀), never a blank cell.
+ const cp = std.unicode.utf8Decode(m.glyph[0..m.glen]) catch 0;
+ try std.testing.expectEqual(@as(u21, 0x2580), cp);
+}
+
+test "ascii option only enables the ascii glyphs" {
+ // the ascii glyph codepoints must be reachable exactly when ascii is on.
+ var seen_block = false;
+ var seen_ascii = false;
+ for (glyphs) |g| if (g.cp == '#') {
+ seen_block = true;
+ };
+ for (glyphs_ascii) |g| if (g.cp == '#') {
+ seen_ascii = true;
+ };
+ try std.testing.expect(!seen_block); // '#' is an ascii-only glyph
+ try std.testing.expect(seen_ascii);
+ try std.testing.expectEqual(glyphs.len + ascii_glyphs.len, glyphs_ascii.len);
+}
+
+test "ascii glyph is chosen when a cell has its exact shape" {
+ // a cell shaped exactly like the font 'S' (ink=white on black) must match 'S'
+ // with ascii on (cost 0), and fall back to some block glyph with ascii off.
+ const s_bits: u64 = 0x003c6630180c663c;
+ var cell: [64][3]u8 = undefined;
+ for (0..64) |p| cell[p] = if ((s_bits >> @intCast(p)) & 1 != 0) commodore[1] else commodore[0];
+ const on = matchCell(&cell, commodore, &glyphs_ascii);
+ try std.testing.expectEqual(@as(u21, 'S'), std.unicode.utf8Decode(on.glyph[0..on.glen]) catch 0);
+ const off = matchCell(&cell, commodore, &glyphs);
+ try std.testing.expect((std.unicode.utf8Decode(off.glyph[0..off.glen]) catch 0) != 'S');
+}
+
+test "render: tiny image produces a grid within bounds" {
+ const a = std.testing.allocator;
+ // 2x2 checker, RGBA
+ var img = [_]u8{0} ** (2 * 2 * 4);
+ img[0] = 255; img[1] = 255; img[2] = 255; img[3] = 255; // (0,0) white
+ img[(3) * 4 + 0] = 255; img[(3) * 4 + 1] = 255; img[(3) * 4 + 2] = 255; img[(3) * 4 + 3] = 255; // (1,1) white
+ const g = try render(a, &img, 2, 2, 10, 10, commodore, true);
+ defer a.free(g.cells);
+ try std.testing.expect(g.gw >= 1 and g.gw <= 10);
+ try std.testing.expect(g.gh >= 1 and g.gh <= 10);
+ try std.testing.expectEqual(g.gw * g.gh, g.cells.len);
+}
diff --git a/term.zig b/term.zig
index 9ef6ff02..13b4ff85 100644
--- a/term.zig
+++ b/term.zig
@@ -164,8 +164,9 @@ pub fn deinit(self: *Term, io: std.Io, gpa: std.mem.Allocator) void {
for (f.redo.items) |s| gpa.free(s);
f.undo.deinit(gpa);
f.redo.deinit(gpa);
- } else if (self.image) |iv| {
+ } else if (self.image) |*iv| {
gpa.free(iv.path);
+ image_mod.deinitView(iv, gpa);
} else {
_ = self.reader.cancel(io) catch {};
self.pty.close(io);
diff --git a/tests.zig b/tests.zig
index bd44e2ea..1af5625d 100644
--- a/tests.zig
+++ b/tests.zig
@@ -392,4 +392,47 @@ pub fn main(init: std.process.Init) !void {
try hs4.send("\t"); // Tab: execute the SELECTION (not the word under cursor)
try hs4.expectWaitFor("ZZSEL5X", 8000, "Tab on a selection did not execute the selected text");
}
+
+ // 15. Image panes open even without kitty graphics, rendering via the PETSCII
+ // fallback, and carry a "Petscii" toggle in their tagline. Write a small PPM,
+ // open it with an acme right-click, confirm the image pane + toggle appear,
+ // and pump frames (smoke-testing the glyph draw doesn't crash).
+ {
+ var hs5 = try Harness.init(gpa, exe.ptr, 24, 80);
+ defer hs5.deinit();
+ try hs5.expectWaitFor("build.zig", 8000, "fresh terminal never auto-ls'd");
+ {
+ var ppm: std.ArrayList(u8) = .empty;
+ defer ppm.deinit(gpa);
+ try ppm.appendSlice(gpa, "P6\n64 64\n255\n"); // diagonal split -> per-cell glyphs
+ var y: usize = 0;
+ while (y < 64) : (y += 1) {
+ var x: usize = 0;
+ while (x < 64) : (x += 1)
+ try ppm.appendSlice(gpa, if (x + y < 64) &[_]u8{ 255, 255, 255 } else &[_]u8{ 0, 0, 255 });
+ }
+ try h.writeFile("/tmp/pardesE2Epet.ppm", ppm.items);
+ }
+ try hs5.send("\x02"); // normal -> tty
+ try hs5.pump(500);
+ try hs5.send("clear\r"); // blank the pane
+ try hs5.pump(900);
+ try hs5.send("\x02"); // tty -> normal
+ try hs5.pump(400);
+ try hs5.send("\x1b[<0;5;8M\x1b[<0;5;8m"); // pin a blank row near col 0
+ try hs5.pump(400);
+ try hs5.send("i");
+ try hs5.pump(300);
+ try hs5.send("/tmp/pardesE2Epet.ppm");
+ try hs5.pump(500);
+ try hs5.send("\x1b"); // -> normal
+ try hs5.pump(400);
+ try hs5.send("\x1b[<2;10;8M\x1b[<2;10;8m"); // right-click the typed path -> open image pane
+ try hs5.expectWaitFor("Petscii", 10000, "right-click on an image path did not open an image pane with the Petscii toggle");
+ try hs5.pump(800); // let the petscii grid render across frames
+ // the diagonal split renders as real block glyphs — assert the full block
+ // shows (a blank body here would mean the grid didn't blit, e.g. a dangling
+ // grapheme slice into a stack-local cell).
+ try hs5.expectContains("█", "petscii image body is blank — no block glyphs rendered");
+ }
}