Blacksite/crates/editor_ui/src/materials/pickers.rs
Rbanh 53dc1e44d8
Some checks are pending
CI / Format, lint, test, build (push) Waiting to run
feat: add production UI gallery and inspector system
2026-07-18 12:05:26 -04:00

421 lines
14 KiB
Rust

//! 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<MaterialSlotAction>,
) {
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(&current.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<MaterialSlotAction>,
) {
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<MaterialSlotAction>) {
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(&current.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::<Vec<(String, String)>>(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);
}
}