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)]
356mod tests {
357 use super::*;
358
359 #[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 #[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 #[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 #[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 #[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 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 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 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 assert_eq!(display, Size::new(100.0, 100.0));
526 }
527
528 #[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)); }
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}