Seregon/StratoSDK

StratoSDK is a framework with a declarative approach similar to Flutter/React, written and designed entirely for Rust.

Rust/27.3 KB/No license
crates/strato-widgets/src/image.rs
1//! Image widget for displaying images in StratoUI applications
2//!
3//! Supports various image formats, scaling modes, and loading states.
4 
5use crate::widget::{generate_id, Widget, WidgetContext, WidgetId};
6use std::path::PathBuf;
7use std::sync::Arc;
8use strato_core::{
9 event::{Event, EventResult},
10 layout::{Constraints, Layout, Size},
11 state::Signal,
12 types::{Color, Point, Rect, Transform},
13 vdom::VNode,
14};
15use strato_renderer::batch::RenderBatch;
16 
17/// Image scaling modes
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub enum ImageFit {
20 /// Fill the entire container, may crop the image
21 Fill,
22 /// Fit the image within the container, maintaining aspect ratio
23 Contain,
24 /// Cover the entire container, maintaining aspect ratio, may crop
25 Cover,
26 /// Scale down to fit if larger, otherwise display at original size
27 ScaleDown,
28 /// Display at original size
29 None,
30}
31 
32/// Image loading state
33#[derive(Debug, Clone, PartialEq)]
34pub enum ImageState {
35 Loading,
36 Loaded(ImageData),
37 Error(String),
38}
39 
40/// Image data representation
41#[derive(Debug, Clone, PartialEq)]
42pub struct ImageData {
43 pub width: u32,
44 pub height: u32,
45 pub data: Arc<Vec<u8>>,
46 pub format: ImageFormat,
47}
48 
49/// Supported image formats
50#[derive(Debug, Clone, Copy, PartialEq, Eq)]
51pub enum ImageFormat {
52 Png,
53 Jpeg,
54 Gif,
55 Webp,
56 Svg,
57 Bmp,
58}
59 
60/// Image source types
61#[derive(Debug, Clone)]
62pub enum ImageSource {
63 /// Load from file path
64 File(PathBuf),
65 /// Load from URL
66 Url(String),
67 /// Use embedded data
68 Data(ImageData),
69 /// Use placeholder
70 Placeholder {
71 width: u32,
72 height: u32,
73 color: Color,
74 },
75}
76 
77/// Image widget styling
78#[derive(Debug, Clone)]
79pub struct ImageStyle {
80 pub fit: ImageFit,
81 pub border_radius: f32,
82 pub opacity: f32,
83 pub filter: ImageFilter,
84 pub background_color: Option<Color>,
85 pub border_color: Option<Color>,
86 pub border_width: f32,
87}
88 
89impl Default for ImageStyle {
90 fn default() -> Self {
91 Self {
92 fit: ImageFit::Contain,
93 border_radius: 0.0,
94 opacity: 1.0,
95 filter: ImageFilter::None,
96 background_color: None,
97 border_color: None,
98 border_width: 0.0,
99 }
100 }
101}
102 
103/// Image filters
104#[derive(Debug, Clone, Copy, PartialEq)]
105pub enum ImageFilter {
106 None,
107 Blur(f32),
108 Brightness(f32),
109 Contrast(f32),
110 Grayscale(f32),
111 Sepia(f32),
112 Saturate(f32),
113 HueRotate(f32),
114 Invert(f32),
115}
116 
117/// Image widget
118pub struct Image {
119 id: WidgetId,
120 source: ImageSource,
121 style: ImageStyle,
122 state: Signal<ImageState>,
123 alt_text: Option<String>,
124 on_load: Option<Box<dyn Fn(&ImageData) + Send + Sync>>,
125 on_error: Option<Box<dyn Fn(&str) + Send + Sync>>,
126 on_click: Option<Box<dyn Fn() + Send + Sync>>,
127 loading_placeholder: Option<VNode>,
128 error_placeholder: Option<VNode>,
129 bounds: Signal<Rect>,
130}
131 
132impl std::fmt::Debug for Image {
133 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
134 f.debug_struct("Image")
135 .field("id", &self.id)
136 .field("source", &self.source)
137 .field("style", &self.style)
138 .field("state", &self.state)
139 .field("alt_text", &self.alt_text)
140 .field("on_load", &self.on_load.as_ref().map(|_| "Fn(&ImageData)"))
141 .field("on_error", &self.on_error.as_ref().map(|_| "Fn(&str)"))
142 .field("on_click", &self.on_click.as_ref().map(|_| "Fn()"))
143 .field("loading_placeholder", &self.loading_placeholder)
144 .field("error_placeholder", &self.error_placeholder)
145 .field("bounds", &self.bounds)
146 .finish()
147 }
148}
149 
150impl Clone for Image {
151 fn clone(&self) -> Self {
152 Self {
153 id: self.id,
154 source: self.source.clone(),
155 style: self.style.clone(),
156 state: self.state.clone(),
157 alt_text: self.alt_text.clone(),
158 on_load: None, // Function pointers can't be cloned
159 on_error: None,
160 on_click: None,
161 loading_placeholder: self.loading_placeholder.clone(),
162 error_placeholder: self.error_placeholder.clone(),
163 bounds: self.bounds.clone(),
164 }
165 }
166}
167 
168impl Image {
169 /// Create a new image widget
170 pub fn new(source: ImageSource) -> Self {
171 let img = Self {
172 id: generate_id(),
173 source,
174 style: ImageStyle::default(),
175 state: Signal::new(ImageState::Loading),
176 alt_text: None,
177 on_load: None,
178 on_error: None,
179 on_click: None,
180 loading_placeholder: None,
181 error_placeholder: None,
182 bounds: Signal::new(Rect::default()),
183 };
184 img.load_image();
185 img
186 }
187 
188 /// Create image from file path
189 pub fn from_file<P: Into<PathBuf>>(path: P) -> Self {
190 Self::new(ImageSource::File(path.into()))
191 }
192 
193 /// Create image from URL
194 pub fn from_url<S: Into<String>>(url: S) -> Self {
195 Self::new(ImageSource::Url(url.into()))
196 }
197 
198 /// Create image from data
199 pub fn from_data(data: ImageData) -> Self {
200 Self::new(ImageSource::Data(data))
201 }
202 
203 /// Create placeholder image
204 pub fn placeholder(width: u32, height: u32, color: Color) -> Self {
205 Self::new(ImageSource::Placeholder {
206 width,
207 height,
208 color,
209 })
210 }
211 
212 /// Set image fit mode
213 pub fn fit(mut self, fit: ImageFit) -> Self {
214 self.style.fit = fit;
215 self
216 }
217 
218 /// Set border radius
219 pub fn border_radius(mut self, radius: f32) -> Self {
220 self.style.border_radius = radius;
221 self
222 }
223 
224 /// Set opacity
225 pub fn opacity(mut self, opacity: f32) -> Self {
226 self.style.opacity = opacity.clamp(0.0, 1.0);
227 self
228 }
229 
230 /// Set image filter
231 pub fn filter(mut self, filter: ImageFilter) -> Self {
232 self.style.filter = filter;
233 self
234 }
235 
236 /// Set background color
237 pub fn background_color(mut self, color: Color) -> Self {
238 self.style.background_color = Some(color);
239 self
240 }
241 
242 /// Set border
243 pub fn border(mut self, width: f32, color: Color) -> Self {
244 self.style.border_width = width;
245 self.style.border_color = Some(color);
246 self
247 }
248 
249 /// Set alt text for accessibility
250 pub fn alt_text<S: Into<String>>(mut self, text: S) -> Self {
251 self.alt_text = Some(text.into());
252 self
253 }
254 
255 /// Set load callback
256 pub fn on_load<F>(mut self, callback: F) -> Self
257 where
258 F: Fn(&ImageData) + Send + Sync + 'static,
259 {
260 self.on_load = Some(Box::new(callback));
261 self
262 }
263 
264 /// Set error callback
265 pub fn on_error<F>(mut self, callback: F) -> Self
266 where
267 F: Fn(&str) + Send + Sync + 'static,
268 {
269 self.on_error = Some(Box::new(callback));
270 self
271 }
272 
273 /// Set click callback
274 pub fn on_click<F>(mut self, callback: F) -> Self
275 where
276 F: Fn() + Send + Sync + 'static,
277 {
278 self.on_click = Some(Box::new(callback));
279 self
280 }
281 
282 /// Set loading placeholder
283 pub fn loading_placeholder(mut self, placeholder: VNode) -> Self {
284 self.loading_placeholder = Some(placeholder);
285 self
286 }
287 
288 /// Set error placeholder
289 pub fn error_placeholder(mut self, placeholder: VNode) -> Self {
290 self.error_placeholder = Some(placeholder);
291 self
292 }
293 
294 /// Get current image state
295 pub fn state(&self) -> ImageState {
296 self.state.get()
297 }
298 
299 /// Load image from source
300 pub fn load_image(&self) {
301 let source = self.source.clone();
302 let state = self.state.clone();
303 
304 // Mark as loading
305 state.set(ImageState::Loading);
306 
307 match source {
308 ImageSource::File(path) => {
309 let state = state.clone();
310 std::thread::spawn(move || match std::fs::read(&path) {
311 Ok(bytes) => {
312 if let Ok(data) = decode_image_data_internal(bytes) {
313 state.set(ImageState::Loaded(data));
314 } else {
315 state.set(ImageState::Error("Failed to decode image".to_string()));
316 }
317 }
318 Err(e) => {
319 state.set(ImageState::Error(format!("Failed to load image: {}", e)));
320 }
321 });
322 }
323 ImageSource::Url(url) => {
324 let state = state.clone();
325 std::thread::spawn(move || {
326 // Fetch image data
327 let client = reqwest::blocking::Client::new();
328 match client
329 .get(&url)
330 .header("User-Agent", "StratoUI/0.1.0")
331 .send()
332 {
333 Ok(response) => {
334 if response.status().is_success() {
335 match response.bytes() {
336 Ok(bytes) => {
337 if let Ok(data) = decode_image_data_internal(bytes.to_vec())
338 {
339 state.set(ImageState::Loaded(data));
340 } else {
341 state.set(ImageState::Error(
342 "Failed to decode image from URL".to_string(),
343 ));
344 }
345 }
346 Err(e) => {
347 state.set(ImageState::Error(format!(
348 "Failed to read bytes: {}",
349 e
350 )));
351 }
352 }
353 } else {
354 state.set(ImageState::Error(format!(
355 "HTTP Error: {}",
356 response.status()
357 )));
358 }
359 }
360 Err(e) => {
361 state.set(ImageState::Error(format!("Failed to fetch URL: {}", e)));
362 }
363 }
364 });
365 }
366 ImageSource::Data(data) => {
367 state.set(ImageState::Loaded(data));
368 }
369 ImageSource::Placeholder {
370 width,
371 height,
372 color,
373 } => {
374 let data = create_placeholder_data_internal(width, height, color);
375 state.set(ImageState::Loaded(data));
376 }
377 }
378 }
379}
380 
381// Internal helper for decoding without &self
382fn decode_image_data_internal(bytes: Vec<u8>) -> Result<ImageData, String> {
383 match image::load_from_memory(&bytes) {
384 Ok(dynamic_image) => {
385 let rgba_image = dynamic_image.to_rgba8();
386 let (width, height) = rgba_image.dimensions();
387 let data = rgba_image.into_raw();
388 
389 Ok(ImageData {
390 width,
391 height,
392 data: Arc::new(data),
393 format: ImageFormat::Png, // Treated as raw RGBA
394 })
395 }
396 Err(e) => Err(format!("Image decoding error: {}", e)),
397 }
398}
399 
400fn create_placeholder_data_internal(width: u32, height: u32, color: Color) -> ImageData {
401 let pixel_count = (width * height) as usize;
402 let mut data = Vec::with_capacity(pixel_count * 4);
403 
404 let r = (color.r * 255.0) as u8;
405 let g = (color.g * 255.0) as u8;
406 let b = (color.b * 255.0) as u8;
407 let a = (color.a * 255.0) as u8;
408 
409 for _ in 0..pixel_count {
410 data.extend_from_slice(&[r, g, b, a]);
411 }
412 
413 ImageData {
414 width,
415 height,
416 data: Arc::new(data),
417 format: ImageFormat::Png,
418 }
419}
420 
421impl Image {
422 // Re-opening impl to fix the struct definition gap if needed, but here we are replacing methods.
423 
424 fn calculate_display_size(&self, container_size: Size, image_size: Size) -> (Size, Rect) {
425 match self.style.fit {
426 ImageFit::Fill => (
427 container_size,
428 Rect::new(0.0, 0.0, container_size.width, container_size.height),
429 ),
430 ImageFit::Contain => {
431 let scale = (container_size.width / image_size.width)
432 .min(container_size.height / image_size.height);
433 let scaled_width = image_size.width * scale;
434 let scaled_height = image_size.height * scale;
435 let x = (container_size.width - scaled_width) / 2.0;
436 let y = (container_size.height - scaled_height) / 2.0;
437 (
438 Size::new(scaled_width, scaled_height),
439 Rect::new(x, y, scaled_width, scaled_height),
440 )
441 }
442 ImageFit::Cover => {
443 let scale = (container_size.width / image_size.width)
444 .max(container_size.height / image_size.height);
445 let scaled_width = image_size.width * scale;
446 let scaled_height = image_size.height * scale;
447 let x = (container_size.width - scaled_width) / 2.0;
448 let y = (container_size.height - scaled_height) / 2.0;
449 (
450 Size::new(scaled_width, scaled_height),
451 Rect::new(x, y, scaled_width, scaled_height),
452 )
453 }
454 ImageFit::ScaleDown => {
455 if image_size.width <= container_size.width
456 && image_size.height <= container_size.height
457 {
458 let x = (container_size.width - image_size.width) / 2.0;
459 let y = (container_size.height - image_size.height) / 2.0;
460 (
461 image_size,
462 Rect::new(x, y, image_size.width, image_size.height),
463 )
464 } else {
465 self.calculate_display_size(container_size, image_size) // Use contain logic
466 }
467 }
468 ImageFit::None => {
469 let x = (container_size.width - image_size.width) / 2.0;
470 let y = (container_size.height - image_size.height) / 2.0;
471 (
472 image_size,
473 Rect::new(x, y, image_size.width, image_size.height),
474 )
475 }
476 }
477 }
478}
479 
480impl Widget for Image {
481 fn id(&self) -> WidgetId {
482 self.id
483 }
484 
485 fn handle_event(&mut self, event: &Event) -> EventResult {
486 let bounds = self.bounds.get();
487 match event {
488 Event::MouseMove(mouse_event) => {
489 let point = Point::new(mouse_event.position.x, mouse_event.position.y);
490 if bounds.contains(point) {
491 EventResult::Handled
492 } else {
493 EventResult::Ignored
494 }
495 }
496 Event::MouseDown(mouse_event) => {
497 let point = Point::new(mouse_event.position.x, mouse_event.position.y);
498 if bounds.contains(point) {
499 if let Some(ref on_click) = self.on_click {
500 on_click();
501 }
502 EventResult::Handled
503 } else {
504 EventResult::Ignored
505 }
506 }
507 _ => EventResult::Ignored,
508 }
509 }
510 
511 fn update(&mut self, _context: &WidgetContext) {
512 // Update widget state if needed
513 }
514 
515 fn layout(&mut self, constraints: Constraints) -> Size {
516 match &self.state.get() {
517 ImageState::Loaded(data) => {
518 let image_size = Size::new(data.width as f32, data.height as f32);
519 let container_size = Size::new(constraints.max_width, constraints.max_height);
520 let (display_size, _) = self.calculate_display_size(container_size, image_size);
521 Size::new(
522 display_size.width.min(constraints.max_width),
523 display_size.height.min(constraints.max_height),
524 )
525 }
526 _ => Size::new(constraints.max_width, constraints.max_height),
527 }
528 }
529 
530 fn render(&self, batch: &mut RenderBatch, layout: Layout) {
531 let bounds = Rect::new(
532 layout.position.x,
533 layout.position.y,
534 layout.size.width,
535 layout.size.height,
536 );
537 self.bounds.set(bounds);
538 
539 let mut background_color = self
540 .style
541 .background_color
542 .unwrap_or(Color::rgba(0.0, 0.0, 0.0, 0.0));
543 
544 match self.state.get() {
545 ImageState::Loaded(data) => {
546 let image_size = Size::new(data.width as f32, data.height as f32);
547 let container_size = Size::new(bounds.width, bounds.height);
548 let (_, display_rect) = self.calculate_display_size(container_size, image_size);
549 
550 let image_rect = Rect::new(
551 bounds.x + display_rect.x,
552 bounds.y + display_rect.y,
553 display_rect.width,
554 display_rect.height,
555 );
556 
557 if self.style.border_radius > 0.0 {
558 // TODO: Implement proper rounded textured quad in renderer
559 // For now, we render the image as a standard textured quad
560 // and apply border radius to the container background if set
561 
562 // Render background if set
563 if background_color.a > 0.0 {
564 batch.add_rounded_rect(
565 bounds,
566 background_color,
567 self.style.border_radius,
568 Transform::identity(),
569 );
570 }
571 
572 // Render Image
573 batch.add_image(
574 self.id,
575 data.data.clone(),
576 data.width,
577 data.height,
578 image_rect,
579 Color::rgba(1.0, 1.0, 1.0, self.style.opacity),
580 );
581 } else {
582 // Render background if set
583 if background_color.a > 0.0 {
584 batch.add_rect(bounds, background_color, Transform::identity());
585 }
586 
587 // Render Image
588 batch.add_image(
589 self.id,
590 data.data.clone(),
591 data.width,
592 data.height,
593 image_rect,
594 Color::rgba(1.0, 1.0, 1.0, self.style.opacity),
595 );
596 }
597 }
598 ImageState::Loading => {
599 if background_color.a == 0.0 {
600 background_color = Color::rgba(0.9, 0.9, 0.9, self.style.opacity);
601 }
602 batch.add_rect(bounds, background_color, Transform::identity());
603 }
604 ImageState::Error(_) => {
605 if background_color.a == 0.0 {
606 background_color = Color::rgba(1.0, 0.3, 0.3, self.style.opacity);
607 }
608 batch.add_rect(bounds, background_color, Transform::identity());
609 }
610 }
611 }
612 
613 fn clone_widget(&self) -> Box<dyn Widget> {
614 Box::new(Image {
615 id: self.id,
616 source: self.source.clone(),
617 style: self.style.clone(),
618 state: self.state.clone(),
619 alt_text: self.alt_text.clone(),
620 on_load: None, // Cannot clone function pointers
621 on_error: None, // Cannot clone function pointers
622 on_click: None, // Cannot clone function pointers
623 loading_placeholder: self.loading_placeholder.clone(),
624 error_placeholder: self.error_placeholder.clone(),
625 bounds: self.bounds.clone(),
626 })
627 }
628 
629 fn as_any(&self) -> &dyn std::any::Any {
630 self
631 }
632 
633 fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
634 self
635 }
636}
637 
638/// Image builder for fluent API
639pub struct ImageBuilder {
640 image: Image,
641}
642 
643impl ImageBuilder {
644 pub fn new(source: ImageSource) -> Self {
645 Self {
646 image: Image::new(source),
647 }
648 }
649 
650 pub fn fit(mut self, fit: ImageFit) -> Self {
651 self.image = self.image.fit(fit);
652 self
653 }
654 
655 pub fn border_radius(mut self, radius: f32) -> Self {
656 self.image = self.image.border_radius(radius);
657 self
658 }
659 
660 pub fn opacity(mut self, opacity: f32) -> Self {
661 self.image = self.image.opacity(opacity);
662 self
663 }
664 
665 pub fn filter(mut self, filter: ImageFilter) -> Self {
666 self.image = self.image.filter(filter);
667 self
668 }
669 
670 pub fn background_color(mut self, color: Color) -> Self {
671 self.image = self.image.background_color(color);
672 self
673 }
674 
675 pub fn border(mut self, width: f32, color: Color) -> Self {
676 self.image = self.image.border(width, color);
677 self
678 }
679 
680 pub fn alt_text<S: Into<String>>(mut self, text: S) -> Self {
681 self.image = self.image.alt_text(text);
682 self
683 }
684 
685 pub fn on_load<F>(mut self, callback: F) -> Self
686 where
687 F: Fn(&ImageData) + Send + Sync + 'static,
688 {
689 self.image = self.image.on_load(callback);
690 self
691 }
692 
693 pub fn on_error<F>(mut self, callback: F) -> Self
694 where
695 F: Fn(&str) + Send + Sync + 'static,
696 {
697 self.image = self.image.on_error(callback);
698 self
699 }
700 
701 pub fn on_click<F>(mut self, callback: F) -> Self
702 where
703 F: Fn() + Send + Sync + 'static,
704 {
705 self.image = self.image.on_click(callback);
706 self
707 }
708 
709 pub fn loading_placeholder(mut self, placeholder: VNode) -> Self {
710 self.image = self.image.loading_placeholder(placeholder);
711 self
712 }
713 
714 pub fn error_placeholder(mut self, placeholder: VNode) -> Self {
715 self.image = self.image.error_placeholder(placeholder);
716 self
717 }
718 
719 pub fn build(mut self) -> Image {
720 self.image.load_image();
721 self.image
722 }
723}
724 
725#[cfg(test)]
726mod tests {
727 use super::*;
728 use strato_core::types::Color;
729 
730 #[test]
731 fn test_image_creation() {
732 let image = Image::from_file("test.png");
733 assert!(matches!(image.source, ImageSource::File(_)));
734 assert_eq!(image.style.fit, ImageFit::Contain);
735 }
736 
737 #[test]
738 fn test_image_builder() {
739 let image = ImageBuilder::new(ImageSource::Url(
740 "https://example.com/image.png".to_string(),
741 ))
742 .fit(ImageFit::Cover)
743 .opacity(0.8)
744 .border_radius(10.0)
745 .alt_text("Test image")
746 .build();
747 
748 assert_eq!(image.style.fit, ImageFit::Cover);
749 assert_eq!(image.style.opacity, 0.8);
750 assert_eq!(image.style.border_radius, 10.0);
751 assert_eq!(image.alt_text, Some("Test image".to_string()));
752 }
753 
754 #[test]
755 fn test_placeholder_image() {
756 let color = Color::rgba(1.0, 0.0, 0.0, 1.0); // Red
757 let image = Image::placeholder(100, 100, color);
758 
759 if let ImageSource::Placeholder {
760 width,
761 height,
762 color: c,
763 } = image.source
764 {
765 assert_eq!(width, 100);
766 assert_eq!(height, 100);
767 assert_eq!(c, color);
768 } else {
769 panic!("Expected placeholder source");
770 }
771 }
772 
773 #[test]
774 fn test_image_fit_calculations() {
775 let image = Image::from_file("test.png");
776 let container_size = Size::new(200.0, 100.0);
777 let image_size = Size::new(100.0, 100.0);
778 
779 let (display_size, rect) = image.calculate_display_size(container_size, image_size);
780 
781 // For contain fit, should maintain aspect ratio
782 assert!(display_size.width <= container_size.width);
783 assert!(display_size.height <= container_size.height);
784 }
785 
786 #[test]
787 fn test_image_filters() {
788 let image = Image::from_file("test.png").filter(ImageFilter::Blur(5.0));
789 
790 assert!(matches!(image.style.filter, ImageFilter::Blur(5.0)));
791 }
792}
793