feat(editor): unify renderer materials and content browser shell
Some checks failed
CI / Format, lint, test, build (push) Has been cancelled
Some checks failed
CI / Format, lint, test, build (push) Has been cancelled
This commit is contained in:
parent
53dc1e44d8
commit
93e23ba194
@ -49,20 +49,19 @@ use crate::assets::{
|
|||||||
};
|
};
|
||||||
use crate::ui::asset_card::{draw_asset_card, draw_asset_status_marker, AssetCardStatus};
|
use crate::ui::asset_card::{draw_asset_card, draw_asset_status_marker, AssetCardStatus};
|
||||||
use crate::ui::document_status::authored_document_status_ui;
|
use crate::ui::document_status::authored_document_status_ui;
|
||||||
use crate::ui::helpers::asset_label;
|
|
||||||
use crate::ui::theme::{
|
use crate::ui::theme::{
|
||||||
panel_heading, ACCENT, BORDER, ELEVATED_BG, ERROR, SUCCESS, TEXT, TEXT_DIM, WARNING, WIDGET_BG,
|
panel_heading, ACCENT, BORDER, ELEVATED_BG, ERROR, SUCCESS, TEXT, TEXT_DIM, TEXT_MUTED,
|
||||||
|
WARNING, WIDGET_BG,
|
||||||
};
|
};
|
||||||
use crate::ui::widgets::{icon_button_small, tool_button};
|
|
||||||
|
|
||||||
const FOOTER_HEIGHT: f32 = 64.0;
|
const FOOTER_HEIGHT: f32 = editor_ui::content_browser::STATUS_BAR_HEIGHT;
|
||||||
const TOOLBAR_HEIGHT: f32 = 40.0;
|
const TOOLBAR_HEIGHT: f32 = editor_ui::content_browser::TOOLBAR_HEIGHT;
|
||||||
const DETAILS_MIN_PANEL_WIDTH: f32 = 760.0;
|
const DETAILS_MIN_PANEL_WIDTH: f32 = 980.0;
|
||||||
const DETAILS_MIN_WIDTH: f32 = 200.0;
|
const DETAILS_MIN_WIDTH: f32 = 200.0;
|
||||||
const DETAILS_RESIZE_HANDLE_WIDTH: f32 = 10.0;
|
const DETAILS_RESIZE_HANDLE_WIDTH: f32 = 10.0;
|
||||||
const TREE_MIN_PANEL_WIDTH: f32 = 540.0;
|
const TREE_MIN_PANEL_WIDTH: f32 = editor_ui::content_browser::COMPACT_BREAKPOINT;
|
||||||
const TREE_MIN_WIDTH: f32 = 150.0;
|
const TREE_MIN_WIDTH: f32 = 180.0;
|
||||||
const TREE_MAX_WIDTH: f32 = 240.0;
|
const TREE_MAX_WIDTH: f32 = 216.0;
|
||||||
const MIN_CONTENT_WIDTH: f32 = 160.0;
|
const MIN_CONTENT_WIDTH: f32 = 160.0;
|
||||||
const COMPACT_LIST_WIDTH: f32 = 560.0;
|
const COMPACT_LIST_WIDTH: f32 = 560.0;
|
||||||
const PHOSPHOR: &str = "phosphor-regular";
|
const PHOSPHOR: &str = "phosphor-regular";
|
||||||
@ -209,9 +208,16 @@ pub fn asset_browser_ui(
|
|||||||
asset_toolbar(world, ui);
|
asset_toolbar(world, ui);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
ui.separator();
|
let (
|
||||||
|
current_folder,
|
||||||
let (current_folder, selected, folders, assets, cache_snapshot, state_snapshot) = {
|
selected,
|
||||||
|
folders,
|
||||||
|
assets,
|
||||||
|
cache_snapshot,
|
||||||
|
state_snapshot,
|
||||||
|
browser_status,
|
||||||
|
selection_count,
|
||||||
|
) = {
|
||||||
let assets = world.resource::<EditorAssets>();
|
let assets = world.resource::<EditorAssets>();
|
||||||
let cache = world.resource::<AssetThumbnailCache>();
|
let cache = world.resource::<AssetThumbnailCache>();
|
||||||
let state = world.resource::<AssetBrowserUiState>();
|
let state = world.resource::<AssetBrowserUiState>();
|
||||||
@ -241,6 +247,8 @@ pub fn asset_browser_ui(
|
|||||||
asset_rows,
|
asset_rows,
|
||||||
cache.snapshot(),
|
cache.snapshot(),
|
||||||
state_snapshot(state),
|
state_snapshot(state),
|
||||||
|
assets.status.clone(),
|
||||||
|
assets.selections.len(),
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -250,7 +258,7 @@ pub fn asset_browser_ui(
|
|||||||
let show_details =
|
let show_details =
|
||||||
state_snapshot.show_details && available_width >= DETAILS_MIN_PANEL_WIDTH;
|
state_snapshot.show_details && available_width >= DETAILS_MIN_PANEL_WIDTH;
|
||||||
let tree_width = if show_tree {
|
let tree_width = if show_tree {
|
||||||
(available_width * 0.22).clamp(TREE_MIN_WIDTH, TREE_MAX_WIDTH)
|
(available_width * 0.18).clamp(TREE_MIN_WIDTH, TREE_MAX_WIDTH)
|
||||||
} else {
|
} else {
|
||||||
0.0
|
0.0
|
||||||
};
|
};
|
||||||
@ -275,7 +283,12 @@ pub fn asset_browser_ui(
|
|||||||
egui::vec2(tree_width, body_height),
|
egui::vec2(tree_width, body_height),
|
||||||
egui::Layout::top_down(egui::Align::Min),
|
egui::Layout::top_down(egui::Align::Min),
|
||||||
|ui| {
|
|ui| {
|
||||||
ui.label(panel_heading("Project"));
|
ui.label(
|
||||||
|
egui::RichText::new("SOURCES")
|
||||||
|
.small()
|
||||||
|
.strong()
|
||||||
|
.color(TEXT_MUTED),
|
||||||
|
);
|
||||||
egui::ScrollArea::vertical()
|
egui::ScrollArea::vertical()
|
||||||
.id_salt("asset_folder_tree")
|
.id_salt("asset_folder_tree")
|
||||||
.max_height(ui.available_height())
|
.max_height(ui.available_height())
|
||||||
@ -396,19 +409,32 @@ pub fn asset_browser_ui(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
ui.separator();
|
|
||||||
exact_region(
|
exact_region(
|
||||||
ui,
|
ui,
|
||||||
egui::vec2(available_width, FOOTER_HEIGHT),
|
egui::vec2(available_width, FOOTER_HEIGHT),
|
||||||
egui::Layout::top_down(egui::Align::Min),
|
egui::Layout::top_down(egui::Align::Min),
|
||||||
|ui| {
|
|ui| {
|
||||||
egui::Frame::new()
|
let direct_assets = assets
|
||||||
.fill(ELEVATED_BG.linear_multiply(0.5))
|
.iter()
|
||||||
.inner_margin(egui::Margin::symmetric(8, 6))
|
.filter(|row| row.asset.folder_path == current_folder)
|
||||||
.show(ui, |ui| {
|
.count();
|
||||||
ui.set_max_height(FOOTER_HEIGHT - 12.0);
|
let direct_folders = folders
|
||||||
selection_footer(world, ui, selected_entities);
|
.iter()
|
||||||
});
|
.filter(|folder| folder.parent.as_deref() == Some(current_folder.as_str()))
|
||||||
|
.count();
|
||||||
|
let summary = if selection_count > 0 {
|
||||||
|
format!("{selection_count} selected")
|
||||||
|
} else {
|
||||||
|
format!("{direct_folders} folders · {direct_assets} assets")
|
||||||
|
};
|
||||||
|
editor_ui::content_browser::content_browser_status_bar(
|
||||||
|
ui,
|
||||||
|
editor_ui::content_browser::ContentBrowserStatusViewModel {
|
||||||
|
summary: &summary,
|
||||||
|
location: ¤t_folder,
|
||||||
|
status: &browser_status,
|
||||||
|
},
|
||||||
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@ -228,17 +228,37 @@ pub(crate) fn top_level_asset_details_panel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn asset_details_header(world: &World, ui: &mut egui::Ui, asset: &EditorAsset) {
|
pub(super) fn asset_details_header(world: &World, ui: &mut egui::Ui, asset: &EditorAsset) {
|
||||||
ui.horizontal_wrapped(|ui| {
|
let thumbnail = world
|
||||||
ui.label(
|
.get_resource::<AssetThumbnailCache>()
|
||||||
egui::RichText::new(kind_icon(&asset.kind).as_str())
|
.and_then(|cache| cache.snapshot().texture_for(asset));
|
||||||
.font(egui::FontId::new(
|
ui.horizontal_top(|ui| {
|
||||||
30.0,
|
let (preview, _) = ui.allocate_exact_size(egui::vec2(96.0, 96.0), egui::Sense::hover());
|
||||||
egui::FontFamily::Name(PHOSPHOR.into()),
|
ui.painter().rect(
|
||||||
))
|
preview,
|
||||||
.color(ACCENT),
|
5.0,
|
||||||
|
WIDGET_BG,
|
||||||
|
egui::Stroke::new(1.0_f32, BORDER),
|
||||||
|
egui::StrokeKind::Inside,
|
||||||
);
|
);
|
||||||
|
if let Some(texture) = thumbnail {
|
||||||
|
ui.painter().image(
|
||||||
|
texture,
|
||||||
|
preview.shrink(4.0),
|
||||||
|
egui::Rect::from_min_max(egui::Pos2::ZERO, egui::pos2(1.0, 1.0)),
|
||||||
|
egui::Color32::WHITE,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
ui.painter().text(
|
||||||
|
preview.center(),
|
||||||
|
egui::Align2::CENTER_CENTER,
|
||||||
|
kind_icon(&asset.kind).as_str(),
|
||||||
|
egui::FontId::new(30.0, egui::FontFamily::Name(PHOSPHOR.into())),
|
||||||
|
TEXT_DIM,
|
||||||
|
);
|
||||||
|
}
|
||||||
ui.vertical(|ui| {
|
ui.vertical(|ui| {
|
||||||
ui.strong(asset.label.as_str());
|
ui.add(egui::Label::new(egui::RichText::new(&asset.label).strong()).truncate())
|
||||||
|
.on_hover_text(&asset.label);
|
||||||
ui.small(egui::RichText::new(kind_label(&asset.kind)).color(TEXT_DIM));
|
ui.small(egui::RichText::new(kind_label(&asset.kind)).color(TEXT_DIM));
|
||||||
if let (Some(state), Some(path)) = (
|
if let (Some(state), Some(path)) = (
|
||||||
world.get_resource::<CollaborationState>(),
|
world.get_resource::<CollaborationState>(),
|
||||||
@ -256,6 +276,8 @@ pub(super) fn asset_details_header(world: &World, ui: &mut egui::Ui, asset: &Edi
|
|||||||
ui.small(egui::RichText::new("UNSAVED").color(WARNING).strong());
|
ui.small(egui::RichText::new("UNSAVED").color(WARNING).strong());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
ui.add_space(4.0);
|
||||||
|
ui.small(egui::RichText::new("Asset identity and import settings").color(TEXT_MUTED));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,11 +7,15 @@ pub(super) fn content_header(
|
|||||||
folders: &[FolderSnapshot],
|
folders: &[FolderSnapshot],
|
||||||
assets: &[AssetRow],
|
assets: &[AssetRow],
|
||||||
) {
|
) {
|
||||||
ui.horizontal_wrapped(|ui| {
|
let folder_label = if current_folder == BUILTINS_FOLDER {
|
||||||
ui.label(panel_heading("Assets"));
|
"Built-ins"
|
||||||
ui.separator();
|
} else {
|
||||||
breadcrumb(world, ui, current_folder);
|
Path::new(current_folder)
|
||||||
});
|
.file_name()
|
||||||
|
.and_then(|name| name.to_str())
|
||||||
|
.unwrap_or(current_folder)
|
||||||
|
};
|
||||||
|
ui.label(panel_heading(folder_label));
|
||||||
let direct_assets = assets
|
let direct_assets = assets
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|row| row.asset.folder_path == current_folder)
|
.filter(|row| row.asset.folder_path == current_folder)
|
||||||
@ -20,8 +24,13 @@ pub(super) fn content_header(
|
|||||||
.iter()
|
.iter()
|
||||||
.filter(|folder| folder.parent.as_deref() == Some(current_folder))
|
.filter(|folder| folder.parent.as_deref() == Some(current_folder))
|
||||||
.count();
|
.count();
|
||||||
|
let sort = world.resource::<AssetBrowserUiState>().sort;
|
||||||
ui.small(
|
ui.small(
|
||||||
egui::RichText::new(format!("{direct_folders} folders, {direct_assets} assets"))
|
egui::RichText::new(format!(
|
||||||
|
"{} items · sorted by {}",
|
||||||
|
direct_folders + direct_assets,
|
||||||
|
sort_label(sort)
|
||||||
|
))
|
||||||
.color(TEXT_DIM),
|
.color(TEXT_DIM),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -67,176 +76,6 @@ pub(super) fn prefetch_asset_row_thumbnails(world: &mut World, rows: &[AssetRow]
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn breadcrumb(world: &mut World, ui: &mut egui::Ui, current_folder: &str) {
|
|
||||||
let mut parts = Vec::new();
|
|
||||||
let mut cursor = Some(current_folder.to_string());
|
|
||||||
while let Some(path) = cursor {
|
|
||||||
let label = if path == BUILTINS_FOLDER {
|
|
||||||
"Built-ins".to_string()
|
|
||||||
} else {
|
|
||||||
Path::new(&path)
|
|
||||||
.file_name()
|
|
||||||
.and_then(|name| name.to_str())
|
|
||||||
.unwrap_or(&path)
|
|
||||||
.to_string()
|
|
||||||
};
|
|
||||||
cursor = path.rfind('/').map(|index| path[..index].to_string());
|
|
||||||
parts.push((path, label));
|
|
||||||
}
|
|
||||||
parts.reverse();
|
|
||||||
for (index, (path, label)) in parts.iter().enumerate() {
|
|
||||||
if index > 0 {
|
|
||||||
ui.label(egui::RichText::new("/").color(TEXT_DIM));
|
|
||||||
}
|
|
||||||
if ui.link(label).clicked() {
|
|
||||||
navigate_content_folder(world, path.clone());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) fn selection_footer(
|
|
||||||
world: &mut World,
|
|
||||||
ui: &mut egui::Ui,
|
|
||||||
selected_entities: &SelectedEntities,
|
|
||||||
) {
|
|
||||||
let selection = world.resource::<EditorAssets>().selected.clone();
|
|
||||||
let selection_count = world.resource::<EditorAssets>().selections.len();
|
|
||||||
if selection_count > 1 {
|
|
||||||
let has_authored_content = !selected_content_paths(world).is_empty();
|
|
||||||
ui.label(format!("{selection_count} items selected"));
|
|
||||||
ui.horizontal_wrapped(|ui| {
|
|
||||||
if ui
|
|
||||||
.add_enabled(has_authored_content, egui::Button::new("Cut"))
|
|
||||||
.clicked()
|
|
||||||
{
|
|
||||||
set_content_clipboard(world, true);
|
|
||||||
}
|
|
||||||
if ui
|
|
||||||
.add_enabled(has_authored_content, egui::Button::new("Copy"))
|
|
||||||
.clicked()
|
|
||||||
{
|
|
||||||
set_content_clipboard(world, false);
|
|
||||||
}
|
|
||||||
if ui
|
|
||||||
.add_enabled(has_authored_content, egui::Button::new("Duplicate"))
|
|
||||||
.clicked()
|
|
||||||
{
|
|
||||||
duplicate_selection(world);
|
|
||||||
}
|
|
||||||
if ui
|
|
||||||
.add_enabled(has_authored_content, egui::Button::new("Move To Trash"))
|
|
||||||
.clicked()
|
|
||||||
{
|
|
||||||
request_delete_for_selection(world);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let selected_embedded = selection
|
|
||||||
.as_ref()
|
|
||||||
.and_then(|selection| embedded_asset_for_selection(world, selection));
|
|
||||||
if let Some(AssetSelection::Folder(path)) = selection.clone() {
|
|
||||||
ui.add(egui::Label::new(format!("Selected folder: {path}")).truncate());
|
|
||||||
ui.horizontal_wrapped(|ui| {
|
|
||||||
if ui.button("Open").clicked() {
|
|
||||||
navigate_content_folder(world, path.clone());
|
|
||||||
}
|
|
||||||
if ui.button("Import Here").clicked() {
|
|
||||||
request_import_to_destination(world, path.clone());
|
|
||||||
}
|
|
||||||
if ui.button("Import To...").clicked() {
|
|
||||||
begin_import_to(world);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} else if let Some(AssetSelection::SubAsset { label, kind, .. }) = selection.clone() {
|
|
||||||
ui.add(
|
|
||||||
egui::Label::new(format!(
|
|
||||||
"Selected: {}: {}",
|
|
||||||
subasset_kind_label(kind),
|
|
||||||
label
|
|
||||||
))
|
|
||||||
.truncate(),
|
|
||||||
);
|
|
||||||
ui.horizontal_wrapped(|ui| match kind {
|
|
||||||
AssetSubAssetKind::Mesh => {
|
|
||||||
if selected_embedded
|
|
||||||
.as_ref()
|
|
||||||
.is_some_and(|embedded| embedded.requires_skinned_hierarchy)
|
|
||||||
{
|
|
||||||
if ui.button("Place Skinned Model").clicked() {
|
|
||||||
if let Some(selection) = selection.as_ref() {
|
|
||||||
place_subasset_operator(world, selection.clone(), Vec3::ZERO);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if ui.button("Place At Origin").clicked() {
|
|
||||||
if let Some(selection) = selection.as_ref() {
|
|
||||||
place_subasset_operator(world, selection.clone(), Vec3::ZERO);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
AssetSubAssetKind::Texture => {
|
|
||||||
if ui.button("Apply Texture To Selection").clicked() {
|
|
||||||
if let Some(asset) = texture_asset_from_subasset_selection(world, &selection) {
|
|
||||||
apply_texture_operator(world, asset, selected_entities);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
AssetSubAssetKind::Material => {
|
|
||||||
ui.small(egui::RichText::new("Embedded source material").color(TEXT_DIM));
|
|
||||||
}
|
|
||||||
AssetSubAssetKind::Skeleton => {
|
|
||||||
ui.small(egui::RichText::new("Inspect-only rig metadata").color(TEXT_DIM));
|
|
||||||
}
|
|
||||||
AssetSubAssetKind::AnimationClip => {
|
|
||||||
let animated_actor = selected_entities
|
|
||||||
.as_slice()
|
|
||||||
.iter()
|
|
||||||
.copied()
|
|
||||||
.find(|entity| world.get::<shared::SkinnedMeshRenderer>(*entity).is_some());
|
|
||||||
let action = if animated_actor.is_some() {
|
|
||||||
"Assign To Selected Actor"
|
|
||||||
} else {
|
|
||||||
"Create Animated Actor"
|
|
||||||
};
|
|
||||||
if ui.button(action).clicked() {
|
|
||||||
if let Some(selection) = selection.as_ref() {
|
|
||||||
if let Some(entity) = animated_actor {
|
|
||||||
assign_animation_clip_operator(world, selection.clone(), entity);
|
|
||||||
} else {
|
|
||||||
place_subasset_operator(world, selection.clone(), Vec3::ZERO);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} else if let Some(asset) = world.resource::<EditorAssets>().selected_asset().cloned() {
|
|
||||||
ui.add(egui::Label::new(format!("Selected: {}", asset_label(&asset))).truncate());
|
|
||||||
ui.horizontal_wrapped(|ui| {
|
|
||||||
if matches!(asset.kind, EditorAssetKind::Texture)
|
|
||||||
&& ui.button("Apply Texture To Selection").clicked()
|
|
||||||
{
|
|
||||||
apply_texture_operator(world, asset.clone(), selected_entities);
|
|
||||||
}
|
|
||||||
if matches!(
|
|
||||||
asset.kind,
|
|
||||||
EditorAssetKind::Primitive(_)
|
|
||||||
| EditorAssetKind::Light(_)
|
|
||||||
| EditorAssetKind::Model
|
|
||||||
| EditorAssetKind::AudioClip
|
|
||||||
| EditorAssetKind::Prefab
|
|
||||||
) && ui.button("Place At Origin").clicked()
|
|
||||||
{
|
|
||||||
place_asset_operator(world, asset.clone(), Vec3::ZERO);
|
|
||||||
}
|
|
||||||
if matches!(asset.kind, EditorAssetKind::Level) && ui.button("Open Scene").clicked() {
|
|
||||||
open_level_asset(world, &asset);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
ui.label(egui::RichText::new("No asset selected").color(TEXT_DIM));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) fn folder_tree_branch(
|
pub(super) fn folder_tree_branch(
|
||||||
world: &mut World,
|
world: &mut World,
|
||||||
ui: &mut egui::Ui,
|
ui: &mut egui::Ui,
|
||||||
|
|||||||
@ -126,6 +126,43 @@
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn content_navigation_history_round_trips_like_a_file_manager() {
|
||||||
|
let mut world = World::new();
|
||||||
|
let mut assets = EditorAssets::default();
|
||||||
|
assets.current_folder = "assets".into();
|
||||||
|
world.insert_resource(assets);
|
||||||
|
world.insert_resource(AssetBrowserUiState::default());
|
||||||
|
|
||||||
|
navigate_content_folder(&mut world, "assets/Props".into());
|
||||||
|
navigate_content_folder(&mut world, "assets/Props/Office".into());
|
||||||
|
assert_eq!(
|
||||||
|
world.resource::<AssetBrowserUiState>().navigation_back,
|
||||||
|
["assets", "assets/Props"]
|
||||||
|
);
|
||||||
|
|
||||||
|
navigate_content_back(&mut world);
|
||||||
|
assert_eq!(
|
||||||
|
world.resource::<EditorAssets>().current_folder,
|
||||||
|
"assets/Props"
|
||||||
|
);
|
||||||
|
navigate_content_back(&mut world);
|
||||||
|
assert_eq!(world.resource::<EditorAssets>().current_folder, "assets");
|
||||||
|
navigate_content_forward(&mut world);
|
||||||
|
assert_eq!(
|
||||||
|
world.resource::<EditorAssets>().current_folder,
|
||||||
|
"assets/Props"
|
||||||
|
);
|
||||||
|
|
||||||
|
navigate_content_folder(&mut world, "assets/Materials".into());
|
||||||
|
assert!(
|
||||||
|
world
|
||||||
|
.resource::<AssetBrowserUiState>()
|
||||||
|
.navigation_forward
|
||||||
|
.is_empty()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn audio_filter_matches_only_audio_clips() {
|
fn audio_filter_matches_only_audio_clips() {
|
||||||
let audio = EditorAsset {
|
let audio = EditorAsset {
|
||||||
|
|||||||
@ -5,22 +5,78 @@ pub(super) fn asset_toolbar(world: &mut World, ui: &mut egui::Ui) {
|
|||||||
let selection_count = world.resource::<EditorAssets>().selections.len();
|
let selection_count = world.resource::<EditorAssets>().selections.len();
|
||||||
let has_editable_selection = !selected_content_paths(world).is_empty();
|
let has_editable_selection = !selected_content_paths(world).is_empty();
|
||||||
let can_rename = selection_count == 1 && has_editable_selection;
|
let can_rename = selection_count == 1 && has_editable_selection;
|
||||||
ui.horizontal(|ui| {
|
|
||||||
if icon_button_small(ui, icons::ARROWS_CLOCKWISE, "Refresh").clicked() {
|
|
||||||
crate::assets::refresh_content_browser(world);
|
|
||||||
}
|
|
||||||
let pending_repairs = world
|
let pending_repairs = world
|
||||||
.get_resource::<PendingExternalMoveRepair>()
|
.get_resource::<PendingExternalMoveRepair>()
|
||||||
.and_then(|pending| pending.review.as_ref())
|
.and_then(|pending| pending.review.as_ref())
|
||||||
.map_or(0, |review| review.conflicts.len());
|
.map_or(0, |review| review.conflicts.len());
|
||||||
if ui
|
let (
|
||||||
.add_enabled(
|
search,
|
||||||
pending_repairs > 0,
|
view,
|
||||||
egui::Button::new(format!("Resolve Moves ({pending_repairs})")),
|
sort,
|
||||||
|
kind_filter,
|
||||||
|
thumbnail_size,
|
||||||
|
recursive,
|
||||||
|
show_details,
|
||||||
|
can_undo,
|
||||||
|
can_paste,
|
||||||
|
can_go_back,
|
||||||
|
can_go_forward,
|
||||||
|
) = {
|
||||||
|
let state = world.resource::<AssetBrowserUiState>();
|
||||||
|
(
|
||||||
|
state.search.clone(),
|
||||||
|
state.view,
|
||||||
|
state.sort,
|
||||||
|
state.kind_filter,
|
||||||
|
state.thumbnail_size,
|
||||||
|
state.recursive,
|
||||||
|
state.show_details,
|
||||||
|
!state.content_undo.is_empty(),
|
||||||
|
state.clipboard.is_some() && current_folder != BUILTINS_FOLDER,
|
||||||
|
!state.navigation_back.is_empty(),
|
||||||
|
!state.navigation_forward.is_empty(),
|
||||||
)
|
)
|
||||||
.on_hover_text("Choose which stable asset identities match ambiguous external moves")
|
};
|
||||||
.clicked()
|
let model = editor_ui::content_browser::ContentBrowserToolbarViewModel {
|
||||||
{
|
path: content_path_segments(¤t_folder),
|
||||||
|
search,
|
||||||
|
kind_filter: kind_filter.into(),
|
||||||
|
sort: sort.into(),
|
||||||
|
view: view.into(),
|
||||||
|
recursive,
|
||||||
|
show_details,
|
||||||
|
thumbnail_size,
|
||||||
|
can_go_parent: current_folder.contains('/'),
|
||||||
|
can_go_back,
|
||||||
|
can_go_forward,
|
||||||
|
can_edit_folder: current_folder != BUILTINS_FOLDER,
|
||||||
|
can_rename,
|
||||||
|
can_edit_selection: has_editable_selection,
|
||||||
|
can_paste,
|
||||||
|
can_undo,
|
||||||
|
pending_repairs,
|
||||||
|
};
|
||||||
|
let actions = editor_ui::content_browser::content_browser_toolbar(ui, &model);
|
||||||
|
let mut visible_results_changed = false;
|
||||||
|
for action in actions {
|
||||||
|
use editor_ui::content_browser::ContentBrowserToolbarAction as Action;
|
||||||
|
match action {
|
||||||
|
Action::Back => navigate_content_back(world),
|
||||||
|
Action::Forward => navigate_content_forward(world),
|
||||||
|
Action::Parent => navigate_to_parent(world),
|
||||||
|
Action::Refresh => crate::assets::refresh_content_browser(world),
|
||||||
|
Action::Navigate(path) => navigate_content_folder(world, path),
|
||||||
|
Action::ImportHere => request_import_to_destination(world, current_folder.clone()),
|
||||||
|
Action::ImportTo => begin_import_to(world),
|
||||||
|
Action::NewFolder => begin_new_folder(world, current_folder.clone()),
|
||||||
|
Action::Rename => begin_rename(world),
|
||||||
|
Action::Duplicate => duplicate_selection(world),
|
||||||
|
Action::Undo => undo_last_content_operation(world),
|
||||||
|
Action::Cut => set_content_clipboard(world, true),
|
||||||
|
Action::Copy => set_content_clipboard(world, false),
|
||||||
|
Action::Paste => paste_content_clipboard(world),
|
||||||
|
Action::OpenTrash => world.resource_mut::<AssetBrowserUiState>().show_trash = true,
|
||||||
|
Action::ReviewMoves => {
|
||||||
if let Some(review) = world
|
if let Some(review) = world
|
||||||
.resource_mut::<PendingExternalMoveRepair>()
|
.resource_mut::<PendingExternalMoveRepair>()
|
||||||
.review
|
.review
|
||||||
@ -29,159 +85,122 @@ pub(super) fn asset_toolbar(world: &mut World, ui: &mut egui::Ui) {
|
|||||||
review.open = true;
|
review.open = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if icon_button_small(ui, icons::UPLOAD, "Import Here").clicked() {
|
Action::SetSearch(search) => {
|
||||||
request_import_to_destination(world, current_folder.clone());
|
world.resource_mut::<AssetBrowserUiState>().search = search;
|
||||||
|
visible_results_changed = true;
|
||||||
}
|
}
|
||||||
if ui
|
Action::SetKindFilter(filter) => {
|
||||||
.add_enabled(
|
world.resource_mut::<AssetBrowserUiState>().kind_filter = filter.into();
|
||||||
current_folder != BUILTINS_FOLDER,
|
visible_results_changed = true;
|
||||||
egui::Button::new("Import To..."),
|
|
||||||
)
|
|
||||||
.clicked()
|
|
||||||
{
|
|
||||||
begin_import_to(world);
|
|
||||||
}
|
}
|
||||||
if icon_button_small(ui, icons::ARROW_UP, "Parent folder").clicked() {
|
Action::SetSort(sort) => world.resource_mut::<AssetBrowserUiState>().sort = sort.into(),
|
||||||
navigate_to_parent(world);
|
Action::SetView(view) => world.resource_mut::<AssetBrowserUiState>().view = view.into(),
|
||||||
|
Action::SetRecursive(recursive) => {
|
||||||
|
world.resource_mut::<AssetBrowserUiState>().recursive = recursive;
|
||||||
|
visible_results_changed = true;
|
||||||
}
|
}
|
||||||
if ui
|
Action::SetShowDetails(show) => {
|
||||||
.add_enabled(
|
world.resource_mut::<AssetBrowserUiState>().show_details = show;
|
||||||
current_folder != BUILTINS_FOLDER,
|
|
||||||
egui::Button::new("New Folder"),
|
|
||||||
)
|
|
||||||
.clicked()
|
|
||||||
{
|
|
||||||
begin_new_folder(world, current_folder.clone());
|
|
||||||
}
|
}
|
||||||
if ui
|
Action::SetThumbnailSize(size) => {
|
||||||
.add_enabled(can_rename, egui::Button::new("Rename"))
|
world.resource_mut::<AssetBrowserUiState>().thumbnail_size = size;
|
||||||
.clicked()
|
|
||||||
{
|
|
||||||
begin_rename(world);
|
|
||||||
}
|
}
|
||||||
if ui
|
|
||||||
.add_enabled(has_editable_selection, egui::Button::new("Duplicate"))
|
|
||||||
.clicked()
|
|
||||||
{
|
|
||||||
duplicate_selection(world);
|
|
||||||
}
|
}
|
||||||
let can_undo_content = !world
|
|
||||||
.resource::<AssetBrowserUiState>()
|
|
||||||
.content_undo
|
|
||||||
.is_empty();
|
|
||||||
if ui
|
|
||||||
.add_enabled(can_undo_content, egui::Button::new("Undo Content"))
|
|
||||||
.clicked()
|
|
||||||
{
|
|
||||||
undo_last_content_operation(world);
|
|
||||||
}
|
}
|
||||||
if ui
|
|
||||||
.add_enabled(has_editable_selection, egui::Button::new("Cut"))
|
|
||||||
.clicked()
|
|
||||||
{
|
|
||||||
set_content_clipboard(world, true);
|
|
||||||
}
|
|
||||||
if ui
|
|
||||||
.add_enabled(has_editable_selection, egui::Button::new("Copy"))
|
|
||||||
.clicked()
|
|
||||||
{
|
|
||||||
set_content_clipboard(world, false);
|
|
||||||
}
|
|
||||||
let can_paste = world.resource::<AssetBrowserUiState>().clipboard.is_some()
|
|
||||||
&& world.resource::<EditorAssets>().current_folder != BUILTINS_FOLDER;
|
|
||||||
if ui
|
|
||||||
.add_enabled(can_paste, egui::Button::new("Paste"))
|
|
||||||
.clicked()
|
|
||||||
{
|
|
||||||
paste_content_clipboard(world);
|
|
||||||
}
|
|
||||||
if ui.small_button("Open Trash").clicked() {
|
|
||||||
world.resource_mut::<AssetBrowserUiState>().show_trash = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
ui.separator();
|
|
||||||
|
|
||||||
let visible_results_changed = {
|
|
||||||
let mut state = world.resource_mut::<AssetBrowserUiState>();
|
|
||||||
let mut visible_results_changed = ui
|
|
||||||
.add(
|
|
||||||
egui::TextEdit::singleline(&mut state.search)
|
|
||||||
.hint_text("Search assets...")
|
|
||||||
.desired_width(fit_width(ui, 120.0, 190.0)),
|
|
||||||
)
|
|
||||||
.changed();
|
|
||||||
visible_results_changed |= ui.checkbox(&mut state.recursive, "Subfolders").changed();
|
|
||||||
|
|
||||||
let previous_kind_filter = state.kind_filter;
|
|
||||||
egui::ComboBox::from_id_salt("asset_kind_filter")
|
|
||||||
.selected_text(kind_filter_label(state.kind_filter))
|
|
||||||
.show_ui(ui, |ui| {
|
|
||||||
ui.selectable_value(&mut state.kind_filter, AssetKindFilter::All, "All");
|
|
||||||
ui.selectable_value(&mut state.kind_filter, AssetKindFilter::Model, "Models");
|
|
||||||
ui.selectable_value(
|
|
||||||
&mut state.kind_filter,
|
|
||||||
AssetKindFilter::Texture,
|
|
||||||
"Textures",
|
|
||||||
);
|
|
||||||
ui.selectable_value(
|
|
||||||
&mut state.kind_filter,
|
|
||||||
AssetKindFilter::Material,
|
|
||||||
"Materials",
|
|
||||||
);
|
|
||||||
ui.selectable_value(&mut state.kind_filter, AssetKindFilter::Audio, "Audio");
|
|
||||||
ui.selectable_value(&mut state.kind_filter, AssetKindFilter::Level, "Levels");
|
|
||||||
ui.selectable_value(&mut state.kind_filter, AssetKindFilter::Prefab, "Prefabs");
|
|
||||||
ui.selectable_value(
|
|
||||||
&mut state.kind_filter,
|
|
||||||
AssetKindFilter::Builtin,
|
|
||||||
"Built-ins",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
visible_results_changed |= state.kind_filter != previous_kind_filter;
|
|
||||||
|
|
||||||
egui::ComboBox::from_id_salt("asset_sort")
|
|
||||||
.selected_text(sort_label(state.sort))
|
|
||||||
.show_ui(ui, |ui| {
|
|
||||||
ui.selectable_value(&mut state.sort, AssetSort::Name, "Name");
|
|
||||||
ui.selectable_value(&mut state.sort, AssetSort::Kind, "Type");
|
|
||||||
ui.selectable_value(&mut state.sort, AssetSort::Modified, "Modified");
|
|
||||||
ui.selectable_value(&mut state.sort, AssetSort::Size, "Size");
|
|
||||||
});
|
|
||||||
|
|
||||||
ui.separator();
|
|
||||||
if tool_button(
|
|
||||||
ui,
|
|
||||||
icons::GRID_FOUR,
|
|
||||||
state.view == AssetBrowserView::Grid,
|
|
||||||
"Grid view",
|
|
||||||
)
|
|
||||||
.clicked()
|
|
||||||
{
|
|
||||||
state.view = AssetBrowserView::Grid;
|
|
||||||
}
|
|
||||||
if tool_button(
|
|
||||||
ui,
|
|
||||||
icons::LIST,
|
|
||||||
state.view == AssetBrowserView::List,
|
|
||||||
"List view",
|
|
||||||
)
|
|
||||||
.clicked()
|
|
||||||
{
|
|
||||||
state.view = AssetBrowserView::List;
|
|
||||||
}
|
|
||||||
if ui.available_width() > 180.0 {
|
|
||||||
ui.label(icon_text(icons::IMAGE_SQUARE, 13.0).color(TEXT_DIM));
|
|
||||||
ui.add_sized(
|
|
||||||
[96.0, 20.0],
|
|
||||||
egui::Slider::new(&mut state.thumbnail_size, 48.0..=112.0).show_value(false),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
ui.checkbox(&mut state.show_details, "Details");
|
|
||||||
visible_results_changed
|
|
||||||
};
|
|
||||||
clear_selection_when_visible_results_change(world, visible_results_changed);
|
clear_selection_when_visible_results_change(world, visible_results_changed);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn content_path_segments(
|
||||||
|
current_folder: &str,
|
||||||
|
) -> Vec<editor_ui::content_browser::ContentPathSegment> {
|
||||||
|
let mut segments = Vec::new();
|
||||||
|
let mut current = String::new();
|
||||||
|
for part in current_folder.split('/').filter(|part| !part.is_empty()) {
|
||||||
|
if !current.is_empty() {
|
||||||
|
current.push('/');
|
||||||
|
}
|
||||||
|
current.push_str(part);
|
||||||
|
segments.push(editor_ui::content_browser::ContentPathSegment {
|
||||||
|
path: current.clone(),
|
||||||
|
label: if current == BUILTINS_FOLDER {
|
||||||
|
"Built-ins".into()
|
||||||
|
} else {
|
||||||
|
part.to_string()
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
segments
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<AssetBrowserView> for editor_ui::content_browser::ContentBrowserViewMode {
|
||||||
|
fn from(value: AssetBrowserView) -> Self {
|
||||||
|
match value {
|
||||||
|
AssetBrowserView::Grid => Self::Grid,
|
||||||
|
AssetBrowserView::List => Self::List,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<editor_ui::content_browser::ContentBrowserViewMode> for AssetBrowserView {
|
||||||
|
fn from(value: editor_ui::content_browser::ContentBrowserViewMode) -> Self {
|
||||||
|
match value {
|
||||||
|
editor_ui::content_browser::ContentBrowserViewMode::Grid => Self::Grid,
|
||||||
|
editor_ui::content_browser::ContentBrowserViewMode::List => Self::List,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<AssetSort> for editor_ui::content_browser::ContentBrowserSort {
|
||||||
|
fn from(value: AssetSort) -> Self {
|
||||||
|
match value {
|
||||||
|
AssetSort::Name => Self::Name,
|
||||||
|
AssetSort::Kind => Self::Kind,
|
||||||
|
AssetSort::Modified => Self::Modified,
|
||||||
|
AssetSort::Size => Self::Size,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<editor_ui::content_browser::ContentBrowserSort> for AssetSort {
|
||||||
|
fn from(value: editor_ui::content_browser::ContentBrowserSort) -> Self {
|
||||||
|
match value {
|
||||||
|
editor_ui::content_browser::ContentBrowserSort::Name => Self::Name,
|
||||||
|
editor_ui::content_browser::ContentBrowserSort::Kind => Self::Kind,
|
||||||
|
editor_ui::content_browser::ContentBrowserSort::Modified => Self::Modified,
|
||||||
|
editor_ui::content_browser::ContentBrowserSort::Size => Self::Size,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<AssetKindFilter> for editor_ui::content_browser::ContentBrowserKindFilter {
|
||||||
|
fn from(value: AssetKindFilter) -> Self {
|
||||||
|
match value {
|
||||||
|
AssetKindFilter::All => Self::All,
|
||||||
|
AssetKindFilter::Model => Self::Model,
|
||||||
|
AssetKindFilter::Texture => Self::Texture,
|
||||||
|
AssetKindFilter::Material => Self::Material,
|
||||||
|
AssetKindFilter::Audio => Self::Audio,
|
||||||
|
AssetKindFilter::Level => Self::Level,
|
||||||
|
AssetKindFilter::Prefab => Self::Prefab,
|
||||||
|
AssetKindFilter::Builtin => Self::Builtin,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<editor_ui::content_browser::ContentBrowserKindFilter> for AssetKindFilter {
|
||||||
|
fn from(value: editor_ui::content_browser::ContentBrowserKindFilter) -> Self {
|
||||||
|
match value {
|
||||||
|
editor_ui::content_browser::ContentBrowserKindFilter::All => Self::All,
|
||||||
|
editor_ui::content_browser::ContentBrowserKindFilter::Model => Self::Model,
|
||||||
|
editor_ui::content_browser::ContentBrowserKindFilter::Texture => Self::Texture,
|
||||||
|
editor_ui::content_browser::ContentBrowserKindFilter::Material => Self::Material,
|
||||||
|
editor_ui::content_browser::ContentBrowserKindFilter::Audio => Self::Audio,
|
||||||
|
editor_ui::content_browser::ContentBrowserKindFilter::Level => Self::Level,
|
||||||
|
editor_ui::content_browser::ContentBrowserKindFilter::Prefab => Self::Prefab,
|
||||||
|
editor_ui::content_browser::ContentBrowserKindFilter::Builtin => Self::Builtin,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub(super) fn clear_selection_when_visible_results_change(world: &mut World, changed: bool) {
|
pub(super) fn clear_selection_when_visible_results_change(world: &mut World, changed: bool) {
|
||||||
if changed && !world.resource::<EditorAssets>().selections.is_empty() {
|
if changed && !world.resource::<EditorAssets>().selections.is_empty() {
|
||||||
|
|||||||
@ -25,6 +25,51 @@ pub(super) fn navigate_to_parent(world: &mut World) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn navigate_content_folder(world: &mut World, folder: String) {
|
pub(super) fn navigate_content_folder(world: &mut World, folder: String) {
|
||||||
|
let current = world.resource::<EditorAssets>().current_folder.clone();
|
||||||
|
if current == folder {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
{
|
||||||
|
let mut state = world.resource_mut::<AssetBrowserUiState>();
|
||||||
|
state.navigation_back.push(current);
|
||||||
|
state.navigation_forward.clear();
|
||||||
|
}
|
||||||
|
set_content_folder(world, folder);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn navigate_content_back(world: &mut World) {
|
||||||
|
let Some(folder) = world
|
||||||
|
.resource_mut::<AssetBrowserUiState>()
|
||||||
|
.navigation_back
|
||||||
|
.pop()
|
||||||
|
else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let current = world.resource::<EditorAssets>().current_folder.clone();
|
||||||
|
world
|
||||||
|
.resource_mut::<AssetBrowserUiState>()
|
||||||
|
.navigation_forward
|
||||||
|
.push(current);
|
||||||
|
set_content_folder(world, folder);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn navigate_content_forward(world: &mut World) {
|
||||||
|
let Some(folder) = world
|
||||||
|
.resource_mut::<AssetBrowserUiState>()
|
||||||
|
.navigation_forward
|
||||||
|
.pop()
|
||||||
|
else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let current = world.resource::<EditorAssets>().current_folder.clone();
|
||||||
|
world
|
||||||
|
.resource_mut::<AssetBrowserUiState>()
|
||||||
|
.navigation_back
|
||||||
|
.push(current);
|
||||||
|
set_content_folder(world, folder);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_content_folder(world: &mut World, folder: String) {
|
||||||
let mut assets = world.resource_mut::<EditorAssets>();
|
let mut assets = world.resource_mut::<EditorAssets>();
|
||||||
assets.current_folder = folder.clone();
|
assets.current_folder = folder.clone();
|
||||||
assets.clear_selection();
|
assets.clear_selection();
|
||||||
|
|||||||
@ -7,7 +7,7 @@ use crate::assets::{AssetSelection, ASSETS_ROOT, BUILTINS_FOLDER};
|
|||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
use shared::MaterialAsset;
|
use shared::MaterialAsset;
|
||||||
|
|
||||||
pub(crate) const ASSET_DETAILS_DEFAULT_WIDTH: f32 = 260.0;
|
pub(crate) const ASSET_DETAILS_DEFAULT_WIDTH: f32 = 290.0;
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
pub(crate) enum AssetBrowserView {
|
pub(crate) enum AssetBrowserView {
|
||||||
@ -44,6 +44,9 @@ pub struct AssetBrowserUiState {
|
|||||||
pub(crate) thumbnail_size: f32,
|
pub(crate) thumbnail_size: f32,
|
||||||
pub(crate) recursive: bool,
|
pub(crate) recursive: bool,
|
||||||
pub(crate) show_details: bool,
|
pub(crate) show_details: bool,
|
||||||
|
/// File-manager navigation history. Content topology operations do not rewrite these stacks.
|
||||||
|
pub(crate) navigation_back: Vec<String>,
|
||||||
|
pub(crate) navigation_forward: Vec<String>,
|
||||||
/// Width of the details pane for the current editor session.
|
/// Width of the details pane for the current editor session.
|
||||||
pub(crate) details_width: f32,
|
pub(crate) details_width: f32,
|
||||||
pub(crate) expanded_folders: HashSet<String>,
|
pub(crate) expanded_folders: HashSet<String>,
|
||||||
@ -183,9 +186,11 @@ impl Default for AssetBrowserUiState {
|
|||||||
view: AssetBrowserView::Grid,
|
view: AssetBrowserView::Grid,
|
||||||
sort: AssetSort::Name,
|
sort: AssetSort::Name,
|
||||||
kind_filter: AssetKindFilter::All,
|
kind_filter: AssetKindFilter::All,
|
||||||
thumbnail_size: 72.0,
|
thumbnail_size: 96.0,
|
||||||
recursive: false,
|
recursive: false,
|
||||||
show_details: true,
|
show_details: true,
|
||||||
|
navigation_back: Vec::new(),
|
||||||
|
navigation_forward: Vec::new(),
|
||||||
details_width: ASSET_DETAILS_DEFAULT_WIDTH,
|
details_width: ASSET_DETAILS_DEFAULT_WIDTH,
|
||||||
expanded_folders,
|
expanded_folders,
|
||||||
expanded_assets: HashSet::new(),
|
expanded_assets: HashSet::new(),
|
||||||
|
|||||||
@ -166,6 +166,9 @@ struct PendingPropertyBlockPromotion {
|
|||||||
slot_id: ComponentInstanceId,
|
slot_id: ComponentInstanceId,
|
||||||
slot_name: String,
|
slot_name: String,
|
||||||
base: MaterialRef,
|
base: MaterialRef,
|
||||||
|
/// Actor-owned assignment captured when the review opened. `None` means the selected base
|
||||||
|
/// was inherited from the model or project defaults and must remain inherited until commit.
|
||||||
|
actor_assignment: Option<MaterialRef>,
|
||||||
block: MaterialPropertyBlock,
|
block: MaterialPropertyBlock,
|
||||||
project_root: PathBuf,
|
project_root: PathBuf,
|
||||||
destination_path: PathBuf,
|
destination_path: PathBuf,
|
||||||
@ -362,6 +365,8 @@ mod component_lifecycle;
|
|||||||
mod dispatch;
|
mod dispatch;
|
||||||
#[path = "inspector/imported_material_preview.rs"]
|
#[path = "inspector/imported_material_preview.rs"]
|
||||||
mod imported_material_preview;
|
mod imported_material_preview;
|
||||||
|
#[path = "inspector/material_resolution.rs"]
|
||||||
|
mod material_resolution;
|
||||||
#[path = "inspector/material_slots.rs"]
|
#[path = "inspector/material_slots.rs"]
|
||||||
mod material_slots;
|
mod material_slots;
|
||||||
#[path = "inspector/mesh_renderers.rs"]
|
#[path = "inspector/mesh_renderers.rs"]
|
||||||
@ -384,6 +389,7 @@ pub(crate) use component_lifecycle::apply_component_card_response;
|
|||||||
use component_lifecycle::*;
|
use component_lifecycle::*;
|
||||||
pub use dispatch::authoring_inspector_ui;
|
pub use dispatch::authoring_inspector_ui;
|
||||||
pub(crate) use dispatch::{add_component_footer, register_builtin_component_inspectors};
|
pub(crate) use dispatch::{add_component_footer, register_builtin_component_inspectors};
|
||||||
|
use material_resolution::*;
|
||||||
use material_slots::*;
|
use material_slots::*;
|
||||||
use mesh_renderers::*;
|
use mesh_renderers::*;
|
||||||
use primitive_domains::*;
|
use primitive_domains::*;
|
||||||
|
|||||||
174
crates/editor/src/ui/inspector/material_resolution.rs
Normal file
174
crates/editor/src/ui/inspector/material_resolution.rs
Normal file
@ -0,0 +1,174 @@
|
|||||||
|
//! Authoring-layer material resolution shared by Inspector display and guarded transactions.
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// Returns only the actor-owned assignment for an exact surface slot.
|
||||||
|
pub(super) fn actor_material_assignment_for_slot(
|
||||||
|
world: &World,
|
||||||
|
entity: Entity,
|
||||||
|
slot_id: &ComponentInstanceId,
|
||||||
|
) -> Option<MaterialRef> {
|
||||||
|
world
|
||||||
|
.get::<SkinnedMeshRenderer>(entity)
|
||||||
|
.and_then(|renderer| renderer.materials.slot(slot_id))
|
||||||
|
.and_then(|slot| slot.material.clone())
|
||||||
|
.or_else(|| {
|
||||||
|
world
|
||||||
|
.get::<StaticMeshRenderer>(entity)
|
||||||
|
.and_then(|renderer| renderer.materials.slot(slot_id))
|
||||||
|
.and_then(|slot| slot.material.clone())
|
||||||
|
})
|
||||||
|
.or_else(|| {
|
||||||
|
world
|
||||||
|
.get::<Primitive>(entity)
|
||||||
|
.filter(|primitive| primitive.surface.id == *slot_id)
|
||||||
|
.and_then(|primitive| primitive.surface.material.clone())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolves authored actor/model/project layers for an exact actor slot.
|
||||||
|
pub(super) fn effective_material_for_actor_slot(
|
||||||
|
world: &World,
|
||||||
|
entity: Entity,
|
||||||
|
slot_id: &ComponentInstanceId,
|
||||||
|
) -> Option<MaterialRef> {
|
||||||
|
actor_material_assignment_for_slot(world, entity, slot_id)
|
||||||
|
.or_else(|| model_material_for_actor_slot(world, entity, slot_id).map(|(base, _)| base))
|
||||||
|
.or_else(|| {
|
||||||
|
world
|
||||||
|
.get_resource::<shared::ProjectContentDefaults>()
|
||||||
|
.and_then(|defaults| defaults.default_material.clone())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn model_material_for_actor_slot(
|
||||||
|
world: &World,
|
||||||
|
entity: Entity,
|
||||||
|
slot_id: &ComponentInstanceId,
|
||||||
|
) -> Option<(MaterialRef, crate::ui::materials::MaterialLayerBadge)> {
|
||||||
|
let asset_id = world
|
||||||
|
.get::<StaticMeshRenderer>(entity)
|
||||||
|
.and_then(|renderer| {
|
||||||
|
renderer
|
||||||
|
.slots
|
||||||
|
.iter()
|
||||||
|
.find(|part| part.material_slot_id == *slot_id)
|
||||||
|
.map(|part| part.mesh.asset_id.clone())
|
||||||
|
})
|
||||||
|
.or_else(|| {
|
||||||
|
world
|
||||||
|
.get::<SkinnedMeshRenderer>(entity)
|
||||||
|
.map(|renderer| renderer.asset_id.clone())
|
||||||
|
})?;
|
||||||
|
let record = world
|
||||||
|
.get_resource::<AssetRegistry>()?
|
||||||
|
.records
|
||||||
|
.iter()
|
||||||
|
.find(|record| record.id.as_string() == asset_id)?;
|
||||||
|
let settings = record.model_import();
|
||||||
|
match crate::assets::static_mesh::material_selection(settings, &slot_id.0) {
|
||||||
|
ModelMaterialSelection::Project(reference) => Some((
|
||||||
|
reference.clone(),
|
||||||
|
crate::ui::materials::MaterialLayerBadge::ModelDefault,
|
||||||
|
)),
|
||||||
|
ModelMaterialSelection::Default => None,
|
||||||
|
ModelMaterialSelection::Source => {
|
||||||
|
let manifest = settings
|
||||||
|
.static_mesh_manifest_path
|
||||||
|
.as_deref()
|
||||||
|
.and_then(|path| load_static_mesh_manifest(path).ok())?;
|
||||||
|
let part = manifest.parts.iter().find(|part| {
|
||||||
|
let part_id = if part.id.trim().is_empty() {
|
||||||
|
part_id_from_label(&part.mesh_label)
|
||||||
|
} else {
|
||||||
|
part.id.clone()
|
||||||
|
};
|
||||||
|
slot_id.0 == format!("slot:{part_id}")
|
||||||
|
})?;
|
||||||
|
let material_id = part
|
||||||
|
.material_id
|
||||||
|
.clone()
|
||||||
|
.filter(|id| !id.trim().is_empty())
|
||||||
|
.or_else(|| part.material_label.as_deref().map(material_id_from_label))?;
|
||||||
|
Some((
|
||||||
|
MaterialRef::new(EditorAssetRef::new(
|
||||||
|
manifest.asset_id,
|
||||||
|
material_id,
|
||||||
|
part.material_slot_name.clone(),
|
||||||
|
)),
|
||||||
|
crate::ui::materials::MaterialLayerBadge::ModelSource,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn material_ref(id: &str) -> MaterialRef {
|
||||||
|
MaterialRef::new(EditorAssetRef::new(id, "material:source", id))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn project_default_is_effective_without_becoming_actor_owned() {
|
||||||
|
let mut world = World::new();
|
||||||
|
let fallback = material_ref("project-default");
|
||||||
|
world.insert_resource(shared::ProjectContentDefaults {
|
||||||
|
default_material: Some(fallback.clone()),
|
||||||
|
});
|
||||||
|
let primitive = Primitive::default();
|
||||||
|
let slot_id = primitive.surface.id.clone();
|
||||||
|
let entity = world.spawn(primitive).id();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
actor_material_assignment_for_slot(&world, entity, &slot_id),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
effective_material_for_actor_slot(&world, entity, &slot_id),
|
||||||
|
Some(fallback)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn static_and_skinned_actor_assignments_override_project_defaults() {
|
||||||
|
let mut world = World::new();
|
||||||
|
world.insert_resource(shared::ProjectContentDefaults {
|
||||||
|
default_material: Some(material_ref("project-default")),
|
||||||
|
});
|
||||||
|
let slot_id = ComponentInstanceId::new("slot:surface");
|
||||||
|
let explicit = material_ref("actor-material");
|
||||||
|
let materials = shared::MaterialSlotSet {
|
||||||
|
slots: vec![shared::MaterialSlot {
|
||||||
|
id: slot_id.clone(),
|
||||||
|
name: "Surface".into(),
|
||||||
|
material: Some(explicit.clone()),
|
||||||
|
}],
|
||||||
|
orphaned_assignments: Vec::new(),
|
||||||
|
};
|
||||||
|
let static_entity = world
|
||||||
|
.spawn(StaticMeshRenderer {
|
||||||
|
materials: materials.clone(),
|
||||||
|
..StaticMeshRenderer::default()
|
||||||
|
})
|
||||||
|
.id();
|
||||||
|
let skinned_entity = world
|
||||||
|
.spawn(SkinnedMeshRenderer {
|
||||||
|
materials,
|
||||||
|
..SkinnedMeshRenderer::default()
|
||||||
|
})
|
||||||
|
.id();
|
||||||
|
|
||||||
|
for entity in [static_entity, skinned_entity] {
|
||||||
|
assert_eq!(
|
||||||
|
actor_material_assignment_for_slot(&world, entity, &slot_id),
|
||||||
|
Some(explicit.clone())
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
effective_material_for_actor_slot(&world, entity, &slot_id),
|
||||||
|
Some(explicit.clone())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -218,7 +218,7 @@ pub(super) fn material_slot_widget_ui(
|
|||||||
}),
|
}),
|
||||||
can_clear: slot.material.is_some(),
|
can_clear: slot.material.is_some(),
|
||||||
can_extract: is_imported_source,
|
can_extract: is_imported_source,
|
||||||
can_create_instance: is_project_material,
|
can_create_instance: is_project_material || is_project_instance,
|
||||||
parameters_initially_open: true,
|
parameters_initially_open: true,
|
||||||
expanded_header_when_parameters_closed,
|
expanded_header_when_parameters_closed,
|
||||||
};
|
};
|
||||||
@ -327,8 +327,15 @@ pub(super) fn material_slot_widget_ui(
|
|||||||
.get::<MaterialPropertyBlocks>(entity)
|
.get::<MaterialPropertyBlocks>(entity)
|
||||||
.and_then(|blocks| blocks.slots.iter().find(|block| block.slot_id == slot.id))
|
.and_then(|blocks| blocks.slots.iter().find(|block| block.slot_id == slot.id))
|
||||||
.cloned();
|
.cloned();
|
||||||
if property_block_promotion_ui(ui, property_block.as_ref(), slot.material.is_some()) {
|
let project_promotion_base = (is_project_material || is_project_instance)
|
||||||
if let (Some(base), Some(block)) = (slot.material.clone(), property_block) {
|
.then(|| effective.clone())
|
||||||
|
.flatten();
|
||||||
|
if property_block_promotion_ui(
|
||||||
|
ui,
|
||||||
|
property_block.as_ref(),
|
||||||
|
project_promotion_base.is_some(),
|
||||||
|
) {
|
||||||
|
if let (Some(base), Some(block)) = (project_promotion_base, property_block) {
|
||||||
open_property_block_promotion_review(
|
open_property_block_promotion_review(
|
||||||
world,
|
world,
|
||||||
entity,
|
entity,
|
||||||
@ -349,67 +356,6 @@ fn stable_actor_widget_id(world: &World, entity: Entity) -> String {
|
|||||||
.unwrap_or_else(|| format!("runtime:{entity:?}"))
|
.unwrap_or_else(|| format!("runtime:{entity:?}"))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn model_material_for_actor_slot(
|
|
||||||
world: &World,
|
|
||||||
entity: Entity,
|
|
||||||
slot_id: &ComponentInstanceId,
|
|
||||||
) -> Option<(MaterialRef, crate::ui::materials::MaterialLayerBadge)> {
|
|
||||||
let asset_id = world
|
|
||||||
.get::<StaticMeshRenderer>(entity)
|
|
||||||
.and_then(|renderer| {
|
|
||||||
renderer
|
|
||||||
.slots
|
|
||||||
.iter()
|
|
||||||
.find(|part| part.material_slot_id == *slot_id)
|
|
||||||
.map(|part| part.mesh.asset_id.clone())
|
|
||||||
})
|
|
||||||
.or_else(|| {
|
|
||||||
world
|
|
||||||
.get::<SkinnedMeshRenderer>(entity)
|
|
||||||
.map(|renderer| renderer.asset_id.clone())
|
|
||||||
})?;
|
|
||||||
let record = world
|
|
||||||
.get_resource::<AssetRegistry>()?
|
|
||||||
.records
|
|
||||||
.iter()
|
|
||||||
.find(|record| record.id.as_string() == asset_id)?;
|
|
||||||
let settings = record.model_import();
|
|
||||||
match crate::assets::static_mesh::material_selection(settings, &slot_id.0) {
|
|
||||||
ModelMaterialSelection::Project(reference) => Some((
|
|
||||||
reference.clone(),
|
|
||||||
crate::ui::materials::MaterialLayerBadge::ModelDefault,
|
|
||||||
)),
|
|
||||||
ModelMaterialSelection::Default => None,
|
|
||||||
ModelMaterialSelection::Source => {
|
|
||||||
let manifest = settings
|
|
||||||
.static_mesh_manifest_path
|
|
||||||
.as_deref()
|
|
||||||
.and_then(|path| load_static_mesh_manifest(path).ok())?;
|
|
||||||
let part = manifest.parts.iter().find(|part| {
|
|
||||||
let part_id = if part.id.trim().is_empty() {
|
|
||||||
part_id_from_label(&part.mesh_label)
|
|
||||||
} else {
|
|
||||||
part.id.clone()
|
|
||||||
};
|
|
||||||
slot_id.0 == format!("slot:{part_id}")
|
|
||||||
})?;
|
|
||||||
let material_id = part
|
|
||||||
.material_id
|
|
||||||
.clone()
|
|
||||||
.filter(|id| !id.trim().is_empty())
|
|
||||||
.or_else(|| part.material_label.as_deref().map(material_id_from_label))?;
|
|
||||||
Some((
|
|
||||||
MaterialRef::new(EditorAssetRef::new(
|
|
||||||
manifest.asset_id,
|
|
||||||
material_id,
|
|
||||||
part.material_slot_name.clone(),
|
|
||||||
)),
|
|
||||||
crate::ui::materials::MaterialLayerBadge::ModelSource,
|
|
||||||
))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn create_instance_and_assign_slot(
|
fn create_instance_and_assign_slot(
|
||||||
world: &mut World,
|
world: &mut World,
|
||||||
entity: Entity,
|
entity: Entity,
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
pub(super) fn skinned_mesh_renderer_ui(world: &mut World, ui: &mut egui::Ui, entity: Entity) {
|
pub(super) fn skinned_mesh_renderer_ui(world: &mut World, ui: &mut egui::Ui, entity: Entity) {
|
||||||
|
let mesh_candidates = static_mesh_asset_ref_candidates(world, AssetRefCandidateKind::Mesh);
|
||||||
let material_candidates = brush_face_material_ref_candidates(world);
|
let material_candidates = brush_face_material_ref_candidates(world);
|
||||||
let material_drop_candidate = world
|
let material_drop_candidate = world
|
||||||
.get_resource::<EditorAssets>()
|
.get_resource::<EditorAssets>()
|
||||||
@ -16,6 +17,8 @@ pub(super) fn skinned_mesh_renderer_ui(world: &mut World, ui: &mut egui::Ui, ent
|
|||||||
let original = renderer.clone();
|
let original = renderer.clone();
|
||||||
let mut changed = false;
|
let mut changed = false;
|
||||||
let mut accepted_drop = false;
|
let mut accepted_drop = false;
|
||||||
|
let mut browse_mesh = false;
|
||||||
|
let mut locate_mesh = false;
|
||||||
let mut options = ComponentCardOptions::removable(
|
let mut options = ComponentCardOptions::removable(
|
||||||
COMPONENT_SKINNED_MESH_RENDERER,
|
COMPONENT_SKINNED_MESH_RENDERER,
|
||||||
"Skinned Mesh Renderer",
|
"Skinned Mesh Renderer",
|
||||||
@ -26,8 +29,69 @@ pub(super) fn skinned_mesh_renderer_ui(world: &mut World, ui: &mut egui::Ui, ent
|
|||||||
options.resettable = false;
|
options.resettable = false;
|
||||||
options.copyable = false;
|
options.copyable = false;
|
||||||
options.summary = "Skeleton-bound renderer";
|
options.summary = "Skeleton-bound renderer";
|
||||||
|
options.body_margin = 0;
|
||||||
let context = component_card_context(world, entity, options);
|
let context = component_card_context(world, entity, options);
|
||||||
let response = component_card(ui, &context, |ui| {
|
let response = component_card(ui, &context, |ui| {
|
||||||
|
let renderer_open_id = ui.make_persistent_id(("skinned_renderer_open", entity));
|
||||||
|
let mut renderer_open = ui
|
||||||
|
.ctx()
|
||||||
|
.data_mut(|data| data.get_persisted::<bool>(renderer_open_id))
|
||||||
|
.unwrap_or(true);
|
||||||
|
let source_candidate = mesh_candidates
|
||||||
|
.iter()
|
||||||
|
.find(|candidate| candidate.reference.asset_id == renderer.asset_id);
|
||||||
|
let label = Path::new(&renderer.path)
|
||||||
|
.file_stem()
|
||||||
|
.and_then(|stem| stem.to_str())
|
||||||
|
.filter(|label| !label.trim().is_empty())
|
||||||
|
.unwrap_or("Skinned mesh");
|
||||||
|
let thumbnail = source_candidate.and_then(|candidate| candidate.texture_id);
|
||||||
|
|
||||||
|
renderer_panel(ui, |ui| {
|
||||||
|
renderer_panel_header(
|
||||||
|
ui,
|
||||||
|
RendererPanelHeaderViewModel {
|
||||||
|
index: 0,
|
||||||
|
label,
|
||||||
|
path: if renderer.path.trim().is_empty() {
|
||||||
|
"No model source assigned"
|
||||||
|
} else {
|
||||||
|
renderer.path.as_str()
|
||||||
|
},
|
||||||
|
slot_count: renderer.materials.slots.len(),
|
||||||
|
texture_id: thumbnail,
|
||||||
|
},
|
||||||
|
&mut renderer_open,
|
||||||
|
|ui| {
|
||||||
|
if editor_ui::design_system::controls::icon_button(
|
||||||
|
ui,
|
||||||
|
icons::CROSSHAIR,
|
||||||
|
"Locate model",
|
||||||
|
source_candidate.is_some(),
|
||||||
|
)
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
locate_mesh = true;
|
||||||
|
}
|
||||||
|
if editor_ui::design_system::controls::icon_button(
|
||||||
|
ui,
|
||||||
|
icons::FOLDER_OPEN,
|
||||||
|
"Browse models",
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
browse_mesh = true;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if !renderer_open {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
egui::Frame::new()
|
||||||
|
.inner_margin(egui::Margin::symmetric(12, 10))
|
||||||
|
.show(ui, |ui| {
|
||||||
|
renderer_properties_header(ui);
|
||||||
property_row(ui, "Source", |ui| {
|
property_row(ui, "Source", |ui| {
|
||||||
ui.add(
|
ui.add(
|
||||||
egui::Label::new(if renderer.path.trim().is_empty() {
|
egui::Label::new(if renderer.path.trim().is_empty() {
|
||||||
@ -44,39 +108,34 @@ pub(super) fn skinned_mesh_renderer_ui(world: &mut World, ui: &mut egui::Ui, ent
|
|||||||
property_row(ui, "Asset ID", |ui| {
|
property_row(ui, "Asset ID", |ui| {
|
||||||
ui.add(
|
ui.add(
|
||||||
egui::Label::new(if renderer.asset_id.trim().is_empty() {
|
egui::Label::new(if renderer.asset_id.trim().is_empty() {
|
||||||
"Legacy/path-only"
|
"Unresolved"
|
||||||
} else {
|
} else {
|
||||||
renderer.asset_id.as_str()
|
renderer.asset_id.as_str()
|
||||||
})
|
})
|
||||||
.truncate(),
|
.truncate(),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
ui.label(
|
ui.small(
|
||||||
egui::RichText::new(
|
egui::RichText::new(
|
||||||
"Preserves the imported skeleton hierarchy and Bevy skinned-mesh bindings.",
|
"Imported skeleton hierarchy and skin bindings remain model-owned.",
|
||||||
)
|
)
|
||||||
.small()
|
|
||||||
.color(TEXT_DIM),
|
.color(TEXT_DIM),
|
||||||
);
|
);
|
||||||
ui.add_space(8.0);
|
});
|
||||||
|
renderer_material_slots_header(ui, renderer.materials.slots.len());
|
||||||
if renderer.materials.slots.is_empty() {
|
if renderer.materials.slots.is_empty() {
|
||||||
ui.label(
|
egui::Frame::new()
|
||||||
|
.inner_margin(egui::Margin::symmetric(12, 10))
|
||||||
|
.show(ui, |ui| {
|
||||||
|
ui.small(
|
||||||
egui::RichText::new("No imported slots; reimport the source model")
|
egui::RichText::new("No imported slots; reimport the source model")
|
||||||
.small()
|
|
||||||
.color(TEXT_DIM),
|
.color(TEXT_DIM),
|
||||||
);
|
);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
let materials = crate::ui::materials::MaterialsSectionViewModel {
|
for (index, slot) in renderer.materials.slots.iter_mut().enumerate() {
|
||||||
slot_ids: renderer
|
let widget = renderer_material_slot_row(ui, index, |ui| {
|
||||||
.materials
|
material_slot_widget_ui(
|
||||||
.slots
|
|
||||||
.iter()
|
|
||||||
.map(|slot| slot.id.0.clone())
|
|
||||||
.collect(),
|
|
||||||
};
|
|
||||||
crate::ui::materials::materials_section(ui, &materials, |ui| {
|
|
||||||
for slot in &mut renderer.materials.slots {
|
|
||||||
let widget = material_slot_widget_ui(
|
|
||||||
world,
|
world,
|
||||||
ui,
|
ui,
|
||||||
entity,
|
entity,
|
||||||
@ -87,12 +146,14 @@ pub(super) fn skinned_mesh_renderer_ui(world: &mut World, ui: &mut egui::Ui, ent
|
|||||||
invalid_drop_reason: invalid_material_drop,
|
invalid_drop_reason: invalid_material_drop,
|
||||||
expanded_header_when_parameters_closed: true,
|
expanded_header_when_parameters_closed: true,
|
||||||
},
|
},
|
||||||
);
|
)
|
||||||
|
});
|
||||||
changed |= widget.changed;
|
changed |= widget.changed;
|
||||||
accepted_drop |= widget.accepted_drop;
|
accepted_drop |= widget.accepted_drop;
|
||||||
ui.add_space(8.0);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
ui.ctx()
|
||||||
|
.data_mut(|data| data.insert_persisted(renderer_open_id, renderer_open));
|
||||||
for orphan in &renderer.materials.orphaned_assignments {
|
for orphan in &renderer.materials.orphaned_assignments {
|
||||||
ui.label(
|
ui.label(
|
||||||
egui::RichText::new(format!(
|
egui::RichText::new(format!(
|
||||||
@ -114,6 +175,17 @@ pub(super) fn skinned_mesh_renderer_ui(world: &mut World, ui: &mut egui::Ui, ent
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
apply_component_card_response(world, entity, response);
|
apply_component_card_response(world, entity, response);
|
||||||
|
if browse_mesh {
|
||||||
|
crate::ui::request_editor_tab(world, crate::ui::EditorTab::AssetBrowser);
|
||||||
|
}
|
||||||
|
if locate_mesh {
|
||||||
|
if let Some(candidate) = mesh_candidates
|
||||||
|
.iter()
|
||||||
|
.find(|candidate| candidate.reference.asset_id == renderer.asset_id)
|
||||||
|
{
|
||||||
|
reveal_asset_in_browser(world, &candidate.folder_path, &candidate.selection);
|
||||||
|
}
|
||||||
|
}
|
||||||
if accepted_drop {
|
if accepted_drop {
|
||||||
clear_asset_drag(world);
|
clear_asset_drag(world);
|
||||||
}
|
}
|
||||||
@ -153,6 +225,8 @@ pub(super) fn static_mesh_renderer_ui(world: &mut World, ui: &mut egui::Ui, enti
|
|||||||
let mut changed = false;
|
let mut changed = false;
|
||||||
let mut accepted_drop = false;
|
let mut accepted_drop = false;
|
||||||
let mut remove_entry = None;
|
let mut remove_entry = None;
|
||||||
|
let mut browse_mesh = false;
|
||||||
|
let mut locate_mesh = None;
|
||||||
for index in 0..renderer.slots.len() {
|
for index in 0..renderer.slots.len() {
|
||||||
ensure_slot_id(&mut renderer.slots[index], index);
|
ensure_slot_id(&mut renderer.slots[index], index);
|
||||||
let part = &renderer.slots[index];
|
let part = &renderer.slots[index];
|
||||||
@ -222,18 +296,26 @@ pub(super) fn static_mesh_renderer_ui(world: &mut World, ui: &mut egui::Ui, enti
|
|||||||
{
|
{
|
||||||
remove_entry = Some(index);
|
remove_entry = Some(index);
|
||||||
}
|
}
|
||||||
editor_ui::design_system::controls::icon_button(
|
if editor_ui::design_system::controls::icon_button(
|
||||||
ui,
|
ui,
|
||||||
icons::CROSSHAIR,
|
icons::CROSSHAIR,
|
||||||
"Locate mesh",
|
"Locate mesh",
|
||||||
true,
|
true,
|
||||||
);
|
)
|
||||||
editor_ui::design_system::controls::icon_button(
|
.clicked()
|
||||||
|
{
|
||||||
|
locate_mesh = Some(entry.mesh.clone());
|
||||||
|
}
|
||||||
|
if editor_ui::design_system::controls::icon_button(
|
||||||
ui,
|
ui,
|
||||||
icons::FOLDER_OPEN,
|
icons::FOLDER_OPEN,
|
||||||
"Browse mesh",
|
"Browse mesh",
|
||||||
true,
|
true,
|
||||||
);
|
)
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
browse_mesh = true;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
if !renderer_open {
|
if !renderer_open {
|
||||||
@ -325,6 +407,12 @@ pub(super) fn static_mesh_renderer_ui(world: &mut World, ui: &mut egui::Ui, enti
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
apply_component_card_response(world, entity, card_response);
|
apply_component_card_response(world, entity, card_response);
|
||||||
|
if browse_mesh {
|
||||||
|
crate::ui::request_editor_tab(world, crate::ui::EditorTab::AssetBrowser);
|
||||||
|
}
|
||||||
|
if let Some(mesh) = locate_mesh.as_ref() {
|
||||||
|
locate_asset_ref(world, Some(mesh), &mesh_candidates);
|
||||||
|
}
|
||||||
if accepted_drop {
|
if accepted_drop {
|
||||||
clear_asset_drag(world);
|
clear_asset_drag(world);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -175,6 +175,7 @@ pub(super) fn plan_material_instance_assignment(
|
|||||||
slot_id: block.slot_id.clone(),
|
slot_id: block.slot_id.clone(),
|
||||||
slot_name: slot_name.to_string(),
|
slot_name: slot_name.to_string(),
|
||||||
base: base.clone(),
|
base: base.clone(),
|
||||||
|
actor_assignment: actor_material_assignment_for_slot(world, entity, &block.slot_id),
|
||||||
block: block.clone(),
|
block: block.clone(),
|
||||||
project_root,
|
project_root,
|
||||||
destination_path,
|
destination_path,
|
||||||
@ -243,25 +244,22 @@ pub(super) fn validate_property_block_promotion(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let current_base = world
|
let current_actor_assignment =
|
||||||
.get::<SkinnedMeshRenderer>(promotion.entity)
|
actor_material_assignment_for_slot(world, promotion.entity, &promotion.slot_id);
|
||||||
.and_then(|renderer| renderer.materials.slot(&promotion.slot_id))
|
if current_actor_assignment != promotion.actor_assignment {
|
||||||
.and_then(|slot| slot.material.as_ref())
|
return Err(
|
||||||
.or_else(|| {
|
"the target renderer slot assignment changed; refresh the promotion review".to_string(),
|
||||||
world
|
);
|
||||||
.get::<StaticMeshRenderer>(promotion.entity)
|
}
|
||||||
.and_then(|renderer| renderer.materials.slot(&promotion.slot_id))
|
let current_base =
|
||||||
.and_then(|slot| slot.material.as_ref())
|
effective_material_for_actor_slot(world, promotion.entity, &promotion.slot_id).ok_or_else(
|
||||||
})
|
|| "the target renderer slot is missing or has no project base".to_string(),
|
||||||
.or_else(|| {
|
)?;
|
||||||
world
|
if current_base != promotion.base {
|
||||||
.get::<Primitive>(promotion.entity)
|
return Err(
|
||||||
.filter(|primitive| primitive.surface.id == promotion.slot_id)
|
"the target renderer slot's effective base changed; refresh the promotion review"
|
||||||
.and_then(|primitive| primitive.surface.material.as_ref())
|
.to_string(),
|
||||||
})
|
);
|
||||||
.ok_or_else(|| "the target renderer slot is missing or has no project base".to_string())?;
|
|
||||||
if current_base != &promotion.base {
|
|
||||||
return Err("the target renderer slot changed; refresh the promotion review".to_string());
|
|
||||||
}
|
}
|
||||||
for source in &promotion.source_snapshots {
|
for source in &promotion.source_snapshots {
|
||||||
if FileSnapshot::capture(&source.path)? != source.snapshot {
|
if FileSnapshot::capture(&source.path)? != source.snapshot {
|
||||||
|
|||||||
564
crates/editor_ui/src/content_browser.rs
Normal file
564
crates/editor_ui/src/content_browser.rs
Normal file
@ -0,0 +1,564 @@
|
|||||||
|
//! Content Browser presentation shared by production and the UI gallery.
|
||||||
|
//!
|
||||||
|
//! The widget owns deterministic Penpot geometry and returns intent. Project navigation,
|
||||||
|
//! transactions, selection, and authored-document state remain in the editor crate.
|
||||||
|
|
||||||
|
use egui;
|
||||||
|
use egui_phosphor_icons::icons;
|
||||||
|
|
||||||
|
use crate::design_system;
|
||||||
|
use crate::design_system::typography::TypeRole;
|
||||||
|
|
||||||
|
pub const TOOLBAR_HEIGHT: f32 = 124.0;
|
||||||
|
pub const STATUS_BAR_HEIGHT: f32 = 32.0;
|
||||||
|
pub const COMPACT_BREAKPOINT: f32 = 720.0;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum ContentBrowserViewMode {
|
||||||
|
Grid,
|
||||||
|
List,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum ContentBrowserKindFilter {
|
||||||
|
All,
|
||||||
|
Model,
|
||||||
|
Texture,
|
||||||
|
Material,
|
||||||
|
Audio,
|
||||||
|
Level,
|
||||||
|
Prefab,
|
||||||
|
Builtin,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ContentBrowserKindFilter {
|
||||||
|
pub const ALL: [Self; 8] = [
|
||||||
|
Self::All,
|
||||||
|
Self::Model,
|
||||||
|
Self::Texture,
|
||||||
|
Self::Material,
|
||||||
|
Self::Audio,
|
||||||
|
Self::Level,
|
||||||
|
Self::Prefab,
|
||||||
|
Self::Builtin,
|
||||||
|
];
|
||||||
|
|
||||||
|
pub const fn label(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::All => "All assets",
|
||||||
|
Self::Model => "Models",
|
||||||
|
Self::Texture => "Textures",
|
||||||
|
Self::Material => "Materials",
|
||||||
|
Self::Audio => "Audio",
|
||||||
|
Self::Level => "Levels",
|
||||||
|
Self::Prefab => "Prefabs",
|
||||||
|
Self::Builtin => "Built-ins",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum ContentBrowserSort {
|
||||||
|
Name,
|
||||||
|
Kind,
|
||||||
|
Modified,
|
||||||
|
Size,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ContentBrowserSort {
|
||||||
|
pub const ALL: [Self; 4] = [Self::Name, Self::Kind, Self::Modified, Self::Size];
|
||||||
|
|
||||||
|
pub const fn label(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Name => "Name",
|
||||||
|
Self::Kind => "Type",
|
||||||
|
Self::Modified => "Modified",
|
||||||
|
Self::Size => "Size",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct ContentPathSegment {
|
||||||
|
pub path: String,
|
||||||
|
pub label: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ContentBrowserToolbarViewModel {
|
||||||
|
pub path: Vec<ContentPathSegment>,
|
||||||
|
pub search: String,
|
||||||
|
pub kind_filter: ContentBrowserKindFilter,
|
||||||
|
pub sort: ContentBrowserSort,
|
||||||
|
pub view: ContentBrowserViewMode,
|
||||||
|
pub recursive: bool,
|
||||||
|
pub show_details: bool,
|
||||||
|
pub thumbnail_size: f32,
|
||||||
|
pub can_go_parent: bool,
|
||||||
|
pub can_go_back: bool,
|
||||||
|
pub can_go_forward: bool,
|
||||||
|
pub can_edit_folder: bool,
|
||||||
|
pub can_rename: bool,
|
||||||
|
pub can_edit_selection: bool,
|
||||||
|
pub can_paste: bool,
|
||||||
|
pub can_undo: bool,
|
||||||
|
pub pending_repairs: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub enum ContentBrowserToolbarAction {
|
||||||
|
Back,
|
||||||
|
Forward,
|
||||||
|
Parent,
|
||||||
|
Refresh,
|
||||||
|
Navigate(String),
|
||||||
|
ImportHere,
|
||||||
|
ImportTo,
|
||||||
|
NewFolder,
|
||||||
|
Rename,
|
||||||
|
Duplicate,
|
||||||
|
Undo,
|
||||||
|
Cut,
|
||||||
|
Copy,
|
||||||
|
Paste,
|
||||||
|
OpenTrash,
|
||||||
|
ReviewMoves,
|
||||||
|
SetSearch(String),
|
||||||
|
SetKindFilter(ContentBrowserKindFilter),
|
||||||
|
SetSort(ContentBrowserSort),
|
||||||
|
SetView(ContentBrowserViewMode),
|
||||||
|
SetRecursive(bool),
|
||||||
|
SetShowDetails(bool),
|
||||||
|
SetThumbnailSize(f32),
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn content_browser_toolbar(
|
||||||
|
ui: &mut egui::Ui,
|
||||||
|
model: &ContentBrowserToolbarViewModel,
|
||||||
|
) -> Vec<ContentBrowserToolbarAction> {
|
||||||
|
let palette = design_system::palette(ui);
|
||||||
|
let mut actions = Vec::new();
|
||||||
|
let width = ui.available_width().max(1.0);
|
||||||
|
let compact = width < COMPACT_BREAKPOINT;
|
||||||
|
let (rect, _) = ui.allocate_exact_size(egui::vec2(width, TOOLBAR_HEIGHT), egui::Sense::hover());
|
||||||
|
ui.painter().rect_filled(rect, 0.0, palette.recessed);
|
||||||
|
ui.painter().line_segment(
|
||||||
|
[rect.left_bottom(), rect.right_bottom()],
|
||||||
|
egui::Stroke::new(1.0_f32, palette.border),
|
||||||
|
);
|
||||||
|
|
||||||
|
let title_bar = egui::Rect::from_min_max(
|
||||||
|
rect.min + egui::vec2(8.0, 2.0),
|
||||||
|
egui::pos2(rect.right() - 8.0, rect.top() + 31.0),
|
||||||
|
);
|
||||||
|
ui.scope_builder(egui::UiBuilder::new().max_rect(title_bar), |ui| {
|
||||||
|
ui.horizontal_centered(|ui| {
|
||||||
|
ui.label(
|
||||||
|
egui::RichText::new(icons::FOLDER_OPEN.as_str())
|
||||||
|
.font(egui::FontId::new(
|
||||||
|
14.0,
|
||||||
|
egui::FontFamily::Name("phosphor-bold".into()),
|
||||||
|
))
|
||||||
|
.color(palette.text_secondary),
|
||||||
|
);
|
||||||
|
ui.label(TypeRole::AssetTitle.text("Content Browser"));
|
||||||
|
let project = egui::Button::new(TypeRole::Eyebrow.text("PROJECT"))
|
||||||
|
.fill(palette.accent_dark)
|
||||||
|
.stroke(egui::Stroke::new(1.0_f32, palette.accent))
|
||||||
|
.corner_radius(egui::CornerRadius::same(4));
|
||||||
|
ui.add(project).on_hover_text("Managed project content");
|
||||||
|
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||||
|
design_system::controls::icon_button(
|
||||||
|
ui,
|
||||||
|
icons::DOTS_THREE_VERTICAL,
|
||||||
|
"Content Browser options",
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
let primary = egui::Rect::from_min_max(
|
||||||
|
egui::pos2(rect.left() + 8.0, rect.top() + 38.0),
|
||||||
|
egui::pos2(rect.right() - 8.0, rect.top() + 76.0),
|
||||||
|
);
|
||||||
|
ui.scope_builder(egui::UiBuilder::new().max_rect(primary), |ui| {
|
||||||
|
ui.horizontal_centered(|ui| {
|
||||||
|
if design_system::controls::icon_button(
|
||||||
|
ui,
|
||||||
|
icons::ARROW_LEFT,
|
||||||
|
"Back",
|
||||||
|
model.can_go_back,
|
||||||
|
)
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
actions.push(ContentBrowserToolbarAction::Back);
|
||||||
|
}
|
||||||
|
if design_system::controls::icon_button(
|
||||||
|
ui,
|
||||||
|
icons::ARROW_RIGHT,
|
||||||
|
"Forward",
|
||||||
|
model.can_go_forward,
|
||||||
|
)
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
actions.push(ContentBrowserToolbarAction::Forward);
|
||||||
|
}
|
||||||
|
if design_system::controls::icon_button(
|
||||||
|
ui,
|
||||||
|
icons::ARROW_UP,
|
||||||
|
"Parent folder",
|
||||||
|
model.can_go_parent,
|
||||||
|
)
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
actions.push(ContentBrowserToolbarAction::Parent);
|
||||||
|
}
|
||||||
|
if design_system::controls::icon_button(
|
||||||
|
ui,
|
||||||
|
icons::ARROWS_CLOCKWISE,
|
||||||
|
"Refresh content",
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
actions.push(ContentBrowserToolbarAction::Refresh);
|
||||||
|
}
|
||||||
|
if ui
|
||||||
|
.add(
|
||||||
|
egui::Button::new(design_system::controls::icon_label(
|
||||||
|
icons::UPLOAD_SIMPLE,
|
||||||
|
"Import Here",
|
||||||
|
TypeRole::Control,
|
||||||
|
13.0,
|
||||||
|
palette.text_primary,
|
||||||
|
))
|
||||||
|
.fill(palette.accent_dark)
|
||||||
|
.stroke(egui::Stroke::new(1.0_f32, palette.accent))
|
||||||
|
.corner_radius(egui::CornerRadius::same(4)),
|
||||||
|
)
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
actions.push(ContentBrowserToolbarAction::ImportHere);
|
||||||
|
}
|
||||||
|
if !compact
|
||||||
|
&& ui
|
||||||
|
.add_enabled(
|
||||||
|
model.can_edit_folder,
|
||||||
|
egui::Button::new(design_system::controls::icon_label(
|
||||||
|
icons::FOLDER_PLUS,
|
||||||
|
"New Folder",
|
||||||
|
TypeRole::Control,
|
||||||
|
13.0,
|
||||||
|
palette.text_primary,
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
actions.push(ContentBrowserToolbarAction::NewFolder);
|
||||||
|
}
|
||||||
|
|
||||||
|
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||||
|
content_operations_menu(ui, model, &mut actions);
|
||||||
|
if model.pending_repairs > 0
|
||||||
|
&& ui
|
||||||
|
.button(format!("Resolve {} moves", model.pending_repairs))
|
||||||
|
.on_hover_text("Review ambiguous external move identities")
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
actions.push(ContentBrowserToolbarAction::ReviewMoves);
|
||||||
|
}
|
||||||
|
if !compact {
|
||||||
|
let mut details = model.show_details;
|
||||||
|
if ui
|
||||||
|
.add(egui::Button::new(design_system::controls::icon_label(
|
||||||
|
icons::INFO,
|
||||||
|
"Details",
|
||||||
|
TypeRole::Control,
|
||||||
|
13.0,
|
||||||
|
palette.text_primary,
|
||||||
|
)))
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
details = !details;
|
||||||
|
actions.push(ContentBrowserToolbarAction::SetShowDetails(details));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (mode, icon, tooltip) in [
|
||||||
|
(ContentBrowserViewMode::List, icons::LIST, "List view"),
|
||||||
|
(ContentBrowserViewMode::Grid, icons::GRID_FOUR, "Grid view"),
|
||||||
|
] {
|
||||||
|
let response = design_system::controls::icon_button(ui, icon, tooltip, true);
|
||||||
|
if model.view == mode {
|
||||||
|
ui.painter().rect_stroke(
|
||||||
|
response.rect,
|
||||||
|
4.0,
|
||||||
|
egui::Stroke::new(1.0_f32, palette.accent),
|
||||||
|
egui::StrokeKind::Inside,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if response.clicked() {
|
||||||
|
actions.push(ContentBrowserToolbarAction::SetView(mode));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut filter = model.kind_filter;
|
||||||
|
egui::ComboBox::from_id_salt("content_browser_kind")
|
||||||
|
.width(if compact { 72.0 } else { 92.0 })
|
||||||
|
.selected_text(filter.label())
|
||||||
|
.show_ui(ui, |ui| {
|
||||||
|
for option in ContentBrowserKindFilter::ALL {
|
||||||
|
ui.selectable_value(&mut filter, option, option.label());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if filter != model.kind_filter {
|
||||||
|
actions.push(ContentBrowserToolbarAction::SetKindFilter(filter));
|
||||||
|
}
|
||||||
|
let mut search = model.search.clone();
|
||||||
|
if ui
|
||||||
|
.add_sized(
|
||||||
|
[if compact { 116.0 } else { 184.0 }, 24.0],
|
||||||
|
egui::TextEdit::singleline(&mut search).hint_text("Search project assets"),
|
||||||
|
)
|
||||||
|
.changed()
|
||||||
|
{
|
||||||
|
actions.push(ContentBrowserToolbarAction::SetSearch(search));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
let secondary = egui::Rect::from_min_max(
|
||||||
|
egui::pos2(rect.left() + 8.0, rect.top() + 84.0),
|
||||||
|
egui::pos2(rect.right() - 8.0, rect.bottom() - 5.0),
|
||||||
|
);
|
||||||
|
ui.scope_builder(egui::UiBuilder::new().max_rect(secondary), |ui| {
|
||||||
|
path_bar(ui, &model.path, &mut actions);
|
||||||
|
});
|
||||||
|
actions
|
||||||
|
}
|
||||||
|
|
||||||
|
fn path_bar(
|
||||||
|
ui: &mut egui::Ui,
|
||||||
|
path: &[ContentPathSegment],
|
||||||
|
actions: &mut Vec<ContentBrowserToolbarAction>,
|
||||||
|
) {
|
||||||
|
let palette = design_system::palette(ui);
|
||||||
|
let (rect, _) = ui.allocate_exact_size(
|
||||||
|
egui::vec2(ui.available_width().max(1.0), 24.0),
|
||||||
|
egui::Sense::hover(),
|
||||||
|
);
|
||||||
|
ui.painter().rect(
|
||||||
|
rect,
|
||||||
|
4.0,
|
||||||
|
palette.recessed,
|
||||||
|
egui::Stroke::new(1.0_f32, palette.border),
|
||||||
|
egui::StrokeKind::Inside,
|
||||||
|
);
|
||||||
|
ui.scope_builder(
|
||||||
|
egui::UiBuilder::new().max_rect(rect.shrink2(egui::vec2(8.0, 2.0))),
|
||||||
|
|ui| {
|
||||||
|
ui.horizontal_centered(|ui| {
|
||||||
|
for (index, segment) in path.iter().enumerate() {
|
||||||
|
if index > 0 {
|
||||||
|
ui.label(TypeRole::Small.text("/").color(palette.text_muted));
|
||||||
|
}
|
||||||
|
let response = ui
|
||||||
|
.add(egui::Button::new(TypeRole::Small.text(&segment.label)).frame(false));
|
||||||
|
if response.clicked() {
|
||||||
|
actions.push(ContentBrowserToolbarAction::Navigate(segment.path.clone()));
|
||||||
|
}
|
||||||
|
response.on_hover_text(&segment.path);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn content_operations_menu(
|
||||||
|
ui: &mut egui::Ui,
|
||||||
|
model: &ContentBrowserToolbarViewModel,
|
||||||
|
actions: &mut Vec<ContentBrowserToolbarAction>,
|
||||||
|
) {
|
||||||
|
let response = ui.menu_button(
|
||||||
|
design_system::controls::icon_label(
|
||||||
|
icons::DOTS_THREE,
|
||||||
|
"Actions",
|
||||||
|
TypeRole::Control,
|
||||||
|
13.0,
|
||||||
|
design_system::palette(ui).text_primary,
|
||||||
|
),
|
||||||
|
|ui| {
|
||||||
|
operation(
|
||||||
|
ui,
|
||||||
|
"Import To…",
|
||||||
|
model.can_edit_folder,
|
||||||
|
ContentBrowserToolbarAction::ImportTo,
|
||||||
|
actions,
|
||||||
|
);
|
||||||
|
operation(
|
||||||
|
ui,
|
||||||
|
"New Folder",
|
||||||
|
model.can_edit_folder,
|
||||||
|
ContentBrowserToolbarAction::NewFolder,
|
||||||
|
actions,
|
||||||
|
);
|
||||||
|
ui.separator();
|
||||||
|
operation(
|
||||||
|
ui,
|
||||||
|
"Rename",
|
||||||
|
model.can_rename,
|
||||||
|
ContentBrowserToolbarAction::Rename,
|
||||||
|
actions,
|
||||||
|
);
|
||||||
|
operation(
|
||||||
|
ui,
|
||||||
|
"Duplicate",
|
||||||
|
model.can_edit_selection,
|
||||||
|
ContentBrowserToolbarAction::Duplicate,
|
||||||
|
actions,
|
||||||
|
);
|
||||||
|
operation(
|
||||||
|
ui,
|
||||||
|
"Cut",
|
||||||
|
model.can_edit_selection,
|
||||||
|
ContentBrowserToolbarAction::Cut,
|
||||||
|
actions,
|
||||||
|
);
|
||||||
|
operation(
|
||||||
|
ui,
|
||||||
|
"Copy",
|
||||||
|
model.can_edit_selection,
|
||||||
|
ContentBrowserToolbarAction::Copy,
|
||||||
|
actions,
|
||||||
|
);
|
||||||
|
operation(
|
||||||
|
ui,
|
||||||
|
"Paste",
|
||||||
|
model.can_paste,
|
||||||
|
ContentBrowserToolbarAction::Paste,
|
||||||
|
actions,
|
||||||
|
);
|
||||||
|
ui.separator();
|
||||||
|
operation(
|
||||||
|
ui,
|
||||||
|
"Undo Content Operation",
|
||||||
|
model.can_undo,
|
||||||
|
ContentBrowserToolbarAction::Undo,
|
||||||
|
actions,
|
||||||
|
);
|
||||||
|
operation(
|
||||||
|
ui,
|
||||||
|
"Open Trash",
|
||||||
|
true,
|
||||||
|
ContentBrowserToolbarAction::OpenTrash,
|
||||||
|
actions,
|
||||||
|
);
|
||||||
|
ui.separator();
|
||||||
|
let mut recursive = model.recursive;
|
||||||
|
if ui.checkbox(&mut recursive, "Include subfolders").changed() {
|
||||||
|
actions.push(ContentBrowserToolbarAction::SetRecursive(recursive));
|
||||||
|
}
|
||||||
|
let mut details = model.show_details;
|
||||||
|
if ui.checkbox(&mut details, "Show details pane").changed() {
|
||||||
|
actions.push(ContentBrowserToolbarAction::SetShowDetails(details));
|
||||||
|
}
|
||||||
|
ui.label(TypeRole::Caption.text("Thumbnail size"));
|
||||||
|
let mut thumbnail_size = model.thumbnail_size;
|
||||||
|
if ui
|
||||||
|
.add(egui::Slider::new(&mut thumbnail_size, 48.0..=112.0).show_value(false))
|
||||||
|
.changed()
|
||||||
|
{
|
||||||
|
actions.push(ContentBrowserToolbarAction::SetThumbnailSize(
|
||||||
|
thumbnail_size,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
ui.label(TypeRole::Caption.text("Sort by"));
|
||||||
|
let mut sort = model.sort;
|
||||||
|
egui::ComboBox::from_id_salt("content_browser_sort")
|
||||||
|
.selected_text(sort.label())
|
||||||
|
.show_ui(ui, |ui| {
|
||||||
|
for option in ContentBrowserSort::ALL {
|
||||||
|
ui.selectable_value(&mut sort, option, option.label());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if sort != model.sort {
|
||||||
|
actions.push(ContentBrowserToolbarAction::SetSort(sort));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
response.response.on_hover_text("Content operations");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn operation(
|
||||||
|
ui: &mut egui::Ui,
|
||||||
|
label: &str,
|
||||||
|
enabled: bool,
|
||||||
|
action: ContentBrowserToolbarAction,
|
||||||
|
actions: &mut Vec<ContentBrowserToolbarAction>,
|
||||||
|
) {
|
||||||
|
if ui.add_enabled(enabled, egui::Button::new(label)).clicked() {
|
||||||
|
actions.push(action);
|
||||||
|
ui.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ContentBrowserStatusViewModel<'a> {
|
||||||
|
pub summary: &'a str,
|
||||||
|
pub location: &'a str,
|
||||||
|
pub status: &'a str,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn content_browser_status_bar(ui: &mut egui::Ui, model: ContentBrowserStatusViewModel<'_>) {
|
||||||
|
let palette = design_system::palette(ui);
|
||||||
|
let (rect, _) = ui.allocate_exact_size(
|
||||||
|
egui::vec2(ui.available_width().max(1.0), STATUS_BAR_HEIGHT),
|
||||||
|
egui::Sense::hover(),
|
||||||
|
);
|
||||||
|
ui.painter().rect_filled(rect, 0.0, palette.recessed);
|
||||||
|
ui.painter().line_segment(
|
||||||
|
[rect.left_top(), rect.right_top()],
|
||||||
|
egui::Stroke::new(1.0_f32, palette.border),
|
||||||
|
);
|
||||||
|
ui.painter().text(
|
||||||
|
rect.left_center() + egui::vec2(10.0, 0.0),
|
||||||
|
egui::Align2::LEFT_CENTER,
|
||||||
|
model.summary,
|
||||||
|
TypeRole::Small.font(),
|
||||||
|
palette.text_secondary,
|
||||||
|
);
|
||||||
|
ui.painter().text(
|
||||||
|
rect.center(),
|
||||||
|
egui::Align2::CENTER_CENTER,
|
||||||
|
model.location,
|
||||||
|
TypeRole::Caption.font(),
|
||||||
|
palette.text_muted,
|
||||||
|
);
|
||||||
|
ui.painter().text(
|
||||||
|
rect.right_center() - egui::vec2(10.0, 0.0),
|
||||||
|
egui::Align2::RIGHT_CENTER,
|
||||||
|
model.status,
|
||||||
|
TypeRole::Small.font(),
|
||||||
|
palette.text_muted,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn penpot_content_browser_geometry_is_stable() {
|
||||||
|
assert_eq!(TOOLBAR_HEIGHT, 124.0);
|
||||||
|
assert_eq!(STATUS_BAR_HEIGHT, 32.0);
|
||||||
|
assert_eq!(COMPACT_BREAKPOINT, 720.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn filters_and_sorts_have_stable_labels() {
|
||||||
|
assert_eq!(ContentBrowserKindFilter::ALL.len(), 8);
|
||||||
|
assert_eq!(ContentBrowserKindFilter::All.label(), "All assets");
|
||||||
|
assert_eq!(ContentBrowserSort::ALL.len(), 4);
|
||||||
|
assert_eq!(ContentBrowserSort::Kind.label(), "Type");
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -4,6 +4,7 @@
|
|||||||
//! not own a Bevy world, project state, authored-document publication, scene mutations, or content
|
//! not own a Bevy world, project state, authored-document publication, scene mutations, or content
|
||||||
//! processing.
|
//! processing.
|
||||||
|
|
||||||
|
pub mod content_browser;
|
||||||
pub mod design_system;
|
pub mod design_system;
|
||||||
pub mod fonts;
|
pub mod fonts;
|
||||||
pub mod inspector;
|
pub mod inspector;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user