//! 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); }