zfin/src/term_graphics.zig

160 lines
6.6 KiB
Zig

//! Kitty graphics protocol emission for the plain CLI commands.
//!
//! The TUI renders bitmap charts through vaxis, which speaks the kitty
//! graphics protocol for us. The non-interactive CLI (`zfin history`,
//! `quote`, `projections`) has no vaxis loop, so it emits the protocol
//! directly: this module turns a raw RGB buffer into the APC escape
//! sequence that a kitty-capable terminal renders inline at the cursor.
//!
//! It also centralizes the inline-chart sizing: the on-screen size is
//! expressed in terminal *columns* (the headline projections/quote
//! charts are wider than the history timeline), and the pixel render
//! size is derived from the terminal's cell pixel dimensions.
const std = @import("std");
/// Default cell pixel size assumed when the terminal hasn't reported one.
/// Kitty scales the transmitted image to the requested `c`=cols,`r`=rows
/// footprint, so an imperfect assumption only affects render crispness,
/// not on-screen layout.
pub const default_cell_w: u32 = 8;
pub const default_cell_h: u32 = 16;
/// Inline-chart target widths in terminal columns. The headline charts
/// (projections, quote) are wider than the history timeline; tuned to
/// roughly match-and-exceed the braille chart's on-screen footprint.
pub const history_cols: u16 = 80;
pub const projection_cols: u16 = 120;
pub const quote_cols: u16 = 120;
/// Maximum base64 payload bytes per APC escape, per the kitty protocol.
const chunk_max: usize = 4096;
pub const PixelDims = struct { width: u32, height: u32 };
/// Pixel dimensions for a chart occupying `cols` x `rows` cells.
pub fn pixelDims(cols: u16, rows: u16, cell_w: u32, cell_h: u32) PixelDims {
return .{ .width = @as(u32, cols) * cell_w, .height = @as(u32, rows) * cell_h };
}
/// Rows for a chart `cols` wide that preserve a ~3:1 width:height pixel
/// aspect (matching the braille chart's proportions), given the cell
/// pixel size. Always at least 1.
pub fn rowsForWidth(cols: u16, cell_w: u32, cell_h: u32) u16 {
const width_px = @as(u32, cols) * cell_w;
const height_px = width_px / 3;
const rows = (height_px + cell_h - 1) / cell_h; // round up
return @intCast(@max(rows, @as(u32, 1)));
}
/// Emit `rgb` (length must be `width_px * height_px * 3`) as a kitty
/// graphics image displayed at the cursor, occupying `cols` x `rows`
/// terminal cells. The payload is base64-encoded and split across as
/// many APC escapes as needed (<= 4096 base64 bytes each). `q=2`
/// suppresses the terminal's OK/error replies since the CLI isn't
/// reading them back; `C=1` keeps the terminal from moving the cursor
/// when the image is placed.
///
/// Because of `C=1` the cursor stays at the image's top-left, so callers
/// MUST print `rows` newlines afterward to advance below the chart. This
/// makes the vertical advance deterministic across terminals (rather
/// than relying on each terminal's default cursor-movement policy).
pub fn emitKittyRGB(
writer: *std.Io.Writer,
alloc: std.mem.Allocator,
rgb: []const u8,
width_px: u32,
height_px: u32,
cols: u16,
rows: u16,
) !void {
const Encoder = std.base64.standard.Encoder;
const b64 = try alloc.alloc(u8, Encoder.calcSize(rgb.len));
defer alloc.free(b64);
_ = Encoder.encode(b64, rgb);
var off: usize = 0;
var first = true;
while (true) {
const end = @min(off + chunk_max, b64.len);
const more = end < b64.len;
try writer.writeAll("\x1b_G");
if (first) {
// First escape carries the image metadata + control keys.
try writer.print(
"a=T,q=2,C=1,f=24,s={d},v={d},c={d},r={d},m={d}",
.{ width_px, height_px, cols, rows, @intFromBool(more) },
);
first = false;
} else {
try writer.print("m={d}", .{@intFromBool(more)});
}
try writer.writeAll(";");
try writer.writeAll(b64[off..end]);
try writer.writeAll("\x1b\\");
off = end;
if (!more) break;
}
}
// ── Tests ─────────────────────────────────────────────────────────────
const testing = std.testing;
test "pixelDims multiplies cells by cell size" {
const d = pixelDims(80, 14, 8, 16);
try testing.expectEqual(@as(u32, 640), d.width);
try testing.expectEqual(@as(u32, 224), d.height);
}
test "rowsForWidth keeps a ~3:1 pixel aspect" {
// 8x16 cells: rows ~= cols/6, rounded up.
try testing.expectEqual(@as(u16, 14), rowsForWidth(80, 8, 16)); // 640/3/16 = 13.3 -> 14
try testing.expectEqual(@as(u16, 20), rowsForWidth(120, 8, 16)); // 960/3/16 = 20
// Never zero, even for a tiny width.
try testing.expect(rowsForWidth(1, 8, 16) >= 1);
}
test "emitKittyRGB: single chunk carries control keys + decodable payload" {
const alloc = testing.allocator;
var aw: std.Io.Writer.Allocating = .init(alloc);
defer aw.deinit();
const rgb = [_]u8{ 0xAA, 0xBB, 0xCC, 0x11, 0x22, 0x33 }; // 2x1 RGB
try emitKittyRGB(&aw.writer, alloc, &rgb, 2, 1, 4, 2);
const out = aw.written();
try testing.expect(std.mem.startsWith(u8, out, "\x1b_G"));
try testing.expect(std.mem.endsWith(u8, out, "\x1b\\"));
try testing.expect(std.mem.indexOf(u8, out, "a=T,q=2,C=1,f=24,s=2,v=1,c=4,r=2,m=0") != null);
// Exactly one APC escape (one terminator).
try testing.expectEqual(@as(usize, 1), std.mem.count(u8, out, "\x1b\\"));
// Payload (between ';' and the ST) round-trips back to the input.
const semi = std.mem.indexOfScalar(u8, out, ';').?;
const payload = out[semi + 1 .. out.len - 2];
var decoded: [6]u8 = undefined;
try std.base64.standard.Decoder.decode(&decoded, payload);
try testing.expectEqualSlices(u8, &rgb, &decoded);
}
test "emitKittyRGB: large payload is chunked with m=1 then a final m=0" {
const alloc = testing.allocator;
var aw: std.Io.Writer.Allocating = .init(alloc);
defer aw.deinit();
// 64x64 RGB = 12288 bytes -> base64 16384 bytes -> exactly 4 chunks.
const rgb = try alloc.alloc(u8, 64 * 64 * 3);
defer alloc.free(rgb);
@memset(rgb, 0x7F);
try emitKittyRGB(&aw.writer, alloc, rgb, 64, 64, 10, 5);
const out = aw.written();
try testing.expectEqual(@as(usize, 4), std.mem.count(u8, out, "\x1b_G"));
// First escape: control keys + m=1 (more chunks follow).
try testing.expect(std.mem.indexOf(u8, out, "a=T,q=2,C=1,f=24,s=64,v=64,c=10,r=5,m=1") != null);
// A continuation escape with no control keys, and the final m=0.
try testing.expect(std.mem.indexOf(u8, out, "\x1b_Gm=1;") != null);
try testing.expect(std.mem.indexOf(u8, out, "\x1b_Gm=0;") != null);
}