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::document_status::authored_document_status_ui;
|
||||
use crate::ui::helpers::asset_label;
|
||||
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 TOOLBAR_HEIGHT: f32 = 40.0;
|
||||
const DETAILS_MIN_PANEL_WIDTH: f32 = 760.0;
|
||||
const FOOTER_HEIGHT: f32 = editor_ui::content_browser::STATUS_BAR_HEIGHT;
|
||||
const TOOLBAR_HEIGHT: f32 = editor_ui::content_browser::TOOLBAR_HEIGHT;
|
||||
const DETAILS_MIN_PANEL_WIDTH: f32 = 980.0;
|
||||
const DETAILS_MIN_WIDTH: f32 = 200.0;
|
||||
const DETAILS_RESIZE_HANDLE_WIDTH: f32 = 10.0;
|
||||
const TREE_MIN_PANEL_WIDTH: f32 = 540.0;
|
||||
const TREE_MIN_WIDTH: f32 = 150.0;
|
||||
const TREE_MAX_WIDTH: f32 = 240.0;
|
||||
const TREE_MIN_PANEL_WIDTH: f32 = editor_ui::content_browser::COMPACT_BREAKPOINT;
|
||||
const TREE_MIN_WIDTH: f32 = 180.0;
|
||||
const TREE_MAX_WIDTH: f32 = 216.0;
|
||||
const MIN_CONTENT_WIDTH: f32 = 160.0;
|
||||
const COMPACT_LIST_WIDTH: f32 = 560.0;
|
||||
const PHOSPHOR: &str = "phosphor-regular";
|
||||
@ -209,9 +208,16 @@ pub fn asset_browser_ui(
|
||||
asset_toolbar(world, ui);
|
||||
},
|
||||
);
|
||||
ui.separator();
|
||||
|
||||
let (current_folder, selected, folders, assets, cache_snapshot, state_snapshot) = {
|
||||
let (
|
||||
current_folder,
|
||||
selected,
|
||||
folders,
|
||||
assets,
|
||||
cache_snapshot,
|
||||
state_snapshot,
|
||||
browser_status,
|
||||
selection_count,
|
||||
) = {
|
||||
let assets = world.resource::<EditorAssets>();
|
||||
let cache = world.resource::<AssetThumbnailCache>();
|
||||
let state = world.resource::<AssetBrowserUiState>();
|
||||
@ -241,6 +247,8 @@ pub fn asset_browser_ui(
|
||||
asset_rows,
|
||||
cache.snapshot(),
|
||||
state_snapshot(state),
|
||||
assets.status.clone(),
|
||||
assets.selections.len(),
|
||||
)
|
||||
};
|
||||
|
||||
@ -250,7 +258,7 @@ pub fn asset_browser_ui(
|
||||
let show_details =
|
||||
state_snapshot.show_details && available_width >= DETAILS_MIN_PANEL_WIDTH;
|
||||
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 {
|
||||
0.0
|
||||
};
|
||||
@ -275,7 +283,12 @@ pub fn asset_browser_ui(
|
||||
egui::vec2(tree_width, body_height),
|
||||
egui::Layout::top_down(egui::Align::Min),
|
||||
|ui| {
|
||||
ui.label(panel_heading("Project"));
|
||||
ui.label(
|
||||
egui::RichText::new("SOURCES")
|
||||
.small()
|
||||
.strong()
|
||||
.color(TEXT_MUTED),
|
||||
);
|
||||
egui::ScrollArea::vertical()
|
||||
.id_salt("asset_folder_tree")
|
||||
.max_height(ui.available_height())
|
||||
@ -396,19 +409,32 @@ pub fn asset_browser_ui(
|
||||
},
|
||||
);
|
||||
|
||||
ui.separator();
|
||||
exact_region(
|
||||
ui,
|
||||
egui::vec2(available_width, FOOTER_HEIGHT),
|
||||
egui::Layout::top_down(egui::Align::Min),
|
||||
|ui| {
|
||||
egui::Frame::new()
|
||||
.fill(ELEVATED_BG.linear_multiply(0.5))
|
||||
.inner_margin(egui::Margin::symmetric(8, 6))
|
||||
.show(ui, |ui| {
|
||||
ui.set_max_height(FOOTER_HEIGHT - 12.0);
|
||||
selection_footer(world, ui, selected_entities);
|
||||
});
|
||||
let direct_assets = assets
|
||||
.iter()
|
||||
.filter(|row| row.asset.folder_path == current_folder)
|
||||
.count();
|
||||
let direct_folders = folders
|
||||
.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) {
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
ui.label(
|
||||
egui::RichText::new(kind_icon(&asset.kind).as_str())
|
||||
.font(egui::FontId::new(
|
||||
30.0,
|
||||
egui::FontFamily::Name(PHOSPHOR.into()),
|
||||
))
|
||||
.color(ACCENT),
|
||||
let thumbnail = world
|
||||
.get_resource::<AssetThumbnailCache>()
|
||||
.and_then(|cache| cache.snapshot().texture_for(asset));
|
||||
ui.horizontal_top(|ui| {
|
||||
let (preview, _) = ui.allocate_exact_size(egui::vec2(96.0, 96.0), egui::Sense::hover());
|
||||
ui.painter().rect(
|
||||
preview,
|
||||
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.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));
|
||||
if let (Some(state), Some(path)) = (
|
||||
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.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],
|
||||
assets: &[AssetRow],
|
||||
) {
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
ui.label(panel_heading("Assets"));
|
||||
ui.separator();
|
||||
breadcrumb(world, ui, current_folder);
|
||||
});
|
||||
let folder_label = if current_folder == BUILTINS_FOLDER {
|
||||
"Built-ins"
|
||||
} else {
|
||||
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
|
||||
.iter()
|
||||
.filter(|row| row.asset.folder_path == current_folder)
|
||||
@ -20,8 +24,13 @@ pub(super) fn content_header(
|
||||
.iter()
|
||||
.filter(|folder| folder.parent.as_deref() == Some(current_folder))
|
||||
.count();
|
||||
let sort = world.resource::<AssetBrowserUiState>().sort;
|
||||
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),
|
||||
);
|
||||
}
|
||||
@ -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(
|
||||
world: &mut World,
|
||||
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]
|
||||
fn audio_filter_matches_only_audio_clips() {
|
||||
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 has_editable_selection = !selected_content_paths(world).is_empty();
|
||||
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
|
||||
.get_resource::<PendingExternalMoveRepair>()
|
||||
.and_then(|pending| pending.review.as_ref())
|
||||
.map_or(0, |review| review.conflicts.len());
|
||||
if ui
|
||||
.add_enabled(
|
||||
pending_repairs > 0,
|
||||
egui::Button::new(format!("Resolve Moves ({pending_repairs})")),
|
||||
let (
|
||||
search,
|
||||
view,
|
||||
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
|
||||
.resource_mut::<PendingExternalMoveRepair>()
|
||||
.review
|
||||
@ -29,158 +85,121 @@ pub(super) fn asset_toolbar(world: &mut World, ui: &mut egui::Ui) {
|
||||
review.open = true;
|
||||
}
|
||||
}
|
||||
if icon_button_small(ui, icons::UPLOAD, "Import Here").clicked() {
|
||||
request_import_to_destination(world, current_folder.clone());
|
||||
Action::SetSearch(search) => {
|
||||
world.resource_mut::<AssetBrowserUiState>().search = search;
|
||||
visible_results_changed = true;
|
||||
}
|
||||
if ui
|
||||
.add_enabled(
|
||||
current_folder != BUILTINS_FOLDER,
|
||||
egui::Button::new("Import To..."),
|
||||
)
|
||||
.clicked()
|
||||
{
|
||||
begin_import_to(world);
|
||||
Action::SetKindFilter(filter) => {
|
||||
world.resource_mut::<AssetBrowserUiState>().kind_filter = filter.into();
|
||||
visible_results_changed = true;
|
||||
}
|
||||
if icon_button_small(ui, icons::ARROW_UP, "Parent folder").clicked() {
|
||||
navigate_to_parent(world);
|
||||
Action::SetSort(sort) => world.resource_mut::<AssetBrowserUiState>().sort = sort.into(),
|
||||
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
|
||||
.add_enabled(
|
||||
current_folder != BUILTINS_FOLDER,
|
||||
egui::Button::new("New Folder"),
|
||||
)
|
||||
.clicked()
|
||||
{
|
||||
begin_new_folder(world, current_folder.clone());
|
||||
Action::SetShowDetails(show) => {
|
||||
world.resource_mut::<AssetBrowserUiState>().show_details = show;
|
||||
}
|
||||
if ui
|
||||
.add_enabled(can_rename, egui::Button::new("Rename"))
|
||||
.clicked()
|
||||
{
|
||||
begin_rename(world);
|
||||
Action::SetThumbnailSize(size) => {
|
||||
world.resource_mut::<AssetBrowserUiState>().thumbnail_size = size;
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
||||
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) {
|
||||
|
||||
@ -25,6 +25,51 @@ pub(super) fn navigate_to_parent(world: &mut World) {
|
||||
}
|
||||
|
||||
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>();
|
||||
assets.current_folder = folder.clone();
|
||||
assets.clear_selection();
|
||||
|
||||
@ -7,7 +7,7 @@ use crate::assets::{AssetSelection, ASSETS_ROOT, BUILTINS_FOLDER};
|
||||
use bevy::prelude::*;
|
||||
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)]
|
||||
pub(crate) enum AssetBrowserView {
|
||||
@ -44,6 +44,9 @@ pub struct AssetBrowserUiState {
|
||||
pub(crate) thumbnail_size: f32,
|
||||
pub(crate) recursive: 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.
|
||||
pub(crate) details_width: f32,
|
||||
pub(crate) expanded_folders: HashSet<String>,
|
||||
@ -183,9 +186,11 @@ impl Default for AssetBrowserUiState {
|
||||
view: AssetBrowserView::Grid,
|
||||
sort: AssetSort::Name,
|
||||
kind_filter: AssetKindFilter::All,
|
||||
thumbnail_size: 72.0,
|
||||
thumbnail_size: 96.0,
|
||||
recursive: false,
|
||||
show_details: true,
|
||||
navigation_back: Vec::new(),
|
||||
navigation_forward: Vec::new(),
|
||||
details_width: ASSET_DETAILS_DEFAULT_WIDTH,
|
||||
expanded_folders,
|
||||
expanded_assets: HashSet::new(),
|
||||
|
||||
@ -166,6 +166,9 @@ struct PendingPropertyBlockPromotion {
|
||||
slot_id: ComponentInstanceId,
|
||||
slot_name: String,
|
||||
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,
|
||||
project_root: PathBuf,
|
||||
destination_path: PathBuf,
|
||||
@ -362,6 +365,8 @@ mod component_lifecycle;
|
||||
mod dispatch;
|
||||
#[path = "inspector/imported_material_preview.rs"]
|
||||
mod imported_material_preview;
|
||||
#[path = "inspector/material_resolution.rs"]
|
||||
mod material_resolution;
|
||||
#[path = "inspector/material_slots.rs"]
|
||||
mod material_slots;
|
||||
#[path = "inspector/mesh_renderers.rs"]
|
||||
@ -384,6 +389,7 @@ pub(crate) use component_lifecycle::apply_component_card_response;
|
||||
use component_lifecycle::*;
|
||||
pub use dispatch::authoring_inspector_ui;
|
||||
pub(crate) use dispatch::{add_component_footer, register_builtin_component_inspectors};
|
||||
use material_resolution::*;
|
||||
use material_slots::*;
|
||||
use mesh_renderers::*;
|
||||
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_extract: is_imported_source,
|
||||
can_create_instance: is_project_material,
|
||||
can_create_instance: is_project_material || is_project_instance,
|
||||
parameters_initially_open: true,
|
||||
expanded_header_when_parameters_closed,
|
||||
};
|
||||
@ -327,8 +327,15 @@ pub(super) fn material_slot_widget_ui(
|
||||
.get::<MaterialPropertyBlocks>(entity)
|
||||
.and_then(|blocks| blocks.slots.iter().find(|block| block.slot_id == slot.id))
|
||||
.cloned();
|
||||
if property_block_promotion_ui(ui, property_block.as_ref(), slot.material.is_some()) {
|
||||
if let (Some(base), Some(block)) = (slot.material.clone(), property_block) {
|
||||
let project_promotion_base = (is_project_material || is_project_instance)
|
||||
.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(
|
||||
world,
|
||||
entity,
|
||||
@ -349,67 +356,6 @@ fn stable_actor_widget_id(world: &World, entity: Entity) -> String {
|
||||
.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(
|
||||
world: &mut World,
|
||||
entity: Entity,
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
use super::*;
|
||||
|
||||
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_drop_candidate = world
|
||||
.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 mut changed = false;
|
||||
let mut accepted_drop = false;
|
||||
let mut browse_mesh = false;
|
||||
let mut locate_mesh = false;
|
||||
let mut options = ComponentCardOptions::removable(
|
||||
COMPONENT_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.copyable = false;
|
||||
options.summary = "Skeleton-bound renderer";
|
||||
options.body_margin = 0;
|
||||
let context = component_card_context(world, entity, options);
|
||||
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| {
|
||||
ui.add(
|
||||
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| {
|
||||
ui.add(
|
||||
egui::Label::new(if renderer.asset_id.trim().is_empty() {
|
||||
"Legacy/path-only"
|
||||
"Unresolved"
|
||||
} else {
|
||||
renderer.asset_id.as_str()
|
||||
})
|
||||
.truncate(),
|
||||
);
|
||||
});
|
||||
ui.label(
|
||||
ui.small(
|
||||
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),
|
||||
);
|
||||
ui.add_space(8.0);
|
||||
});
|
||||
renderer_material_slots_header(ui, renderer.materials.slots.len());
|
||||
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")
|
||||
.small()
|
||||
.color(TEXT_DIM),
|
||||
);
|
||||
});
|
||||
}
|
||||
let materials = crate::ui::materials::MaterialsSectionViewModel {
|
||||
slot_ids: renderer
|
||||
.materials
|
||||
.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(
|
||||
for (index, slot) in renderer.materials.slots.iter_mut().enumerate() {
|
||||
let widget = renderer_material_slot_row(ui, index, |ui| {
|
||||
material_slot_widget_ui(
|
||||
world,
|
||||
ui,
|
||||
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,
|
||||
expanded_header_when_parameters_closed: true,
|
||||
},
|
||||
);
|
||||
)
|
||||
});
|
||||
changed |= widget.changed;
|
||||
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 {
|
||||
ui.label(
|
||||
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);
|
||||
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 {
|
||||
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 accepted_drop = false;
|
||||
let mut remove_entry = None;
|
||||
let mut browse_mesh = false;
|
||||
let mut locate_mesh = None;
|
||||
for index in 0..renderer.slots.len() {
|
||||
ensure_slot_id(&mut renderer.slots[index], 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);
|
||||
}
|
||||
editor_ui::design_system::controls::icon_button(
|
||||
if editor_ui::design_system::controls::icon_button(
|
||||
ui,
|
||||
icons::CROSSHAIR,
|
||||
"Locate mesh",
|
||||
true,
|
||||
);
|
||||
editor_ui::design_system::controls::icon_button(
|
||||
)
|
||||
.clicked()
|
||||
{
|
||||
locate_mesh = Some(entry.mesh.clone());
|
||||
}
|
||||
if editor_ui::design_system::controls::icon_button(
|
||||
ui,
|
||||
icons::FOLDER_OPEN,
|
||||
"Browse mesh",
|
||||
true,
|
||||
);
|
||||
)
|
||||
.clicked()
|
||||
{
|
||||
browse_mesh = true;
|
||||
}
|
||||
},
|
||||
);
|
||||
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);
|
||||
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 {
|
||||
clear_asset_drag(world);
|
||||
}
|
||||
|
||||
@ -175,6 +175,7 @@ pub(super) fn plan_material_instance_assignment(
|
||||
slot_id: block.slot_id.clone(),
|
||||
slot_name: slot_name.to_string(),
|
||||
base: base.clone(),
|
||||
actor_assignment: actor_material_assignment_for_slot(world, entity, &block.slot_id),
|
||||
block: block.clone(),
|
||||
project_root,
|
||||
destination_path,
|
||||
@ -243,25 +244,22 @@ pub(super) fn validate_property_block_promotion(
|
||||
);
|
||||
}
|
||||
}
|
||||
let current_base = world
|
||||
.get::<SkinnedMeshRenderer>(promotion.entity)
|
||||
.and_then(|renderer| renderer.materials.slot(&promotion.slot_id))
|
||||
.and_then(|slot| slot.material.as_ref())
|
||||
.or_else(|| {
|
||||
world
|
||||
.get::<StaticMeshRenderer>(promotion.entity)
|
||||
.and_then(|renderer| renderer.materials.slot(&promotion.slot_id))
|
||||
.and_then(|slot| slot.material.as_ref())
|
||||
})
|
||||
.or_else(|| {
|
||||
world
|
||||
.get::<Primitive>(promotion.entity)
|
||||
.filter(|primitive| primitive.surface.id == promotion.slot_id)
|
||||
.and_then(|primitive| primitive.surface.material.as_ref())
|
||||
})
|
||||
.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());
|
||||
let current_actor_assignment =
|
||||
actor_material_assignment_for_slot(world, promotion.entity, &promotion.slot_id);
|
||||
if current_actor_assignment != promotion.actor_assignment {
|
||||
return Err(
|
||||
"the target renderer slot assignment changed; refresh the promotion review".to_string(),
|
||||
);
|
||||
}
|
||||
let current_base =
|
||||
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(),
|
||||
)?;
|
||||
if current_base != promotion.base {
|
||||
return Err(
|
||||
"the target renderer slot's effective base changed; refresh the promotion review"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
for source in &promotion.source_snapshots {
|
||||
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
|
||||
//! processing.
|
||||
|
||||
pub mod content_browser;
|
||||
pub mod design_system;
|
||||
pub mod fonts;
|
||||
pub mod inspector;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user