Skip to main content

nuviz_cli/terminal/
render.rs

1use std::io::{self, Write};
2use std::path::Path;
3
4use anyhow::{Context, Result};
5use base64::Engine;
6use image::imageops::FilterType;
7use image::{DynamicImage, GenericImageView, Rgba};
8
9use super::capability::{ColorDepth, TerminalCapabilities};
10
11/// Render an image to the terminal using the best available protocol.
12pub fn render_image(
13    path: &Path,
14    caps: &TerminalCapabilities,
15    max_width: u32,
16    max_height: u32,
17) -> Result<()> {
18    let img =
19        image::open(path).with_context(|| format!("Failed to open image: {}", path.display()))?;
20    render_dynamic_image(&img, caps, max_width, max_height)
21}
22
23/// Render a DynamicImage (already loaded) to the terminal.
24pub fn render_dynamic_image(
25    img: &DynamicImage,
26    caps: &TerminalCapabilities,
27    max_width: u32,
28    max_height: u32,
29) -> Result<()> {
30    let img = resize_to_fit(img, max_width, max_height);
31
32    if caps.supports_kitty_graphics {
33        render_kitty(&img)?;
34    } else if caps.supports_iterm2 {
35        render_iterm2(&img)?;
36    } else if caps.supports_sixel {
37        render_sixel(&img)?;
38    } else {
39        render_halfblock(&img, caps)?;
40    }
41
42    Ok(())
43}
44
45/// Render two images side by side.
46pub fn render_image_pair(
47    left: &Path,
48    right: &Path,
49    caps: &TerminalCapabilities,
50    max_width: u32,
51    max_height: u32,
52) -> Result<()> {
53    let left_img =
54        image::open(left).with_context(|| format!("Failed to open: {}", left.display()))?;
55    let right_img =
56        image::open(right).with_context(|| format!("Failed to open: {}", right.display()))?;
57
58    // Each image gets half the width, minus 2 cols for separator
59    let half_width = max_width.saturating_sub(2) / 2;
60    let left_resized = resize_to_fit(&left_img, half_width, max_height);
61    let right_resized = resize_to_fit(&right_img, half_width, max_height);
62
63    // For graphics protocols, render sequentially with a gap
64    if caps.supports_kitty_graphics || caps.supports_iterm2 || caps.supports_sixel {
65        // Combine into a single image with separator
66        let combined = combine_side_by_side(&left_resized, &right_resized, 4);
67        render_dynamic_image(&combined, caps, max_width, max_height)?;
68    } else {
69        // Half-block: render combined image
70        let combined = combine_side_by_side(&left_resized, &right_resized, 4);
71        render_halfblock(&combined, caps)?;
72    }
73
74    Ok(())
75}
76
77/// Resize image to fit within max dimensions while preserving aspect ratio.
78fn resize_to_fit(img: &DynamicImage, max_width: u32, max_height: u32) -> DynamicImage {
79    let (w, h) = img.dimensions();
80    if w <= max_width && h <= max_height {
81        return img.clone();
82    }
83    img.resize(max_width, max_height, FilterType::Lanczos3)
84}
85
86/// Combine two images side by side with a separator gap.
87fn combine_side_by_side(left: &DynamicImage, right: &DynamicImage, gap: u32) -> DynamicImage {
88    let (lw, lh) = left.dimensions();
89    let (rw, rh) = right.dimensions();
90    let total_width = lw + gap + rw;
91    let total_height = lh.max(rh);
92
93    let mut combined = DynamicImage::new_rgba8(total_width, total_height);
94    image::imageops::overlay(&mut combined, left, 0, 0);
95    image::imageops::overlay(&mut combined, right, (lw + gap) as i64, 0);
96    combined
97}
98
99/// Kitty Graphics Protocol: transmit PNG as base64 chunks.
100fn render_kitty(img: &DynamicImage) -> Result<()> {
101    let png_data = encode_png(img)?;
102    let encoded = base64::engine::general_purpose::STANDARD.encode(&png_data);
103
104    let stdout = io::stdout();
105    let mut out = stdout.lock();
106
107    // Chunked transmission (4096 bytes per chunk)
108    let chunk_size = 4096;
109    let chunks: Vec<&str> = encoded
110        .as_bytes()
111        .chunks(chunk_size)
112        .map(|c| std::str::from_utf8(c).unwrap_or(""))
113        .collect();
114
115    for (i, chunk) in chunks.iter().enumerate() {
116        let more = if i < chunks.len() - 1 { 1 } else { 0 };
117        if i == 0 {
118            // First chunk: include action and format
119            write!(out, "\x1b_Ga=T,f=100,m={more};{chunk}\x1b\\")?;
120        } else {
121            write!(out, "\x1b_Gm={more};{chunk}\x1b\\")?;
122        }
123    }
124    writeln!(out)?;
125    out.flush()?;
126    Ok(())
127}
128
129/// iTerm2 Inline Image Protocol.
130fn render_iterm2(img: &DynamicImage) -> Result<()> {
131    let png_data = encode_png(img)?;
132    let encoded = base64::engine::general_purpose::STANDARD.encode(&png_data);
133    let (w, h) = img.dimensions();
134
135    let stdout = io::stdout();
136    let mut out = stdout.lock();
137
138    write!(
139        out,
140        "\x1b]1337;File=inline=1;width={w}px;height={h}px;preserveAspectRatio=1:{encoded}\x07"
141    )?;
142    writeln!(out)?;
143    out.flush()?;
144    Ok(())
145}
146
147/// Sixel rendering: quantize to 256 colors and emit DCS sequences.
148fn render_sixel(img: &DynamicImage) -> Result<()> {
149    let rgba = img.to_rgba8();
150    let (width, height) = rgba.dimensions();
151
152    let stdout = io::stdout();
153    let mut out = stdout.lock();
154
155    // Build a simple 256-color palette by uniform quantization
156    // DCS q  ... ST
157    write!(out, "\x1bPq")?;
158
159    // Register 216 colors (6x6x6 cube) + leave room
160    for r in 0..6u8 {
161        for g in 0..6u8 {
162            for b in 0..6u8 {
163                let idx = r as u32 * 36 + g as u32 * 6 + b as u32;
164                let ri = (r as u32 * 100) / 5;
165                let gi = (g as u32 * 100) / 5;
166                let bi = (b as u32 * 100) / 5;
167                write!(out, "#{idx};2;{ri};{gi};{bi}")?;
168            }
169        }
170    }
171
172    // Render in 6-pixel-high bands
173    let mut y = 0u32;
174    while y < height {
175        for color_idx in 0..216u32 {
176            let mut has_pixels = false;
177            let mut sixel_data = Vec::with_capacity(width as usize);
178
179            for x in 0..width {
180                let mut sixel_bits: u8 = 0;
181                for dy in 0..6u32 {
182                    let py = y + dy;
183                    if py < height {
184                        let pixel = rgba.get_pixel(x, py);
185                        if pixel[3] > 127 && nearest_color(pixel) == color_idx {
186                            sixel_bits |= 1 << dy;
187                            has_pixels = true;
188                        }
189                    }
190                }
191                sixel_data.push(sixel_bits + 0x3f);
192            }
193
194            if has_pixels {
195                write!(out, "#{color_idx}")?;
196                for &b in &sixel_data {
197                    out.write_all(&[b])?;
198                }
199                write!(out, "$")?; // CR within sixel band
200            }
201        }
202        write!(out, "-")?; // Next sixel band (line feed)
203        y += 6;
204    }
205
206    write!(out, "\x1b\\")?; // ST
207    writeln!(out)?;
208    out.flush()?;
209    Ok(())
210}
211
212/// Map RGBA pixel to nearest 6x6x6 cube index.
213fn nearest_color(pixel: &Rgba<u8>) -> u32 {
214    let r = ((pixel[0] as u32 + 25) / 51).min(5);
215    let g = ((pixel[1] as u32 + 25) / 51).min(5);
216    let b = ((pixel[2] as u32 + 25) / 51).min(5);
217    r * 36 + g * 6 + b
218}
219
220/// Half-block fallback: use upper/lower half-block chars with 24-bit ANSI colors.
221/// Each character cell encodes 2 vertical pixels.
222fn render_halfblock(img: &DynamicImage, caps: &TerminalCapabilities) -> Result<()> {
223    let rgba = img.to_rgba8();
224    let (width, height) = rgba.dimensions();
225
226    let stdout = io::stdout();
227    let mut out = stdout.lock();
228
229    let mut y = 0u32;
230    while y < height {
231        for x in 0..width {
232            let top = rgba.get_pixel(x, y);
233            let bottom = if y + 1 < height {
234                *rgba.get_pixel(x, y + 1)
235            } else {
236                Rgba([0, 0, 0, 0])
237            };
238
239            match caps.color_depth {
240                ColorDepth::TrueColor => {
241                    // Upper half block: foreground = top, background = bottom
242                    write!(
243                        out,
244                        "\x1b[38;2;{};{};{}m\x1b[48;2;{};{};{}m\u{2580}",
245                        top[0], top[1], top[2], bottom[0], bottom[1], bottom[2]
246                    )?;
247                }
248                ColorDepth::Colors256 => {
249                    let fg = to_256_color(top[0], top[1], top[2]);
250                    let bg = to_256_color(bottom[0], bottom[1], bottom[2]);
251                    write!(out, "\x1b[38;5;{fg}m\x1b[48;5;{bg}m\u{2580}")?;
252                }
253                ColorDepth::Colors16 => {
254                    // Basic fallback — just use half blocks with default colors
255                    write!(out, "\u{2580}")?;
256                }
257            }
258        }
259        write!(out, "\x1b[0m")?; // Reset
260        writeln!(out)?;
261        y += 2;
262    }
263
264    out.flush()?;
265    Ok(())
266}
267
268/// Map RGB to 256-color xterm palette (16-231 color cube).
269fn to_256_color(r: u8, g: u8, b: u8) -> u8 {
270    let ri = ((r as u16 + 25) / 51).min(5) as u8;
271    let gi = ((g as u16 + 25) / 51).min(5) as u8;
272    let bi = ((b as u16 + 25) / 51).min(5) as u8;
273    16 + 36 * ri + 6 * gi + bi
274}
275
276/// Encode a DynamicImage as PNG bytes.
277fn encode_png(img: &DynamicImage) -> Result<Vec<u8>> {
278    let mut buf = Vec::new();
279    let mut cursor = io::Cursor::new(&mut buf);
280    img.write_to(&mut cursor, image::ImageFormat::Png)?;
281    Ok(buf)
282}
283
284/// Get terminal dimensions in pixels (if available) or estimate from character cells.
285pub fn get_terminal_pixel_size() -> (u32, u32) {
286    // Try ioctl TIOCGWINSZ for pixel size
287    if let Ok((cols, rows)) = crossterm::terminal::size() {
288        // Estimate: typical terminal character is ~8px wide, ~16px tall
289        let pixel_width = cols as u32 * 8;
290        let pixel_height = rows as u32 * 16;
291        return (pixel_width, pixel_height);
292    }
293    (640, 480) // fallback
294}
295
296/// Get terminal size in character cells.
297#[allow(dead_code)]
298pub fn get_terminal_char_size() -> (u16, u16) {
299    crossterm::terminal::size().unwrap_or((80, 24))
300}
301
302#[cfg(test)]
303mod tests {
304    use super::*;
305
306    #[test]
307    fn test_nearest_color() {
308        assert_eq!(nearest_color(&Rgba([0, 0, 0, 255])), 0);
309        assert_eq!(nearest_color(&Rgba([255, 255, 255, 255])), 215);
310        assert_eq!(nearest_color(&Rgba([255, 0, 0, 255])), 180);
311    }
312
313    #[test]
314    fn test_to_256_color() {
315        assert_eq!(to_256_color(0, 0, 0), 16);
316        assert_eq!(to_256_color(255, 255, 255), 231);
317    }
318
319    #[test]
320    fn test_resize_to_fit_no_resize_needed() {
321        let img = DynamicImage::new_rgba8(100, 100);
322        let resized = resize_to_fit(&img, 200, 200);
323        assert_eq!(resized.dimensions(), (100, 100));
324    }
325
326    #[test]
327    fn test_resize_to_fit_downscale() {
328        let img = DynamicImage::new_rgba8(400, 200);
329        let resized = resize_to_fit(&img, 200, 200);
330        assert!(resized.width() <= 200);
331        assert!(resized.height() <= 200);
332    }
333
334    #[test]
335    fn test_combine_side_by_side() {
336        let left = DynamicImage::new_rgba8(50, 100);
337        let right = DynamicImage::new_rgba8(60, 80);
338        let combined = combine_side_by_side(&left, &right, 4);
339        assert_eq!(combined.width(), 114); // 50 + 4 + 60
340        assert_eq!(combined.height(), 100); // max(100, 80)
341    }
342
343    #[test]
344    fn test_encode_png() {
345        let img = DynamicImage::new_rgba8(10, 10);
346        let data = encode_png(&img).unwrap();
347        assert!(!data.is_empty());
348        // PNG magic bytes
349        assert_eq!(&data[..4], &[0x89, 0x50, 0x4E, 0x47]);
350    }
351}