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
11pub 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
23pub 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
45pub 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 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 if caps.supports_kitty_graphics || caps.supports_iterm2 || caps.supports_sixel {
65 let combined = combine_side_by_side(&left_resized, &right_resized, 4);
67 render_dynamic_image(&combined, caps, max_width, max_height)?;
68 } else {
69 let combined = combine_side_by_side(&left_resized, &right_resized, 4);
71 render_halfblock(&combined, caps)?;
72 }
73
74 Ok(())
75}
76
77fn 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
86fn 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
99fn 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 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 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
129fn 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
147fn 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 write!(out, "\x1bPq")?;
158
159 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 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, "$")?; }
201 }
202 write!(out, "-")?; y += 6;
204 }
205
206 write!(out, "\x1b\\")?; writeln!(out)?;
208 out.flush()?;
209 Ok(())
210}
211
212fn 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
220fn 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 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 write!(out, "\u{2580}")?;
256 }
257 }
258 }
259 write!(out, "\x1b[0m")?; writeln!(out)?;
261 y += 2;
262 }
263
264 out.flush()?;
265 Ok(())
266}
267
268fn 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
276fn 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
284pub fn get_terminal_pixel_size() -> (u32, u32) {
286 if let Ok((cols, rows)) = crossterm::terminal::size() {
288 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) }
295
296#[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); assert_eq!(combined.height(), 100); }
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 assert_eq!(&data[..4], &[0x89, 0x50, 0x4E, 0x47]);
350 }
351}