1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
14pub enum ImageFit {
15 Cover,
17 #[default]
19 Contain,
20 Fill,
22 None,
24 ScaleDown,
26}
27
28#[derive(Debug, Clone, Serialize, Deserialize)]
30pub struct Image {
31 source: String,
33 alt: String,
35 fit: ImageFit,
37 width: Option<f32>,
39 height: Option<f32>,
41 #[serde(skip)]
43 loading: bool,
44 #[serde(skip)]
46 error: bool,
47 accessible_name_value: Option<String>,
49 test_id_value: Option<String>,
51 #[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 #[must_use]
76 pub fn new(source: impl Into<String>) -> Self {
77 Self {
78 source: source.into(),
79 ..Self::default()
80 }
81 }
82
83 #[must_use]
85 pub fn source(mut self, source: impl Into<String>) -> Self {
86 self.source = source.into();
87 self
88 }
89
90 #[must_use]
92 pub fn alt(mut self, alt: impl Into<String>) -> Self {
93 self.alt = alt.into();
94 self
95 }
96
97 #[must_use]
99 pub const fn fit(mut self, fit: ImageFit) -> Self {
100 self.fit = fit;
101 self
102 }
103
104 #[must_use]
106 pub fn width(mut self, width: f32) -> Self {
107 self.width = Some(width.max(0.0));
108 self
109 }
110
111 #[must_use]
113 pub fn height(mut self, height: f32) -> Self {
114 self.height = Some(height.max(0.0));
115 self
116 }
117
118 #[must_use]
120 pub fn size(self, width: f32, height: f32) -> Self {
121 self.width(width).height(height)
122 }
123
124 #[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 #[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 #[must_use]
140 pub fn get_source(&self) -> &str {
141 &self.source
142 }
143
144 #[must_use]
146 pub fn get_alt(&self) -> &str {
147 &self.alt
148 }
149
150 #[must_use]
152 pub const fn get_fit(&self) -> ImageFit {
153 self.fit
154 }
155
156 #[must_use]
158 pub const fn get_width(&self) -> Option<f32> {
159 self.width
160 }
161
162 #[must_use]
164 pub const fn get_height(&self) -> Option<f32> {
165 self.height
166 }
167
168 #[must_use]
170 pub const fn is_loading(&self) -> bool {
171 self.loading
172 }
173
174 #[must_use]
176 pub const fn has_error(&self) -> bool {
177 self.error
178 }
179
180 pub fn set_loading(&mut self, loading: bool) {
182 self.loading = loading;
183 }
184
185 pub fn set_error(&mut self, error: bool) {
187 self.error = error;
188 }
189
190 #[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 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 let display_size = self.calculate_display_size(self.bounds.size());
254
255 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 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
317impl 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 #[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 #[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 #[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 #[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 #[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 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 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 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 assert_eq!(display, Size::new(100.0, 100.0));
527 }
528
529 #[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)); }
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}