Skip to main content

presentar_widgets/
image.rs

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