Lines
93.85 %
Functions
52.17 %
Branches
100 %
//! Kitty terminal graphics renderer.
//!
//! Renders a [`ChartSpec`] to an RGB bitmap via plotters, encodes it as
//! PNG via the `image` crate, then wraps the PNG in kitty's graphics
//! Application Programming Command (APC) escape sequence so the result
//! can be written directly to stdout inside a kitty-compatible terminal.
//! Protocol reference: <https://sw.kovidgoyal.net/kitty/graphics-protocol/>.
//! Key choices:
//! - `f=100` — the payload is PNG, not raw RGB. Lets the terminal
//! decode via libpng without us having to specify pixel dimensions.
//! - `a=T` — transmit and display immediately at the cursor.
//! - `m=1`/`m=0` — chunking. The protocol caps each APC chunk at 4096
//! base64 characters; any more gets silently dropped.
use base64::Engine;
use base64::engine::general_purpose::STANDARD as BASE64;
use image::{ImageBuffer, Rgb};
use plotters::prelude::*;
use crate::draw::draw_on;
use crate::spec::ChartSpec;
/// Dimensions, in pixels, for the rendered chart.
#[derive(Debug, Clone, Copy)]
pub struct KittyOpts {
pub width_px: u32,
pub height_px: u32,
}
impl Default for KittyOpts {
fn default() -> Self {
Self {
width_px: 640,
height_px: 400,
/// The kitty graphics protocol caps a single APC chunk's base64 payload
/// at this many characters.
const KITTY_CHUNK_MAX: usize = 4096;
/// Render `spec` to a PNG and return the kitty APC escape string that
/// displays it at the current cursor.
///
/// Returns an empty string when drawing fails or when PNG encoding
/// fails. A blank chart is the least intrusive fallback: the TUI's
/// chart pane will simply show nothing rather than garbling the
/// terminal with malformed control bytes.
#[must_use]
pub fn render_kitty(spec: &ChartSpec, opts: KittyOpts) -> String {
match render_png(spec, opts) {
Some(png) => encode_png_apc(&png),
None => String::new(),
/// Render `spec` to a PNG byte vector. Returns `None` on any drawing or
/// encoding failure; callers decide whether to substitute a text
/// fallback.
pub fn render_png(spec: &ChartSpec, opts: KittyOpts) -> Option<Vec<u8>> {
let width = opts.width_px;
let height = opts.height_px;
let byte_len = (width as usize)
.checked_mul(height as usize)?
.checked_mul(3)?;
let mut rgb: Vec<u8> = vec![0xff; byte_len];
let drew = {
let backend = BitMapBackend::with_buffer(&mut rgb, (width, height));
let root = backend.into_drawing_area();
draw_on(&root, spec).is_ok() && root.present().is_ok()
};
if !drew {
return None;
let buffer: ImageBuffer<Rgb<u8>, _> = ImageBuffer::from_raw(width, height, rgb)?;
let mut png = Vec::new();
let mut cursor = std::io::Cursor::new(&mut png);
buffer.write_to(&mut cursor, image::ImageFormat::Png).ok()?;
Some(png)
/// Encode PNG bytes as one or more kitty APC graphics commands. The
/// chunks share an implicit image ID; the final chunk sets `m=0` to
/// tell the terminal to display.
pub fn encode_png_apc(png: &[u8]) -> String {
let encoded = BASE64.encode(png);
let mut out = String::with_capacity(encoded.len() + 64);
let chunks: Vec<&str> = encoded
.as_bytes()
.chunks(KITTY_CHUNK_MAX)
.map(|c| std::str::from_utf8(c).unwrap_or(""))
.collect();
if chunks.is_empty() {
return String::new();
for (i, chunk) in chunks.iter().enumerate() {
let first = i == 0;
let last = i + 1 == chunks.len();
let more = i32::from(!last);
if first {
out.push_str(&format!("\x1b_Gf=100,a=T,m={more};"));
} else {
out.push_str(&format!("\x1b_Gm={more};"));
out.push_str(chunk);
out.push_str("\x1b\\");
out
#[cfg(test)]
mod tests {
use super::*;
use crate::spec::{ChartKind, Series, SeriesPoint};
fn sample(kind: ChartKind) -> ChartSpec {
ChartSpec {
title: "Test".to_string(),
kind,
x_label: "X".to_string(),
y_label: "Y".to_string(),
series: vec![Series {
label: "A".to_string(),
commodity_symbol: "USD".to_string(),
points: vec![
SeriesPoint {
x: "Jan".to_string(),
y_num: 100,
y_denom: 1,
},
x: "Feb".to_string(),
y_num: 200,
],
}],
notes: vec![],
#[test]
fn render_kitty_wraps_output_in_apc_framing() {
let out = render_kitty(
&sample(ChartKind::Bar),
KittyOpts {
width_px: 320,
height_px: 200,
);
assert!(!out.is_empty(), "kitty output should not be empty");
assert!(out.starts_with("\x1b_G"), "missing opening APC marker");
assert!(out.ends_with("\x1b\\"), "missing closing APC marker");
assert!(out.contains("f=100"), "first chunk must declare PNG format");
assert!(out.contains("a=T"), "first chunk must request display");
fn render_png_produces_valid_png_magic_bytes() {
let png = render_png(
&sample(ChartKind::Line),
)
.expect("bitmap render should succeed");
assert_eq!(
&png[..8],
&[0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a],
"PNG magic bytes mismatch"
fn encode_png_apc_round_trips_payload() {
let png = b"\x89PNG\r\n\x1a\nsome-fake-payload";
let framed = encode_png_apc(png);
let payload: String = framed
.split("\x1b\\")
.filter(|s| !s.is_empty())
.map(|chunk| {
let semi = chunk.find(';').expect("semicolon separator");
&chunk[semi + 1..]
})
let decoded = BASE64.decode(payload).expect("decoded base64");
assert_eq!(decoded, png);
fn encode_png_apc_chunks_large_payload() {
// Produce a PNG whose base64 encoding is > 4096 chars so
// chunking logic is exercised.
let png: Vec<u8> = (0..4000_u32).flat_map(u32::to_le_bytes).collect();
let framed = encode_png_apc(&png);
let start_markers = framed.matches("\x1b_G").count();
assert!(
start_markers >= 2,
"expected at least 2 APC chunks, got {start_markers}"
assert!(framed.contains("m=1"), "intermediate chunks must set m=1");
assert!(framed.contains("m=0"), "final chunk must set m=0");
fn encode_png_apc_handles_empty_input() {
assert!(encode_png_apc(&[]).is_empty());