ricecoder_images/
display.rs

1//! Terminal display of images with ASCII fallback.
2
3use crate::config::DisplayConfig;
4use crate::error::{ImageError, ImageResult};
5use crate::models::ImageMetadata;
6
7/// Displays images in the terminal with ASCII fallback support.
8///
9/// Provides terminal rendering of images with:
10/// - Metadata display (format, size, dimensions)
11/// - ASCII placeholder for unsupported terminals
12/// - Automatic resizing to fit terminal bounds (max 80x30)
13/// - Multi-image support with vertical organization
14pub struct ImageDisplay {
15    pub(crate) config: DisplayConfig,
16}
17
18impl ImageDisplay {
19    /// Create a new image display with default configuration.
20    pub fn new() -> Self {
21        Self {
22            config: DisplayConfig::default(),
23        }
24    }
25
26    /// Create a new image display with custom configuration.
27    pub fn with_config(config: DisplayConfig) -> Self {
28        Self { config }
29    }
30
31    /// Render a single image with metadata.
32    ///
33    /// # Arguments
34    ///
35    /// * `metadata` - Image metadata containing format, size, dimensions
36    ///
37    /// # Returns
38    ///
39    /// Rendered image string with metadata
40    pub fn render_image(&self, metadata: &ImageMetadata) -> ImageResult<String> {
41        let mut output = String::new();
42
43        // Add metadata header
44        output.push_str(&self.render_metadata(metadata)?);
45        output.push('\n');
46
47        // Add ASCII placeholder
48        output.push_str(&self.render_ascii_placeholder());
49
50        Ok(output)
51    }
52
53    /// Render metadata for an image.
54    ///
55    /// # Arguments
56    ///
57    /// * `metadata` - Image metadata
58    ///
59    /// # Returns
60    ///
61    /// Formatted metadata string
62    fn render_metadata(&self, metadata: &ImageMetadata) -> ImageResult<String> {
63        let (width, height) = metadata.dimensions();
64        let size_mb = metadata.size_mb();
65        let format = metadata.format_str();
66
67        Ok(format!(
68            "[Image] {} | {}x{} | {:.1} MB",
69            format.to_uppercase(),
70            width,
71            height,
72            size_mb
73        ))
74    }
75
76    /// Render ASCII placeholder for unsupported terminals.
77    ///
78    /// # Returns
79    ///
80    /// ASCII art placeholder string
81    fn render_ascii_placeholder(&self) -> String {
82        let placeholder_char = &self.config.placeholder_char;
83        let width = self.config.max_width as usize;
84        let height = 10; // Use fixed small height instead of max_height
85
86        let mut output = String::new();
87
88        // Create a simple ASCII box
89        for row in 0..height {
90            if row == 0 || row == height - 1 {
91                // Top and bottom borders
92                output.push_str(&placeholder_char.repeat(width));
93            } else if row == 1 || row == height - 2 {
94                // Second and second-to-last rows with borders
95                output.push_str(placeholder_char);
96                output.push_str(&" ".repeat(width.saturating_sub(2)));
97                output.push_str(placeholder_char);
98            } else {
99                // Middle rows with borders
100                output.push_str(placeholder_char);
101                output.push_str(&" ".repeat(width.saturating_sub(2)));
102                output.push_str(placeholder_char);
103            }
104            output.push('\n');
105        }
106
107        output
108    }
109
110    /// Calculate resized dimensions to fit within terminal bounds.
111    ///
112    /// # Arguments
113    ///
114    /// * `original_width` - Original image width in pixels
115    /// * `original_height` - Original image height in pixels
116    ///
117    /// # Returns
118    ///
119    /// Resized dimensions (width, height) in characters
120    pub fn calculate_resized_dimensions(
121        &self,
122        original_width: u32,
123        original_height: u32,
124    ) -> (u32, u32) {
125        if original_width == 0 || original_height == 0 {
126            return (self.config.max_width, self.config.max_height);
127        }
128
129        let max_width = self.config.max_width;
130        let max_height = self.config.max_height;
131
132        // Calculate aspect ratio
133        let aspect_ratio = original_width as f64 / original_height as f64;
134
135        // Calculate dimensions if we scale to max width
136        let height_at_max_width = (max_width as f64 / aspect_ratio) as u32;
137        
138        // If scaling to max width fits within height, use it
139        if height_at_max_width <= max_height {
140            (max_width, height_at_max_width.max(1))
141        } else {
142            // Otherwise scale to max height
143            let width_at_max_height = (max_height as f64 * aspect_ratio) as u32;
144            (width_at_max_height.max(1), max_height)
145        }
146    }
147
148    /// Verify that display includes all required metadata.
149    ///
150    /// # Arguments
151    ///
152    /// * `display_output` - The rendered display string
153    /// * `metadata` - The image metadata
154    ///
155    /// # Returns
156    ///
157    /// True if all metadata is present, error otherwise
158    pub fn verify_metadata_present(
159        &self,
160        display_output: &str,
161        metadata: &ImageMetadata,
162    ) -> ImageResult<bool> {
163        let format = metadata.format_str().to_uppercase();
164        let (width, height) = metadata.dimensions();
165
166        // Check if format is present
167        if !display_output.contains(&format) {
168            return Err(ImageError::DisplayError(
169                "Format not found in display output".to_string(),
170            ));
171        }
172
173        // Check if dimensions are present
174        let dimensions_str = format!("{}x{}", width, height);
175        if !display_output.contains(&dimensions_str) {
176            return Err(ImageError::DisplayError(
177                "Dimensions not found in display output".to_string(),
178            ));
179        }
180
181        Ok(true)
182    }
183
184    /// Verify that display fits within terminal bounds.
185    ///
186    /// # Arguments
187    ///
188    /// * `display_output` - The rendered display string
189    ///
190    /// # Returns
191    ///
192    /// True if display fits within bounds, error otherwise
193    pub fn verify_fits_in_terminal(&self, display_output: &str) -> ImageResult<bool> {
194        let lines: Vec<&str> = display_output.lines().collect();
195        let height = lines.len() as u32;
196
197        if height > self.config.max_height {
198            return Err(ImageError::DisplayError(format!(
199                "Display height {} exceeds maximum {}",
200                height, self.config.max_height
201            )));
202        }
203
204        for line in lines {
205            let width = line.chars().count() as u32;
206            if width > self.config.max_width {
207                return Err(ImageError::DisplayError(format!(
208                    "Display width {} exceeds maximum {}",
209                    width, self.config.max_width
210                )));
211            }
212        }
213
214        Ok(true)
215    }
216
217    /// Render multiple images organized vertically with separators.
218    ///
219    /// # Arguments
220    ///
221    /// * `metadata_list` - List of image metadata to render
222    ///
223    /// # Returns
224    ///
225    /// Rendered multi-image display string
226    pub fn render_multiple_images(&self, metadata_list: &[ImageMetadata]) -> ImageResult<String> {
227        if metadata_list.is_empty() {
228            return Ok(String::new());
229        }
230
231        let mut output = String::new();
232
233        for (index, metadata) in metadata_list.iter().enumerate() {
234            // Add separator between images
235            if index > 0 {
236                output.push_str(&self.render_separator());
237                output.push('\n');
238            }
239
240            // Render image
241            let image_display = self.render_image(metadata)?;
242            output.push_str(&image_display);
243
244            // Add newline after each image except the last
245            if index < metadata_list.len() - 1 {
246                output.push('\n');
247            }
248        }
249
250        Ok(output)
251    }
252
253    /// Render a separator between images.
254    ///
255    /// # Returns
256    ///
257    /// Separator string
258    fn render_separator(&self) -> String {
259        let separator_char = "─";
260        separator_char.repeat(self.config.max_width as usize)
261    }
262
263    /// Verify that multiple images are organized vertically.
264    ///
265    /// # Arguments
266    ///
267    /// * `display_output` - The rendered display string
268    /// * `metadata_list` - List of image metadata
269    ///
270    /// # Returns
271    ///
272    /// True if images are properly organized, error otherwise
273    pub fn verify_multiple_images_organized(
274        &self,
275        display_output: &str,
276        metadata_list: &[ImageMetadata],
277    ) -> ImageResult<bool> {
278        if metadata_list.is_empty() {
279            return Ok(true);
280        }
281
282        // Check that all images are present
283        for metadata in metadata_list {
284            let format = metadata.format_str().to_uppercase();
285            if !display_output.contains(&format) {
286                return Err(ImageError::DisplayError(
287                    "Not all images present in display output".to_string(),
288                ));
289            }
290        }
291
292        // Check that separators are present between images (if more than one)
293        if metadata_list.len() > 1 {
294            // Count separator lines (lines that are all separator characters)
295            let separator_lines = display_output
296                .lines()
297                .filter(|line| line.chars().all(|c| c == '─' || c.is_whitespace()))
298                .count();
299            
300            if separator_lines == 0 {
301                return Err(ImageError::DisplayError(
302                    "No separators found between images".to_string(),
303                ));
304            }
305        }
306
307        // Verify overall display fits in terminal
308        self.verify_fits_in_terminal(display_output)?;
309
310        Ok(true)
311    }
312}
313
314impl Default for ImageDisplay {
315    fn default() -> Self {
316        Self::new()
317    }
318}
319
320#[cfg(test)]
321mod tests {
322    use super::*;
323    use crate::formats::ImageFormat;
324    use std::path::PathBuf;
325
326    #[test]
327    fn test_display_creation() {
328        let _display = ImageDisplay::new();
329    }
330
331    #[test]
332    fn test_display_with_config() {
333        let config = DisplayConfig {
334            max_width: 100,
335            max_height: 50,
336            placeholder_char: "█".to_string(),
337        };
338        let display = ImageDisplay::with_config(config);
339        assert_eq!(display.config.max_width, 100);
340        assert_eq!(display.config.max_height, 50);
341    }
342
343    #[test]
344    fn test_render_metadata() {
345        let display = ImageDisplay::new();
346        let metadata = ImageMetadata::new(
347            PathBuf::from("/path/to/image.png"),
348            ImageFormat::Png,
349            1024 * 1024,
350            800,
351            600,
352            "abc123".to_string(),
353        );
354
355        let metadata_str = display.render_metadata(&metadata).unwrap();
356        assert!(metadata_str.contains("PNG"));
357        assert!(metadata_str.contains("800x600"));
358        assert!(metadata_str.contains("1.0 MB"));
359    }
360
361    #[test]
362    fn test_render_ascii_placeholder() {
363        let display = ImageDisplay::new();
364        let placeholder = display.render_ascii_placeholder();
365
366        // Should have multiple lines
367        let lines: Vec<&str> = placeholder.lines().collect();
368        assert!(lines.len() > 0);
369
370        // Should fit within max dimensions
371        assert!(lines.len() as u32 <= display.config.max_height);
372        for line in lines {
373            assert!(line.chars().count() as u32 <= display.config.max_width);
374        }
375    }
376
377    #[test]
378    fn test_render_image() {
379        let display = ImageDisplay::new();
380        let metadata = ImageMetadata::new(
381            PathBuf::from("/path/to/image.png"),
382            ImageFormat::Png,
383            1024 * 1024,
384            800,
385            600,
386            "abc123".to_string(),
387        );
388
389        let rendered = display.render_image(&metadata).unwrap();
390        assert!(rendered.contains("PNG"));
391        assert!(rendered.contains("800x600"));
392        assert!(rendered.contains("█")); // Placeholder character
393    }
394
395    #[test]
396    fn test_calculate_resized_dimensions_width_limited() {
397        let display = ImageDisplay::new();
398        // Use an image that is very wide but not too tall (so width is the limiting factor)
399        let (resized_width, resized_height) = display.calculate_resized_dimensions(800, 100);
400
401        // Width should be at max
402        assert_eq!(resized_width, display.config.max_width);
403        // Height should be proportional
404        assert!(resized_height <= display.config.max_height);
405    }
406
407    #[test]
408    fn test_calculate_resized_dimensions_height_limited() {
409        let display = ImageDisplay::new();
410        let (resized_width, resized_height) = display.calculate_resized_dimensions(400, 1200);
411
412        // Height should be at max
413        assert_eq!(resized_height, display.config.max_height);
414        // Width should be proportional
415        assert!(resized_width <= display.config.max_width);
416    }
417
418    #[test]
419    fn test_calculate_resized_dimensions_zero_dimensions() {
420        let display = ImageDisplay::new();
421        let (resized_width, resized_height) = display.calculate_resized_dimensions(0, 0);
422
423        // Should return max dimensions
424        assert_eq!(resized_width, display.config.max_width);
425        assert_eq!(resized_height, display.config.max_height);
426    }
427
428    #[test]
429    fn test_verify_metadata_present() {
430        let display = ImageDisplay::new();
431        let metadata = ImageMetadata::new(
432            PathBuf::from("/path/to/image.png"),
433            ImageFormat::Png,
434            1024 * 1024,
435            800,
436            600,
437            "abc123".to_string(),
438        );
439
440        let rendered = display.render_image(&metadata).unwrap();
441        let result = display.verify_metadata_present(&rendered, &metadata);
442        assert!(result.is_ok());
443        assert!(result.unwrap());
444    }
445
446    #[test]
447    fn test_verify_fits_in_terminal() {
448        let display = ImageDisplay::new();
449        let metadata = ImageMetadata::new(
450            PathBuf::from("/path/to/image.png"),
451            ImageFormat::Png,
452            1024 * 1024,
453            800,
454            600,
455            "abc123".to_string(),
456        );
457
458        let rendered = display.render_image(&metadata).unwrap();
459        let result = display.verify_fits_in_terminal(&rendered);
460        assert!(result.is_ok());
461        assert!(result.unwrap());
462    }
463
464    #[test]
465    fn test_render_separator() {
466        let display = ImageDisplay::new();
467        let separator = display.render_separator();
468
469        // Should contain separator characters
470        assert!(separator.contains("─"));
471        // Should be approximately max_width length
472        assert!(separator.len() > 0);
473    }
474
475    #[test]
476    fn test_render_multiple_images_empty() {
477        let display = ImageDisplay::new();
478        let metadata_list: Vec<ImageMetadata> = vec![];
479
480        let rendered = display.render_multiple_images(&metadata_list).unwrap();
481        assert_eq!(rendered, "");
482    }
483
484    #[test]
485    fn test_render_multiple_images_single() {
486        let display = ImageDisplay::new();
487        let metadata = ImageMetadata::new(
488            PathBuf::from("/path/to/image.png"),
489            ImageFormat::Png,
490            1024 * 1024,
491            800,
492            600,
493            "abc123".to_string(),
494        );
495
496        let rendered = display.render_multiple_images(&[metadata]).unwrap();
497        assert!(rendered.contains("PNG"));
498        assert!(rendered.contains("800x600"));
499    }
500
501    #[test]
502    fn test_render_multiple_images_multiple() {
503        let display = ImageDisplay::new();
504        let metadata1 = ImageMetadata::new(
505            PathBuf::from("/path/to/image1.png"),
506            ImageFormat::Png,
507            1024 * 1024,
508            800,
509            600,
510            "abc123".to_string(),
511        );
512        let metadata2 = ImageMetadata::new(
513            PathBuf::from("/path/to/image2.jpg"),
514            ImageFormat::Jpeg,
515            2048 * 1024,
516            1024,
517            768,
518            "def456".to_string(),
519        );
520
521        let rendered = display.render_multiple_images(&[metadata1, metadata2]).unwrap();
522
523        // Should contain both images
524        assert!(rendered.contains("PNG"));
525        assert!(rendered.contains("JPG"));
526        assert!(rendered.contains("800x600"));
527        assert!(rendered.contains("1024x768"));
528
529        // Should contain separator
530        assert!(rendered.contains("─"));
531    }
532
533    #[test]
534    fn test_verify_multiple_images_organized() {
535        let display = ImageDisplay::new();
536        let metadata1 = ImageMetadata::new(
537            PathBuf::from("/path/to/image1.png"),
538            ImageFormat::Png,
539            1024 * 1024,
540            800,
541            600,
542            "abc123".to_string(),
543        );
544        let metadata2 = ImageMetadata::new(
545            PathBuf::from("/path/to/image2.jpg"),
546            ImageFormat::Jpeg,
547            2048 * 1024,
548            1024,
549            768,
550            "def456".to_string(),
551        );
552
553        let rendered = display.render_multiple_images(&[metadata1.clone(), metadata2.clone()]).unwrap();
554        let result = display.verify_multiple_images_organized(&rendered, &[metadata1, metadata2]);
555        assert!(result.is_ok());
556        assert!(result.unwrap());
557    }
558}