1
//! Kitty terminal graphics renderer.
2
//!
3
//! Renders a [`ChartSpec`] to an RGB bitmap via plotters, encodes it as
4
//! PNG via the `image` crate, then wraps the PNG in kitty's graphics
5
//! Application Programming Command (APC) escape sequence so the result
6
//! can be written directly to stdout inside a kitty-compatible terminal.
7
//!
8
//! Protocol reference: <https://sw.kovidgoyal.net/kitty/graphics-protocol/>.
9
//! Key choices:
10
//!
11
//! - `f=100` — the payload is PNG, not raw RGB. Lets the terminal
12
//!   decode via libpng without us having to specify pixel dimensions.
13
//! - `a=T` — transmit and display immediately at the cursor.
14
//! - `m=1`/`m=0` — chunking. The protocol caps each APC chunk at 4096
15
//!   base64 characters; any more gets silently dropped.
16

            
17
use base64::Engine;
18
use base64::engine::general_purpose::STANDARD as BASE64;
19
use image::{ImageBuffer, Rgb};
20
use plotters::prelude::*;
21

            
22
use crate::draw::draw_on;
23
use crate::spec::ChartSpec;
24

            
25
/// Dimensions, in pixels, for the rendered chart.
26
#[derive(Debug, Clone, Copy)]
27
pub struct KittyOpts {
28
    pub width_px: u32,
29
    pub height_px: u32,
30
}
31

            
32
impl Default for KittyOpts {
33
1
    fn default() -> Self {
34
1
        Self {
35
1
            width_px: 640,
36
1
            height_px: 400,
37
1
        }
38
1
    }
39
}
40

            
41
/// The kitty graphics protocol caps a single APC chunk's base64 payload
42
/// at this many characters.
43
const KITTY_CHUNK_MAX: usize = 4096;
44

            
45
/// Render `spec` to a PNG and return the kitty APC escape string that
46
/// displays it at the current cursor.
47
///
48
/// Returns an empty string when drawing fails or when PNG encoding
49
/// fails. A blank chart is the least intrusive fallback: the TUI's
50
/// chart pane will simply show nothing rather than garbling the
51
/// terminal with malformed control bytes.
52
#[must_use]
53
1
pub fn render_kitty(spec: &ChartSpec, opts: KittyOpts) -> String {
54
1
    match render_png(spec, opts) {
55
1
        Some(png) => encode_png_apc(&png),
56
        None => String::new(),
57
    }
58
1
}
59

            
60
/// Render `spec` to a PNG byte vector. Returns `None` on any drawing or
61
/// encoding failure; callers decide whether to substitute a text
62
/// fallback.
63
#[must_use]
64
3
pub fn render_png(spec: &ChartSpec, opts: KittyOpts) -> Option<Vec<u8>> {
65
3
    let width = opts.width_px;
66
3
    let height = opts.height_px;
67
3
    let byte_len = (width as usize)
68
3
        .checked_mul(height as usize)?
69
3
        .checked_mul(3)?;
70
3
    let mut rgb: Vec<u8> = vec![0xff; byte_len];
71

            
72
3
    let drew = {
73
3
        let backend = BitMapBackend::with_buffer(&mut rgb, (width, height));
74
3
        let root = backend.into_drawing_area();
75
3
        draw_on(&root, spec).is_ok() && root.present().is_ok()
76
    };
77

            
78
3
    if !drew {
79
        return None;
80
3
    }
81

            
82
3
    let buffer: ImageBuffer<Rgb<u8>, _> = ImageBuffer::from_raw(width, height, rgb)?;
83
3
    let mut png = Vec::new();
84
3
    let mut cursor = std::io::Cursor::new(&mut png);
85
3
    buffer.write_to(&mut cursor, image::ImageFormat::Png).ok()?;
86
3
    Some(png)
87
3
}
88

            
89
/// Encode PNG bytes as one or more kitty APC graphics commands. The
90
/// chunks share an implicit image ID; the final chunk sets `m=0` to
91
/// tell the terminal to display.
92
#[must_use]
93
4
pub fn encode_png_apc(png: &[u8]) -> String {
94
4
    let encoded = BASE64.encode(png);
95
4
    let mut out = String::with_capacity(encoded.len() + 64);
96
4
    let chunks: Vec<&str> = encoded
97
4
        .as_bytes()
98
4
        .chunks(KITTY_CHUNK_MAX)
99
10
        .map(|c| std::str::from_utf8(c).unwrap_or(""))
100
4
        .collect();
101

            
102
4
    if chunks.is_empty() {
103
1
        return String::new();
104
3
    }
105

            
106
10
    for (i, chunk) in chunks.iter().enumerate() {
107
10
        let first = i == 0;
108
10
        let last = i + 1 == chunks.len();
109
10
        let more = i32::from(!last);
110
10
        if first {
111
3
            out.push_str(&format!("\x1b_Gf=100,a=T,m={more};"));
112
7
        } else {
113
7
            out.push_str(&format!("\x1b_Gm={more};"));
114
7
        }
115
10
        out.push_str(chunk);
116
10
        out.push_str("\x1b\\");
117
    }
118
3
    out
119
4
}
120

            
121
#[cfg(test)]
122
mod tests {
123
    use super::*;
124
    use crate::spec::{ChartKind, Series, SeriesPoint};
125

            
126
    /// The SVG test cannot cover this: SVGBackend emits `<text>` nodes and uses
127
    /// the font only for layout, while BitMapBackend RASTERISES glyphs through
128
    /// ab_glyph. Draw on a white canvas and require dark pixels — if font
129
    /// registration or rasterisation broke, `render_png` returns None (drawing
130
    /// error) or the canvas stays blank.
131
    #[test]
132
1
    fn render_png_rasterises_label_glyphs() {
133
1
        let png = render_png(&sample(ChartKind::Bar), KittyOpts::default())
134
1
            .expect("render_png returned None — drawing failed, not merely unstyled");
135
1
        let img = image::load_from_memory(&png).expect("not a decodable PNG");
136
1
        let dark = img
137
1
            .to_rgb8()
138
1
            .pixels()
139
341233
            .filter(|p| p.0.iter().all(|&c| c < 0x40))
140
1
            .count();
141
1
        assert!(
142
1
            dark > 200,
143
            "only {dark} dark pixels — axis labels and captions were not rasterised"
144
        );
145
1
    }
146

            
147
3
    fn sample(kind: ChartKind) -> ChartSpec {
148
3
        ChartSpec {
149
3
            title: "Test".to_string(),
150
3
            kind,
151
3
            x_label: "X".to_string(),
152
3
            y_label: "Y".to_string(),
153
3
            series: vec![Series {
154
3
                label: "A".to_string(),
155
3
                commodity_symbol: "USD".to_string(),
156
3
                points: vec![
157
3
                    SeriesPoint {
158
3
                        x: "Jan".to_string(),
159
3
                        y_num: 100,
160
3
                        y_denom: 1,
161
3
                    },
162
3
                    SeriesPoint {
163
3
                        x: "Feb".to_string(),
164
3
                        y_num: 200,
165
3
                        y_denom: 1,
166
3
                    },
167
3
                ],
168
3
            }],
169
3
            notes: vec![],
170
3
        }
171
3
    }
172

            
173
    #[test]
174
1
    fn render_kitty_wraps_output_in_apc_framing() {
175
1
        let out = render_kitty(
176
1
            &sample(ChartKind::Bar),
177
1
            KittyOpts {
178
1
                width_px: 320,
179
1
                height_px: 200,
180
1
            },
181
        );
182
1
        assert!(!out.is_empty(), "kitty output should not be empty");
183
1
        assert!(out.starts_with("\x1b_G"), "missing opening APC marker");
184
1
        assert!(out.ends_with("\x1b\\"), "missing closing APC marker");
185
1
        assert!(out.contains("f=100"), "first chunk must declare PNG format");
186
1
        assert!(out.contains("a=T"), "first chunk must request display");
187
1
    }
188

            
189
    #[test]
190
1
    fn render_png_produces_valid_png_magic_bytes() {
191
1
        let png = render_png(
192
1
            &sample(ChartKind::Line),
193
1
            KittyOpts {
194
1
                width_px: 320,
195
1
                height_px: 200,
196
1
            },
197
        )
198
1
        .expect("bitmap render should succeed");
199
1
        assert_eq!(
200
1
            &png[..8],
201
            &[0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a],
202
            "PNG magic bytes mismatch"
203
        );
204
1
    }
205

            
206
    #[test]
207
1
    fn encode_png_apc_round_trips_payload() {
208
1
        let png = b"\x89PNG\r\n\x1a\nsome-fake-payload";
209
1
        let framed = encode_png_apc(png);
210
1
        let payload: String = framed
211
1
            .split("\x1b\\")
212
2
            .filter(|s| !s.is_empty())
213
1
            .map(|chunk| {
214
1
                let semi = chunk.find(';').expect("semicolon separator");
215
1
                &chunk[semi + 1..]
216
1
            })
217
1
            .collect();
218
1
        let decoded = BASE64.decode(payload).expect("decoded base64");
219
1
        assert_eq!(decoded, png);
220
1
    }
221

            
222
    #[test]
223
1
    fn encode_png_apc_chunks_large_payload() {
224
        // Produce a PNG whose base64 encoding is > 4096 chars so
225
        // chunking logic is exercised.
226
1
        let png: Vec<u8> = (0..4000_u32).flat_map(u32::to_le_bytes).collect();
227
1
        let framed = encode_png_apc(&png);
228
1
        let start_markers = framed.matches("\x1b_G").count();
229
1
        assert!(
230
1
            start_markers >= 2,
231
            "expected at least 2 APC chunks, got {start_markers}"
232
        );
233
1
        assert!(framed.contains("m=1"), "intermediate chunks must set m=1");
234
1
        assert!(framed.contains("m=0"), "final chunk must set m=0");
235
1
    }
236

            
237
    #[test]
238
1
    fn encode_png_apc_handles_empty_input() {
239
1
        assert!(encode_png_apc(&[]).is_empty());
240
1
    }
241
}