Skip to main content

telex/
image.rs

1//! Image widget for displaying images using the Kitty graphics protocol.
2//!
3//! Supports PNG, JPEG, and GIF formats. Kitty handles format detection
4//! and GIF animation natively.
5//!
6//! # Example
7//!
8//! ```rust,ignore
9//! // Embed image at compile time
10//! View::image()
11//!     .data(include_bytes!("logo.png"))
12//!     .build()
13//!
14//! // Or load from file at runtime
15//! View::image()
16//!     .file("assets/animation.gif")
17//!     .build()
18//! ```
19
20use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _};
21use std::sync::atomic::{AtomicU32, Ordering};
22
23/// Source of image data.
24#[derive(Clone)]
25pub enum ImageSource {
26    /// Raw image bytes (PNG, JPEG, GIF, etc.)
27    Data(Vec<u8>),
28    /// Path to image file (loaded at render time)
29    File(String),
30}
31
32/// A pending image to be rendered via Kitty protocol.
33#[derive(Clone)]
34pub struct PendingImage {
35    /// Cell column position
36    pub cell_x: u16,
37    /// Cell row position
38    pub cell_y: u16,
39    /// Image data
40    pub data: Vec<u8>,
41    /// Unique ID for this image
42    pub id: u32,
43    /// Width in cells (for placeholder sizing)
44    pub cell_width: u16,
45    /// Height in cells (for placeholder sizing)
46    pub cell_height: u16,
47}
48
49/// Generate a unique image ID.
50pub fn next_image_id() -> u32 {
51    static NEXT_ID: AtomicU32 = AtomicU32::new(10000); // Start high to avoid collision with canvas IDs
52    NEXT_ID.fetch_add(1, Ordering::Relaxed)
53}
54
55/// Encode raw image file data as Kitty graphics protocol escape sequences.
56///
57/// Uses `f=0` for auto-detection of format (PNG, JPEG, GIF, etc.).
58/// Kitty handles GIF animation natively.
59pub fn encode_kitty_image(data: &[u8], cell_x: u16, cell_y: u16, image_id: u32) -> String {
60    if data.is_empty() {
61        return String::new();
62    }
63
64    // Encode image data as base64
65    let b64_data = BASE64.encode(data);
66
67    // Kitty protocol uses chunked transmission for large images
68    const CHUNK_SIZE: usize = 4096;
69
70    let mut result = String::new();
71    let chunks: Vec<&str> = b64_data
72        .as_bytes()
73        .chunks(CHUNK_SIZE)
74        .map(|c| std::str::from_utf8(c).unwrap_or(""))
75        .collect();
76
77    let total_chunks = chunks.len();
78
79    for (i, chunk) in chunks.iter().enumerate() {
80        let is_first = i == 0;
81        let is_last = i == total_chunks - 1;
82        let more = if is_last { 0 } else { 1 };
83
84        if is_first {
85            // First chunk includes full header
86            // a=T means transmit and display
87            // f=100 means PNG format (Kitty auto-detects from data)
88            // t=d means direct (data follows in payload)
89            // i=ID for image identification
90            // q=2 suppresses responses
91            result.push_str(&format!(
92                "\x1b_Ga=T,f=100,i={},t=d,m={},q=2;{}\x1b\\",
93                image_id, more, chunk
94            ));
95        } else {
96            // Continuation chunks
97            result.push_str(&format!("\x1b_Gm={};{}\x1b\\", more, chunk));
98        }
99    }
100
101    // Position the image at the specified cell
102    result.insert_str(0, &format!("\x1b[{};{}H", cell_y + 1, cell_x + 1));
103
104    result
105}
106
107/// Try to detect image dimensions from file header.
108/// Returns (width, height) in pixels, or None if unknown.
109pub fn detect_image_dimensions(data: &[u8]) -> Option<(u32, u32)> {
110    // GIF: GIF87a or GIF89a (check first since it has the shortest header)
111    if data.len() >= 10 && (data.starts_with(b"GIF87a") || data.starts_with(b"GIF89a")) {
112        let width = u16::from_le_bytes([data[6], data[7]]) as u32;
113        let height = u16::from_le_bytes([data[8], data[9]]) as u32;
114        return Some((width, height));
115    }
116
117    // PNG: 89 50 4E 47 0D 0A 1A 0A
118    // PNG dimensions are at bytes 16-23 (width: 16-19, height: 20-23)
119    if data.len() >= 24 && data.starts_with(&[0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]) {
120        let width = u32::from_be_bytes([data[16], data[17], data[18], data[19]]);
121        let height = u32::from_be_bytes([data[20], data[21], data[22], data[23]]);
122        return Some((width, height));
123    }
124
125    // JPEG: FF D8 FF
126    if data.starts_with(&[0xFF, 0xD8, 0xFF]) {
127        // JPEG dimensions require parsing markers - simplified approach
128        // Look for SOF0 (0xFFC0) or SOF2 (0xFFC2) marker
129        let mut i = 2;
130        while i + 9 < data.len() {
131            if data[i] == 0xFF {
132                let marker = data[i + 1];
133                if marker == 0xC0 || marker == 0xC2 {
134                    // SOF marker found: height at i+5, width at i+7
135                    let height = u16::from_be_bytes([data[i + 5], data[i + 6]]) as u32;
136                    let width = u16::from_be_bytes([data[i + 7], data[i + 8]]) as u32;
137                    return Some((width, height));
138                }
139                // Skip to next marker
140                if i + 3 < data.len() {
141                    let len = u16::from_be_bytes([data[i + 2], data[i + 3]]) as usize;
142                    i += 2 + len;
143                } else {
144                    break;
145                }
146            } else {
147                i += 1;
148            }
149        }
150    }
151
152    None
153}
154
155/// Estimate cell dimensions from pixel dimensions.
156/// Assumes roughly 10 pixels per cell width and 20 pixels per cell height.
157pub fn pixels_to_cells(width: u32, height: u32) -> (u16, u16) {
158    let cell_width = ((width as f32) / 10.0).ceil() as u16;
159    let cell_height = ((height as f32) / 20.0).ceil() as u16;
160    (cell_width.max(1), cell_height.max(1))
161}
162
163#[cfg(test)]
164mod tests {
165    use super::*;
166
167    #[test]
168    fn test_png_detection() {
169        // Minimal PNG header with dimensions 100x50
170        let mut png_data = vec![0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A];
171        png_data.extend_from_slice(&[0, 0, 0, 13]); // IHDR length
172        png_data.extend_from_slice(b"IHDR");
173        png_data.extend_from_slice(&100u32.to_be_bytes()); // width
174        png_data.extend_from_slice(&50u32.to_be_bytes()); // height
175
176        assert_eq!(detect_image_dimensions(&png_data), Some((100, 50)));
177    }
178
179    #[test]
180    fn test_gif_detection() {
181        // Minimal GIF header with dimensions 200x100
182        let mut gif_data = b"GIF89a".to_vec();
183        gif_data.extend_from_slice(&200u16.to_le_bytes()); // width
184        gif_data.extend_from_slice(&100u16.to_le_bytes()); // height
185
186        assert_eq!(detect_image_dimensions(&gif_data), Some((200, 100)));
187    }
188
189    #[test]
190    fn test_pixels_to_cells() {
191        assert_eq!(pixels_to_cells(100, 100), (10, 5));
192        assert_eq!(pixels_to_cells(50, 20), (5, 1));
193        assert_eq!(pixels_to_cells(1, 1), (1, 1));
194    }
195
196    #[test]
197    fn test_next_image_id_increments() {
198        let id1 = next_image_id();
199        let id2 = next_image_id();
200        assert!(id2 > id1);
201    }
202}