//! Image decoding, pixel placement, and glyph approximation. const std = @import("std"); const zstbi = @import("zstbi"); /// The 16-colour ANSI table below is the only thing this file ever wanted from /// the emulator, and a build without one still renders images — see /// `pardes.terminal_panes`. const terminal_panes = @import("pardes.zig").terminal_panes; const ghostty_vt = if (terminal_panes) @import("ghostty-vt") else struct {}; const pdf_enabled = @import("pardes_config").mupdf; const pardes = @import("pardes.zig"); const config = @import("config.zig"); const filesystem = @import("fs.zig"); const dump = @import("dump.zig"); // Glyph matching adapted from caioluders/petsciinator. pub const GlyphArt = struct { 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; pub const Cell = struct { glyph: [4]u8 = .{ ' ', 0, 0, 0 }, glyph_len: u3 = 1, fg: u4 = 1, bg: u4 = 0, }; pub const Grid = struct { cells: []Cell, cols: usize, rows: 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 }; 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 glyphMask(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 = glyphMask(.{ 0xff, 0xff, 0xff, 0xff, 0, 0, 0, 0 }) }, // ▀ top half .{ .cp = 0x2584, .bits = glyphMask(.{ 0, 0, 0, 0, 0xff, 0xff, 0xff, 0xff }) }, // ▄ bottom half .{ .cp = 0x2598, .bits = glyphMask(.{ 0x0f, 0x0f, 0x0f, 0x0f, 0, 0, 0, 0 }) }, // ▘ TL .{ .cp = 0x259d, .bits = glyphMask(.{ 0xf0, 0xf0, 0xf0, 0xf0, 0, 0, 0, 0 }) }, // ▝ TR .{ .cp = 0x2596, .bits = glyphMask(.{ 0, 0, 0, 0, 0x0f, 0x0f, 0x0f, 0x0f }) }, // ▖ BL .{ .cp = 0x2597, .bits = glyphMask(.{ 0, 0, 0, 0, 0xf0, 0xf0, 0xf0, 0xf0 }) }, // ▗ BR .{ .cp = 0x259a, .bits = glyphMask(.{ 0x0f, 0x0f, 0x0f, 0x0f, 0xf0, 0xf0, 0xf0, 0xf0 }) }, // ▚ TL+BR .{ .cp = 0x259e, .bits = glyphMask(.{ 0xf0, 0xf0, 0xf0, 0xf0, 0x0f, 0x0f, 0x0f, 0x0f }) }, // ▞ TR+BL .{ .cp = 0x259b, .bits = glyphMask(.{ 0xff, 0xff, 0xff, 0xff, 0x0f, 0x0f, 0x0f, 0x0f }) }, // ▛ ¬BR .{ .cp = 0x259c, .bits = glyphMask(.{ 0xff, 0xff, 0xff, 0xff, 0xf0, 0xf0, 0xf0, 0xf0 }) }, // ▜ ¬BL .{ .cp = 0x2599, .bits = glyphMask(.{ 0x0f, 0x0f, 0x0f, 0x0f, 0xff, 0xff, 0xff, 0xff }) }, // ▙ ¬TR .{ .cp = 0x259f, .bits = glyphMask(.{ 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 = glyphMask(.{ 0, 0, 0, 0xff, 0xff, 0, 0, 0 }) }, // ─ .{ .cp = 0x2502, .bits = glyphMask(.{ 0x18, 0x18, 0x18, 0x18, 0x18, 0x18, 0x18, 0x18 }) }, // │ .{ .cp = 0x253c, .bits = glyphMask(.{ 0x18, 0x18, 0x18, 0xff, 0xff, 0x18, 0x18, 0x18 }) }, // ┼ .{ .cp = 0x2572, .bits = glyphMask(.{ 0x01, 0x02, 0x04, 0x08, 0x10, 0x20, 0x40, 0x80 }) }, // ╲ .{ .cp = 0x2571, .bits = glyphMask(.{ 0x80, 0x40, 0x20, 0x10, 0x08, 0x04, 0x02, 0x01 }) }, // ╱ .{ .cp = 0x2573, .bits = glyphMask(.{ 0x81, 0x42, 0x24, 0x18, 0x18, 0x24, 0x42, 0x81 }) }, // ╳ }; 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; 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.glyph_len = @intCast(n); return c; } pub fn render(gpa: std.mem.Allocator, rgba: []const u8, iw: usize, ih: usize, max_cols: usize, max_rows: usize, pal: Palette, ascii: bool) !Grid { if (iw == 0 or ih == 0 or max_cols == 0 or max_rows == 0) return .{ .cells = try gpa.alloc(Cell, 0), .cols = 0, .rows = 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 cols = max_cols; var rows = (max_cols * ih) / (2 * iw); if (rows > max_rows) { rows = max_rows; cols = (max_rows * 2 * iw) / ih; } cols = std.math.clamp(cols, 1, max_cols); rows = std.math.clamp(rows, 1, max_rows); const cells = try gpa.alloc(Cell, cols * rows); var cy: usize = 0; while (cy < rows) : (cy += 1) { const ry0 = cy * ih / rows; const ry1 = @max(ry0 + 1, (cy + 1) * ih / rows); var cx: usize = 0; while (cx < cols) : (cx += 1) { const rx0 = cx * iw / cols; const rx1 = @max(rx0 + 1, (cx + 1) * iw / cols); 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 * cols + cx] = matchCell(&cell, pal, gset); } } return .{ .cells = cells, .cols = cols, .rows = rows }; } 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.glyph_len]) 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.glyph_len]) catch 0); const off = matchCell(&cell, commodore, &glyphs); try std.testing.expect((std.unicode.utf8Decode(off.glyph[0..off.glyph_len]) 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.cols >= 1 and g.cols <= 10); try std.testing.expect(g.rows >= 1 and g.rows <= 10); try std.testing.expectEqual(g.cols * g.rows, g.cells.len); } }; pub const terminal_palette = [16][3]u8{ .{ 0x1D, 0x1F, 0x21 }, // black .{ 0xCC, 0x66, 0x66 }, // red .{ 0xB5, 0xBD, 0x68 }, // green .{ 0xF0, 0xC6, 0x74 }, // yellow .{ 0x81, 0xA2, 0xBE }, // blue .{ 0xB2, 0x94, 0xBB }, // magenta .{ 0x8A, 0xBE, 0xB7 }, // cyan .{ 0xC5, 0xC8, 0xC6 }, // white .{ 0x66, 0x66, 0x66 }, // bright black .{ 0xD5, 0x4E, 0x53 }, // bright red .{ 0xB9, 0xCA, 0x4A }, // bright green .{ 0xE7, 0xC5, 0x47 }, // bright yellow .{ 0x7A, 0xA6, 0xDA }, // bright blue .{ 0xC3, 0x97, 0xD8 }, // bright magenta .{ 0x70, 0xC0, 0xB1 }, // bright cyan .{ 0xEA, 0xEA, 0xEA }, // bright white }; comptime { if (terminal_panes) for (terminal_palette, 0..) |rgb, i| { const c = ghostty_vt.color.default[i]; if (rgb[0] != c.r or rgb[1] != c.g or rgb[2] != c.b) @compileError( "terminal palette differs from the emulator", ); }; } /// cap the longest side before keeping/transmitting: a pane is at most a /// screenful of cells, multi-thousand-pixel photos waste decode/transmit time const MAX_DIM: u32 = 1280; pub const PaletteMode = enum { commodore, terminal }; /// How a native pixel attachment is placed in its pane body. `contain` is the /// historical image-pane policy: shrink only when necessary and keep the /// image at the top left. The axis-specific modes may enlarge, letterbox the /// unconstrained axis, or crop it when it overflows. pub const NativeFit = if (pdf_enabled) enum { contain, width, height } else enum { contain }; pub const PixelRect = struct { x: u32 = 0, y: u32 = 0, w: u32 = 0, h: u32 = 0, }; pub const PixelPoint = struct { x: u32, y: u32 }; /// Source pixels and their destination inside a pane body's pixel rectangle. /// Both rectangles are half-open. Rounding is always toward the inside, so a /// backend can draw this result without a backend-specific overflow clip. pub const NativeGeometry = struct { src: PixelRect, dst: PixelRect, /// Map a destination pixel back into the retained source crop. This is the /// same integer transform the texture rectangle describes; points in a /// letterbox return null. pub fn sourcePoint(self: NativeGeometry, x: u32, y: u32) ?PixelPoint { if (self.src.w == 0 or self.src.h == 0 or self.dst.w == 0 or self.dst.h == 0) return null; if (x < self.dst.x or y < self.dst.y) return null; const dx = x - self.dst.x; const dy = y - self.dst.y; if (dx >= self.dst.w or dy >= self.dst.h) return null; return .{ .x = self.src.x + @as(u32, @intCast(@as(u64, dx) * self.src.w / self.dst.w)), .y = self.src.y + @as(u32, @intCast(@as(u64, dy) * self.src.h / self.dst.h)), }; } }; fn panOffset(overflow: u32, pan: u16) u32 { if (overflow == 0 or pan == 0) return 0; if (pan == std.math.maxInt(u16)) return overflow; // Round, rather than truncate, so the midpoint is the visual midpoint. return @intCast((@as(u64, overflow) * pan + std.math.maxInt(u16) / 2) / std.math.maxInt(u16)); } fn fitWidth(src_w: u32, src_h: u32, body_w: u32, body_h: u32, pan_y: u16) ?NativeGeometry { const scaled_h = @as(u64, src_h) * body_w; if (scaled_h <= @as(u64, body_h) * src_w) { const full_h: u32 = @intCast(@max(1, scaled_h / src_w)); return .{ .src = .{ .w = src_w, .h = src_h }, .dst = .{ .y = (body_h - full_h) / 2, .w = body_w, .h = full_h }, }; } // Retain the largest whole source-pixel crop which cannot extend beyond // the body at this scale. The sub-pixel remainder becomes at most a small // centered letterbox instead of leaking into an adjacent cell in Kitty. const crop_h_wide = @as(u64, body_h) * src_w / body_w; if (crop_h_wide == 0) return null; const crop_h: u32 = @min(src_h, @as(u32, @intCast(crop_h_wide))); const dst_h: u32 = @min(body_h, @max(1, @as(u32, @intCast(@as(u64, crop_h) * body_w / src_w)))); return .{ .src = .{ .y = panOffset(src_h - crop_h, pan_y), .w = src_w, .h = crop_h, }, .dst = .{ .y = (body_h - dst_h) / 2, .w = body_w, .h = dst_h }, }; } fn fitHeight(src_w: u32, src_h: u32, body_w: u32, body_h: u32, pan_x: u16) ?NativeGeometry { const scaled_w = @as(u64, src_w) * body_h; if (scaled_w <= @as(u64, body_w) * src_h) { const full_w: u32 = @intCast(@max(1, scaled_w / src_h)); return .{ .src = .{ .w = src_w, .h = src_h }, .dst = .{ .x = (body_w - full_w) / 2, .w = full_w, .h = body_h }, }; } const crop_w_wide = @as(u64, body_w) * src_h / body_h; if (crop_w_wide == 0) return null; const crop_w: u32 = @min(src_w, @as(u32, @intCast(crop_w_wide))); const dst_w: u32 = @min(body_w, @max(1, @as(u32, @intCast(@as(u64, crop_w) * body_h / src_h)))); return .{ .src = .{ .x = panOffset(src_w - crop_w, pan_x), .w = crop_w, .h = src_h, }, .dst = .{ .x = (body_w - dst_w) / 2, .w = dst_w, .h = body_h }, }; } /// Compute native image placement without floating point. Source dimensions /// are limited to u32 because SDL's texture API and Kitty's crop parameters /// cannot describe anything larger. Kitty applies the stricter u16 check at /// its protocol boundary. pub fn nativeGeometry( iw: usize, ih: usize, body_pixel_w: u32, body_pixel_h: u32, fit: NativeFit, pan_x: u16, pan_y: u16, ) ?NativeGeometry { if (comptime !pdf_enabled) { return containGeometry(iw, ih, body_pixel_w, body_pixel_h); } if (iw == 0 or ih == 0 or body_pixel_w == 0 or body_pixel_h == 0 or iw > std.math.maxInt(u32) or ih > std.math.maxInt(u32)) return null; const src_w: u32 = @intCast(iw); const src_h: u32 = @intCast(ih); return switch (fit) { .width => fitWidth(src_w, src_h, body_pixel_w, body_pixel_h, pan_y), .height => fitHeight(src_w, src_h, body_pixel_w, body_pixel_h, pan_x), .contain => blk: { if (src_w <= body_pixel_w and src_h <= body_pixel_h) break :blk .{ .src = .{ .w = src_w, .h = src_h }, .dst = .{ .w = src_w, .h = src_h }, }; // Compare scale ratios without floating point. The tighter bound // is exact; the other is rounded inward. Contain intentionally // ignores pan and keeps the historical top-left placement. const dst: PixelRect = if (@as(u64, body_pixel_w) * src_h <= @as(u64, body_pixel_h) * src_w) .{ .w = body_pixel_w, .h = @intCast(@max(1, @as(u64, src_h) * body_pixel_w / src_w)), } else .{ .w = @intCast(@max(1, @as(u64, src_w) * body_pixel_h / src_h)), .h = body_pixel_h, }; break :blk .{ .src = .{ .w = src_w, .h = src_h }, .dst = dst }; }, }; } /// The ordinary image-pane policy, separate from the PDF-only fit/pan /// machinery so feature-off backends retain their original contain-only path. pub fn containGeometry(iw: usize, ih: usize, max_w: u32, max_h: u32) ?NativeGeometry { if (iw == 0 or ih == 0 or max_w == 0 or max_h == 0 or iw > std.math.maxInt(u32) or ih > std.math.maxInt(u32)) return null; const src_w: u32 = @intCast(iw); const src_h: u32 = @intCast(ih); if (src_w <= max_w and src_h <= max_h) return .{ .src = .{ .w = src_w, .h = src_h }, .dst = .{ .w = src_w, .h = src_h }, }; const dst: PixelRect = if (@as(u64, max_w) * src_h <= @as(u64, max_h) * src_w) .{ .w = max_w, .h = @intCast(@max(1, @as(u64, src_h) * max_w / src_w)), } else .{ .w = @intCast(@max(1, @as(u64, src_w) * max_h / src_h)), .h = max_h, }; return .{ .src = .{ .w = src_w, .h = src_h }, .dst = dst }; } /// Compatibility helper for callers which need only the old contain size. pub const Fit = struct { w: u32, h: u32 }; pub fn contain(iw: usize, ih: usize, max_w: u32, max_h: u32) Fit { const geometry = containGeometry(iw, ih, max_w, max_h) orelse return .{ .w = 0, .h = 0 }; return .{ .w = geometry.dst.w, .h = geometry.dst.h }; } test "native image contain keeps aspect, bounds, top-left, and small-image size" { try std.testing.expectEqual(Fit{ .w = 40, .h = 20 }, contain(400, 200, 40, 40)); try std.testing.expectEqual(Fit{ .w = 20, .h = 40 }, contain(200, 400, 40, 40)); try std.testing.expectEqual(Fit{ .w = 17, .h = 9 }, contain(17, 9, 40, 40)); try std.testing.expectEqual(Fit{ .w = 0, .h = 0 }, contain(17, 9, 0, 40)); const odd = containGeometry(403, 211, 101, 47).?; try std.testing.expectEqual(PixelRect{ .w = 89, .h = 47 }, odd.dst); try std.testing.expectEqual(PixelRect{ .w = 403, .h = 211 }, odd.src); } test "native fit width handles portrait crop, pan extrema, and landscape letterbox" { if (comptime !pdf_enabled) return; const top = nativeGeometry(600, 1000, 800, 600, .width, 0, 0).?; try std.testing.expectEqual(PixelRect{ .w = 600, .h = 450 }, top.src); try std.testing.expectEqual(PixelRect{ .w = 800, .h = 600 }, top.dst); const middle = nativeGeometry(600, 1000, 800, 600, .width, 0, 32768).?; try std.testing.expectEqual(@as(u32, 275), middle.src.y); const bottom = nativeGeometry(600, 1000, 800, 600, .width, 65535, 65535).?; try std.testing.expectEqual(@as(u32, 550), bottom.src.y); const landscape = nativeGeometry(1000, 600, 800, 600, .width, 0, 65535).?; try std.testing.expectEqual(PixelRect{ .w = 1000, .h = 600 }, landscape.src); try std.testing.expectEqual(PixelRect{ .y = 60, .w = 800, .h = 480 }, landscape.dst); } test "native fit height handles portrait letterbox and landscape crop" { if (comptime !pdf_enabled) return; const portrait = nativeGeometry(600, 1000, 800, 600, .height, 65535, 0).?; try std.testing.expectEqual(PixelRect{ .w = 600, .h = 1000 }, portrait.src); try std.testing.expectEqual(PixelRect{ .x = 220, .w = 360, .h = 600 }, portrait.dst); const left = nativeGeometry(1000, 600, 800, 600, .height, 0, 0).?; try std.testing.expectEqual(PixelRect{ .w = 800, .h = 600 }, left.src); try std.testing.expectEqual(PixelRect{ .w = 800, .h = 600 }, left.dst); const right = nativeGeometry(1000, 600, 800, 600, .height, 65535, 0).?; try std.testing.expectEqual(@as(u32, 200), right.src.x); } test "native fit odd ratios round inward and map points through letterboxes and crops" { if (comptime !pdf_enabled) return; const width = nativeGeometry(403, 211, 101, 47, .width, 0, 32768).?; try std.testing.expectEqual(PixelRect{ .y = 12, .w = 403, .h = 187 }, width.src); try std.testing.expectEqual(PixelRect{ .w = 101, .h = 46 }, width.dst); const mapped = width.sourcePoint(100, 45).?; try std.testing.expect(mapped.x < width.src.x + width.src.w); try std.testing.expect(mapped.y < width.src.y + width.src.h); try std.testing.expect(width.sourcePoint(101, 46) == null); const height = nativeGeometry(403, 211, 101, 47, .height, 32768, 0).?; try std.testing.expectEqual(PixelRect{ .w = 403, .h = 211 }, height.src); try std.testing.expectEqual(PixelRect{ .x = 6, .w = 89, .h = 47 }, height.dst); try std.testing.expect(height.sourcePoint(5, 20) == null); try std.testing.expect(height.sourcePoint(6, 20) != null); } test "native geometry rejects dimensions which its backend contract cannot represent" { try std.testing.expect(containGeometry(0, 10, 20, 20) == null); if (@bitSizeOf(usize) > 32) try std.testing.expect(containGeometry(@as(usize, std.math.maxInt(u32)) + 1, 10, 20, 20) == null); if (comptime pdf_enabled) { try std.testing.expect(nativeGeometry(10, 10, 0, 20, .width, 0, 0) == null); if (@bitSizeOf(usize) > 32) try std.testing.expect(nativeGeometry(@as(usize, std.math.maxInt(u32)) + 1, 10, 20, 20, .height, 0, 0) == null); } } test "native geometry is total at extreme accepted aspect ratios" { if (comptime !pdf_enabled) return; const largest = std.math.maxInt(u32); try std.testing.expect(nativeGeometry(1, largest, largest, 1, .width, 0, 65535) == null); try std.testing.expect(nativeGeometry(largest, 1, 1, largest, .height, 65535, 0) == null); const wide = nativeGeometry(largest, 1, largest, 1, .width, 0, 0).?; try std.testing.expectEqual(PixelRect{ .w = largest, .h = 1 }, wide.dst); const tall = nativeGeometry(1, largest, 1, largest, .height, 0, 0).?; try std.testing.expectEqual(PixelRect{ .w = 1, .h = largest }, tall.dst); } /// call once at startup / exit (stb_image's allocator shim) pub fn start(io: std.Io, gpa: std.mem.Allocator) void { zstbi.init(io, gpa); started = true; } pub fn stop() void { started = false; zstbi.deinit(); } /// stb_image has its allocator (`start`): before, nothing of it may run. var started = false; extern fn stbi_info_from_memory(buffer: [*]const u8, len: c_int, x: *c_int, y: *c_int, comp: *c_int) c_int; /// Whether `bytes` are an image `decode` can read, from its header alone. pub fn readable(bytes: []const u8) bool { if (bytes.len == 0 or bytes.len > std.math.maxInt(c_int)) return false; var w: c_int = 0; var h: c_int = 0; var c: c_int = 0; if (!started) return true; // ponytail: no stb_image yet (a unit test), so no judging return stbi_info_from_memory(bytes.ptr, @intCast(bytes.len), &w, &h, &c) == 1; } /// decode + downscale to RGBA, gpa-owned. Returns null on any failure — the /// pane then simply shows a blank body. pub fn decode(gpa: std.mem.Allocator, bytes: []const u8) ?struct { rgba: []u8, w: usize, h: usize } { if (bytes.len == 0) return null; var img = zstbi.Image.loadFromMemory(bytes, 4) catch return null; // 4 = RGBA defer img.deinit(); 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; const rgba = gpa.dupe(u8, src.data) catch return null; return .{ .rgba = rgba, .w = src.width, .h = src.height }; } // ---- the image pane ---- const GridKey = struct { cols: u16 = 0, rows: u16 = 0, palette: PaletteMode = .commodore, ascii: bool = true, }; pub const State = struct { path: []u8, glyph_art: bool = false, pmode: PaletteMode = .commodore, ascii: bool = true, tried: bool = false, rgba: []u8 = &.{}, iw: usize = 0, ih: usize = 0, /// Dump-loaded bytes, decoded lazily by the same path as a disk image. raw: []u8 = &.{}, grid: GlyphArt.Grid = .{ .cells = &.{}, .cols = 0, .rows = 0 }, grid_key: GridKey = .{}, pub fn deinit(state: *State, gpa: std.mem.Allocator) void { gpa.free(state.path); if (state.rgba.len > 0) gpa.free(state.rgba); if (state.raw.len > 0) gpa.free(state.raw); if (state.grid.cells.len > 0) gpa.free(state.grid.cells); state.* = undefined; } }; /// Construct an image pane from a path and optionally transferred dump bytes. /// `raw` must be image_gpa-owned and ownership transfers only on success. /// Without them the file is read here, not at the first draw: a draw /// must not go out into the host (`pardes.turn`), and the path may be a /// mount this editor serves. A file that cannot be read draws blank. pub fn create(p: *pardes.Pardes, id: usize, path: []const u8, raw: []u8) !*pardes.Pane { var bytes = raw; var read_here = false; if (bytes.len == 0) { // The slot stays ours across the read: another request may // make a pane meanwhile. p.reserved_slots[id] = true; defer p.reserved_slots[id] = false; if (filesystem.read(p, path)) |from_disk| { defer p.gpa.free(from_disk); bytes = try p.image_gpa.dupe(u8, from_disk); read_here = true; } else |_| {} } errdefer if (read_here) p.image_gpa.free(bytes); const path_copy = try p.image_gpa.dupe(u8, path); errdefer p.image_gpa.free(path_copy); const pane = try p.newDocPane(id); pane.image = .{ .path = path_copy, .raw = bytes }; return pane; } /// Serialize the binary image record used by images and, for dump-schema /// compatibility, PDFs. Common pane metadata is supplied by the core. pub fn dumpPane( p: *pardes.Pardes, arena: std.mem.Allocator, pane: *const pardes.Pane, tag: []const u8, body: []const u8, scroll: usize, path: []const u8, raw: []const u8, ) !dump.Pane { const bytes = if (raw.len > 0) raw else filesystem.read(p, path) catch &.{}; defer if (raw.len == 0) p.gpa.free(bytes); return .{ .kind = .image, .tag = tag, .body = body, .scroll = scroll, .cols = pane.cols, .rows = pane.rows, .vweight = pane.vweight, .collapsed = pane.collapsed, .image = .{ .path = path, .bytes_b64 = if (bytes.len > 0) try dump.encodeBytes(arena, bytes) else "", .petscii = if (pane.image) |state| state.glyph_art else false, .palette = if (pane.image) |state| switch (state.pmode) { .commodore => .commodore, .terminal => .terminal, } else .commodore, .ascii = if (pane.image) |state| state.ascii else true, }, }; } pub fn restore(p: *pardes.Pardes, id: usize, src: dump.Pane) !*pardes.Pane { const saved = src.image.?; var raw: []u8 = if (saved.bytes_b64.len > 0) try dump.decodeBytes(p.image_gpa, saved.bytes_b64) else &.{}; errdefer if (raw.len > 0) p.image_gpa.free(raw); const pane = try create(p, id, saved.path, raw); raw = &.{}; pane.image.?.glyph_art = saved.petscii; pane.image.?.pmode = switch (saved.palette) { .commodore => .commodore, .terminal => .terminal, }; pane.image.?.ascii = saved.ascii; pane.cols = @max(1, src.cols); pane.rows = @max(1, src.rows); return pane; } pub fn toggleGlyphArt(state: *State) void { state.glyph_art = !state.glyph_art; } pub fn togglePalette(state: *State) void { state.pmode = if (state.pmode == .commodore) .terminal else .commodore; } pub fn toggleAscii(state: *State) void { state.ascii = !state.ascii; } /// Image renderer choices are pane-local, not global Config values. Keep them /// queryable where they apply: the live tag beside the image's path. pub fn tagPrefix(arena: std.mem.Allocator, state: *const State) ![]u8 { return std.fmt.allocPrint( arena, "{s} petscii:{s} palette:{s} ascii:{s} {s}", .{ config.tag_image, if (state.glyph_art) "on" else "off", @tagName(state.pmode), if (state.ascii) "on" else "off", state.path, }, ); } pub fn legacySavedPrefix(state: *const State, saved_tag: []const u8) ?[]const u8 { const lead = config.tag_image ++ " "; if (!std.mem.startsWith(u8, saved_tag, lead)) return null; const path_at = lead.len; if (!std.mem.startsWith(u8, saved_tag[path_at..], state.path)) return null; return saved_tag[0 .. path_at + state.path.len]; } fn ensureDecoded(p: *pardes.Pardes, state: *State) void { if (state.tried) return; state.tried = true; if (state.raw.len == 0) return; // nothing was readable at open if (decode(p.image_gpa, state.raw)) |decoded| { state.rgba = decoded.rgba; state.iw = decoded.w; state.ih = decoded.h; } } fn ensureGrid(p: *pardes.Pardes, state: *State, cols: u16, rows: u16) void { const wanted = GridKey{ .cols = cols, .rows = rows, .palette = state.pmode, .ascii = state.ascii }; if (state.grid.cells.len > 0 and std.meta.eql(state.grid_key, wanted)) return; if (state.grid.cells.len > 0) p.image_gpa.free(state.grid.cells); const palette = switch (state.pmode) { .commodore => GlyphArt.commodore, .terminal => terminal_palette, }; state.grid = GlyphArt.render( p.image_gpa, state.rgba, state.iw, state.ih, cols, rows, palette, state.ascii, ) catch .{ .cells = &.{}, .cols = 0, .rows = 0 }; state.grid_key = wanted; } pub fn draw( p: *pardes.Pardes, s: *pardes.Surface, state: *State, pane_id: u8, serial: u32, x: u16, y: u16, cols: u16, rows: u16, ) void { ensureDecoded(p, state); if (state.rgba.len == 0 or cols == 0 or rows == 0) return; if (!state.glyph_art and p.native_images) { _ = p.appendImagePlace(.{ .pane = pane_id, .serial = serial, .x = x, .y = y, .w = cols, .h = rows, .rgba = state.rgba, .iw = state.iw, .ih = state.ih, }); return; } ensureGrid(p, state, cols, rows); if (state.grid.cells.len == 0) return; const offx = if (cols > state.grid.cols) (@as(usize, cols) - state.grid.cols) / 2 else 0; const offy = if (rows > state.grid.rows) (@as(usize, rows) - state.grid.rows) / 2 else 0; for (0..state.grid.rows) |cy| for (0..state.grid.cols) |cx| { const cell = &state.grid.cells[cy * state.grid.cols + cx]; const fg: pardes.Color = switch (state.pmode) { .commodore => .{ .rgb = GlyphArt.commodore[cell.fg] }, .terminal => .{ .index = cell.fg }, }; const bg: pardes.Color = switch (state.pmode) { .commodore => .{ .rgb = GlyphArt.commodore[cell.bg] }, .terminal => .{ .index = cell.bg }, }; const sx = x + @as(u16, @intCast(offx + cx)); const sy = y + @as(u16, @intCast(offy + cy)); if (sx < s.cols and sy < s.rows) s.set(sx, sy, cell.glyph[0..cell.glyph_len], .{ .fg = fg, .bg = bg }); }; }