//! Penpot-styled material and texture asset pickers. use egui; use egui_phosphor_icons::icons; use material_schema::EditorAssetRef; use super::panel::{MaterialPickerCandidate, MaterialSlotAction, MaterialSlotPanelViewModel}; use super::{MaterialTextureCandidate, MaterialThumbnailPresentation, TextureSlotResponse}; use crate::design_system; use crate::design_system::controls::icon_label; use crate::design_system::typography::TypeRole; const MATERIAL_PICKER_WIDTH: f32 = 244.0; const TEXTURE_PICKER_WIDTH: f32 = 420.0; const PICKER_ROW_HEIGHT: f32 = 44.0; const PICKER_THUMBNAIL: f32 = 36.0; pub(super) fn material_picker_popup( _ui: &mut egui::Ui, id: egui::Id, anchor: &egui::Response, view: &MaterialSlotPanelViewModel, actions: &mut Vec, ) { egui::Popup::menu(anchor) .id(id) .width(MATERIAL_PICKER_WIDTH) .show(|ui| { ui.label( TypeRole::Section .text("Material Assets") .color(design_system::palette(ui).text_primary), ); if let Some(current) = view.effective.as_ref() { if let Some(candidate) = find_material_candidate(&view.candidates, current) { material_menu_row(ui, candidate, true, actions); } else { unresolved_material_menu_row(ui, current); } } let recent_keys = recent_keys(ui, "material_picker_recents"); let recent = candidates_for_recent_keys(&view.candidates, &recent_keys); for candidate in recent.into_iter().take(4) { let selected = view .effective .as_ref() .is_some_and(|current| same_reference(current, &candidate.reference)); if !selected { material_menu_row(ui, candidate, false, actions); } } picker_footer(ui, view.assigned.is_some(), actions); }); } pub(super) fn texture_picker_popup( _ui: &mut egui::Ui, id: egui::Id, anchor: &egui::Response, candidates: &[MaterialTextureCandidate], selected: Option<&MaterialTextureCandidate>, response: &mut TextureSlotResponse, ) { egui::Popup::menu(anchor) .id(id) .width(TEXTURE_PICKER_WIDTH) .show(|ui| { picker_heading( ui, "Select Texture", "Project textures and imported subassets", ); if let Some(current) = selected { picker_section_label(ui, "CURRENT"); texture_candidate_row(ui, current, true, response); } let recent_keys = recent_keys(ui, "texture_picker_recents"); let recent = candidates_for_recent_texture_keys(candidates, &recent_keys); if !recent.is_empty() { picker_section_label(ui, "RECENT"); for candidate in recent.into_iter().take(4) { texture_candidate_row(ui, candidate, false, response); } } picker_section_label(ui, "PROJECT TEXTURES"); egui::ScrollArea::vertical() .id_salt(id.with("project_textures")) .max_height(240.0) .auto_shrink([false, true]) .show(ui, |ui| { if candidates.is_empty() { empty_picker_row(ui, "No project textures"); } for candidate in candidates { let selected = selected.is_some_and(|current| { same_reference(¤t.reference, &candidate.reference) }); texture_candidate_row(ui, candidate, selected, response); } }); let palette = design_system::palette(ui); ui.separator(); if ui .add(egui::Button::new(icon_label( icons::FOLDER_OPEN, "Browse Asset Library", TypeRole::Control, 14.0, palette.text_primary, ))) .clicked() { response.browse_library = true; ui.close(); } if selected.is_some() && ui .add(egui::Button::new(icon_label( icons::X, "Clear Texture", TypeRole::Control, 14.0, palette.text_primary, ))) .clicked() { response.clear = true; ui.close(); } }); } fn picker_heading(ui: &mut egui::Ui, title: &str, subtitle: &str) { let palette = design_system::palette(ui); ui.label(TypeRole::Title.text(title).color(palette.text_primary)); ui.label(TypeRole::Small.text(subtitle).color(palette.text_muted)); ui.separator(); } fn picker_section_label(ui: &mut egui::Ui, label: &str) { let palette = design_system::palette(ui); ui.add_space(4.0); ui.label(TypeRole::Caption.text(label).color(palette.text_muted)); } fn material_menu_row( ui: &mut egui::Ui, candidate: &MaterialPickerCandidate, selected: bool, actions: &mut Vec, ) { let response = picker_asset_row( ui, &candidate.label, &candidate.detail, candidate.thumbnail, selected, ); if response.clicked() { remember_recent(ui, "material_picker_recents", &candidate.reference); actions.push(MaterialSlotAction::Assign { reference: candidate.reference.clone(), from_drop: false, }); ui.close(); } } fn texture_candidate_row( ui: &mut egui::Ui, candidate: &MaterialTextureCandidate, selected: bool, response: &mut TextureSlotResponse, ) { let row = picker_asset_row( ui, &candidate.label, &candidate.detail, candidate.thumbnail, selected, ); if row.clicked() { remember_recent(ui, "texture_picker_recents", &candidate.reference); response.selected = Some(candidate.clone()); ui.close(); } } fn picker_asset_row( ui: &mut egui::Ui, label: &str, detail: &str, thumbnail: MaterialThumbnailPresentation, selected: bool, ) -> egui::Response { let palette = design_system::palette(ui); let (rect, response) = ui.allocate_exact_size( egui::vec2(ui.available_width().max(1.0), PICKER_ROW_HEIGHT), egui::Sense::click(), ); let fill = if selected { palette.accent_dark } else if response.hovered() { palette.elevated } else { palette.recessed }; ui.painter().rect( rect, 5.0, fill, egui::Stroke::new( 1.0_f32, if selected { palette.accent } else { palette.border }, ), egui::StrokeKind::Inside, ); let thumb = egui::Rect::from_min_size( rect.left_top() + egui::vec2(4.0, 4.0), egui::vec2(PICKER_THUMBNAIL, PICKER_THUMBNAIL), ); paint_thumbnail(ui, thumb, thumbnail); let text_rect = egui::Rect::from_min_max( egui::pos2(thumb.right() + 8.0, rect.top() + 5.0), egui::pos2(rect.right() - 8.0, rect.bottom() - 5.0), ); let mut child = ui.new_child( egui::UiBuilder::new() .max_rect(text_rect) .layout(egui::Layout::top_down(egui::Align::Min)), ); child.set_clip_rect(rect.intersect(ui.clip_rect())); child .add(egui::Label::new(TypeRole::Body.text(label).color(palette.text_primary)).truncate()) .on_hover_text(label); child .add(egui::Label::new(TypeRole::Small.text(detail).color(palette.text_muted)).truncate()) .on_hover_text(detail); response } fn paint_thumbnail(ui: &egui::Ui, rect: egui::Rect, thumbnail: MaterialThumbnailPresentation) { let palette = design_system::palette(ui); ui.painter().rect_filled(rect, 4.0, palette.control); match thumbnail { MaterialThumbnailPresentation::Ready(texture) => { ui.painter().image( texture, rect, egui::Rect::from_min_max(egui::Pos2::ZERO, egui::pos2(1.0, 1.0)), egui::Color32::WHITE, ); } MaterialThumbnailPresentation::Pending => { ui.painter().text( rect.center(), egui::Align2::CENTER_CENTER, icons::CIRCLE_NOTCH.as_str(), egui::FontId::new(14.0, egui::FontFamily::Name("phosphor-bold".into())), palette.text_muted, ); } MaterialThumbnailPresentation::Failed => { ui.painter().text( rect.center(), egui::Align2::CENTER_CENTER, icons::WARNING.as_str(), egui::FontId::new(14.0, egui::FontFamily::Name("phosphor-bold".into())), palette.error, ); } MaterialThumbnailPresentation::Empty => { ui.painter().text( rect.center(), egui::Align2::CENTER_CENTER, icons::PALETTE.as_str(), egui::FontId::new(14.0, egui::FontFamily::Name("phosphor-bold".into())), palette.text_muted, ); } } ui.painter().rect_stroke( rect, 4.0, egui::Stroke::new(1.0_f32, palette.border), egui::StrokeKind::Inside, ); } fn picker_footer(ui: &mut egui::Ui, can_clear: bool, actions: &mut Vec) { let palette = design_system::palette(ui); ui.separator(); if ui .add(egui::Button::new(icon_label( icons::FOLDER_OPEN, "Browse Asset Library", TypeRole::Control, 14.0, palette.text_primary, ))) .clicked() { actions.push(MaterialSlotAction::BrowseLibrary); ui.close(); } if can_clear && ui .add(egui::Button::new(icon_label( icons::X, "Clear Material", TypeRole::Control, 14.0, palette.text_primary, ))) .clicked() { actions.push(MaterialSlotAction::Clear); ui.close(); } } fn unresolved_material_menu_row(ui: &mut egui::Ui, current: &EditorAssetRef) { let palette = design_system::palette(ui); ui.add_sized( [ui.available_width().max(1.0), 28.0], egui::Button::new(TypeRole::Body.text(¤t.label).color(palette.error)).selected(true), ) .on_hover_text("The currently assigned material could not be resolved"); } fn empty_picker_row(ui: &mut egui::Ui, message: &str) { let palette = design_system::palette(ui); ui.add_sized( [ui.available_width().max(1.0), 32.0], egui::Label::new(TypeRole::Body.text(message).color(palette.text_muted)), ); } fn same_reference(left: &EditorAssetRef, right: &EditorAssetRef) -> bool { left.asset_id == right.asset_id && left.sub_asset_id == right.sub_asset_id } fn find_material_candidate<'a>( candidates: &'a [MaterialPickerCandidate], reference: &EditorAssetRef, ) -> Option<&'a MaterialPickerCandidate> { candidates .iter() .find(|candidate| same_reference(&candidate.reference, reference)) } fn recent_keys(ui: &egui::Ui, salt: &'static str) -> Vec<(String, String)> { let id = ui.make_persistent_id(salt); ui.ctx() .data_mut(|data| data.get_persisted::>(id)) .unwrap_or_default() } fn remember_recent(ui: &egui::Ui, salt: &'static str, reference: &EditorAssetRef) { let id = ui.make_persistent_id(salt); let mut keys = recent_keys(ui, salt); let key = (reference.asset_id.clone(), reference.sub_asset_id.clone()); keys.retain(|current| current != &key); keys.insert(0, key); keys.truncate(8); ui.ctx().data_mut(|data| data.insert_persisted(id, keys)); } fn candidates_for_recent_keys<'a>( candidates: &'a [MaterialPickerCandidate], keys: &[(String, String)], ) -> Vec<&'a MaterialPickerCandidate> { keys.iter() .filter_map(|(asset_id, sub_asset_id)| { candidates.iter().find(|candidate| { candidate.reference.asset_id == *asset_id && candidate.reference.sub_asset_id == *sub_asset_id }) }) .collect() } fn candidates_for_recent_texture_keys<'a>( candidates: &'a [MaterialTextureCandidate], keys: &[(String, String)], ) -> Vec<&'a MaterialTextureCandidate> { keys.iter() .filter_map(|(asset_id, sub_asset_id)| { candidates.iter().find(|candidate| { candidate.reference.asset_id == *asset_id && candidate.reference.sub_asset_id == *sub_asset_id }) }) .collect() } #[cfg(test)] mod tests { use super::*; #[test] fn penpot_picker_reference_comparison_uses_stable_identity_not_label() { let left = EditorAssetRef::new("asset", "material:source", "Left"); let right = EditorAssetRef::new("asset", "material:source", "Renamed"); assert!(same_reference(&left, &right)); } #[test] fn penpot_picker_geometry_matches_penpot_assets() { assert_eq!(MATERIAL_PICKER_WIDTH, 244.0); assert_eq!(TEXTURE_PICKER_WIDTH, 420.0); assert_eq!(PICKER_ROW_HEIGHT, 44.0); assert_eq!(PICKER_THUMBNAIL, 36.0); } }