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