1use 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#[derive(Clone, Copy, Debug, PartialEq)]
88pub enum ImageListVariant {
89 Standard,
90 Masonry,
91 Woven,
92}
93
94#[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
157fn 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 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#[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 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 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 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 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 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
269fn 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
285fn 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 pub fn standard() -> Self {
301 Self::new(ImageListVariant::Standard)
302 }
303
304 pub fn masonry() -> Self {
306 Self::new(ImageListVariant::Masonry)
307 }
308
309 pub fn woven() -> Self {
311 Self::new(ImageListVariant::Woven)
312 }
313
314 fn new(variant: ImageListVariant) -> Self {
315 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 pub fn columns(mut self, columns: usize) -> Self {
333 self.columns = columns.max(1);
334 self
335 }
336
337 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 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 pub fn item_spacing(mut self, spacing: f32) -> Self {
362 self.item_spacing = spacing;
363 self
364 }
365
366 pub fn text_protected(mut self, protected: bool) -> Self {
368 self.text_protected = protected;
369 self
370 }
371
372 pub fn corner_radius(mut self, corner_radius: impl Into<CornerRadius>) -> Self {
374 self.corner_radius = corner_radius.into();
375 self
376 }
377
378 pub fn id_salt(mut self, salt: impl Into<String>) -> Self {
380 self.id_salt = Some(salt.into());
381 self
382 }
383
384 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 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 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 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 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, ImageListVariant::Masonry => item_width * 1.2, ImageListVariant::Woven => item_width * 0.8, };
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 ui.painter().rect_filled(rect, corner_radius, background_color);
483
484 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 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 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 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..]; load_image_from_bytes(bytes_str)
541 } else {
542 load_image_from_file(image_source)
543 };
544
545 item.loaded_image = loaded_image;
547 }
548 }
549
550 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 println!("SHOWING X MARK for item: {}", item.label);
572 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 let text_color = if text_protected {
586 Color32::WHITE
587 } else {
588 get_global_color("onSurface")
589 };
590
591 if text_protected {
592 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 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 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 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
662pub fn image_list() -> MaterialImageList<'static> {
664 MaterialImageList::standard()
665}
666
667pub fn masonry_image_list() -> MaterialImageList<'static> {
669 MaterialImageList::masonry()
670}
671
672pub fn woven_image_list() -> MaterialImageList<'static> {
674 MaterialImageList::woven()
675}