diff options
| author | Gabriel Schneider <[email protected]> | 2026-06-30 20:29:43 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-06-30 22:21:44 -0300 |
| commit | f6089186cfe1517073fafc8979a7546ec773d6c1 (patch) | |
| tree | cfb474b5a5cff5d7197aa5084ea6013a0c53df6f | |
| parent | 8b60c2c29c016c7db0fa6ee63769cea1defd9b3c (diff) | |
| download | pardes-full-prototype.tar.gz pardes-full-prototype.zip | |
stealing petsciinatorfull-prototype
| -rw-r--r-- | build.zig | 13 | ||||
| -rw-r--r-- | image.zig | 125 | ||||
| -rw-r--r-- | main.zig | 46 | ||||
| -rw-r--r-- | petscii.zig | 446 | ||||
| -rw-r--r-- | term.zig | 3 | ||||
| -rw-r--r-- | tests.zig | 43 |
6 files changed, 655 insertions, 21 deletions
@@ -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. @@ -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 {}; } @@ -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); +} @@ -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); @@ -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"); + } } |
