Skip to main content

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::{CornerRadius, Stroke},
81    Rect, Response, Sense, Ui, Vec2, Widget,
82};
83use image::GenericImageView;
84use std::env;
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(
175                    [size.0 as usize, size.1 as usize],
176                    &pixels,
177                ))
178            }
179            Err(_) => None,
180        }
181    } else {
182        None
183    }
184}
185
186/// Load image from URL (requires ondemand feature)
187#[cfg(feature = "ondemand")]
188fn load_image_from_url(url: &str, tmppath: &str) -> Option<egui::ColorImage> {
189    use std::hash::{Hash, Hasher};
190    use std::io::Read;
191
192    let mut hasher = std::collections::hash_map::DefaultHasher::new();
193    url.hash(&mut hasher);
194    let url_hash = format!("{:x}", hasher.finish());
195    let filename = format!("img_{}", url_hash);
196    let filepath = std::path::Path::new(tmppath).join(&filename);
197
198    // Check if file already exists with any extension
199    let possible_files = [
200        filepath.with_extension("png"),
201        filepath.with_extension("jpg"),
202        filepath.with_extension("gif"),
203        filepath.with_extension("webp"),
204        filepath.clone(),
205    ];
206
207    let existing_file = possible_files.iter().find(|f| f.exists());
208
209    if existing_file.is_none() {
210        // Try to download the image with timeout and user agent
211        let agent = ureq::AgentBuilder::new()
212            .timeout_read(std::time::Duration::from_secs(10))
213            .timeout_write(std::time::Duration::from_secs(10))
214            .user_agent("egui-material3/1.0")
215            .build();
216
217        match agent.get(url).call() {
218            Ok(response) => {
219                let status = response.status();
220                if status == 200 {
221                    let mut bytes = Vec::new();
222                    if let Ok(_) = response.into_reader().read_to_end(&mut bytes) {
223                        if !bytes.is_empty() {
224                            // Detect image format from content and add appropriate extension
225                            let extension = if bytes.starts_with(&[0x89, 0x50, 0x4E, 0x47]) {
226                                "png"
227                            } else if bytes.starts_with(&[0xFF, 0xD8, 0xFF]) {
228                                "jpg"
229                            } else if bytes.starts_with(&[0x47, 0x49, 0x46]) {
230                                "gif"
231                            } else if bytes.starts_with(&[0x52, 0x49, 0x46, 0x46])
232                                && bytes.len() > 12
233                                && &bytes[8..12] == b"WEBP"
234                            {
235                                "webp"
236                            } else {
237                                "png"
238                            };
239
240                            let filepath_with_ext = filepath.with_extension(extension);
241                            let _ = std::fs::write(&filepath_with_ext, &bytes);
242                        }
243                    }
244                }
245            }
246            Err(_) => {}
247        }
248    }
249
250    // Try to load the image from cache
251    if let Some(existing_filepath) = possible_files.iter().find(|f| f.exists()) {
252        match image::open(existing_filepath) {
253            Ok(image) => {
254                let original_size = image.dimensions();
255
256                // Resize large images to max 512x512 to avoid memory issues
257                let resized_image = if original_size.0 > 512 || original_size.1 > 512 {
258                    image.resize(512, 512, image::imageops::FilterType::Lanczos3)
259                } else {
260                    image
261                };
262
263                let size = resized_image.dimensions();
264                let image_buffer = resized_image.to_rgba8();
265                let pixels = image_buffer.into_raw();
266                Some(egui::ColorImage::from_rgba_unmultiplied(
267                    [size.0 as usize, size.1 as usize],
268                    &pixels,
269                ))
270            }
271            Err(_) => None,
272        }
273    } else {
274        None
275    }
276}
277
278/// Load image from data URL (base64 encoded)
279fn load_image_from_data_url(data_url: &str) -> Option<egui::ColorImage> {
280    if let Some(comma_pos) = data_url.find(',') {
281        let data_part = &data_url[comma_pos + 1..];
282        if let Ok(bytes) =
283            base64::Engine::decode(&base64::engine::general_purpose::STANDARD, data_part)
284        {
285            if let Ok(image) = image::load_from_memory(&bytes) {
286                let size = image.dimensions();
287                let image_buffer = image.to_rgba8();
288                let pixels = image_buffer.into_raw();
289                return Some(egui::ColorImage::from_rgba_unmultiplied(
290                    [size.0 as usize, size.1 as usize],
291                    &pixels,
292                ));
293            }
294        }
295    }
296    None
297}
298
299/// Load image from bytes (hex encoded)
300fn load_image_from_bytes(bytes_str: &str) -> Option<egui::ColorImage> {
301    if let Ok(bytes) = hex::decode(bytes_str) {
302        if let Ok(image) = image::load_from_memory(&bytes) {
303            let size = image.dimensions();
304            let image_buffer = image.to_rgba8();
305            let pixels = image_buffer.into_raw();
306            return Some(egui::ColorImage::from_rgba_unmultiplied(
307                [size.0 as usize, size.1 as usize],
308                &pixels,
309            ));
310        }
311    }
312    None
313}
314
315impl<'a> MaterialImageList<'a> {
316    /// Create a new standard image list.
317    pub fn standard() -> Self {
318        Self::new(ImageListVariant::Standard)
319    }
320
321    /// Create a new masonry image list.
322    pub fn masonry() -> Self {
323        Self::new(ImageListVariant::Masonry)
324    }
325
326    /// Create a new woven image list.
327    pub fn woven() -> Self {
328        Self::new(ImageListVariant::Woven)
329    }
330
331    fn new(variant: ImageListVariant) -> Self {
332        // create img folder in tmp dir using let dir = env::temp_dir(), save the path to tmppath on MaterialImageList
333        let tmppath = env::temp_dir().join("egui_material3_img");
334        let _ = std::fs::create_dir_all(&tmppath);
335
336        Self {
337            variant,
338            items: Vec::new(),
339            columns: 3,
340            item_spacing: 8.0,
341            text_protected: false,
342            corner_radius: CornerRadius::from(4.0),
343            id_salt: None,
344            tmppath: tmppath.to_string_lossy().to_string(),
345        }
346    }
347
348    /// Set number of columns.
349    pub fn columns(mut self, columns: usize) -> Self {
350        self.columns = columns.max(1);
351        self
352    }
353
354    /// Add an image item.
355    pub fn item(mut self, label: impl Into<String>, image_source: impl Into<String>) -> Self {
356        self.items.push(ImageListItem::new(label, image_source));
357        self
358    }
359
360    /// Add an image item with callback.
361    pub fn item_with_callback<F>(
362        mut self,
363        label: impl Into<String>,
364        image_source: impl Into<String>,
365        callback: F,
366    ) -> Self
367    where
368        F: Fn() + Send + Sync + 'static,
369    {
370        self.items
371            .push(ImageListItem::new(label, image_source).on_click(callback));
372        self
373    }
374
375    /// Set item spacing.
376    pub fn item_spacing(mut self, spacing: f32) -> Self {
377        self.item_spacing = spacing;
378        self
379    }
380
381    /// Enable text protection overlay.
382    pub fn text_protected(mut self, protected: bool) -> Self {
383        self.text_protected = protected;
384        self
385    }
386
387    /// Set corner radius.
388    pub fn corner_radius(mut self, corner_radius: impl Into<CornerRadius>) -> Self {
389        self.corner_radius = corner_radius.into();
390        self
391    }
392
393    /// Set unique ID salt to prevent ID clashes.
394    pub fn id_salt(mut self, salt: impl Into<String>) -> Self {
395        self.id_salt = Some(salt.into());
396        self
397    }
398
399    /// Add items from a collection of file paths.
400    pub fn items_from_paths<I, P>(mut self, paths: I) -> Self
401    where
402        I: IntoIterator<Item = P>,
403        P: AsRef<std::path::Path>,
404    {
405        for (i, path) in paths.into_iter().enumerate() {
406            let path_str = path.as_ref().to_string_lossy().to_string();
407            let filename = if let Some(file_name) = path.as_ref().file_name() {
408                file_name.to_string_lossy().to_string()
409            } else {
410                format!("Image {}", i + 1)
411            };
412            self.items.push(ImageListItem::new(filename, path_str));
413        }
414        self
415    }
416
417    /// Add items from a collection of URLs.
418    pub fn items_from_urls<I, S>(mut self, urls: I) -> Self
419    where
420        I: IntoIterator<Item = S>,
421        S: Into<String>,
422    {
423        for (i, url) in urls.into_iter().enumerate() {
424            let url_str = url.into();
425            let label = format!("Online {}", i + 1);
426            self.items.push(ImageListItem::new(label, url_str));
427        }
428        self
429    }
430
431    /// Add items from a collection of byte arrays (for embedded images).
432    pub fn items_from_bytes<I>(mut self, bytes_collection: I) -> Self
433    where
434        I: IntoIterator<Item = Vec<u8>>,
435    {
436        for (i, bytes) in bytes_collection.into_iter().enumerate() {
437            let label = format!("Embedded {}", i + 1);
438            // Convert bytes to hex string for the bytes: protocol
439            let hex_string = format!("bytes:{}", hex::encode(bytes));
440            self.items.push(ImageListItem::new(label, hex_string));
441        }
442        self
443    }
444
445    fn get_image_list_style(&self) -> Color32 {
446        get_global_color("surface")
447    }
448}
449
450impl<'a> Default for MaterialImageList<'a> {
451    fn default() -> Self {
452        Self::standard()
453    }
454}
455
456impl Widget for MaterialImageList<'_> {
457    fn ui(self, ui: &mut Ui) -> Response {
458        let background_color = self.get_image_list_style();
459
460        let MaterialImageList {
461            variant,
462            mut items,
463            columns,
464            item_spacing,
465            text_protected,
466            corner_radius,
467            id_salt,
468            #[cfg_attr(not(feature = "ondemand"), allow(unused_variables))]
469            tmppath,
470        } = self;
471
472        if items.is_empty() {
473            return ui.allocate_response(Vec2::ZERO, Sense::hover());
474        }
475
476        // Calculate grid dimensions
477        let available_width = ui.available_width();
478        let item_width = (available_width - (columns - 1) as f32 * item_spacing) / columns as f32;
479        let item_height = match variant {
480            ImageListVariant::Standard => item_width, // Square items
481            ImageListVariant::Masonry => item_width * 1.2, // Slightly taller
482            ImageListVariant::Woven => item_width * 0.8, // Slightly shorter
483        };
484
485        let rows = (items.len() + columns - 1) / columns;
486        let total_height = rows as f32 * (item_height + item_spacing) - item_spacing;
487        let total_width = available_width;
488
489        let response = ui.allocate_response(Vec2::new(total_width, total_height), Sense::hover());
490        let rect = response.rect;
491
492        if ui.is_rect_visible(rect) {
493            // Draw background
494            ui.painter()
495                .rect_filled(rect, corner_radius, background_color);
496
497            // Draw items in grid
498            for (index, item) in items.iter_mut().enumerate() {
499                let row = index / columns;
500                let col = index % columns;
501
502                let item_x = rect.min.x + col as f32 * (item_width + item_spacing);
503                let item_y = rect.min.y + row as f32 * (item_height + item_spacing);
504
505                let item_rect = Rect::from_min_size(
506                    egui::pos2(item_x, item_y),
507                    Vec2::new(item_width, item_height),
508                );
509
510                // Handle item interaction with unique ID
511                let item_id = if let Some(ref salt) = id_salt {
512                    egui::Id::new((salt, "image_item", index))
513                } else {
514                    egui::Id::new(("image_item", index, &item.label))
515                };
516
517                let item_response = ui.interact(item_rect, item_id, Sense::click());
518                if item_response.hovered() {
519                    let hover_color = get_global_color("primary").linear_multiply(0.08);
520                    ui.painter()
521                        .rect_filled(item_rect, corner_radius, hover_color);
522                }
523
524                if item_response.clicked() {
525                    if let Some(callback) = &item.on_click {
526                        callback();
527                    }
528                }
529
530                // Draw placeholder image (rectangle with border)
531                let image_rect = item_rect.shrink(2.0);
532                let image_bg = get_global_color("surfaceVariant");
533                let image_border = Stroke::new(1.0, get_global_color("outline"));
534
535                ui.painter()
536                    .rect_filled(image_rect, corner_radius, image_bg);
537                ui.painter().rect_stroke(
538                    image_rect,
539                    corner_radius,
540                    image_border,
541                    egui::epaint::StrokeKind::Outside,
542                );
543
544                // Load and cache image if not already loaded
545                if item.loaded_image.is_none() {
546                    if let Some(ref image_source) = item.image_source {
547                        let loaded_image = if image_source.starts_with("http://")
548                            || image_source.starts_with("https://")
549                        {
550                            #[cfg(feature = "ondemand")]
551                            {
552                                load_image_from_url(image_source, &tmppath)
553                            }
554                            #[cfg(not(feature = "ondemand"))]
555                            {
556                                None
557                            }
558                        } else if image_source.starts_with("data:") {
559                            load_image_from_data_url(image_source)
560                        } else if image_source.starts_with("bytes:") {
561                            let bytes_str = &image_source[6..]; // Remove "bytes:" prefix
562                            load_image_from_bytes(bytes_str)
563                        } else {
564                            load_image_from_file(image_source)
565                        };
566
567                        // Cache the loaded image (even if None for failed loads)
568                        item.loaded_image = loaded_image;
569                    }
570                }
571
572                // Render the image if available
573                let mut failed = false;
574                if let Some(ref color_image) = item.loaded_image {
575                    let texture_name = format!("image_texture_{}_{}", item_id.value(), item.label);
576                    let texture_id = ui.ctx().load_texture(
577                        texture_name,
578                        color_image.clone(),
579                        Default::default(),
580                    );
581                    ui.painter().image(
582                        texture_id.id(),
583                        image_rect,
584                        egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)),
585                        Color32::WHITE,
586                    );
587                } else {
588                    failed = true;
589                }
590
591                if failed {
592                    // Debug: Print when showing X marks
593                    println!("SHOWING X MARK for item: {}", item.label);
594                    // Draw a simple "X" to indicate failed image load
595                    let line_color = get_global_color("error");
596                    ui.painter().line_segment(
597                        [image_rect.min, image_rect.max],
598                        Stroke::new(2.0, line_color),
599                    );
600                    ui.painter().line_segment(
601                        [
602                            egui::pos2(image_rect.min.x, image_rect.max.y),
603                            egui::pos2(image_rect.max.x, image_rect.min.y),
604                        ],
605                        Stroke::new(2.0, line_color),
606                    );
607                }
608
609                // Draw text overlay or below image
610                let text_color = if text_protected {
611                    Color32::WHITE
612                } else {
613                    get_global_color("onSurface")
614                };
615
616                if text_protected {
617                    // Draw dark overlay for text protection
618                    let overlay_rect = Rect::from_min_size(
619                        egui::pos2(image_rect.min.x, image_rect.max.y - 40.0),
620                        Vec2::new(image_rect.width(), 40.0),
621                    );
622                    let overlay_color = Color32::from_rgba_unmultiplied(0, 0, 0, 128);
623                    ui.painter()
624                        .rect_filled(overlay_rect, CornerRadius::ZERO, overlay_color);
625
626                    // Draw text on overlay
627                    let text_pos = egui::pos2(image_rect.min.x + 8.0, image_rect.max.y - 30.0);
628                    ui.painter().text(
629                        text_pos,
630                        egui::Align2::LEFT_TOP,
631                        &item.label,
632                        egui::FontId::proportional(12.0),
633                        text_color,
634                    );
635
636                    if let Some(supporting_text) = &item.supporting_text {
637                        let support_text_pos =
638                            egui::pos2(image_rect.min.x + 8.0, image_rect.max.y - 16.0);
639                        ui.painter().text(
640                            support_text_pos,
641                            egui::Align2::LEFT_TOP,
642                            supporting_text,
643                            egui::FontId::proportional(10.0),
644                            get_global_color("onSurfaceVariant"),
645                        );
646                    }
647                } else {
648                    // Draw text below image
649                    let text_y = item_rect.max.y - 30.0;
650                    let text_pos = egui::pos2(item_rect.min.x + 4.0, text_y);
651
652                    ui.painter().text(
653                        text_pos,
654                        egui::Align2::LEFT_TOP,
655                        &item.label,
656                        egui::FontId::proportional(12.0),
657                        text_color,
658                    );
659                    // draw image_source if avalilable
660                    if let Some(image_source) = &item.image_source {
661                        let source_pos = egui::pos2(item_rect.min.x + 4.0, text_y + 14.0);
662                        ui.painter().text(
663                            source_pos,
664                            egui::Align2::LEFT_TOP,
665                            image_source,
666                            egui::FontId::proportional(10.0),
667                            get_global_color("onSurfaceVariant"),
668                        );
669                    }
670
671                    if let Some(supporting_text) = &item.supporting_text {
672                        let support_text_pos = egui::pos2(item_rect.min.x + 4.0, text_y + 14.0);
673                        ui.painter().text(
674                            support_text_pos,
675                            egui::Align2::LEFT_TOP,
676                            supporting_text,
677                            egui::FontId::proportional(10.0),
678                            get_global_color("onSurfaceVariant"),
679                        );
680                    }
681                }
682            }
683        }
684
685        response
686    }
687}
688
689/// Convenience function to create a standard image list.
690pub fn image_list() -> MaterialImageList<'static> {
691    MaterialImageList::standard()
692}
693
694/// Convenience function to create a masonry image list.
695pub fn masonry_image_list() -> MaterialImageList<'static> {
696    MaterialImageList::masonry()
697}
698
699/// Convenience function to create a woven image list.
700pub fn woven_image_list() -> MaterialImageList<'static> {
701    MaterialImageList::woven()
702}