ricecoder_tui/
image_widget.rs

1//! Image widget for displaying images in the terminal UI
2//!
3//! This module provides the `ImageWidget` for rendering images in the ricecoder-tui,
4//! integrating with ricecoder-images for display and metadata rendering.
5//!
6//! # Requirements
7//!
8//! - Req 5.1: Display images in terminal using ricecoder-images ImageDisplay
9//! - Req 5.2: Show metadata (format, size, dimensions)
10//! - Req 5.3: ASCII placeholder for unsupported terminals
11//! - Req 5.4: Resize to fit terminal bounds (max 80x30)
12//! - Req 5.5: Organize multiple images vertically with separators
13
14use std::path::PathBuf;
15
16/// Image widget for displaying images in the terminal
17///
18/// Provides:
19/// - Single and multiple image display
20/// - Metadata rendering (format, size, dimensions)
21/// - ASCII placeholder fallback
22/// - Automatic resizing to fit terminal bounds
23/// - Vertical organization with separators
24///
25/// # Requirements
26///
27/// - Req 5.1: Display images in terminal using ricecoder-images ImageDisplay
28/// - Req 5.2: Show metadata (format, size, dimensions)
29/// - Req 5.3: ASCII placeholder for unsupported terminals
30/// - Req 5.4: Resize to fit terminal bounds (max 80x30)
31/// - Req 5.5: Organize multiple images vertically with separators
32#[derive(Debug, Clone)]
33pub struct ImageWidget {
34    /// Image file paths to display
35    pub images: Vec<PathBuf>,
36    /// Whether the widget is visible
37    pub visible: bool,
38    /// Maximum width for display (characters)
39    pub max_width: u32,
40    /// Maximum height for display (characters)
41    pub max_height: u32,
42    /// Whether to show metadata
43    pub show_metadata: bool,
44    /// Whether to use ASCII placeholder
45    pub use_ascii_placeholder: bool,
46}
47
48impl ImageWidget {
49    /// Create a new image widget
50    pub fn new() -> Self {
51        Self {
52            images: Vec::new(),
53            visible: true,
54            max_width: 80,
55            max_height: 30,
56            show_metadata: true,
57            use_ascii_placeholder: true,
58        }
59    }
60
61    /// Create a new image widget with specific dimensions
62    pub fn with_dimensions(width: u32, height: u32) -> Self {
63        Self {
64            images: Vec::new(),
65            visible: true,
66            max_width: width,
67            max_height: height,
68            show_metadata: true,
69            use_ascii_placeholder: true,
70        }
71    }
72
73    /// Add an image to the widget
74    ///
75    /// # Arguments
76    ///
77    /// * `path` - Path to the image file
78    ///
79    /// # Requirements
80    ///
81    /// - Req 5.1: Add image to widget for display
82    pub fn add_image(&mut self, path: PathBuf) {
83        if !self.images.contains(&path) {
84            self.images.push(path);
85        }
86    }
87
88    /// Add multiple images to the widget
89    ///
90    /// # Arguments
91    ///
92    /// * `paths` - Paths to the image files
93    ///
94    /// # Requirements
95    ///
96    /// - Req 5.5: Organize multiple images vertically with separators
97    pub fn add_images(&mut self, paths: Vec<PathBuf>) {
98        for path in paths {
99            self.add_image(path);
100        }
101    }
102
103    /// Remove an image from the widget
104    ///
105    /// # Arguments
106    ///
107    /// * `path` - Path to the image to remove
108    ///
109    /// # Returns
110    ///
111    /// True if image was removed, false if not found
112    pub fn remove_image(&mut self, path: &PathBuf) -> bool {
113        if let Some(pos) = self.images.iter().position(|p| p == path) {
114            self.images.remove(pos);
115            true
116        } else {
117            false
118        }
119    }
120
121    /// Clear all images from the widget
122    pub fn clear_images(&mut self) {
123        self.images.clear();
124    }
125
126    /// Get the number of images in the widget
127    pub fn image_count(&self) -> usize {
128        self.images.len()
129    }
130
131    /// Check if the widget has any images
132    pub fn has_images(&self) -> bool {
133        !self.images.is_empty()
134    }
135
136    /// Get the images in the widget
137    pub fn get_images(&self) -> &[PathBuf] {
138        &self.images
139    }
140
141    /// Show the widget
142    pub fn show(&mut self) {
143        self.visible = true;
144    }
145
146    /// Hide the widget
147    pub fn hide(&mut self) {
148        self.visible = false;
149    }
150
151    /// Toggle widget visibility
152    pub fn toggle_visibility(&mut self) {
153        self.visible = !self.visible;
154    }
155
156    /// Set the maximum display dimensions
157    ///
158    /// # Arguments
159    ///
160    /// * `width` - Maximum width in characters
161    /// * `height` - Maximum height in characters
162    ///
163    /// # Requirements
164    ///
165    /// - Req 5.4: Resize to fit terminal bounds (max 80x30)
166    pub fn set_dimensions(&mut self, width: u32, height: u32) {
167        self.max_width = width;
168        self.max_height = height;
169    }
170
171    /// Enable metadata display
172    pub fn enable_metadata(&mut self) {
173        self.show_metadata = true;
174    }
175
176    /// Disable metadata display
177    pub fn disable_metadata(&mut self) {
178        self.show_metadata = false;
179    }
180
181    /// Enable ASCII placeholder
182    pub fn enable_ascii_placeholder(&mut self) {
183        self.use_ascii_placeholder = true;
184    }
185
186    /// Disable ASCII placeholder
187    pub fn disable_ascii_placeholder(&mut self) {
188        self.use_ascii_placeholder = false;
189    }
190
191    /// Render the widget as a string
192    ///
193    /// # Returns
194    ///
195    /// Rendered widget string
196    ///
197    /// # Requirements
198    ///
199    /// - Req 5.1: Display images in terminal using ricecoder-images ImageDisplay
200    /// - Req 5.2: Show metadata (format, size, dimensions)
201    /// - Req 5.3: ASCII placeholder for unsupported terminals
202    /// - Req 5.4: Resize to fit terminal bounds (max 80x30)
203    /// - Req 5.5: Organize multiple images vertically with separators
204    pub fn render(&self) -> String {
205        if !self.visible || self.images.is_empty() {
206            return String::new();
207        }
208
209        let mut output = String::new();
210
211        // Render each image
212        for (index, _path) in self.images.iter().enumerate() {
213            // Add separator between images
214            if index > 0 {
215                output.push_str(&self.render_separator());
216                output.push('\n');
217            }
218
219            // Render image placeholder with metadata
220            output.push_str(&self.render_image_placeholder(index));
221
222            // Add newline after each image except the last
223            if index < self.images.len() - 1 {
224                output.push('\n');
225            }
226        }
227
228        output
229    }
230
231    /// Render a single image placeholder
232    fn render_image_placeholder(&self, index: usize) -> String {
233        let mut output = String::new();
234
235        // Add metadata header if enabled
236        if self.show_metadata {
237            output.push_str(&format!("[Image {}] ", index + 1));
238            if let Some(path) = self.images.get(index) {
239                output.push_str(&format!("{}", path.display()));
240            }
241            output.push('\n');
242        }
243
244        // Add ASCII placeholder if enabled
245        if self.use_ascii_placeholder {
246            output.push_str(&self.render_ascii_placeholder());
247        }
248
249        output
250    }
251
252    /// Render ASCII placeholder for an image
253    fn render_ascii_placeholder(&self) -> String {
254        let placeholder_char = "█";
255        let width = self.max_width as usize;
256        let height = 10; // Use fixed small height
257
258        let mut output = String::new();
259
260        // Create a simple ASCII box
261        for row in 0..height {
262            if row == 0 || row == height - 1 {
263                // Top and bottom borders
264                output.push_str(&placeholder_char.repeat(width));
265            } else if row == 1 || row == height - 2 {
266                // Second and second-to-last rows with borders
267                output.push_str(placeholder_char);
268                output.push_str(&" ".repeat(width.saturating_sub(2)));
269                output.push_str(placeholder_char);
270            } else {
271                // Middle rows with borders
272                output.push_str(placeholder_char);
273                output.push_str(&" ".repeat(width.saturating_sub(2)));
274                output.push_str(placeholder_char);
275            }
276            output.push('\n');
277        }
278
279        output
280    }
281
282    /// Render a separator between images
283    fn render_separator(&self) -> String {
284        let separator_char = "─";
285        separator_char.repeat(self.max_width as usize)
286    }
287}
288
289impl Default for ImageWidget {
290    fn default() -> Self {
291        Self::new()
292    }
293}
294
295#[cfg(test)]
296mod tests {
297    use super::*;
298
299    #[test]
300    fn test_image_widget_creation() {
301        let widget = ImageWidget::new();
302        assert!(widget.visible);
303        assert_eq!(widget.max_width, 80);
304        assert_eq!(widget.max_height, 30);
305        assert!(widget.show_metadata);
306        assert!(widget.use_ascii_placeholder);
307        assert_eq!(widget.image_count(), 0);
308    }
309
310    #[test]
311    fn test_image_widget_with_dimensions() {
312        let widget = ImageWidget::with_dimensions(100, 50);
313        assert_eq!(widget.max_width, 100);
314        assert_eq!(widget.max_height, 50);
315    }
316
317    #[test]
318    fn test_add_image() {
319        let mut widget = ImageWidget::new();
320        let path = PathBuf::from("/path/to/image.png");
321
322        widget.add_image(path.clone());
323        assert_eq!(widget.image_count(), 1);
324        assert!(widget.has_images());
325        assert_eq!(widget.get_images()[0], path);
326    }
327
328    #[test]
329    fn test_add_duplicate_image() {
330        let mut widget = ImageWidget::new();
331        let path = PathBuf::from("/path/to/image.png");
332
333        widget.add_image(path.clone());
334        widget.add_image(path.clone());
335
336        // Should not add duplicate
337        assert_eq!(widget.image_count(), 1);
338    }
339
340    #[test]
341    fn test_add_multiple_images() {
342        let mut widget = ImageWidget::new();
343        let paths = vec![
344            PathBuf::from("/path/to/image1.png"),
345            PathBuf::from("/path/to/image2.jpg"),
346            PathBuf::from("/path/to/image3.gif"),
347        ];
348
349        widget.add_images(paths.clone());
350        assert_eq!(widget.image_count(), 3);
351    }
352
353    #[test]
354    fn test_remove_image() {
355        let mut widget = ImageWidget::new();
356        let path = PathBuf::from("/path/to/image.png");
357
358        widget.add_image(path.clone());
359        assert_eq!(widget.image_count(), 1);
360
361        let removed = widget.remove_image(&path);
362        assert!(removed);
363        assert_eq!(widget.image_count(), 0);
364    }
365
366    #[test]
367    fn test_remove_image_not_found() {
368        let mut widget = ImageWidget::new();
369        let path = PathBuf::from("/path/to/image.png");
370
371        let removed = widget.remove_image(&path);
372        assert!(!removed);
373    }
374
375    #[test]
376    fn test_clear_images() {
377        let mut widget = ImageWidget::new();
378        widget.add_images(vec![
379            PathBuf::from("/path/to/image1.png"),
380            PathBuf::from("/path/to/image2.jpg"),
381        ]);
382
383        assert_eq!(widget.image_count(), 2);
384        widget.clear_images();
385        assert_eq!(widget.image_count(), 0);
386    }
387
388    #[test]
389    fn test_visibility() {
390        let mut widget = ImageWidget::new();
391        assert!(widget.visible);
392
393        widget.hide();
394        assert!(!widget.visible);
395
396        widget.show();
397        assert!(widget.visible);
398
399        widget.toggle_visibility();
400        assert!(!widget.visible);
401    }
402
403    #[test]
404    fn test_set_dimensions() {
405        let mut widget = ImageWidget::new();
406        widget.set_dimensions(100, 50);
407
408        assert_eq!(widget.max_width, 100);
409        assert_eq!(widget.max_height, 50);
410    }
411
412    #[test]
413    fn test_metadata_toggle() {
414        let mut widget = ImageWidget::new();
415        assert!(widget.show_metadata);
416
417        widget.disable_metadata();
418        assert!(!widget.show_metadata);
419
420        widget.enable_metadata();
421        assert!(widget.show_metadata);
422    }
423
424    #[test]
425    fn test_ascii_placeholder_toggle() {
426        let mut widget = ImageWidget::new();
427        assert!(widget.use_ascii_placeholder);
428
429        widget.disable_ascii_placeholder();
430        assert!(!widget.use_ascii_placeholder);
431
432        widget.enable_ascii_placeholder();
433        assert!(widget.use_ascii_placeholder);
434    }
435
436    #[test]
437    fn test_render_empty_widget() {
438        let widget = ImageWidget::new();
439        let rendered = widget.render();
440        assert_eq!(rendered, "");
441    }
442
443    #[test]
444    fn test_render_hidden_widget() {
445        let mut widget = ImageWidget::new();
446        widget.add_image(PathBuf::from("/path/to/image.png"));
447        widget.hide();
448
449        let rendered = widget.render();
450        assert_eq!(rendered, "");
451    }
452
453    #[test]
454    fn test_render_single_image() {
455        let mut widget = ImageWidget::new();
456        widget.add_image(PathBuf::from("/path/to/image.png"));
457
458        let rendered = widget.render();
459        assert!(!rendered.is_empty());
460        assert!(rendered.contains("Image 1"));
461        assert!(rendered.contains("image.png"));
462        assert!(rendered.contains("█")); // ASCII placeholder
463    }
464
465    #[test]
466    fn test_render_multiple_images() {
467        let mut widget = ImageWidget::new();
468        widget.add_images(vec![
469            PathBuf::from("/path/to/image1.png"),
470            PathBuf::from("/path/to/image2.jpg"),
471        ]);
472
473        let rendered = widget.render();
474        assert!(rendered.contains("Image 1"));
475        assert!(rendered.contains("Image 2"));
476        assert!(rendered.contains("image1.png"));
477        assert!(rendered.contains("image2.jpg"));
478        assert!(rendered.contains("─")); // Separator
479    }
480
481    #[test]
482    fn test_render_without_metadata() {
483        let mut widget = ImageWidget::new();
484        widget.add_image(PathBuf::from("/path/to/image.png"));
485        widget.disable_metadata();
486
487        let rendered = widget.render();
488        assert!(!rendered.contains("Image 1"));
489        assert!(rendered.contains("█")); // ASCII placeholder still present
490    }
491
492    #[test]
493    fn test_render_without_ascii_placeholder() {
494        let mut widget = ImageWidget::new();
495        widget.add_image(PathBuf::from("/path/to/image.png"));
496        widget.disable_ascii_placeholder();
497
498        let rendered = widget.render();
499        assert!(rendered.contains("Image 1"));
500        assert!(!rendered.contains("█")); // No ASCII placeholder
501    }
502
503    #[test]
504    fn test_render_fits_within_bounds() {
505        let mut widget = ImageWidget::with_dimensions(80, 30);
506        widget.add_image(PathBuf::from("/path/to/image.png"));
507
508        let rendered = widget.render();
509        let lines: Vec<&str> = rendered.lines().collect();
510
511        // Check that height is within bounds
512        assert!(lines.len() as u32 <= widget.max_height);
513
514        // Check that width is within bounds
515        for line in lines {
516            assert!(line.chars().count() as u32 <= widget.max_width);
517        }
518    }
519}