presentar_widgets/
image.rs

1//! Image widget for displaying images.
2
3use presentar_core::{
4    widget::{AccessibleRole, LayoutResult},
5    Canvas, Constraints, Event, Rect, Size, TypeId, Widget,
6};
7use serde::{Deserialize, Serialize};
8use std::any::Any;
9
10/// How the image should be scaled to fit its container.
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
12pub enum ImageFit {
13    /// Scale to fill the container, may crop
14    Cover,
15    /// Scale to fit entirely within container, may have letterboxing
16    #[default]
17    Contain,
18    /// Stretch to fill container exactly (may distort)
19    Fill,
20    /// Don't scale, display at natural size
21    None,
22    /// Scale down only if larger than container
23    ScaleDown,
24}
25
26/// Image widget.
27#[derive(Debug, Clone, Serialize, Deserialize)]
28pub struct Image {
29    /// Image source URI
30    source: String,
31    /// Alternative text for accessibility
32    alt: String,
33    /// How to fit the image
34    fit: ImageFit,
35    /// Intrinsic width (natural size)
36    width: Option<f32>,
37    /// Intrinsic height (natural size)
38    height: Option<f32>,
39    /// Whether image is loading
40    #[serde(skip)]
41    loading: bool,
42    /// Whether image failed to load
43    #[serde(skip)]
44    error: bool,
45    /// Accessible name override
46    accessible_name_value: Option<String>,
47    /// Test ID
48    test_id_value: Option<String>,
49    /// Cached bounds
50    #[serde(skip)]
51    bounds: Rect,
52}
53
54impl Default for Image {
55    fn default() -> Self {
56        Self {
57            source: String::new(),
58            alt: String::new(),
59            fit: ImageFit::Contain,
60            width: None,
61            height: None,
62            loading: false,
63            error: false,
64            accessible_name_value: None,
65            test_id_value: None,
66            bounds: Rect::default(),
67        }
68    }
69}
70
71impl Image {
72    /// Create a new image with source.
73    #[must_use]
74    pub fn new(source: impl Into<String>) -> Self {
75        Self {
76            source: source.into(),
77            ..Self::default()
78        }
79    }
80
81    /// Set the image source.
82    #[must_use]
83    pub fn source(mut self, source: impl Into<String>) -> Self {
84        self.source = source.into();
85        self
86    }
87
88    /// Set the alt text.
89    #[must_use]
90    pub fn alt(mut self, alt: impl Into<String>) -> Self {
91        self.alt = alt.into();
92        self
93    }
94
95    /// Set how the image should fit.
96    #[must_use]
97    pub const fn fit(mut self, fit: ImageFit) -> Self {
98        self.fit = fit;
99        self
100    }
101
102    /// Set the intrinsic width.
103    #[must_use]
104    pub fn width(mut self, width: f32) -> Self {
105        self.width = Some(width.max(0.0));
106        self
107    }
108
109    /// Set the intrinsic height.
110    #[must_use]
111    pub fn height(mut self, height: f32) -> Self {
112        self.height = Some(height.max(0.0));
113        self
114    }
115
116    /// Set both width and height.
117    #[must_use]
118    pub fn size(self, width: f32, height: f32) -> Self {
119        self.width(width).height(height)
120    }
121
122    /// Set the accessible name.
123    #[must_use]
124    pub fn accessible_name(mut self, name: impl Into<String>) -> Self {
125        self.accessible_name_value = Some(name.into());
126        self
127    }
128
129    /// Set the test ID.
130    #[must_use]
131    pub fn test_id(mut self, id: impl Into<String>) -> Self {
132        self.test_id_value = Some(id.into());
133        self
134    }
135
136    /// Get the image source.
137    #[must_use]
138    pub fn get_source(&self) -> &str {
139        &self.source
140    }
141
142    /// Get the alt text.
143    #[must_use]
144    pub fn get_alt(&self) -> &str {
145        &self.alt
146    }
147
148    /// Get the fit mode.
149    #[must_use]
150    pub const fn get_fit(&self) -> ImageFit {
151        self.fit
152    }
153
154    /// Get the intrinsic width.
155    #[must_use]
156    pub const fn get_width(&self) -> Option<f32> {
157        self.width
158    }
159
160    /// Get the intrinsic height.
161    #[must_use]
162    pub const fn get_height(&self) -> Option<f32> {
163        self.height
164    }
165
166    /// Check if image is loading.
167    #[must_use]
168    pub const fn is_loading(&self) -> bool {
169        self.loading
170    }
171
172    /// Check if image failed to load.
173    #[must_use]
174    pub const fn has_error(&self) -> bool {
175        self.error
176    }
177
178    /// Set loading state.
179    pub fn set_loading(&mut self, loading: bool) {
180        self.loading = loading;
181    }
182
183    /// Set error state.
184    pub fn set_error(&mut self, error: bool) {
185        self.error = error;
186    }
187
188    /// Calculate aspect ratio.
189    #[must_use]
190    pub fn aspect_ratio(&self) -> Option<f32> {
191        match (self.width, self.height) {
192            (Some(w), Some(h)) if h > 0.0 => Some(w / h),
193            _ => None,
194        }
195    }
196
197    /// Calculate display size given container constraints.
198    fn calculate_display_size(&self, container: Size) -> Size {
199        let intrinsic = Size::new(
200            self.width.unwrap_or(container.width),
201            self.height.unwrap_or(container.height),
202        );
203
204        match self.fit {
205            ImageFit::Fill => container,
206            ImageFit::None => intrinsic,
207            ImageFit::Contain => {
208                let scale =
209                    (container.width / intrinsic.width).min(container.height / intrinsic.height);
210                Size::new(intrinsic.width * scale, intrinsic.height * scale)
211            }
212            ImageFit::Cover => {
213                let scale =
214                    (container.width / intrinsic.width).max(container.height / intrinsic.height);
215                Size::new(intrinsic.width * scale, intrinsic.height * scale)
216            }
217            ImageFit::ScaleDown => {
218                if intrinsic.width <= container.width && intrinsic.height <= container.height {
219                    intrinsic
220                } else {
221                    let scale = (container.width / intrinsic.width)
222                        .min(container.height / intrinsic.height);
223                    Size::new(intrinsic.width * scale, intrinsic.height * scale)
224                }
225            }
226        }
227    }
228}
229
230impl Widget for Image {
231    fn type_id(&self) -> TypeId {
232        TypeId::of::<Self>()
233    }
234
235    fn measure(&self, constraints: Constraints) -> Size {
236        let preferred = Size::new(self.width.unwrap_or(100.0), self.height.unwrap_or(100.0));
237        constraints.constrain(preferred)
238    }
239
240    fn layout(&mut self, bounds: Rect) -> LayoutResult {
241        self.bounds = bounds;
242        LayoutResult {
243            size: bounds.size(),
244        }
245    }
246
247    fn paint(&self, canvas: &mut dyn Canvas) {
248        // Draw placeholder or image
249        // In a real implementation, this would render the actual image
250        // For now, we draw a placeholder rectangle
251        let display_size = self.calculate_display_size(self.bounds.size());
252
253        // Center the image in bounds
254        let x_offset = (self.bounds.width - display_size.width) / 2.0;
255        let y_offset = (self.bounds.height - display_size.height) / 2.0;
256
257        let image_rect = Rect::new(
258            self.bounds.x + x_offset,
259            self.bounds.y + y_offset,
260            display_size.width,
261            display_size.height,
262        );
263
264        // Draw placeholder (light gray for loading, red tint for error)
265        let color = if self.error {
266            presentar_core::Color::new(0.9, 0.7, 0.7, 1.0)
267        } else if self.loading {
268            presentar_core::Color::new(0.9, 0.9, 0.9, 1.0)
269        } else {
270            presentar_core::Color::new(0.8, 0.8, 0.8, 1.0)
271        };
272
273        canvas.fill_rect(image_rect, color);
274    }
275
276    fn event(&mut self, _event: &Event) -> Option<Box<dyn Any + Send>> {
277        None
278    }
279
280    fn children(&self) -> &[Box<dyn Widget>] {
281        &[]
282    }
283
284    fn children_mut(&mut self) -> &mut [Box<dyn Widget>] {
285        &mut []
286    }
287
288    fn is_interactive(&self) -> bool {
289        false
290    }
291
292    fn is_focusable(&self) -> bool {
293        false
294    }
295
296    fn accessible_name(&self) -> Option<&str> {
297        self.accessible_name_value
298            .as_deref()
299            .or(if self.alt.is_empty() {
300                None
301            } else {
302                Some(&self.alt)
303            })
304    }
305
306    fn accessible_role(&self) -> AccessibleRole {
307        AccessibleRole::Image
308    }
309
310    fn test_id(&self) -> Option<&str> {
311        self.test_id_value.as_deref()
312    }
313}
314
315#[cfg(test)]
316mod tests {
317    use super::*;
318
319    // ===== ImageFit Tests =====
320
321    #[test]
322    fn test_image_fit_default() {
323        assert_eq!(ImageFit::default(), ImageFit::Contain);
324    }
325
326    #[test]
327    fn test_image_fit_equality() {
328        assert_eq!(ImageFit::Cover, ImageFit::Cover);
329        assert_ne!(ImageFit::Cover, ImageFit::Contain);
330    }
331
332    // ===== Image Construction Tests =====
333
334    #[test]
335    fn test_image_new() {
336        let img = Image::new("https://example.com/image.png");
337        assert_eq!(img.get_source(), "https://example.com/image.png");
338        assert!(img.get_alt().is_empty());
339    }
340
341    #[test]
342    fn test_image_default() {
343        let img = Image::default();
344        assert!(img.get_source().is_empty());
345        assert!(img.get_alt().is_empty());
346        assert_eq!(img.get_fit(), ImageFit::Contain);
347        assert!(img.get_width().is_none());
348        assert!(img.get_height().is_none());
349    }
350
351    #[test]
352    fn test_image_builder() {
353        let img = Image::new("photo.jpg")
354            .alt("A beautiful sunset")
355            .fit(ImageFit::Cover)
356            .width(800.0)
357            .height(600.0)
358            .accessible_name("Sunset photo")
359            .test_id("hero-image");
360
361        assert_eq!(img.get_source(), "photo.jpg");
362        assert_eq!(img.get_alt(), "A beautiful sunset");
363        assert_eq!(img.get_fit(), ImageFit::Cover);
364        assert_eq!(img.get_width(), Some(800.0));
365        assert_eq!(img.get_height(), Some(600.0));
366        assert_eq!(Widget::accessible_name(&img), Some("Sunset photo"));
367        assert_eq!(Widget::test_id(&img), Some("hero-image"));
368    }
369
370    #[test]
371    fn test_image_source() {
372        let img = Image::default().source("new-source.png");
373        assert_eq!(img.get_source(), "new-source.png");
374    }
375
376    #[test]
377    fn test_image_size() {
378        let img = Image::default().size(1920.0, 1080.0);
379        assert_eq!(img.get_width(), Some(1920.0));
380        assert_eq!(img.get_height(), Some(1080.0));
381    }
382
383    #[test]
384    fn test_image_width_min() {
385        let img = Image::default().width(-100.0);
386        assert_eq!(img.get_width(), Some(0.0));
387    }
388
389    #[test]
390    fn test_image_height_min() {
391        let img = Image::default().height(-50.0);
392        assert_eq!(img.get_height(), Some(0.0));
393    }
394
395    // ===== State Tests =====
396
397    #[test]
398    fn test_image_loading_state() {
399        let mut img = Image::new("image.png");
400        assert!(!img.is_loading());
401        img.set_loading(true);
402        assert!(img.is_loading());
403    }
404
405    #[test]
406    fn test_image_error_state() {
407        let mut img = Image::new("broken.png");
408        assert!(!img.has_error());
409        img.set_error(true);
410        assert!(img.has_error());
411    }
412
413    // ===== Aspect Ratio Tests =====
414
415    #[test]
416    fn test_image_aspect_ratio() {
417        let img = Image::default().size(1600.0, 900.0);
418        let ratio = img.aspect_ratio().unwrap();
419        assert!((ratio - 16.0 / 9.0).abs() < 0.001);
420    }
421
422    #[test]
423    fn test_image_aspect_ratio_square() {
424        let img = Image::default().size(100.0, 100.0);
425        assert_eq!(img.aspect_ratio(), Some(1.0));
426    }
427
428    #[test]
429    fn test_image_aspect_ratio_no_dimensions() {
430        let img = Image::default();
431        assert!(img.aspect_ratio().is_none());
432    }
433
434    #[test]
435    fn test_image_aspect_ratio_zero_height() {
436        let img = Image::default().width(100.0).height(0.0);
437        assert!(img.aspect_ratio().is_none());
438    }
439
440    // ===== Display Size Calculation Tests =====
441
442    #[test]
443    fn test_display_size_fill() {
444        let img = Image::default().size(100.0, 100.0).fit(ImageFit::Fill);
445        let display = img.calculate_display_size(Size::new(200.0, 150.0));
446        assert_eq!(display, Size::new(200.0, 150.0));
447    }
448
449    #[test]
450    fn test_display_size_none() {
451        let img = Image::default().size(100.0, 100.0).fit(ImageFit::None);
452        let display = img.calculate_display_size(Size::new(200.0, 150.0));
453        assert_eq!(display, Size::new(100.0, 100.0));
454    }
455
456    #[test]
457    fn test_display_size_contain() {
458        let img = Image::default().size(200.0, 100.0).fit(ImageFit::Contain);
459        let display = img.calculate_display_size(Size::new(100.0, 100.0));
460        // Should scale down to fit, maintaining aspect ratio
461        assert_eq!(display, Size::new(100.0, 50.0));
462    }
463
464    #[test]
465    fn test_display_size_cover() {
466        let img = Image::default().size(200.0, 100.0).fit(ImageFit::Cover);
467        let display = img.calculate_display_size(Size::new(100.0, 100.0));
468        // Should scale to cover, may crop
469        assert_eq!(display, Size::new(200.0, 100.0));
470    }
471
472    #[test]
473    fn test_display_size_scale_down_smaller() {
474        let img = Image::default().size(50.0, 50.0).fit(ImageFit::ScaleDown);
475        let display = img.calculate_display_size(Size::new(100.0, 100.0));
476        // Image is smaller, should not scale
477        assert_eq!(display, Size::new(50.0, 50.0));
478    }
479
480    #[test]
481    fn test_display_size_scale_down_larger() {
482        let img = Image::default().size(200.0, 200.0).fit(ImageFit::ScaleDown);
483        let display = img.calculate_display_size(Size::new(100.0, 100.0));
484        // Image is larger, should scale down
485        assert_eq!(display, Size::new(100.0, 100.0));
486    }
487
488    // ===== Widget Trait Tests =====
489
490    #[test]
491    fn test_image_type_id() {
492        let img = Image::new("test.png");
493        assert_eq!(Widget::type_id(&img), TypeId::of::<Image>());
494    }
495
496    #[test]
497    fn test_image_measure_with_size() {
498        let img = Image::default().size(200.0, 150.0);
499        let size = img.measure(Constraints::loose(Size::new(500.0, 500.0)));
500        assert_eq!(size, Size::new(200.0, 150.0));
501    }
502
503    #[test]
504    fn test_image_measure_default_size() {
505        let img = Image::default();
506        let size = img.measure(Constraints::loose(Size::new(500.0, 500.0)));
507        assert_eq!(size, Size::new(100.0, 100.0)); // Default placeholder size
508    }
509
510    #[test]
511    fn test_image_layout() {
512        let mut img = Image::new("test.png");
513        let bounds = Rect::new(10.0, 20.0, 200.0, 150.0);
514        let result = img.layout(bounds);
515        assert_eq!(result.size, Size::new(200.0, 150.0));
516        assert_eq!(img.bounds, bounds);
517    }
518
519    #[test]
520    fn test_image_children() {
521        let img = Image::new("test.png");
522        assert!(img.children().is_empty());
523    }
524
525    #[test]
526    fn test_image_is_interactive() {
527        let img = Image::new("test.png");
528        assert!(!img.is_interactive());
529    }
530
531    #[test]
532    fn test_image_is_focusable() {
533        let img = Image::new("test.png");
534        assert!(!img.is_focusable());
535    }
536
537    #[test]
538    fn test_image_accessible_role() {
539        let img = Image::new("test.png");
540        assert_eq!(img.accessible_role(), AccessibleRole::Image);
541    }
542
543    #[test]
544    fn test_image_accessible_name_from_alt() {
545        let img = Image::new("photo.jpg").alt("Mountain landscape");
546        assert_eq!(Widget::accessible_name(&img), Some("Mountain landscape"));
547    }
548
549    #[test]
550    fn test_image_accessible_name_override() {
551        let img = Image::new("photo.jpg")
552            .alt("Photo")
553            .accessible_name("Beautiful mountain landscape at sunset");
554        assert_eq!(
555            Widget::accessible_name(&img),
556            Some("Beautiful mountain landscape at sunset")
557        );
558    }
559
560    #[test]
561    fn test_image_accessible_name_none() {
562        let img = Image::new("decorative.png");
563        assert_eq!(Widget::accessible_name(&img), None);
564    }
565
566    #[test]
567    fn test_image_test_id() {
568        let img = Image::new("test.png").test_id("profile-avatar");
569        assert_eq!(Widget::test_id(&img), Some("profile-avatar"));
570    }
571}