421 lines
14 KiB
Rust
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(¤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<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(¤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::<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);
|
|
}
|
|
}
|