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)]
356mod tests {
357    use super::*;
358
359    // ===== ImageFit Tests =====
360
361    #[test]
362    fn test_image_fit_default() {
363        assert_eq!(ImageFit::default(), ImageFit::Contain);
364    }
365
366    #[test]
367    fn test_image_fit_equality() {
368        assert_eq!(ImageFit::Cover, ImageFit::Cover);
369        assert_ne!(ImageFit::Cover, ImageFit::Contain);
370    }
371
372    // ===== Image Construction Tests =====
373
374    #[test]
375    fn test_image_new() {
376        let img = Image::new("https://example.com/image.png");
377        assert_eq!(img.get_source(), "https://example.com/image.png");
378        assert!(img.get_alt().is_empty());
379    }
380
381    #[test]
382    fn test_image_default() {
383        let img = Image::default();
384        assert!(img.get_source().is_empty());
385        assert!(img.get_alt().is_empty());
386        assert_eq!(img.get_fit(), ImageFit::Contain);
387        assert!(img.get_width().is_none());
388        assert!(img.get_height().is_none());
389    }
390
391    #[test]
392    fn test_image_builder() {
393        let img = Image::new("photo.jpg")
394            .alt("A beautiful sunset")
395            .fit(ImageFit::Cover)
396            .width(800.0)
397            .height(600.0)
398            .accessible_name("Sunset photo")
399            .test_id("hero-image");
400
401        assert_eq!(img.get_source(), "photo.jpg");
402        assert_eq!(img.get_alt(), "A beautiful sunset");
403        assert_eq!(img.get_fit(), ImageFit::Cover);
404        assert_eq!(img.get_width(), Some(800.0));
405        assert_eq!(img.get_height(), Some(600.0));
406        assert_eq!(Widget::accessible_name(&img), Some("Sunset photo"));
407        assert_eq!(Widget::test_id(&img), Some("hero-image"));
408    }
409
410    #[test]
411    fn test_image_source() {
412        let img = Image::default().source("new-source.png");
413        assert_eq!(img.get_source(), "new-source.png");
414    }
415
416    #[test]
417    fn test_image_size() {
418        let img = Image::default().size(1920.0, 1080.0);
419        assert_eq!(img.get_width(), Some(1920.0));
420        assert_eq!(img.get_height(), Some(1080.0));
421    }
422
423    #[test]
424    fn test_image_width_min() {
425        let img = Image::default().width(-100.0);
426        assert_eq!(img.get_width(), Some(0.0));
427    }
428
429    #[test]
430    fn test_image_height_min() {
431        let img = Image::default().height(-50.0);
432        assert_eq!(img.get_height(), Some(0.0));
433    }
434
435    // ===== State Tests =====
436
437    #[test]
438    fn test_image_loading_state() {
439        let mut img = Image::new("image.png");
440        assert!(!img.is_loading());
441        img.set_loading(true);
442        assert!(img.is_loading());
443    }
444
445    #[test]
446    fn test_image_error_state() {
447        let mut img = Image::new("broken.png");
448        assert!(!img.has_error());
449        img.set_error(true);
450        assert!(img.has_error());
451    }
452
453    // ===== Aspect Ratio Tests =====
454
455    #[test]
456    fn test_image_aspect_ratio() {
457        let img = Image::default().size(1600.0, 900.0);
458        let ratio = img.aspect_ratio().unwrap();
459        assert!((ratio - 16.0 / 9.0).abs() < 0.001);
460    }
461
462    #[test]
463    fn test_image_aspect_ratio_square() {
464        let img = Image::default().size(100.0, 100.0);
465        assert_eq!(img.aspect_ratio(), Some(1.0));
466    }
467
468    #[test]
469    fn test_image_aspect_ratio_no_dimensions() {
470        let img = Image::default();
471        assert!(img.aspect_ratio().is_none());
472    }
473
474    #[test]
475    fn test_image_aspect_ratio_zero_height() {
476        let img = Image::default().width(100.0).height(0.0);
477        assert!(img.aspect_ratio().is_none());
478    }
479
480    // ===== Display Size Calculation Tests =====
481
482    #[test]
483    fn test_display_size_fill() {
484        let img = Image::default().size(100.0, 100.0).fit(ImageFit::Fill);
485        let display = img.calculate_display_size(Size::new(200.0, 150.0));
486        assert_eq!(display, Size::new(200.0, 150.0));
487    }
488
489    #[test]
490    fn test_display_size_none() {
491        let img = Image::default().size(100.0, 100.0).fit(ImageFit::None);
492        let display = img.calculate_display_size(Size::new(200.0, 150.0));
493        assert_eq!(display, Size::new(100.0, 100.0));
494    }
495
496    #[test]
497    fn test_display_size_contain() {
498        let img = Image::default().size(200.0, 100.0).fit(ImageFit::Contain);
499        let display = img.calculate_display_size(Size::new(100.0, 100.0));
500        // Should scale down to fit, maintaining aspect ratio
501        assert_eq!(display, Size::new(100.0, 50.0));
502    }
503
504    #[test]
505    fn test_display_size_cover() {
506        let img = Image::default().size(200.0, 100.0).fit(ImageFit::Cover);
507        let display = img.calculate_display_size(Size::new(100.0, 100.0));
508        // Should scale to cover, may crop
509        assert_eq!(display, Size::new(200.0, 100.0));
510    }
511
512    #[test]
513    fn test_display_size_scale_down_smaller() {
514        let img = Image::default().size(50.0, 50.0).fit(ImageFit::ScaleDown);
515        let display = img.calculate_display_size(Size::new(100.0, 100.0));
516        // Image is smaller, should not scale
517        assert_eq!(display, Size::new(50.0, 50.0));
518    }
519
520    #[test]
521    fn test_display_size_scale_down_larger() {
522        let img = Image::default().size(200.0, 200.0).fit(ImageFit::ScaleDown);
523        let display = img.calculate_display_size(Size::new(100.0, 100.0));
524        // Image is larger, should scale down
525        assert_eq!(display, Size::new(100.0, 100.0));
526    }
527
528    // ===== Widget Trait Tests =====
529
530    #[test]
531    fn test_image_type_id() {
532        let img = Image::new("test.png");
533        assert_eq!(Widget::type_id(&img), TypeId::of::<Image>());
534    }
535
536    #[test]
537    fn test_image_measure_with_size() {
538        let img = Image::default().size(200.0, 150.0);
539        let size = img.measure(Constraints::loose(Size::new(500.0, 500.0)));
540        assert_eq!(size, Size::new(200.0, 150.0));
541    }
542
543    #[test]
544    fn test_image_measure_default_size() {
545        let img = Image::default();
546        let size = img.measure(Constraints::loose(Size::new(500.0, 500.0)));
547        assert_eq!(size, Size::new(100.0, 100.0)); // Default placeholder size
548    }
549
550    #[test]
551    fn test_image_layout() {
552        let mut img = Image::new("test.png");
553        let bounds = Rect::new(10.0, 20.0, 200.0, 150.0);
554        let result = img.layout(bounds);
555        assert_eq!(result.size, Size::new(200.0, 150.0));
556        assert_eq!(img.bounds, bounds);
557    }
558
559    #[test]
560    fn test_image_children() {
561        let img = Image::new("test.png");
562        assert!(img.children().is_empty());
563    }
564
565    #[test]
566    fn test_image_is_interactive() {
567        let img = Image::new("test.png");
568        assert!(!img.is_interactive());
569    }
570
571    #[test]
572    fn test_image_is_focusable() {
573        let img = Image::new("test.png");
574        assert!(!img.is_focusable());
575    }
576
577    #[test]
578    fn test_image_accessible_role() {
579        let img = Image::new("test.png");
580        assert_eq!(img.accessible_role(), AccessibleRole::Image);
581    }
582
583    #[test]
584    fn test_image_accessible_name_from_alt() {
585        let img = Image::new("photo.jpg").alt("Mountain landscape");
586        assert_eq!(Widget::accessible_name(&img), Some("Mountain landscape"));
587    }
588
589    #[test]
590    fn test_image_accessible_name_override() {
591        let img = Image::new("photo.jpg")
592            .alt("Photo")
593            .accessible_name("Beautiful mountain landscape at sunset");
594        assert_eq!(
595            Widget::accessible_name(&img),
596            Some("Beautiful mountain landscape at sunset")
597        );
598    }
599
600    #[test]
601    fn test_image_accessible_name_none() {
602        let img = Image::new("decorative.png");
603        assert_eq!(Widget::accessible_name(&img), None);
604    }
605
606    #[test]
607    fn test_image_test_id() {
608        let img = Image::new("test.png").test_id("profile-avatar");
609        assert_eq!(Widget::test_id(&img), Some("profile-avatar"));
610    }
611}