egui_material3/
imagelist.rs

1//! # Material Design Image Lists
2//!
3//! This module provides Material Design 3 image list components with comprehensive
4//! image source support and intelligent caching capabilities.
5//!
6//! ## Features
7//!
8//! - **Multiple image sources**: Local files, online URLs, and embedded byte arrays
9//! - **Smart caching**: Downloaded images are cached with proper file extensions
10//! - **Format detection**: Automatic detection of PNG, JPEG, GIF, and WebP formats
11//! - **Performance optimized**: Efficient loading and UI repainting
12//! - **Error handling**: Graceful fallback with visual indicators
13//!
14//! ## Usage
15//!
16//! ### Local Images
17//! ```rust,no_run
18//! use egui_material3::image_list;
19//!
20//! ui.add(image_list()
21//!     .columns(3)
22//!     .item_spacing(8.0)
23//!     .items_from_paths(glob::glob("resources/*.png")?));
24//! ```
25//!
26//! ### Online Images (OnDemand Feature)
27//!
28//! Enable the `ondemand` feature in your `Cargo.toml`:
29//! ```toml
30//! [dependencies]
31//! egui-material3 = { version = "0.0.6", features = ["ondemand"] }
32//! ```
33//!
34//! ```rust,no_run
35//! use egui_material3::image_list;
36//!
37//! ui.add(image_list()
38//!     .columns(4)
39//!     .item_spacing(8.0)
40//!     .items_from_urls(vec![
41//!         "https://example.com/image1.jpg".to_string(),
42//!         "https://example.com/image2.png".to_string(),
43//!     ]));
44//! ```
45//!
46//! ### Embedded Images
47//! ```rust,no_run
48//! use egui_material3::image_list;
49//!
50//! ui.add(image_list()
51//!     .columns(2)
52//!     .item_spacing(8.0)
53//!     .items_from_bytes(vec![
54//!         include_bytes!("image1.png").to_vec(),
55//!         include_bytes!("image2.png").to_vec(),
56//!     ]));
57//! ```
58//!
59//! ## OnDemand Feature Details
60//!
61//! When the `ondemand` feature is enabled, the image list provides:
62//!
63//! - **Automatic downloading**: Images are downloaded from URLs on first access
64//! - **Smart caching**: Downloaded images are saved to `/tmp/egui_material3_img/` with proper extensions
65//! - **Format detection**: File extensions are determined from content (PNG, JPEG, GIF, WebP)
66//! - **Efficient reuse**: Cached images are reused without re-downloading
67//! - **Performance optimization**: UI only repaints when new images are available
68//! - **Error handling**: Failed downloads show visual indicators instead of crashing
69//!
70//! ### Cache Management
71//!
72//! - Cache directory: `/tmp/egui_material3_img/`
73//! - File naming: `img_{hash}.{extension}` (e.g., `img_abc123.png`)
74//! - Automatic cleanup: Cache persists between runs for efficiency
75//! - Manual cleanup: Remove `/tmp/egui_material3_img/` to clear cache
76
77use crate::theme::get_global_color;
78use egui::{
79    ecolor::Color32,
80    epaint::{Stroke, CornerRadius},
81    Rect, Response, Sense, Ui, Vec2, Widget
82};
83use std::env;
84use image::GenericImageView;
85
86/// Material Design image list variants.
87#[derive(Clone, Copy, Debug, PartialEq)]
88pub enum ImageListVariant {
89    Standard,
90    Masonry,
91    Woven,
92}
93
94/// Material Design image list component.
95///
96/// Image lists display a collection of images in an organized grid.
97/// They're commonly used to display a collection of photos or other images.
98///
99/// ```
100/// # egui::__run_test_ui(|ui| {
101/// let image_list = MaterialImageList::standard()
102///     .columns(3)
103///     .item("Image 1", "320x240.png")
104///     .item("Image 2", "320x240.png")
105///     .item("Image 3", "320x240.png");
106///
107/// ui.add(image_list);
108/// # });
109/// ```
110#[must_use = "You should put this widget in a ui with `ui.add(widget);`"]
111pub struct MaterialImageList<'a> {
112    variant: ImageListVariant,
113    items: Vec<ImageListItem<'a>>,
114    columns: usize,
115    item_spacing: f32,
116    text_protected: bool,
117    corner_radius: CornerRadius,
118    id_salt: Option<String>,
119    tmppath: String,
120}
121
122pub struct ImageListItem<'a> {
123    pub label: String,
124    pub image_source: Option<String>,
125    pub supporting_text: Option<String>,
126    pub on_click: Option<Box<dyn Fn() + Send + Sync>>,
127    pub loaded_image: Option<egui::ColorImage>,
128    _phantom: std::marker::PhantomData<&'a ()>,
129}
130
131impl<'a> ImageListItem<'a> {
132    pub fn new(label: impl Into<String>, image_source: impl Into<String>) -> Self {
133        Self {
134            label: label.into(),
135            image_source: Some(image_source.into()),
136            supporting_text: None,
137            on_click: None,
138            loaded_image: None,
139            _phantom: std::marker::PhantomData,
140        }
141    }
142    
143    pub fn supporting_text(mut self, text: impl Into<String>) -> Self {
144        self.supporting_text = Some(text.into());
145        self
146    }
147    
148    pub fn on_click<F>(mut self, callback: F) -> Self 
149    where
150        F: Fn() + Send + Sync + 'static,
151    {
152        self.on_click = Some(Box::new(callback));
153        self
154    }
155}
156
157/// Load image from a local file path
158fn load_image_from_file(file_path: &str) -> Option<egui::ColorImage> {
159    if std::path::Path::new(file_path).exists() {
160        match image::open(file_path) {
161            Ok(image) => {
162                let original_size = image.dimensions();
163                
164                // Resize large images to max 512x512 to avoid memory issues
165                let resized_image = if original_size.0 > 512 || original_size.1 > 512 {
166                    image.resize(512, 512, image::imageops::FilterType::Lanczos3)
167                } else {
168                    image
169                };
170                
171                let size = resized_image.dimensions();
172                let image_buffer = resized_image.to_rgba8();
173                let pixels = image_buffer.into_raw();
174                Some(egui::ColorImage::from_rgba_unmultiplied([size.0 as usize, size.1 as usize], &pixels))
175            }
176            Err(_) => None
177        }
178    } else {
179        None
180    }
181}
182
183/// Load image from URL (requires ondemand feature)
184#[cfg(feature = "ondemand")]
185fn load_image_from_url(url: &str, tmppath: &str) -> Option<egui::ColorImage> {
186    use std::io::Read;
187    use std::hash::{Hash, Hasher};
188    
189    let mut hasher = std::collections::hash_map::DefaultHasher::new();
190    url.hash(&mut hasher);
191    let url_hash = format!("{:x}", hasher.finish());
192    let filename = format!("img_{}", url_hash);
193    let filepath = std::path::Path::new(tmppath).join(&filename);
194
195    // Check if file already exists with any extension
196    let possible_files = [
197        filepath.with_extension("png"),
198        filepath.with_extension("jpg"),
199        filepath.with_extension("gif"),
200        filepath.with_extension("webp"),
201        filepath.clone()
202    ];
203
204    let existing_file = possible_files.iter().find(|f| f.exists());
205
206    if existing_file.is_none() {
207        // Try to download the image with timeout and user agent
208        let agent = ureq::AgentBuilder::new()
209            .timeout_read(std::time::Duration::from_secs(10))
210            .timeout_write(std::time::Duration::from_secs(10))
211            .user_agent("egui-material3/1.0")
212            .build();
213
214        match agent.get(url).call() {
215            Ok(response) => {
216                let status = response.status();
217                if status == 200 {
218                    let mut bytes = Vec::new();
219                    if let Ok(_) = response.into_reader().read_to_end(&mut bytes) {
220                        if !bytes.is_empty() {
221                            // Detect image format from content and add appropriate extension
222                            let extension = if bytes.starts_with(&[0x89, 0x50, 0x4E, 0x47]) {
223                                "png"
224                            } else if bytes.starts_with(&[0xFF, 0xD8, 0xFF]) {
225                                "jpg"
226                            } else if bytes.starts_with(&[0x47, 0x49, 0x46]) {
227                                "gif"
228                            } else if bytes.starts_with(&[0x52, 0x49, 0x46, 0x46]) && bytes.len() > 12 && &bytes[8..12] == b"WEBP" {
229                                "webp"
230                            } else {
231                                "png"
232                            };
233
234                            let filepath_with_ext = filepath.with_extension(extension);
235                            let _ = std::fs::write(&filepath_with_ext, &bytes);
236                        }
237                    }
238                }
239            }
240            Err(_) => {}
241        }
242    }
243
244    // Try to load the image from cache
245    if let Some(existing_filepath) = possible_files.iter().find(|f| f.exists()) {
246        match image::open(existing_filepath) {
247            Ok(image) => {
248                let original_size = image.dimensions();
249
250                // Resize large images to max 512x512 to avoid memory issues
251                let resized_image = if original_size.0 > 512 || original_size.1 > 512 {
252                    image.resize(512, 512, image::imageops::FilterType::Lanczos3)
253                } else {
254                    image
255                };
256
257                let size = resized_image.dimensions();
258                let image_buffer = resized_image.to_rgba8();
259                let pixels = image_buffer.into_raw();
260                Some(egui::ColorImage::from_rgba_unmultiplied([size.0 as usize, size.1 as usize], &pixels))
261            }
262            Err(_) => None
263        }
264    } else {
265        None
266    }
267}
268
269/// Load image from data URL (base64 encoded)
270fn load_image_from_data_url(data_url: &str) -> Option<egui::ColorImage> {
271    if let Some(comma_pos) = data_url.find(',') {
272        let data_part = &data_url[comma_pos + 1..];
273        if let Ok(bytes) = base64::Engine::decode(&base64::engine::general_purpose::STANDARD, data_part) {
274            if let Ok(image) = image::load_from_memory(&bytes) {
275                let size = image.dimensions();
276                let image_buffer = image.to_rgba8();
277                let pixels = image_buffer.into_raw();
278                return Some(egui::ColorImage::from_rgba_unmultiplied([size.0 as usize, size.1 as usize], &pixels));
279            }
280        }
281    }
282    None
283}
284
285/// Load image from bytes (hex encoded)
286fn load_image_from_bytes(bytes_str: &str) -> Option<egui::ColorImage> {
287    if let Ok(bytes) = hex::decode(bytes_str) {
288        if let Ok(image) = image::load_from_memory(&bytes) {
289            let size = image.dimensions();
290            let image_buffer = image.to_rgba8();
291            let pixels = image_buffer.into_raw();
292            return Some(egui::ColorImage::from_rgba_unmultiplied([size.0 as usize, size.1 as usize], &pixels));
293        }
294    }
295    None
296}
297
298impl<'a> MaterialImageList<'a> {
299    /// Create a new standard image list.
300    pub fn standard() -> Self {
301        Self::new(ImageListVariant::Standard)
302    }
303
304    /// Create a new masonry image list.
305    pub fn masonry() -> Self {
306        Self::new(ImageListVariant::Masonry)
307    }
308
309    /// Create a new woven image list.
310    pub fn woven() -> Self {
311        Self::new(ImageListVariant::Woven)
312    }
313
314    fn new(variant: ImageListVariant) -> Self {
315        // create img folder in tmp dir using let dir = env::temp_dir(), save the path to tmppath on MaterialImageList
316        let tmppath = env::temp_dir().join("egui_material3_img");
317        let _ = std::fs::create_dir_all(&tmppath);
318
319        Self {
320            variant,
321            items: Vec::new(),
322            columns: 3,
323            item_spacing: 8.0,
324            text_protected: false,
325            corner_radius: CornerRadius::from(4.0),
326            id_salt: None,
327            tmppath: tmppath.to_string_lossy().to_string(),
328        }
329    }
330
331    /// Set number of columns.
332    pub fn columns(mut self, columns: usize) -> Self {
333        self.columns = columns.max(1);
334        self
335    }
336
337    /// Add an image item.
338    pub fn item(mut self, label: impl Into<String>, image_source: impl Into<String>) -> Self {
339        self.items.push(ImageListItem::new(label, image_source));
340        self
341    }
342
343    /// Add an image item with callback.
344    pub fn item_with_callback<F>(
345        mut self, 
346        label: impl Into<String>, 
347        image_source: impl Into<String>,
348        callback: F
349    ) -> Self 
350    where
351        F: Fn() + Send + Sync + 'static,
352    {
353        self.items.push(
354            ImageListItem::new(label, image_source)
355                .on_click(callback)
356        );
357        self
358    }
359
360    /// Set item spacing.
361    pub fn item_spacing(mut self, spacing: f32) -> Self {
362        self.item_spacing = spacing;
363        self
364    }
365
366    /// Enable text protection overlay.
367    pub fn text_protected(mut self, protected: bool) -> Self {
368        self.text_protected = protected;
369        self
370    }
371
372    /// Set corner radius.
373    pub fn corner_radius(mut self, corner_radius: impl Into<CornerRadius>) -> Self {
374        self.corner_radius = corner_radius.into();
375        self
376    }
377
378    /// Set unique ID salt to prevent ID clashes.
379    pub fn id_salt(mut self, salt: impl Into<String>) -> Self {
380        self.id_salt = Some(salt.into());
381        self
382    }
383
384    /// Add items from a collection of file paths.
385    pub fn items_from_paths<I, P>(mut self, paths: I) -> Self
386    where
387        I: IntoIterator<Item = P>,
388        P: AsRef<std::path::Path>,
389    {
390        for (i, path) in paths.into_iter().enumerate() {
391            let path_str = path.as_ref().to_string_lossy().to_string();
392            let filename = if let Some(file_name) = path.as_ref().file_name() {
393                file_name.to_string_lossy().to_string()
394            } else {
395                format!("Image {}", i + 1)
396            };
397            self.items.push(ImageListItem::new(filename, path_str));
398        }
399        self
400    }
401
402    /// Add items from a collection of URLs.
403    pub fn items_from_urls<I, S>(mut self, urls: I) -> Self
404    where
405        I: IntoIterator<Item = S>,
406        S: Into<String>,
407    {
408        for (i, url) in urls.into_iter().enumerate() {
409            let url_str = url.into();
410            let label = format!("Online {}", i + 1);
411            self.items.push(ImageListItem::new(label, url_str));
412        }
413        self
414    }
415
416    /// Add items from a collection of byte arrays (for embedded images).
417    pub fn items_from_bytes<I>(mut self, bytes_collection: I) -> Self
418    where
419        I: IntoIterator<Item = Vec<u8>>,
420    {
421        for (i, bytes) in bytes_collection.into_iter().enumerate() {
422            let label = format!("Embedded {}", i + 1);
423            // Convert bytes to hex string for the bytes: protocol
424            let hex_string = format!("bytes:{}", hex::encode(bytes));
425            self.items.push(ImageListItem::new(label, hex_string));
426        }
427        self
428    }
429
430    fn get_image_list_style(&self) -> Color32 {
431        get_global_color("surface")
432    }
433}
434
435impl<'a> Default for MaterialImageList<'a> {
436    fn default() -> Self {
437        Self::standard()
438    }
439}
440
441impl Widget for MaterialImageList<'_> {
442    fn ui(self, ui: &mut Ui) -> Response {
443        let background_color = self.get_image_list_style();
444
445        let MaterialImageList {
446            variant,
447            mut items,
448            columns,
449            item_spacing,
450            text_protected,
451            corner_radius,
452            id_salt,
453            #[cfg_attr(not(feature = "ondemand"), allow(unused_variables))]
454            tmppath,
455        } = self;
456
457        if items.is_empty() {
458            return ui.allocate_response(Vec2::ZERO, Sense::hover());
459        }
460
461        // Calculate grid dimensions
462        let available_width = ui.available_width();
463        let item_width = (available_width - (columns - 1) as f32 * item_spacing) / columns as f32;
464        let item_height = match variant {
465            ImageListVariant::Standard => item_width, // Square items
466            ImageListVariant::Masonry => item_width * 1.2, // Slightly taller
467            ImageListVariant::Woven => item_width * 0.8, // Slightly shorter
468        };
469        
470        let rows = (items.len() + columns - 1) / columns;
471        let total_height = rows as f32 * (item_height + item_spacing) - item_spacing;
472        let total_width = available_width;
473
474        let response = ui.allocate_response(
475            Vec2::new(total_width, total_height), 
476            Sense::hover()
477        );
478        let rect = response.rect;
479
480        if ui.is_rect_visible(rect) {
481            // Draw background
482            ui.painter().rect_filled(rect, corner_radius, background_color);
483
484            // Draw items in grid
485            for (index, item) in items.iter_mut().enumerate() {
486                let row = index / columns;
487                let col = index % columns;
488                
489                let item_x = rect.min.x + col as f32 * (item_width + item_spacing);
490                let item_y = rect.min.y + row as f32 * (item_height + item_spacing);
491                
492                let item_rect = Rect::from_min_size(
493                    egui::pos2(item_x, item_y),
494                    Vec2::new(item_width, item_height)
495                );
496
497                // Handle item interaction with unique ID
498                let item_id = if let Some(ref salt) = id_salt {
499                    egui::Id::new((salt, "image_item", index))
500                } else {
501                    egui::Id::new(("image_item", index, &item.label))
502                };
503                
504                let item_response = ui.interact(item_rect, item_id, Sense::click());
505                if item_response.hovered() {
506                    let hover_color = get_global_color("primary").linear_multiply(0.08);
507                    ui.painter().rect_filled(item_rect, corner_radius, hover_color);
508                }
509
510                if item_response.clicked() {
511                    if let Some(callback) = &item.on_click {
512                        callback();
513                    }
514                }
515
516                // Draw placeholder image (rectangle with border)
517                let image_rect = item_rect.shrink(2.0);
518                let image_bg = get_global_color("surfaceVariant");
519                let image_border = Stroke::new(1.0, get_global_color("outline"));
520                
521                ui.painter().rect_filled(image_rect, corner_radius, image_bg);
522                ui.painter().rect_stroke(image_rect, corner_radius, image_border, egui::epaint::StrokeKind::Outside);
523
524                // Load and cache image if not already loaded
525                if item.loaded_image.is_none() {
526                    if let Some(ref image_source) = item.image_source {
527                        let loaded_image = if image_source.starts_with("http://") || image_source.starts_with("https://") {
528                            #[cfg(feature = "ondemand")]
529                            {
530                                load_image_from_url(image_source, &tmppath)
531                            }
532                            #[cfg(not(feature = "ondemand"))]
533                            {
534                                None
535                            }
536                        } else if image_source.starts_with("data:") {
537                            load_image_from_data_url(image_source)
538                        } else if image_source.starts_with("bytes:") {
539                            let bytes_str = &image_source[6..]; // Remove "bytes:" prefix
540                            load_image_from_bytes(bytes_str)
541                        } else {
542                            load_image_from_file(image_source)
543                        };
544                        
545                        // Cache the loaded image (even if None for failed loads)
546                        item.loaded_image = loaded_image;
547                    }
548                }
549
550                // Render the image if available
551                let mut failed = false;
552                if let Some(ref color_image) = item.loaded_image {
553                    let texture_name = format!("image_texture_{}_{}", item_id.value(), item.label);
554                    let texture_id = ui.ctx().load_texture(
555                        texture_name,
556                        color_image.clone(),
557                        Default::default()
558                    );
559                    ui.painter().image(
560                        texture_id.id(),
561                        image_rect,
562                        egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)),
563                        Color32::WHITE,
564                    );
565                } else {
566                    failed = true;
567                }
568
569                if failed {
570                    // Debug: Print when showing X marks
571                    println!("SHOWING X MARK for item: {}", item.label);
572                    // Draw a simple "X" to indicate failed image load
573                    let line_color = get_global_color("error");
574                    ui.painter().line_segment(
575                        [image_rect.min, image_rect.max],
576                        Stroke::new(2.0, line_color),
577                    );
578                    ui.painter().line_segment(
579                        [egui::pos2(image_rect.min.x, image_rect.max.y), egui::pos2(image_rect.max.x, image_rect.min.y)],
580                        Stroke::new(2.0, line_color),
581                    );
582                }
583
584                // Draw text overlay or below image
585                let text_color = if text_protected {
586                    Color32::WHITE
587                } else {
588                    get_global_color("onSurface")
589                };
590
591                if text_protected {
592                    // Draw dark overlay for text protection
593                    let overlay_rect = Rect::from_min_size(
594                        egui::pos2(image_rect.min.x, image_rect.max.y - 40.0),
595                        Vec2::new(image_rect.width(), 40.0)
596                    );
597                    let overlay_color = Color32::from_rgba_unmultiplied(0, 0, 0, 128);
598                    ui.painter().rect_filled(overlay_rect, CornerRadius::ZERO, overlay_color);
599                    
600                    // Draw text on overlay
601                    let text_pos = egui::pos2(image_rect.min.x + 8.0, image_rect.max.y - 30.0);
602                    ui.painter().text(
603                        text_pos,
604                        egui::Align2::LEFT_TOP,
605                        &item.label,
606                        egui::FontId::proportional(12.0),
607                        text_color
608                    );
609                    
610                    if let Some(supporting_text) = &item.supporting_text {
611                        let support_text_pos = egui::pos2(image_rect.min.x + 8.0, image_rect.max.y - 16.0);
612                        ui.painter().text(
613                            support_text_pos,
614                            egui::Align2::LEFT_TOP,
615                            supporting_text,
616                            egui::FontId::proportional(10.0),
617                            get_global_color("onSurfaceVariant")
618                        );
619                    }
620                } else {
621                    // Draw text below image
622                    let text_y = item_rect.max.y - 30.0;
623                    let text_pos = egui::pos2(item_rect.min.x + 4.0, text_y);
624                    
625                    ui.painter().text(
626                        text_pos,
627                        egui::Align2::LEFT_TOP,
628                        &item.label,
629                        egui::FontId::proportional(12.0),
630                        text_color
631                    );
632                    // draw image_source if avalilable
633                    if let Some(image_source) = &item.image_source {
634                        let source_pos = egui::pos2(item_rect.min.x + 4.0, text_y + 14.0);
635                        ui.painter().text(
636                            source_pos,
637                            egui::Align2::LEFT_TOP,
638                            image_source,
639                            egui::FontId::proportional(10.0),
640                            get_global_color("onSurfaceVariant")
641                        );
642                    }
643                    
644                    if let Some(supporting_text) = &item.supporting_text {
645                        let support_text_pos = egui::pos2(item_rect.min.x + 4.0, text_y + 14.0);
646                        ui.painter().text(
647                            support_text_pos,
648                            egui::Align2::LEFT_TOP,
649                            supporting_text,
650                            egui::FontId::proportional(10.0),
651                            get_global_color("onSurfaceVariant")
652                        );
653                    }
654                }
655            }
656        }
657
658        response
659    }
660}
661
662/// Convenience function to create a standard image list.
663pub fn image_list() -> MaterialImageList<'static> {
664    MaterialImageList::standard()
665}
666
667/// Convenience function to create a masonry image list.
668pub fn masonry_image_list() -> MaterialImageList<'static> {
669    MaterialImageList::masonry()
670}
671
672/// Convenience function to create a woven image list.
673pub fn woven_image_list() -> MaterialImageList<'static> {
674    MaterialImageList::woven()
675}