From 93e23ba194a581aebadce5f4b30c2bd48b19aa71 Mon Sep 17 00:00:00 2001 From: Rbanh Date: Sat, 18 Jul 2026 14:05:26 -0400 Subject: [PATCH] feat(editor): unify renderer materials and content browser shell --- crates/editor/src/ui/asset_browser/panel.rs | 70 ++- .../src/ui/asset_browser/panel/details.rs | 40 +- .../src/ui/asset_browser/panel/navigation.rs | 193 +----- .../src/ui/asset_browser/panel/tests/b.rs | 37 ++ .../ui/asset_browser/panel/toolbar_import.rs | 357 +++++------ .../src/ui/asset_browser/panel/utilities.rs | 45 ++ crates/editor/src/ui/asset_browser/state.rs | 9 +- crates/editor/src/ui/inspector.rs | 6 + .../src/ui/inspector/material_resolution.rs | 174 ++++++ .../editor/src/ui/inspector/material_slots.rs | 74 +-- .../editor/src/ui/inspector/mesh_renderers.rs | 216 +++++-- .../src/ui/inspector/property_blocks.rs | 36 +- crates/editor_ui/src/content_browser.rs | 564 ++++++++++++++++++ crates/editor_ui/src/lib.rs | 1 + 14 files changed, 1296 insertions(+), 526 deletions(-) create mode 100644 crates/editor/src/ui/inspector/material_resolution.rs create mode 100644 crates/editor_ui/src/content_browser.rs diff --git a/crates/editor/src/ui/asset_browser/panel.rs b/crates/editor/src/ui/asset_browser/panel.rs index 12792bb..5a73b83 100644 --- a/crates/editor/src/ui/asset_browser/panel.rs +++ b/crates/editor/src/ui/asset_browser/panel.rs @@ -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::(); let cache = world.resource::(); let state = world.resource::(); @@ -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, + }, + ); }, ); }, diff --git a/crates/editor/src/ui/asset_browser/panel/details.rs b/crates/editor/src/ui/asset_browser/panel/details.rs index c902798..154aa0e 100644 --- a/crates/editor/src/ui/asset_browser/panel/details.rs +++ b/crates/editor/src/ui/asset_browser/panel/details.rs @@ -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::() + .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::(), @@ -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)); }); }); } diff --git a/crates/editor/src/ui/asset_browser/panel/navigation.rs b/crates/editor/src/ui/asset_browser/panel/navigation.rs index a95dacc..f3c4226 100644 --- a/crates/editor/src/ui/asset_browser/panel/navigation.rs +++ b/crates/editor/src/ui/asset_browser/panel/navigation.rs @@ -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,9 +24,14 @@ pub(super) fn content_header( .iter() .filter(|folder| folder.parent.as_deref() == Some(current_folder)) .count(); + let sort = world.resource::().sort; ui.small( - egui::RichText::new(format!("{direct_folders} folders, {direct_assets} assets")) - .color(TEXT_DIM), + 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::().selected.clone(); - let selection_count = world.resource::().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::(*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::().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, diff --git a/crates/editor/src/ui/asset_browser/panel/tests/b.rs b/crates/editor/src/ui/asset_browser/panel/tests/b.rs index bd0875f..49540c7 100644 --- a/crates/editor/src/ui/asset_browser/panel/tests/b.rs +++ b/crates/editor/src/ui/asset_browser/panel/tests/b.rs @@ -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::().navigation_back, + ["assets", "assets/Props"] + ); + + navigate_content_back(&mut world); + assert_eq!( + world.resource::().current_folder, + "assets/Props" + ); + navigate_content_back(&mut world); + assert_eq!(world.resource::().current_folder, "assets"); + navigate_content_forward(&mut world); + assert_eq!( + world.resource::().current_folder, + "assets/Props" + ); + + navigate_content_folder(&mut world, "assets/Materials".into()); + assert!( + world + .resource::() + .navigation_forward + .is_empty() + ); + } + #[test] fn audio_filter_matches_only_audio_clips() { let audio = EditorAsset { diff --git a/crates/editor/src/ui/asset_browser/panel/toolbar_import.rs b/crates/editor/src/ui/asset_browser/panel/toolbar_import.rs index bb49b72..227b497 100644 --- a/crates/editor/src/ui/asset_browser/panel/toolbar_import.rs +++ b/crates/editor/src/ui/asset_browser/panel/toolbar_import.rs @@ -5,182 +5,201 @@ pub(super) fn asset_toolbar(world: &mut World, ui: &mut egui::Ui) { let selection_count = world.resource::().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::() - .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})")), - ) - .on_hover_text("Choose which stable asset identities match ambiguous external moves") - .clicked() - { - if let Some(review) = world - .resource_mut::() - .review - .as_mut() - { - review.open = true; + let pending_repairs = world + .get_resource::() + .and_then(|pending| pending.review.as_ref()) + .map_or(0, |review| review.conflicts.len()); + 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::(); + ( + 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(), + ) + }; + 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::().show_trash = true, + Action::ReviewMoves => { + if let Some(review) = world + .resource_mut::() + .review + .as_mut() + { + review.open = true; + } + } + Action::SetSearch(search) => { + world.resource_mut::().search = search; + visible_results_changed = true; + } + Action::SetKindFilter(filter) => { + world.resource_mut::().kind_filter = filter.into(); + visible_results_changed = true; + } + Action::SetSort(sort) => world.resource_mut::().sort = sort.into(), + Action::SetView(view) => world.resource_mut::().view = view.into(), + Action::SetRecursive(recursive) => { + world.resource_mut::().recursive = recursive; + visible_results_changed = true; + } + Action::SetShowDetails(show) => { + world.resource_mut::().show_details = show; + } + Action::SetThumbnailSize(size) => { + world.resource_mut::().thumbnail_size = size; } } - if icon_button_small(ui, icons::UPLOAD, "Import Here").clicked() { - request_import_to_destination(world, current_folder.clone()); - } - if ui - .add_enabled( - current_folder != BUILTINS_FOLDER, - egui::Button::new("Import To..."), - ) - .clicked() - { - begin_import_to(world); - } - if icon_button_small(ui, icons::ARROW_UP, "Parent folder").clicked() { - navigate_to_parent(world); - } - if ui - .add_enabled( - current_folder != BUILTINS_FOLDER, - egui::Button::new("New Folder"), - ) - .clicked() - { - begin_new_folder(world, current_folder.clone()); - } - if ui - .add_enabled(can_rename, egui::Button::new("Rename")) - .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::() - .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::().clipboard.is_some() - && world.resource::().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::().show_trash = true; - } + } + clear_selection_when_visible_results_change(world, visible_results_changed); +} - ui.separator(); +fn content_path_segments( + current_folder: &str, +) -> Vec { + 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 +} - let visible_results_changed = { - let mut state = world.resource_mut::(); - 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(); +impl From for editor_ui::content_browser::ContentBrowserViewMode { + fn from(value: AssetBrowserView) -> Self { + match value { + AssetBrowserView::Grid => Self::Grid, + AssetBrowserView::List => Self::List, + } + } +} - 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; +impl From 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, + } + } +} - 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"); - }); +impl From 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, + } + } +} - 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); - }); +impl From 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 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 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) { diff --git a/crates/editor/src/ui/asset_browser/panel/utilities.rs b/crates/editor/src/ui/asset_browser/panel/utilities.rs index ff719f5..5b766f3 100644 --- a/crates/editor/src/ui/asset_browser/panel/utilities.rs +++ b/crates/editor/src/ui/asset_browser/panel/utilities.rs @@ -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::().current_folder.clone(); + if current == folder { + return; + } + { + let mut state = world.resource_mut::(); + 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::() + .navigation_back + .pop() + else { + return; + }; + let current = world.resource::().current_folder.clone(); + world + .resource_mut::() + .navigation_forward + .push(current); + set_content_folder(world, folder); +} + +pub(super) fn navigate_content_forward(world: &mut World) { + let Some(folder) = world + .resource_mut::() + .navigation_forward + .pop() + else { + return; + }; + let current = world.resource::().current_folder.clone(); + world + .resource_mut::() + .navigation_back + .push(current); + set_content_folder(world, folder); +} + +fn set_content_folder(world: &mut World, folder: String) { let mut assets = world.resource_mut::(); assets.current_folder = folder.clone(); assets.clear_selection(); diff --git a/crates/editor/src/ui/asset_browser/state.rs b/crates/editor/src/ui/asset_browser/state.rs index 93a201e..6ebbdcc 100644 --- a/crates/editor/src/ui/asset_browser/state.rs +++ b/crates/editor/src/ui/asset_browser/state.rs @@ -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, + pub(crate) navigation_forward: Vec, /// Width of the details pane for the current editor session. pub(crate) details_width: f32, pub(crate) expanded_folders: HashSet, @@ -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(), diff --git a/crates/editor/src/ui/inspector.rs b/crates/editor/src/ui/inspector.rs index 7c2a8ce..9012710 100644 --- a/crates/editor/src/ui/inspector.rs +++ b/crates/editor/src/ui/inspector.rs @@ -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, 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::*; diff --git a/crates/editor/src/ui/inspector/material_resolution.rs b/crates/editor/src/ui/inspector/material_resolution.rs new file mode 100644 index 0000000..1c5dfa4 --- /dev/null +++ b/crates/editor/src/ui/inspector/material_resolution.rs @@ -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 { + world + .get::(entity) + .and_then(|renderer| renderer.materials.slot(slot_id)) + .and_then(|slot| slot.material.clone()) + .or_else(|| { + world + .get::(entity) + .and_then(|renderer| renderer.materials.slot(slot_id)) + .and_then(|slot| slot.material.clone()) + }) + .or_else(|| { + world + .get::(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 { + 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::() + .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::(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::(entity) + .map(|renderer| renderer.asset_id.clone()) + })?; + let record = world + .get_resource::()? + .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()) + ); + } + } +} diff --git a/crates/editor/src/ui/inspector/material_slots.rs b/crates/editor/src/ui/inspector/material_slots.rs index 444e037..88a9641 100644 --- a/crates/editor/src/ui/inspector/material_slots.rs +++ b/crates/editor/src/ui/inspector/material_slots.rs @@ -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::(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::(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::(entity) - .map(|renderer| renderer.asset_id.clone()) - })?; - let record = world - .get_resource::()? - .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, diff --git a/crates/editor/src/ui/inspector/mesh_renderers.rs b/crates/editor/src/ui/inspector/mesh_renderers.rs index 3277013..d6f4f01 100644 --- a/crates/editor/src/ui/inspector/mesh_renderers.rs +++ b/crates/editor/src/ui/inspector/mesh_renderers.rs @@ -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::() @@ -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,73 +29,131 @@ 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| { - property_row(ui, "Source", |ui| { - ui.add( - egui::Label::new(if renderer.path.trim().is_empty() { - "Unassigned" - } else { - renderer.path.as_str() - }) - .truncate(), - ); - }); - property_row(ui, "Scene", |ui| { - ui.label(renderer.scene_index.to_string()); - }); - property_row(ui, "Asset ID", |ui| { - ui.add( - egui::Label::new(if renderer.asset_id.trim().is_empty() { - "Legacy/path-only" - } else { - renderer.asset_id.as_str() - }) - .truncate(), - ); - }); - ui.label( - egui::RichText::new( - "Preserves the imported skeleton hierarchy and Bevy skinned-mesh bindings.", - ) - .small() - .color(TEXT_DIM), - ); - ui.add_space(8.0); - if renderer.materials.slots.is_empty() { - ui.label( - 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( - world, - ui, - entity, - slot, - MaterialSlotWidgetContext { - candidates: &material_candidates, - drop_candidate: material_drop_candidate.as_ref(), - invalid_drop_reason: invalid_material_drop, - expanded_header_when_parameters_closed: true, + let renderer_open_id = ui.make_persistent_id(("skinned_renderer_open", entity)); + let mut renderer_open = ui + .ctx() + .data_mut(|data| data.get_persisted::(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() { + "Unassigned" + } else { + renderer.path.as_str() + }) + .truncate(), + ); + }); + property_row(ui, "Scene", |ui| { + ui.label(renderer.scene_index.to_string()); + }); + property_row(ui, "Asset ID", |ui| { + ui.add( + egui::Label::new(if renderer.asset_id.trim().is_empty() { + "Unresolved" + } else { + renderer.asset_id.as_str() + }) + .truncate(), + ); + }); + ui.small( + egui::RichText::new( + "Imported skeleton hierarchy and skin bindings remain model-owned.", + ) + .color(TEXT_DIM), + ); + }); + renderer_material_slots_header(ui, renderer.materials.slots.len()); + if renderer.materials.slots.is_empty() { + 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") + .color(TEXT_DIM), + ); + }); + } + 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, + slot, + MaterialSlotWidgetContext { + candidates: &material_candidates, + drop_candidate: material_drop_candidate.as_ref(), + 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); } diff --git a/crates/editor/src/ui/inspector/property_blocks.rs b/crates/editor/src/ui/inspector/property_blocks.rs index 2a8e223..494cc1f 100644 --- a/crates/editor/src/ui/inspector/property_blocks.rs +++ b/crates/editor/src/ui/inspector/property_blocks.rs @@ -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::(promotion.entity) - .and_then(|renderer| renderer.materials.slot(&promotion.slot_id)) - .and_then(|slot| slot.material.as_ref()) - .or_else(|| { - world - .get::(promotion.entity) - .and_then(|renderer| renderer.materials.slot(&promotion.slot_id)) - .and_then(|slot| slot.material.as_ref()) - }) - .or_else(|| { - world - .get::(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 { diff --git a/crates/editor_ui/src/content_browser.rs b/crates/editor_ui/src/content_browser.rs new file mode 100644 index 0000000..758dcfe --- /dev/null +++ b/crates/editor_ui/src/content_browser.rs @@ -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, + 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 { + 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, +) { + 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, +) { + 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, +) { + 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"); + } +} diff --git a/crates/editor_ui/src/lib.rs b/crates/editor_ui/src/lib.rs index 1b6879d..5c26ef6 100644 --- a/crates/editor_ui/src/lib.rs +++ b/crates/editor_ui/src/lib.rs @@ -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;