160 lines
6.6 KiB
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);
|
|
}
|