From 9e23ae731f95dc351ecc744d6ca61f6bc4467317 Mon Sep 17 00:00:00 2001 From: Rbanh Date: Mon, 13 Jul 2026 06:39:30 -0400 Subject: [PATCH] Fix scoped editor UI action state --- crates/editor/src/ext/extensibility.rs | 7 +- crates/editor/src/history/mod.rs | 46 +++--- crates/editor/src/ui/component_registry.rs | 5 + crates/editor/src/ui/diagnostics.rs | 58 +++++--- crates/editor/src/ui/inspector.rs | 135 +++++++++++------ crates/editor/src/ui/menu.rs | 3 +- crates/editor/src/ui/mod.rs | 145 +++++++++++++++++-- crates/editor/src/ui/navigation_inspector.rs | 53 ++++++- crates/editor/src/ui/toolbar.rs | 90 ++++++++++-- 9 files changed, 428 insertions(+), 114 deletions(-) diff --git a/crates/editor/src/ext/extensibility.rs b/crates/editor/src/ext/extensibility.rs index 1828e73..125b0d8 100644 --- a/crates/editor/src/ext/extensibility.rs +++ b/crates/editor/src/ext/extensibility.rs @@ -78,7 +78,12 @@ impl EditorCommandRegistry { } } -/// Custom inspector body for level objects (game crates register via [`register_actor_inspector_section`]). +/// Custom inspector body for level objects (game crates register via +/// [`register_actor_inspector_section`]). Synchronous [`Self::ui`] callbacks run while +/// [`UiState`] is scoped out of `World`; implementations must not request that resource. Keep +/// extension-owned transient state in independently registered resources, use +/// [`crate::ui::request_ui_selection`] and [`crate::ui::request_editor_tab`] for host UI changes, +/// or defer other cross-panel work through extension-owned commands or events. pub trait ActorInspectorSection: Send + Sync { fn id(&self) -> &str; fn title(&self) -> &str; diff --git a/crates/editor/src/history/mod.rs b/crates/editor/src/history/mod.rs index 06a9007..4ab858b 100644 --- a/crates/editor/src/history/mod.rs +++ b/crates/editor/src/history/mod.rs @@ -19,7 +19,6 @@ use shared::{ use crate::operators::{ActiveOperator, OperatorPhase, OperatorStatus}; use crate::scene_io::{SceneIo, SceneIoRequest}; -use crate::selection::SelectedEntity; use crate::state::scene_tools_active; use crate::ui::hierarchy_ops::{ apply_sibling_change, apply_sibling_change_new, hierarchy_drop_violation, next_sibling_index, @@ -2872,32 +2871,15 @@ fn despawn_entity(world: &mut World, entity: Option) { } fn select_one(world: &mut World, entity: Entity) { - if let Some(mut ui_state) = world.get_resource_mut::() { - ui_state.selected_entities.clear(); - ui_state.selected_entities.select_replace(entity); - } - world.resource_mut::().0 = Some(entity); + crate::ui::request_ui_selection(world, &[entity]); } fn select_many(world: &mut World, entities: &[Entity]) { - if let Some(mut ui_state) = world.get_resource_mut::() { - ui_state.selected_entities.clear(); - for (index, entity) in entities.iter().enumerate() { - if index == 0 { - ui_state.selected_entities.select_replace(*entity); - } else { - ui_state.selected_entities.select_maybe_add(*entity, true); - } - } - } - world.resource_mut::().0 = entities.first().copied(); + crate::ui::request_ui_selection(world, entities); } pub fn clear_selection(world: &mut World) { - if let Some(mut ui_state) = world.get_resource_mut::() { - ui_state.selected_entities.clear(); - } - world.resource_mut::().0 = None; + crate::ui::request_ui_selection(world, &[]); } pub fn is_level_object(world: &World, entity: Entity) -> bool { @@ -2939,6 +2921,28 @@ mod tests { use super::*; use crate::operators::test_harness::{assert_undo_redo_round_trip, OperatorInvariantHarness}; use crate::operators::{ActiveOperator, OperatorPhase}; + use crate::selection::SelectedEntity; + use crate::ui::PendingUiSelection; + + #[test] + fn history_select_many_queues_exact_selection_while_ui_state_is_scoped_out() { + let first = Entity::from_bits(1); + let second = Entity::from_bits(2); + let mut world = World::new(); + world.init_resource::(); + world.init_resource::(); + world.insert_resource(UiState::default_layout()); + + world.resource_scope::(|world, _ui_state| { + assert!(!world.contains_resource::()); + select_many(world, &[first, second]); + assert_eq!( + world.resource::().0.as_deref(), + Some([first, second].as_slice()) + ); + assert_eq!(world.resource::().0, Some(first)); + }); + } #[derive(Component, Reflect, Default, Debug, Clone, PartialEq)] #[reflect(Component, Default)] diff --git a/crates/editor/src/ui/component_registry.rs b/crates/editor/src/ui/component_registry.rs index ac08921..dbc3e31 100644 --- a/crates/editor/src/ui/component_registry.rs +++ b/crates/editor/src/ui/component_registry.rs @@ -47,6 +47,11 @@ pub struct EditorComponentRegistry { inspectors: HashMap<&'static str, ComponentInspectorFn>, } +/// Synchronous inspector callbacks run while [`crate::ui::UiState`] is scoped out of `World`. +/// They must not request that resource. Keep extension-owned transient state in independently +/// registered resources, use [`crate::ui::request_ui_selection`] and +/// [`crate::ui::request_editor_tab`] for host UI changes, or defer other cross-panel work through +/// extension-owned commands or events. pub type ComponentInspectorFn = fn(&mut World, &mut egui::Ui, Entity); impl EditorComponentRegistry { diff --git a/crates/editor/src/ui/diagnostics.rs b/crates/editor/src/ui/diagnostics.rs index f96f4ef..2466267 100644 --- a/crates/editor/src/ui/diagnostics.rs +++ b/crates/editor/src/ui/diagnostics.rs @@ -13,8 +13,6 @@ use crate::history::set_brush_with_history; use crate::history::EditorHistory; use crate::project_io::ProjectWorkspace; use crate::scene_io::{SceneIo, SceneIoEventSeverity}; -use crate::selection::SelectedEntity; -use crate::ui::UiState; use crate::viewport::collider_diagnostics::{ ColliderDiagnosticSeverity, ColliderDiagnosticsState, ColliderOverlayStatus, }; @@ -123,11 +121,7 @@ pub fn diagnostics_ui(world: &mut World, ui: &mut egui::Ui) { .iter(world) .find_map(|(entity, id)| (id.0 == actor_id).then_some(entity)) { - world.resource_mut::().0 = Some(entity); - world - .resource_mut::() - .selected_entities - .select_replace(entity); + request_diagnostic_selection(world, entity); } } } @@ -250,11 +244,7 @@ fn collider_diagnostics_ui(world: &mut World, ui: &mut egui::Ui) { ui.colored_label(color, row.shape_label); ui.label(&row.actor_name); if ui.small_button("Select").clicked() { - world.resource_mut::().0 = Some(row.entity); - world - .resource_mut::() - .selected_entities - .select_replace(row.entity); + request_diagnostic_selection(world, row.entity); } }); for diagnostic in &row.diagnostics { @@ -304,11 +294,7 @@ fn brush_diagnostics_ui(world: &mut World, ui: &mut egui::Ui) { ui.colored_label(color, row.status_label()); ui.label(row.name.as_str()); if ui.button("Select").clicked() { - world.resource_mut::().0 = Some(row.entity); - world - .resource_mut::() - .selected_entities - .select_replace(row.entity); + request_diagnostic_selection(world, row.entity); } if ui .add_enabled(row.error_count > 0, egui::Button::new("Reset Cube")) @@ -323,6 +309,10 @@ fn brush_diagnostics_ui(world: &mut World, ui: &mut egui::Ui) { } } +fn request_diagnostic_selection(world: &mut World, entity: Entity) { + crate::ui::request_ui_selection(world, &[entity]); +} + #[derive(Debug)] struct BrushDiagnosticRow { entity: Entity, @@ -397,3 +387,37 @@ pub fn diagnostics_window(world: &mut World, ctx: &egui::Context, open: &mut boo diagnostics_ui(world, ui); }); } + +#[cfg(test)] +mod tests { + use super::*; + use crate::selection::SelectedEntity; + use crate::ui::{PendingUiSelection, UiState}; + + #[test] + fn diagnostic_selection_reconciles_while_ui_state_is_scoped_out() { + let before = Entity::from_bits(1); + let requested = Entity::from_bits(2); + let mut world = World::new(); + world.init_resource::(); + world.init_resource::(); + world.resource_mut::().0 = Some(before); + + let mut ui_state = UiState::default_layout(); + ui_state.selected_entities.select_replace(before); + world.insert_resource(ui_state); + + world.resource_scope::(|world, mut ui_state| { + assert!(!world.contains_resource::()); + request_diagnostic_selection(world, requested); + let pending = world.resource_mut::().0.take(); + super::super::reconcile_operator_selection( + &mut ui_state.selected_entities, + &[before], + world.resource::().0, + pending.as_deref(), + ); + assert_eq!(ui_state.selected_entities.as_slice(), &[requested]); + }); + } +} diff --git a/crates/editor/src/ui/inspector.rs b/crates/editor/src/ui/inspector.rs index 7c08356..5d6342d 100644 --- a/crates/editor/src/ui/inspector.rs +++ b/crates/editor/src/ui/inspector.rs @@ -37,7 +37,6 @@ use crate::history::{ set_primitive_with_history, set_rigid_body_with_history, set_static_mesh_renderer_with_history, EditorEntitySnapshot, }; -use crate::selection::SelectedEntity; use crate::ui::theme::{ panel_heading, BORDER, ELEVATED_BG, PANEL_BG_DARK, SELECTION_BG_MUTED, TEXT_DIM, TEXT_MUTED, WIDGET_BG, @@ -51,9 +50,8 @@ use crate::viewport::collider_diagnostics::{ use super::component_registry::{ EditorComponentCategory, EditorComponentDescriptor, EditorComponentRegistry, }; -use super::dock_tabs::open_and_focus_tab; use super::helpers::create_scene_sun_override_from_project_settings; -use super::{EditorTab, UiState}; +use super::EditorTab; use crate::assets::asset_db::{find_asset_by_path, AssetRegistry}; use crate::assets::static_mesh::{ load_static_mesh_manifest, material_id_from_label, part_id_from_label, @@ -135,6 +133,7 @@ pub(crate) struct InspectorPanelState { add_component_scroll_selected: bool, add_component_selected_index: usize, add_component_target: Option, + collapsed_components: HashSet, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -276,13 +275,9 @@ pub(crate) fn component_card_context( state_key, ); let collapsed = world - .get_resource::() - .map(|state| { - state - .inspector_collapsed_components - .contains(&component_card_key(world, entity, options.type_name)) - }) - .unwrap_or(false); + .resource::() + .collapsed_components + .contains(&component_card_key(world, entity, options.type_name)); let pasteable = options.copyable && world .get_resource::() @@ -326,12 +321,11 @@ fn apply_component_card_response_for_type( ) { if let Some(collapsed) = response.collapsed { let key = component_card_key(world, entity, type_name); - if let Some(mut ui_state) = world.get_resource_mut::() { - if collapsed { - ui_state.inspector_collapsed_components.insert(key); - } else { - ui_state.inspector_collapsed_components.remove(&key); - } + let mut panel_state = world.resource_mut::(); + if collapsed { + panel_state.collapsed_components.insert(key); + } else { + panel_state.collapsed_components.remove(&key); } } @@ -2362,18 +2356,15 @@ fn locate_asset_ref( return; }; + reveal_asset_in_browser(world, &candidate.folder_path, &candidate.selection); +} + +fn reveal_asset_in_browser(world: &mut World, folder_path: &str, selection: &AssetSelection) { if let Some(mut assets) = world.get_resource_mut::() { - assets.current_folder = candidate.folder_path.clone(); - assets.select(candidate.selection.clone()); - } - if let Some(mut ui_state) = world.get_resource_mut::() { - let panel_nodes = ui_state.panel_nodes; - open_and_focus_tab( - &mut ui_state.dock_state, - EditorTab::AssetBrowser, - &panel_nodes, - ); + assets.current_folder = folder_path.to_string(); + assets.select(selection.clone()); } + super::request_editor_tab(world, EditorTab::AssetBrowser); } fn skinned_mesh_renderer_ui(world: &mut World, ui: &mut egui::Ui, entity: Entity) { @@ -4625,10 +4616,7 @@ pub fn project_sun_ui(world: &mut World, ui: &mut egui::Ui, entity: Entity) { if create_override { let sun = create_scene_sun_override_from_project_settings(world); - world.resource_mut::().0 = Some(sun); - if let Some(mut ui_state) = world.get_resource_mut::() { - ui_state.selected_entities.select_replace(sun); - } + crate::ui::request_ui_selection(world, &[sun]); } } @@ -4881,18 +4869,7 @@ fn locate_texture_asset( return; }; - if let Some(mut assets) = world.get_resource_mut::() { - assets.current_folder = candidate.folder_path.clone(); - assets.select(candidate.selection.clone()); - } - if let Some(mut ui_state) = world.get_resource_mut::() { - let panel_nodes = ui_state.panel_nodes; - open_and_focus_tab( - &mut ui_state.dock_state, - EditorTab::AssetBrowser, - &panel_nodes, - ); - } + reveal_asset_in_browser(world, &candidate.folder_path, &candidate.selection); } fn option_string_ui(ui: &mut egui::Ui, label: &str, value: &mut Option) -> bool { @@ -4924,6 +4901,80 @@ fn option_string_ui(ui: &mut egui::Ui, label: &str, value: &mut Option) mod collider_inspector_tests { use super::*; use crate::history::{apply_command_undo, EditorHistory}; + use crate::ui::{DockTabRequest, UiState}; + + #[test] + fn component_collapse_persists_while_ui_state_is_scoped_out() { + let mut world = World::new(); + world.init_resource::(); + world.init_resource::(); + let entity = world + .spawn((ActorId::new("collapsed-component"), Transform::IDENTITY)) + .id(); + world.insert_resource(UiState::default_layout()); + + world.resource_scope::(|world, _ui_state| { + assert!(!world.contains_resource::()); + apply_component_card_response( + world, + entity, + ComponentCardResponse { + type_name: COMPONENT_TRANSFORM, + collapsed: Some(true), + ..Default::default() + }, + ); + let context = component_card_context( + world, + entity, + ComponentCardOptions::fixed(COMPONENT_TRANSFORM, "Transform", icons::CUBE), + ); + assert!(context.collapsed); + }); + } + + #[test] + fn locate_texture_requests_asset_browser_while_ui_state_is_scoped_out() { + let path = "assets/textures/scoped-locate.png"; + let folder = "assets/textures"; + let selection = AssetSelection::File(path.to_string()); + let mut world = World::new(); + world.insert_resource(EditorAssets { + folders: Vec::new(), + assets: vec![EditorAsset { + label: "Scoped Locate".into(), + path: Some(path.into()), + folder_path: folder.into(), + kind: EditorAssetKind::Texture, + }], + current_folder: crate::assets::ASSETS_ROOT.into(), + selected: None, + dragging: None, + status: String::new(), + }); + world.init_resource::(); + world.insert_resource(UiState::default_layout()); + let candidate = TextureAssetCandidate { + label: "Scoped Locate".into(), + path: path.into(), + folder_path: folder.into(), + selection: selection.clone(), + texture_id: None, + }; + + world.resource_scope::(|world, _ui_state| { + assert!(!world.contains_resource::()); + locate_texture_asset(world, Some(path), &[candidate]); + }); + + let assets = world.resource::(); + assert_eq!(assets.current_folder, folder); + assert_eq!(assets.selected.as_ref(), Some(&selection)); + assert_eq!( + world.resource::().0, + Some(EditorTab::AssetBrowser) + ); + } #[test] fn shape_switch_preserves_dimensions_and_is_one_undoable_edit() { diff --git a/crates/editor/src/ui/menu.rs b/crates/editor/src/ui/menu.rs index 8724d7c..50f8baf 100644 --- a/crates/editor/src/ui/menu.rs +++ b/crates/editor/src/ui/menu.rs @@ -40,6 +40,7 @@ pub fn top_menu_bar( selected: &SelectedEntities, dock_state: &mut DockState, panel_nodes: &mut PanelNodes, + viewport_rect: egui::Rect, ) { egui::Panel::top("editor_menu_bar").show_inside(root_ui, |ui| { egui::MenuBar::new().ui(ui, |ui| { @@ -209,7 +210,7 @@ pub fn top_menu_bar( ("Create Link", NavigationActorType::Link), ] { if menu_item(ui, label, None, true).clicked() { - spawn_navigation_actor(world, actor_type); + spawn_navigation_actor(world, actor_type, viewport_rect); ui.close(); } } diff --git a/crates/editor/src/ui/mod.rs b/crates/editor/src/ui/mod.rs index 00770df..9052914 100644 --- a/crates/editor/src/ui/mod.rs +++ b/crates/editor/src/ui/mod.rs @@ -26,8 +26,6 @@ mod toolbar; mod viewport_chrome; mod widgets; -use std::collections::HashSet; - use bevy::prelude::*; use bevy::window::{CursorGrabMode, CursorOptions, PrimaryWindow}; use bevy_egui::{egui, EguiContext, EguiPrimaryContextPass, PrimaryEguiContext}; @@ -71,7 +69,6 @@ pub struct UiState { pub selected_entities: SelectedEntities, pub renaming_entity: Option, pub rename_buffer: String, - pub(crate) inspector_collapsed_components: HashSet, pub(crate) panel_nodes: PanelNodes, last_mode_tab: Option, } @@ -79,6 +76,56 @@ pub struct UiState { #[derive(Resource, Default)] struct DockTabRequest(Option); +#[derive(Resource, Default, Debug)] +pub(crate) struct PendingUiSelection(pub(crate) Option>); + +fn replace_selected_entities(selected: &mut SelectedEntities, entities: &[Entity]) { + selected.clear(); + for (index, entity) in entities.iter().enumerate() { + if index == 0 { + selected.select_replace(*entity); + } else { + selected.select_maybe_add(*entity, true); + } + } +} + +/// Requests an editor selection change from synchronous UI or extension callbacks. +/// +/// Returns `false` when the editor selection resources are not installed. A normally configured +/// editor provides them through [`crate::EditorPluginGroup`]. +pub fn request_ui_selection(world: &mut World, entities: &[Entity]) -> bool { + { + let Some(mut selected) = world.get_resource_mut::() else { + return false; + }; + selected.0 = entities.first().copied(); + } + if world.contains_resource::() { + { + let mut ui_state = world.resource_mut::(); + replace_selected_entities(&mut ui_state.selected_entities, entities); + } + if let Some(mut pending) = world.get_resource_mut::() { + pending.0 = None; + } + } else if let Some(mut pending) = world.get_resource_mut::() { + pending.0 = Some(entities.to_vec()); + } + true +} + +/// Requests that an editor tab be opened and focused after the current synchronous UI pass. +/// +/// Returns `false` when [`EditorUiPlugin`] is not installed. +pub fn request_editor_tab(world: &mut World, tab: EditorTab) -> bool { + let Some(mut request) = world.get_resource_mut::() else { + return false; + }; + request.0 = Some(tab); + true +} + pub fn egui_captures_keyboard(ctx: &egui::Context) -> bool { ctx.egui_wants_keyboard_input() } @@ -126,7 +173,6 @@ impl UiState { selected_entities: SelectedEntities::default(), renaming_entity: None, rename_buffer: String::new(), - inspector_collapsed_components: HashSet::new(), panel_nodes, last_mode_tab: None, } @@ -138,7 +184,7 @@ impl UiState { )] fn ui(&mut self, world: &mut World, ctx: &mut egui::Context, dt: f32) { apply_editor_theme(ctx); - let selected_before_ui = world.resource::().0; + let selected_before_ui: Vec = self.selected_entities.iter().collect(); let mode = *world.resource::>().get(); if self.last_mode_tab != Some(mode) { @@ -169,9 +215,15 @@ impl UiState { &self.selected_entities, &mut self.dock_state, &mut self.panel_nodes, + self.viewport_rect, ); - editor_toolbar_panel(world, root_ui, &mut self.selected_entities); + editor_toolbar_panel( + world, + root_ui, + &mut self.selected_entities, + self.viewport_rect, + ); status_bar_ui(world, root_ui, &self.selected_entities, mode); let mut viewer = TabViewer { @@ -233,11 +285,13 @@ impl UiState { world.resource_mut::().shortcuts_open = true; } + let pending_selection = world.resource_mut::().0.take(); let requested_selection = world.resource::().0; reconcile_operator_selection( &mut self.selected_entities, - selected_before_ui, + &selected_before_ui, requested_selection, + pending_selection.as_deref(), ); let selected = self.selected_entities.as_slice().first().copied(); world.resource_mut::().0 = selected; @@ -246,16 +300,24 @@ impl UiState { fn reconcile_operator_selection( selected_entities: &mut SelectedEntities, - selected_before_ui: Option, + selected_before_ui: &[Entity], requested_selection: Option, + pending_selection: Option<&[Entity]>, ) { - let selected_after_ui = selected_entities.as_slice().first().copied(); - if requested_selection == selected_before_ui || selected_after_ui != selected_before_ui { + if selected_entities.as_slice() != selected_before_ui { + return; + } + if let Some(entities) = pending_selection { + replace_selected_entities(selected_entities, entities); + return; + } + if requested_selection == selected_before_ui.first().copied() { return; } - selected_entities.clear(); if let Some(entity) = requested_selection { - selected_entities.select_replace(entity); + replace_selected_entities(selected_entities, &[entity]); + } else { + selected_entities.clear(); } } @@ -310,6 +372,7 @@ impl Plugin for EditorUiPlugin { .init_resource::() .init_resource::() .init_resource::() + .init_resource::() .init_resource::() .init_resource::() .init_resource::() @@ -476,7 +539,7 @@ mod tests { let mut selected = SelectedEntities::default(); selected.select_replace(before); - reconcile_operator_selection(&mut selected, Some(before), Some(spawned)); + reconcile_operator_selection(&mut selected, &[before], Some(spawned), None); assert_eq!(selected.as_slice(), &[spawned]); } @@ -489,8 +552,62 @@ mod tests { let mut selected = SelectedEntities::default(); selected.select_replace(clicked); - reconcile_operator_selection(&mut selected, Some(before), Some(requested)); + reconcile_operator_selection( + &mut selected, + &[before], + Some(requested), + Some(&[requested, Entity::from_bits(4)]), + ); assert_eq!(selected.as_slice(), &[clicked]); } + + #[test] + fn direct_ui_secondary_selection_change_beats_pending_operator_selection() { + let a = Entity::from_bits(1); + let b = Entity::from_bits(2); + let c = Entity::from_bits(3); + let d = Entity::from_bits(4); + let e = Entity::from_bits(5); + let mut selected = SelectedEntities::default(); + selected.select_replace(a); + selected.select_maybe_add(c, true); + + reconcile_operator_selection(&mut selected, &[a, b], Some(d), Some(&[d, e])); + + assert_eq!(selected.as_slice(), &[a, c]); + } + + #[test] + fn menu_scope_multi_selection_request_preserves_all_entities() { + let before = Entity::from_bits(1); + let first_duplicate = Entity::from_bits(2); + let second_duplicate = Entity::from_bits(3); + let mut world = World::new(); + world.init_resource::(); + world.init_resource::(); + world.resource_mut::().0 = Some(before); + + let mut ui_state = UiState::default_layout(); + ui_state.selected_entities.select_replace(before); + world.insert_resource(ui_state); + + world.resource_scope::(|world, mut ui_state| { + assert!(!world.contains_resource::()); + request_ui_selection(world, &[first_duplicate, second_duplicate]); + + let pending = world.resource_mut::().0.take(); + reconcile_operator_selection( + &mut ui_state.selected_entities, + &[before], + world.resource::().0, + pending.as_deref(), + ); + + assert_eq!( + ui_state.selected_entities.as_slice(), + &[first_duplicate, second_duplicate] + ); + }); + } } diff --git a/crates/editor/src/ui/navigation_inspector.rs b/crates/editor/src/ui/navigation_inspector.rs index 5478657..bb59c6d 100644 --- a/crates/editor/src/ui/navigation_inspector.rs +++ b/crates/editor/src/ui/navigation_inspector.rs @@ -858,10 +858,16 @@ pub enum NavigationActorType { Link, } -pub fn spawn_navigation_actor(world: &mut World, actor_type: NavigationActorType) -> Entity { - let rect = world.resource::().viewport_rect; +pub fn spawn_navigation_actor( + world: &mut World, + actor_type: NavigationActorType, + viewport_rect: egui::Rect, +) -> Entity { let settings = world.resource::().clone(); - let translation = snap_translation(editor_spawn_ground_position(world, rect), &settings); + let translation = snap_translation( + editor_spawn_ground_position(world, viewport_rect), + &settings, + ); let (name, bounds, obstacle, area, link) = match actor_type { NavigationActorType::Bounds => ( "Navigation Bounds", @@ -1019,8 +1025,49 @@ pub fn sync_navigation_state(world: &mut World) { #[cfg(test)] mod tests { use super::*; + use crate::history::EditorHistory; + use crate::scene_io::SceneIo; use crate::selection::SelectedEntity; + use crate::ui::{PendingUiSelection, UiState}; use scene::navigation::NavigationGeometryInput; + use shared::{ActorKind, LevelObject}; + + #[test] + fn navigation_spawn_works_while_ui_state_is_scoped_out_of_world() { + let mut world = World::new(); + world.init_resource::(); + world.init_resource::(); + world.init_resource::(); + world.init_resource::(); + world.init_resource::(); + + let mut ui_state = UiState::default_layout(); + ui_state.viewport_rect = + egui::Rect::from_min_size(egui::pos2(100.0, 80.0), egui::vec2(1280.0, 720.0)); + world.insert_resource(ui_state); + + let entity = world.resource_scope::(|world, mut ui_state| { + assert!(!world.contains_resource::()); + let entity = + spawn_navigation_actor(world, NavigationActorType::Bounds, ui_state.viewport_rect); + let pending = world.resource_mut::().0.take(); + super::super::reconcile_operator_selection( + &mut ui_state.selected_entities, + &[], + world.resource::().0, + pending.as_deref(), + ); + assert_eq!(ui_state.selected_entities.as_slice(), &[entity]); + entity + }); + + assert!(world.contains_resource::()); + assert!(world.get::(entity).is_some()); + assert!(world.get::(entity).is_some()); + assert_eq!(world.get::(entity), Some(&ActorKind::Navigation)); + assert_eq!(world.resource::().0, Some(entity)); + assert_eq!(world.resource::().undo_depth(), 1); + } #[test] fn world_affine_uses_current_authored_parent_chain() { diff --git a/crates/editor/src/ui/toolbar.rs b/crates/editor/src/ui/toolbar.rs index 80a9358..bdc0f05 100644 --- a/crates/editor/src/ui/toolbar.rs +++ b/crates/editor/src/ui/toolbar.rs @@ -8,7 +8,6 @@ use egui_phosphor_icons::icons; use crate::history::spawn_with_history; use crate::scene_io::{SceneIo, SceneIoRequest}; use crate::state::{EditorMode, PlayPaused, PlayPossession}; -use crate::ui::UiState; use crate::viewport::brush_tool::start_draw_brush_tool; use crate::viewport::{editor_spawn_ground_position, snap_translation, ViewportSettings}; use shared::PrimitiveShape; @@ -30,15 +29,21 @@ pub fn editor_toolbar_panel( world: &mut World, root_ui: &mut egui::Ui, selected: &mut SelectedEntities, + viewport_rect: egui::Rect, ) { egui::Panel::top("editor_toolbar") .exact_size(TOOLBAR_HEIGHT) .show_inside(root_ui, |ui| { - toolbar_ui(world, ui, selected); + toolbar_ui(world, ui, selected, viewport_rect); }); } -pub fn toolbar_ui(world: &mut World, ui: &mut egui::Ui, selected: &mut SelectedEntities) { +pub fn toolbar_ui( + world: &mut World, + ui: &mut egui::Ui, + selected: &mut SelectedEntities, + viewport_rect: egui::Rect, +) { let (toolbar_rect, _) = ui.allocate_exact_size( egui::vec2(ui.available_width(), TOOLBAR_HEIGHT - 2.0), egui::Sense::hover(), @@ -65,7 +70,7 @@ pub fn toolbar_ui(world: &mut World, ui: &mut egui::Ui, selected: &mut SelectedE .layout(egui::Layout::left_to_right(egui::Align::Center)), ); left_ui.spacing_mut().item_spacing.x = 6.0; - left_toolbar(world, &mut left_ui); + left_toolbar(world, &mut left_ui, viewport_rect); let mut transport_ui = ui.new_child( egui::UiBuilder::new() @@ -100,7 +105,7 @@ pub fn toolbar_ui(world: &mut World, ui: &mut egui::Ui, selected: &mut SelectedE ); } -fn left_toolbar(world: &mut World, ui: &mut egui::Ui) { +fn left_toolbar(world: &mut World, ui: &mut egui::Ui, viewport_rect: egui::Rect) { brand_mark_frame().show(ui, |ui| { ui.set_min_width(118.0); ui.set_min_height(30.0); @@ -126,16 +131,16 @@ fn left_toolbar(world: &mut World, ui: &mut egui::Ui) { toolbar_group_frame().show(ui, |ui| { panel_toolbar_row(ui, |ui| { if icon_button(ui, icons::CUBE, "Spawn cube").clicked() { - spawn_primitive_at_camera(world, PrimitiveShape::Box, "Cube"); + spawn_primitive_at_camera(world, PrimitiveShape::Box, "Cube", viewport_rect); } if icon_button(ui, icons::MOUNTAINS, "Spawn ramp").clicked() { - spawn_primitive_at_camera(world, PrimitiveShape::Ramp, "Ramp"); + spawn_primitive_at_camera(world, PrimitiveShape::Ramp, "Ramp", viewport_rect); } if icon_button(ui, icons::SPHERE, "Spawn sphere").clicked() { - spawn_primitive_at_camera(world, PrimitiveShape::Sphere, "Sphere"); + spawn_primitive_at_camera(world, PrimitiveShape::Sphere, "Sphere", viewport_rect); } if icon_button(ui, icons::LIGHTBULB, "Spawn point light").clicked() { - let pos = camera_ground_spawn(world); + let pos = camera_ground_spawn(world, viewport_rect); spawn_with_history(world, light_snapshot("Point Light", pos)); } if icon_button(ui, icons::PENCIL_SIMPLE_LINE, "Draw brush (B)").clicked() { @@ -157,6 +162,7 @@ fn left_toolbar(world: &mut World, ui: &mut egui::Ui) { super::navigation_inspector::spawn_navigation_actor( world, super::navigation_inspector::NavigationActorType::Bounds, + viewport_rect, ); } } @@ -232,14 +238,68 @@ fn transport_controls(world: &mut World, ui: &mut egui::Ui) { } } -fn camera_ground_spawn(world: &mut World) -> Vec3 { - let rect = world.resource::().viewport_rect; +fn camera_ground_spawn(world: &mut World, viewport_rect: egui::Rect) -> Vec3 { let settings = world.resource::().clone(); - let pos = editor_spawn_ground_position(world, rect); + let pos = editor_spawn_ground_position(world, viewport_rect); snap_translation(pos, &settings) } -fn spawn_primitive_at_camera(world: &mut World, shape: PrimitiveShape, name: &str) { - let pos = camera_ground_spawn(world); - spawn_with_history(world, primitive_snapshot(name, shape, pos)); +fn spawn_primitive_at_camera( + world: &mut World, + shape: PrimitiveShape, + name: &str, + viewport_rect: egui::Rect, +) -> Entity { + let pos = camera_ground_spawn(world, viewport_rect); + spawn_with_history(world, primitive_snapshot(name, shape, pos)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::history::EditorHistory; + use crate::scene_io::SceneIo; + use crate::selection::SelectedEntity; + use crate::ui::{PendingUiSelection, UiState}; + use shared::{LevelObject, Primitive}; + + #[test] + fn primitive_spawn_works_while_ui_state_is_scoped_out_of_world() { + let mut world = World::new(); + world.init_resource::(); + world.init_resource::(); + world.init_resource::(); + world.init_resource::(); + world.init_resource::(); + + let mut ui_state = UiState::default_layout(); + ui_state.viewport_rect = + egui::Rect::from_min_size(egui::pos2(100.0, 80.0), egui::vec2(1280.0, 720.0)); + world.insert_resource(ui_state); + + let entity = world.resource_scope::(|world, mut ui_state| { + assert!(!world.contains_resource::()); + let entity = spawn_primitive_at_camera( + world, + PrimitiveShape::Box, + "Cube", + ui_state.viewport_rect, + ); + let pending = world.resource_mut::().0.take(); + super::super::reconcile_operator_selection( + &mut ui_state.selected_entities, + &[], + world.resource::().0, + pending.as_deref(), + ); + assert_eq!(ui_state.selected_entities.as_slice(), &[entity]); + entity + }); + + assert!(world.contains_resource::()); + assert!(world.get::(entity).is_some()); + assert!(world.get::(entity).is_some()); + assert_eq!(world.resource::().0, Some(entity)); + assert_eq!(world.resource::().undo_depth(), 1); + } }