1use 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#[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(
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#[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 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 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 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 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 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
278fn 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
299fn 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 pub fn standard() -> Self {
318 Self::new(ImageListVariant::Standard)
319 }
320
321 pub fn masonry() -> Self {
323 Self::new(ImageListVariant::Masonry)
324 }
325
326 pub fn woven() -> Self {
328 Self::new(ImageListVariant::Woven)
329 }
330
331 fn new(variant: ImageListVariant) -> Self {
332 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 pub fn columns(mut self, columns: usize) -> Self {
350 self.columns = columns.max(1);
351 self
352 }
353
354 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 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 pub fn item_spacing(mut self, spacing: f32) -> Self {
377 self.item_spacing = spacing;
378 self
379 }
380
381 pub fn text_protected(mut self, protected: bool) -> Self {
383 self.text_protected = protected;
384 self
385 }
386
387 pub fn corner_radius(mut self, corner_radius: impl Into<CornerRadius>) -> Self {
389 self.corner_radius = corner_radius.into();
390 self
391 }
392
393 pub fn id_salt(mut self, salt: impl Into<String>) -> Self {
395 self.id_salt = Some(salt.into());
396 self
397 }
398
399 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 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 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 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 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, ImageListVariant::Masonry => item_width * 1.2, ImageListVariant::Woven => item_width * 0.8, };
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 ui.painter()
495 .rect_filled(rect, corner_radius, background_color);
496
497 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 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 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 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..]; load_image_from_bytes(bytes_str)
563 } else {
564 load_image_from_file(image_source)
565 };
566
567 item.loaded_image = loaded_image;
569 }
570 }
571
572 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 println!("SHOWING X MARK for item: {}", item.label);
594 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 let text_color = if text_protected {
611 Color32::WHITE
612 } else {
613 get_global_color("onSurface")
614 };
615
616 if text_protected {
617 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 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 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 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
689pub fn image_list() -> MaterialImageList<'static> {
691 MaterialImageList::standard()
692}
693
694pub fn masonry_image_list() -> MaterialImageList<'static> {
696 MaterialImageList::masonry()
697}
698
699pub fn woven_image_list() -> MaterialImageList<'static> {
701 MaterialImageList::woven()
702}