Skip to main content

rpi_tui/
image.rs

1//! Image component for terminal display.
2//!
3//! Displays images using Kitty or iTerm2 protocols.
4
5use std::any::Any;
6use std::sync::Mutex;
7
8use super::component::Component;
9use super::terminal_image::{
10    allocate_image_id, get_capabilities, get_image_dimensions, render_image, ImageProtocol,
11    ImageRenderOptions,
12};
13use crate::utils::visible_width;
14
15/// Image theme.
16#[derive(Debug, Clone)]
17pub struct ImageTheme {
18    /// Placeholder background color (ANSI escape).
19    pub placeholder_bg: &'static str,
20    /// Placeholder border color (ANSI escape).
21    pub border_color: &'static str,
22    /// Alt text color function.
23    pub alt_text_color: fn(&str) -> String,
24}
25
26impl Default for ImageTheme {
27    fn default() -> Self {
28        Self {
29            placeholder_bg: "\x1b[48;5;238m", // Dark gray background
30            border_color: "\x1b[38;5;244m",   // Gray border
31            alt_text_color: |s| format!("\x1b[38;5;250m{}\x1b[0m", s),
32        }
33    }
34}
35
36/// Image options.
37#[derive(Debug, Clone)]
38pub struct ImageOptions {
39    /// Width in cells (None = auto).
40    pub width: Option<u16>,
41    /// Height in cells (None = auto).
42    pub height: Option<u16>,
43    /// Preserve aspect ratio.
44    pub preserve_aspect: bool,
45    /// Alt text for fallback.
46    pub alt_text: Option<String>,
47    /// Title for the image.
48    pub title: Option<String>,
49}
50
51impl Default for ImageOptions {
52    fn default() -> Self {
53        Self {
54            width: None,
55            height: None,
56            preserve_aspect: true,
57            alt_text: None,
58            title: None,
59        }
60    }
61}
62
63/// Image component.
64pub struct Image {
65    data: Mutex<Option<Vec<u8>>>,
66    url: Mutex<Option<String>>,
67    options: ImageOptions,
68    theme: ImageTheme,
69    image_id: Mutex<Option<u32>>,
70    cached_render: Mutex<Option<(usize, String)>>,
71    inline_visible: Mutex<bool>,
72}
73
74impl Image {
75    /// Create a new empty image.
76    pub fn new() -> Self {
77        Self {
78            data: Mutex::new(None),
79            url: Mutex::new(None),
80            options: ImageOptions::default(),
81            theme: ImageTheme::default(),
82            image_id: Mutex::new(None),
83            cached_render: Mutex::new(None),
84            inline_visible: Mutex::new(true),
85        }
86    }
87
88    /// Create an image from data.
89    pub fn from_data(data: Vec<u8>, options: ImageOptions) -> Self {
90        let id = allocate_image_id();
91        Self {
92            data: Mutex::new(Some(data)),
93            url: Mutex::new(None),
94            options,
95            theme: ImageTheme::default(),
96            image_id: Mutex::new(Some(id)),
97            cached_render: Mutex::new(None),
98            inline_visible: Mutex::new(true),
99        }
100    }
101
102    /// Create an image from a URL.
103    pub fn from_url(url: &str, options: ImageOptions) -> Self {
104        Self {
105            data: Mutex::new(None),
106            url: Mutex::new(Some(url.to_string())),
107            options,
108            theme: ImageTheme::default(),
109            image_id: Mutex::new(None),
110            cached_render: Mutex::new(None),
111            inline_visible: Mutex::new(true),
112        }
113    }
114
115    /// Set image data.
116    pub fn set_data(&self, data: Vec<u8>) {
117        if let Ok(mut d) = self.data.lock() {
118            *d = Some(data);
119        }
120        if let Ok(mut id) = self.image_id.lock() {
121            *id = Some(allocate_image_id());
122        }
123        if let Ok(mut cached) = self.cached_render.lock() {
124            *cached = None;
125        }
126    }
127
128    /// Set image URL.
129    pub fn set_url(&self, url: &str) {
130        if let Ok(mut u) = self.url.lock() {
131            *u = Some(url.to_string());
132        }
133        if let Ok(mut cached) = self.cached_render.lock() {
134            *cached = None;
135        }
136    }
137
138    /// Set options.
139    pub fn set_options(&mut self, options: ImageOptions) {
140        self.options = options;
141        if let Ok(mut cached) = self.cached_render.lock() {
142            *cached = None;
143        }
144    }
145
146    /// Set alt text.
147    pub fn set_alt_text(&mut self, alt_text: &str) {
148        self.options.alt_text = Some(alt_text.to_string());
149    }
150
151    /// Set theme.
152    pub fn set_theme(&mut self, theme: ImageTheme) {
153        self.theme = theme;
154        if let Ok(mut cached) = self.cached_render.lock() {
155            *cached = None;
156        }
157    }
158
159    /// Check if image is loaded.
160    pub fn is_loaded(&self) -> bool {
161        self.data.lock().map(|d| d.is_some()).unwrap_or(false)
162            || self.url.lock().map(|u| u.is_some()).unwrap_or(false)
163    }
164
165    /// Get image dimensions if available.
166    pub fn get_dimensions(&self) -> Option<(u32, u32)> {
167        let data = self.data.lock().ok()?;
168        let d = data.as_ref()?;
169        let dims = get_image_dimensions(d)?;
170        Some((dims.width, dims.height))
171    }
172
173    /// Render the image.
174    fn render_image(&self, width: usize) -> String {
175        if width == 0 {
176            return String::new();
177        }
178        if !*self.inline_visible.lock().unwrap() {
179            return crate::utils::truncate_to_width("[Image]", width, "");
180        }
181        let caps = get_capabilities();
182
183        // Check if we have cached render
184        if let Ok(cached) = self.cached_render.lock() {
185            if let Some((cached_width, rendered)) = cached.as_ref() {
186                if *cached_width == width {
187                    return rendered.clone();
188                }
189            }
190        }
191
192        // Calculate dimensions
193        let (img_width, img_height) = if let Some((w, h)) = self.get_dimensions() {
194            // Calculate cell dimensions
195            let cell_dims = super::terminal_image::get_cell_dimensions();
196            let cell_width = self
197                .options
198                .width
199                .unwrap_or_else(|| {
200                    ((w as f32 / cell_dims.width.max(1) as f32).ceil() as u16).min(60)
201                })
202                .min(width.saturating_sub(2).max(1).min(u16::MAX as usize) as u16)
203                .max(1);
204            let cell_height = self
205                .options
206                .height
207                .unwrap_or_else(|| {
208                    ((h as f64 / w.max(1) as f64) * cell_width as f64 * cell_dims.width as f64
209                        / cell_dims.height.max(1) as f64)
210                        .ceil() as u16
211                })
212                .max(1);
213            let max_height = self
214                .options
215                .height
216                .unwrap_or_else(|| {
217                    (cell_width as f64 * cell_dims.width as f64 / cell_dims.height.max(1) as f64)
218                        .ceil() as u16
219                })
220                .max(1);
221            if self.options.preserve_aspect && cell_height > max_height {
222                (
223                    ((cell_width as f64 * max_height as f64 / cell_height as f64).floor() as u16)
224                        .max(1),
225                    max_height,
226                )
227            } else {
228                (cell_width, cell_height)
229            }
230        } else {
231            (
232                self.options.width.unwrap_or(width as u16),
233                self.options.height.unwrap_or(5),
234            )
235        };
236
237        // Render based on protocol
238        let result = if caps.protocol == ImageProtocol::None {
239            // Fallback: render placeholder
240            crate::utils::truncate_to_width(
241                self.options.alt_text.as_deref().unwrap_or("[Image]"),
242                width,
243                "",
244            )
245        } else if let Ok(data) = self.data.lock() {
246            if let Some(ref img_data) = *data {
247                let id = self.image_id.lock().ok().and_then(|id| *id);
248                let options = ImageRenderOptions {
249                    width: Some(img_width),
250                    height: Some(img_height),
251                    preserve_aspect: self.options.preserve_aspect,
252                    x: 0,
253                    y: 0,
254                    z_index: None,
255                    id,
256                };
257                // Keep cursor position stable and reserve the image's rows in
258                // the retained layout so following text cannot overlap it.
259                let sequence = render_image(img_data, &options);
260                let rows = img_height.saturating_sub(1) as usize;
261                if caps.protocol == ImageProtocol::Kitty {
262                    format!("{sequence}{}", "\n".repeat(rows))
263                } else {
264                    let up = if rows > 0 {
265                        format!("\x1b[{rows}A")
266                    } else {
267                        String::new()
268                    };
269                    format!("{}{up}{sequence}", "\n".repeat(rows))
270                }
271            } else {
272                self.render_placeholder(img_width as usize, img_height as usize, width)
273            }
274        } else {
275            self.render_placeholder(img_width as usize, img_height as usize, width)
276        };
277
278        // Cache the result
279        if let Ok(mut cached) = self.cached_render.lock() {
280            *cached = Some((width, result.clone()));
281        }
282
283        result
284    }
285
286    /// Hide terminal graphics while retaining an attachment placeholder.
287    pub fn set_inline_visible(&self, visible: bool) {
288        let mut current = self.inline_visible.lock().unwrap();
289        if *current == visible {
290            return;
291        }
292        *current = visible;
293        *self.cached_render.lock().unwrap() = None;
294    }
295
296    /// Render a placeholder when image can't be displayed.
297    fn render_placeholder(&self, img_width: usize, img_height: usize, max_width: usize) -> String {
298        let width = img_width.min(max_width);
299        let height = img_height.max(3);
300
301        let border_h = "─".repeat(width.saturating_sub(2));
302        let inner_width = width.saturating_sub(2);
303
304        let mut lines = Vec::new();
305
306        // Top border
307        lines.push(format!("{}╭{}╮\x1b[0m", self.theme.border_color, border_h));
308
309        // Content
310        let alt_text = self.options.alt_text.as_deref().unwrap_or("[Image]");
311        let title = self.options.title.as_deref().unwrap_or("");
312
313        // Calculate content lines
314        let content_height = height.saturating_sub(2);
315        let alt_text_line = (self.theme.alt_text_color)(alt_text);
316        let title_line = if !title.is_empty() {
317            Some((self.theme.alt_text_color)(title))
318        } else {
319            None
320        };
321
322        for i in 0..content_height {
323            let content = if i == 0 {
324                if let Some(ref t) = title_line {
325                    t.clone()
326                } else {
327                    alt_text_line.clone()
328                }
329            } else if i == 1 && title_line.is_some() {
330                alt_text_line.clone()
331            } else {
332                " ".repeat(inner_width)
333            };
334
335            let content_padded = if visible_width(&content) < inner_width {
336                format!(
337                    "{}{}",
338                    content,
339                    " ".repeat(inner_width - visible_width(&content))
340                )
341            } else {
342                crate::utils::truncate_to_width(&content, inner_width, "")
343            };
344
345            lines.push(format!(
346                "{}│{}{}\x1b[0m│\x1b[0m",
347                self.theme.border_color, self.theme.placeholder_bg, content_padded
348            ));
349        }
350
351        // Bottom border
352        lines.push(format!("{}╰{}╯\x1b[0m", self.theme.border_color, border_h));
353
354        lines.join("\n")
355    }
356}
357
358impl Default for Image {
359    fn default() -> Self {
360        Self::new()
361    }
362}
363
364impl Component for Image {
365    fn render(&self, width: usize) -> Vec<String> {
366        let rendered = self.render_image(width);
367        rendered.split('\n').map(|s| s.to_string()).collect()
368    }
369
370    fn invalidate(&self) {
371        if let Ok(mut cached) = self.cached_render.lock() {
372            *cached = None;
373        }
374    }
375
376    fn as_any(&self) -> &dyn Any {
377        self
378    }
379}
380
381#[cfg(test)]
382mod tests {
383    use super::*;
384
385    #[test]
386    fn image_visibility_retains_placeholder_and_resizing_rebuilds_layout() {
387        let image = Image::new();
388        image.set_inline_visible(false);
389        assert_eq!(image.render(80), vec!["[Image]"]);
390        assert_eq!(image.render(3), vec!["[Im"]);
391        assert_eq!(image.render(0), vec![""]);
392        image.set_inline_visible(true);
393        let wide = image.render(80);
394        let narrow = image.render(3);
395        assert_ne!(wide, narrow);
396        assert!(narrow.iter().all(|line| visible_width(line) <= 3));
397    }
398
399    #[test]
400    fn test_image_new() {
401        let image = Image::new();
402        assert!(!image.is_loaded());
403    }
404
405    #[test]
406    fn test_image_from_data() {
407        let data = vec![0x89, 0x50, 0x4E, 0x47]; // PNG header start
408        let image = Image::from_data(data, ImageOptions::default());
409        assert!(image.is_loaded());
410    }
411
412    #[test]
413    fn test_image_placeholder() {
414        let image = Image::new();
415        let lines = image.render(20);
416        assert!(!lines.is_empty());
417    }
418}