Fix scoped editor UI action state

This commit is contained in:
Rbanh 2026-07-13 06:39:30 -04:00
parent 4b33f32357
commit 9e23ae731f
9 changed files with 428 additions and 114 deletions

View File

@ -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 { pub trait ActorInspectorSection: Send + Sync {
fn id(&self) -> &str; fn id(&self) -> &str;
fn title(&self) -> &str; fn title(&self) -> &str;

View File

@ -19,7 +19,6 @@ use shared::{
use crate::operators::{ActiveOperator, OperatorPhase, OperatorStatus}; use crate::operators::{ActiveOperator, OperatorPhase, OperatorStatus};
use crate::scene_io::{SceneIo, SceneIoRequest}; use crate::scene_io::{SceneIo, SceneIoRequest};
use crate::selection::SelectedEntity;
use crate::state::scene_tools_active; use crate::state::scene_tools_active;
use crate::ui::hierarchy_ops::{ use crate::ui::hierarchy_ops::{
apply_sibling_change, apply_sibling_change_new, hierarchy_drop_violation, next_sibling_index, 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<Entity>) {
} }
fn select_one(world: &mut World, entity: Entity) { fn select_one(world: &mut World, entity: Entity) {
if let Some(mut ui_state) = world.get_resource_mut::<UiState>() { crate::ui::request_ui_selection(world, &[entity]);
ui_state.selected_entities.clear();
ui_state.selected_entities.select_replace(entity);
}
world.resource_mut::<SelectedEntity>().0 = Some(entity);
} }
fn select_many(world: &mut World, entities: &[Entity]) { fn select_many(world: &mut World, entities: &[Entity]) {
if let Some(mut ui_state) = world.get_resource_mut::<UiState>() { crate::ui::request_ui_selection(world, entities);
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::<SelectedEntity>().0 = entities.first().copied();
} }
pub fn clear_selection(world: &mut World) { pub fn clear_selection(world: &mut World) {
if let Some(mut ui_state) = world.get_resource_mut::<UiState>() { crate::ui::request_ui_selection(world, &[]);
ui_state.selected_entities.clear();
}
world.resource_mut::<SelectedEntity>().0 = None;
} }
pub fn is_level_object(world: &World, entity: Entity) -> bool { pub fn is_level_object(world: &World, entity: Entity) -> bool {
@ -2939,6 +2921,28 @@ mod tests {
use super::*; use super::*;
use crate::operators::test_harness::{assert_undo_redo_round_trip, OperatorInvariantHarness}; use crate::operators::test_harness::{assert_undo_redo_round_trip, OperatorInvariantHarness};
use crate::operators::{ActiveOperator, OperatorPhase}; 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::<SelectedEntity>();
world.init_resource::<PendingUiSelection>();
world.insert_resource(UiState::default_layout());
world.resource_scope::<UiState, _>(|world, _ui_state| {
assert!(!world.contains_resource::<UiState>());
select_many(world, &[first, second]);
assert_eq!(
world.resource::<PendingUiSelection>().0.as_deref(),
Some([first, second].as_slice())
);
assert_eq!(world.resource::<SelectedEntity>().0, Some(first));
});
}
#[derive(Component, Reflect, Default, Debug, Clone, PartialEq)] #[derive(Component, Reflect, Default, Debug, Clone, PartialEq)]
#[reflect(Component, Default)] #[reflect(Component, Default)]

View File

@ -47,6 +47,11 @@ pub struct EditorComponentRegistry {
inspectors: HashMap<&'static str, ComponentInspectorFn>, 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); pub type ComponentInspectorFn = fn(&mut World, &mut egui::Ui, Entity);
impl EditorComponentRegistry { impl EditorComponentRegistry {

View File

@ -13,8 +13,6 @@ use crate::history::set_brush_with_history;
use crate::history::EditorHistory; use crate::history::EditorHistory;
use crate::project_io::ProjectWorkspace; use crate::project_io::ProjectWorkspace;
use crate::scene_io::{SceneIo, SceneIoEventSeverity}; use crate::scene_io::{SceneIo, SceneIoEventSeverity};
use crate::selection::SelectedEntity;
use crate::ui::UiState;
use crate::viewport::collider_diagnostics::{ use crate::viewport::collider_diagnostics::{
ColliderDiagnosticSeverity, ColliderDiagnosticsState, ColliderOverlayStatus, ColliderDiagnosticSeverity, ColliderDiagnosticsState, ColliderOverlayStatus,
}; };
@ -123,11 +121,7 @@ pub fn diagnostics_ui(world: &mut World, ui: &mut egui::Ui) {
.iter(world) .iter(world)
.find_map(|(entity, id)| (id.0 == actor_id).then_some(entity)) .find_map(|(entity, id)| (id.0 == actor_id).then_some(entity))
{ {
world.resource_mut::<SelectedEntity>().0 = Some(entity); request_diagnostic_selection(world, entity);
world
.resource_mut::<UiState>()
.selected_entities
.select_replace(entity);
} }
} }
} }
@ -250,11 +244,7 @@ fn collider_diagnostics_ui(world: &mut World, ui: &mut egui::Ui) {
ui.colored_label(color, row.shape_label); ui.colored_label(color, row.shape_label);
ui.label(&row.actor_name); ui.label(&row.actor_name);
if ui.small_button("Select").clicked() { if ui.small_button("Select").clicked() {
world.resource_mut::<SelectedEntity>().0 = Some(row.entity); request_diagnostic_selection(world, row.entity);
world
.resource_mut::<UiState>()
.selected_entities
.select_replace(row.entity);
} }
}); });
for diagnostic in &row.diagnostics { 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.colored_label(color, row.status_label());
ui.label(row.name.as_str()); ui.label(row.name.as_str());
if ui.button("Select").clicked() { if ui.button("Select").clicked() {
world.resource_mut::<SelectedEntity>().0 = Some(row.entity); request_diagnostic_selection(world, row.entity);
world
.resource_mut::<UiState>()
.selected_entities
.select_replace(row.entity);
} }
if ui if ui
.add_enabled(row.error_count > 0, egui::Button::new("Reset Cube")) .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)] #[derive(Debug)]
struct BrushDiagnosticRow { struct BrushDiagnosticRow {
entity: Entity, entity: Entity,
@ -397,3 +387,37 @@ pub fn diagnostics_window(world: &mut World, ctx: &egui::Context, open: &mut boo
diagnostics_ui(world, ui); 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::<SelectedEntity>();
world.init_resource::<PendingUiSelection>();
world.resource_mut::<SelectedEntity>().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::<UiState, _>(|world, mut ui_state| {
assert!(!world.contains_resource::<UiState>());
request_diagnostic_selection(world, requested);
let pending = world.resource_mut::<PendingUiSelection>().0.take();
super::super::reconcile_operator_selection(
&mut ui_state.selected_entities,
&[before],
world.resource::<SelectedEntity>().0,
pending.as_deref(),
);
assert_eq!(ui_state.selected_entities.as_slice(), &[requested]);
});
}
}

View File

@ -37,7 +37,6 @@ use crate::history::{
set_primitive_with_history, set_rigid_body_with_history, set_static_mesh_renderer_with_history, set_primitive_with_history, set_rigid_body_with_history, set_static_mesh_renderer_with_history,
EditorEntitySnapshot, EditorEntitySnapshot,
}; };
use crate::selection::SelectedEntity;
use crate::ui::theme::{ use crate::ui::theme::{
panel_heading, BORDER, ELEVATED_BG, PANEL_BG_DARK, SELECTION_BG_MUTED, TEXT_DIM, TEXT_MUTED, panel_heading, BORDER, ELEVATED_BG, PANEL_BG_DARK, SELECTION_BG_MUTED, TEXT_DIM, TEXT_MUTED,
WIDGET_BG, WIDGET_BG,
@ -51,9 +50,8 @@ use crate::viewport::collider_diagnostics::{
use super::component_registry::{ use super::component_registry::{
EditorComponentCategory, EditorComponentDescriptor, EditorComponentRegistry, EditorComponentCategory, EditorComponentDescriptor, EditorComponentRegistry,
}; };
use super::dock_tabs::open_and_focus_tab;
use super::helpers::create_scene_sun_override_from_project_settings; 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::asset_db::{find_asset_by_path, AssetRegistry};
use crate::assets::static_mesh::{ use crate::assets::static_mesh::{
load_static_mesh_manifest, material_id_from_label, part_id_from_label, 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_scroll_selected: bool,
add_component_selected_index: usize, add_component_selected_index: usize,
add_component_target: Option<Entity>, add_component_target: Option<Entity>,
collapsed_components: HashSet<String>,
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
@ -276,13 +275,9 @@ pub(crate) fn component_card_context(
state_key, state_key,
); );
let collapsed = world let collapsed = world
.get_resource::<UiState>() .resource::<InspectorPanelState>()
.map(|state| { .collapsed_components
state .contains(&component_card_key(world, entity, options.type_name));
.inspector_collapsed_components
.contains(&component_card_key(world, entity, options.type_name))
})
.unwrap_or(false);
let pasteable = options.copyable let pasteable = options.copyable
&& world && world
.get_resource::<InspectorClipboard>() .get_resource::<InspectorClipboard>()
@ -326,12 +321,11 @@ fn apply_component_card_response_for_type(
) { ) {
if let Some(collapsed) = response.collapsed { if let Some(collapsed) = response.collapsed {
let key = component_card_key(world, entity, type_name); let key = component_card_key(world, entity, type_name);
if let Some(mut ui_state) = world.get_resource_mut::<UiState>() { let mut panel_state = world.resource_mut::<InspectorPanelState>();
if collapsed { if collapsed {
ui_state.inspector_collapsed_components.insert(key); panel_state.collapsed_components.insert(key);
} else { } else {
ui_state.inspector_collapsed_components.remove(&key); panel_state.collapsed_components.remove(&key);
}
} }
} }
@ -2362,18 +2356,15 @@ fn locate_asset_ref(
return; 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::<EditorAssets>() { if let Some(mut assets) = world.get_resource_mut::<EditorAssets>() {
assets.current_folder = candidate.folder_path.clone(); assets.current_folder = folder_path.to_string();
assets.select(candidate.selection.clone()); assets.select(selection.clone());
}
if let Some(mut ui_state) = world.get_resource_mut::<UiState>() {
let panel_nodes = ui_state.panel_nodes;
open_and_focus_tab(
&mut ui_state.dock_state,
EditorTab::AssetBrowser,
&panel_nodes,
);
} }
super::request_editor_tab(world, EditorTab::AssetBrowser);
} }
fn skinned_mesh_renderer_ui(world: &mut World, ui: &mut egui::Ui, entity: Entity) { 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 { if create_override {
let sun = create_scene_sun_override_from_project_settings(world); let sun = create_scene_sun_override_from_project_settings(world);
world.resource_mut::<SelectedEntity>().0 = Some(sun); crate::ui::request_ui_selection(world, &[sun]);
if let Some(mut ui_state) = world.get_resource_mut::<crate::ui::UiState>() {
ui_state.selected_entities.select_replace(sun);
}
} }
} }
@ -4881,18 +4869,7 @@ fn locate_texture_asset(
return; return;
}; };
if let Some(mut assets) = world.get_resource_mut::<EditorAssets>() { reveal_asset_in_browser(world, &candidate.folder_path, &candidate.selection);
assets.current_folder = candidate.folder_path.clone();
assets.select(candidate.selection.clone());
}
if let Some(mut ui_state) = world.get_resource_mut::<UiState>() {
let panel_nodes = ui_state.panel_nodes;
open_and_focus_tab(
&mut ui_state.dock_state,
EditorTab::AssetBrowser,
&panel_nodes,
);
}
} }
fn option_string_ui(ui: &mut egui::Ui, label: &str, value: &mut Option<String>) -> bool { fn option_string_ui(ui: &mut egui::Ui, label: &str, value: &mut Option<String>) -> bool {
@ -4924,6 +4901,80 @@ fn option_string_ui(ui: &mut egui::Ui, label: &str, value: &mut Option<String>)
mod collider_inspector_tests { mod collider_inspector_tests {
use super::*; use super::*;
use crate::history::{apply_command_undo, EditorHistory}; 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::<EditorComponentRegistry>();
world.init_resource::<InspectorPanelState>();
let entity = world
.spawn((ActorId::new("collapsed-component"), Transform::IDENTITY))
.id();
world.insert_resource(UiState::default_layout());
world.resource_scope::<UiState, _>(|world, _ui_state| {
assert!(!world.contains_resource::<UiState>());
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::<DockTabRequest>();
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::<UiState, _>(|world, _ui_state| {
assert!(!world.contains_resource::<UiState>());
locate_texture_asset(world, Some(path), &[candidate]);
});
let assets = world.resource::<EditorAssets>();
assert_eq!(assets.current_folder, folder);
assert_eq!(assets.selected.as_ref(), Some(&selection));
assert_eq!(
world.resource::<DockTabRequest>().0,
Some(EditorTab::AssetBrowser)
);
}
#[test] #[test]
fn shape_switch_preserves_dimensions_and_is_one_undoable_edit() { fn shape_switch_preserves_dimensions_and_is_one_undoable_edit() {

View File

@ -40,6 +40,7 @@ pub fn top_menu_bar(
selected: &SelectedEntities, selected: &SelectedEntities,
dock_state: &mut DockState<EditorTab>, dock_state: &mut DockState<EditorTab>,
panel_nodes: &mut PanelNodes, panel_nodes: &mut PanelNodes,
viewport_rect: egui::Rect,
) { ) {
egui::Panel::top("editor_menu_bar").show_inside(root_ui, |ui| { egui::Panel::top("editor_menu_bar").show_inside(root_ui, |ui| {
egui::MenuBar::new().ui(ui, |ui| { egui::MenuBar::new().ui(ui, |ui| {
@ -209,7 +210,7 @@ pub fn top_menu_bar(
("Create Link", NavigationActorType::Link), ("Create Link", NavigationActorType::Link),
] { ] {
if menu_item(ui, label, None, true).clicked() { if menu_item(ui, label, None, true).clicked() {
spawn_navigation_actor(world, actor_type); spawn_navigation_actor(world, actor_type, viewport_rect);
ui.close(); ui.close();
} }
} }

View File

@ -26,8 +26,6 @@ mod toolbar;
mod viewport_chrome; mod viewport_chrome;
mod widgets; mod widgets;
use std::collections::HashSet;
use bevy::prelude::*; use bevy::prelude::*;
use bevy::window::{CursorGrabMode, CursorOptions, PrimaryWindow}; use bevy::window::{CursorGrabMode, CursorOptions, PrimaryWindow};
use bevy_egui::{egui, EguiContext, EguiPrimaryContextPass, PrimaryEguiContext}; use bevy_egui::{egui, EguiContext, EguiPrimaryContextPass, PrimaryEguiContext};
@ -71,7 +69,6 @@ pub struct UiState {
pub selected_entities: SelectedEntities, pub selected_entities: SelectedEntities,
pub renaming_entity: Option<Entity>, pub renaming_entity: Option<Entity>,
pub rename_buffer: String, pub rename_buffer: String,
pub(crate) inspector_collapsed_components: HashSet<String>,
pub(crate) panel_nodes: PanelNodes, pub(crate) panel_nodes: PanelNodes,
last_mode_tab: Option<EditorMode>, last_mode_tab: Option<EditorMode>,
} }
@ -79,6 +76,56 @@ pub struct UiState {
#[derive(Resource, Default)] #[derive(Resource, Default)]
struct DockTabRequest(Option<EditorTab>); struct DockTabRequest(Option<EditorTab>);
#[derive(Resource, Default, Debug)]
pub(crate) struct PendingUiSelection(pub(crate) Option<Vec<Entity>>);
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::<SelectedEntity>() else {
return false;
};
selected.0 = entities.first().copied();
}
if world.contains_resource::<UiState>() {
{
let mut ui_state = world.resource_mut::<UiState>();
replace_selected_entities(&mut ui_state.selected_entities, entities);
}
if let Some(mut pending) = world.get_resource_mut::<PendingUiSelection>() {
pending.0 = None;
}
} else if let Some(mut pending) = world.get_resource_mut::<PendingUiSelection>() {
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::<DockTabRequest>() else {
return false;
};
request.0 = Some(tab);
true
}
pub fn egui_captures_keyboard(ctx: &egui::Context) -> bool { pub fn egui_captures_keyboard(ctx: &egui::Context) -> bool {
ctx.egui_wants_keyboard_input() ctx.egui_wants_keyboard_input()
} }
@ -126,7 +173,6 @@ impl UiState {
selected_entities: SelectedEntities::default(), selected_entities: SelectedEntities::default(),
renaming_entity: None, renaming_entity: None,
rename_buffer: String::new(), rename_buffer: String::new(),
inspector_collapsed_components: HashSet::new(),
panel_nodes, panel_nodes,
last_mode_tab: None, last_mode_tab: None,
} }
@ -138,7 +184,7 @@ impl UiState {
)] )]
fn ui(&mut self, world: &mut World, ctx: &mut egui::Context, dt: f32) { fn ui(&mut self, world: &mut World, ctx: &mut egui::Context, dt: f32) {
apply_editor_theme(ctx); apply_editor_theme(ctx);
let selected_before_ui = world.resource::<SelectedEntity>().0; let selected_before_ui: Vec<Entity> = self.selected_entities.iter().collect();
let mode = *world.resource::<State<EditorMode>>().get(); let mode = *world.resource::<State<EditorMode>>().get();
if self.last_mode_tab != Some(mode) { if self.last_mode_tab != Some(mode) {
@ -169,9 +215,15 @@ impl UiState {
&self.selected_entities, &self.selected_entities,
&mut self.dock_state, &mut self.dock_state,
&mut self.panel_nodes, &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); status_bar_ui(world, root_ui, &self.selected_entities, mode);
let mut viewer = TabViewer { let mut viewer = TabViewer {
@ -233,11 +285,13 @@ impl UiState {
world.resource_mut::<ViewportUiState>().shortcuts_open = true; world.resource_mut::<ViewportUiState>().shortcuts_open = true;
} }
let pending_selection = world.resource_mut::<PendingUiSelection>().0.take();
let requested_selection = world.resource::<SelectedEntity>().0; let requested_selection = world.resource::<SelectedEntity>().0;
reconcile_operator_selection( reconcile_operator_selection(
&mut self.selected_entities, &mut self.selected_entities,
selected_before_ui, &selected_before_ui,
requested_selection, requested_selection,
pending_selection.as_deref(),
); );
let selected = self.selected_entities.as_slice().first().copied(); let selected = self.selected_entities.as_slice().first().copied();
world.resource_mut::<SelectedEntity>().0 = selected; world.resource_mut::<SelectedEntity>().0 = selected;
@ -246,16 +300,24 @@ impl UiState {
fn reconcile_operator_selection( fn reconcile_operator_selection(
selected_entities: &mut SelectedEntities, selected_entities: &mut SelectedEntities,
selected_before_ui: Option<Entity>, selected_before_ui: &[Entity],
requested_selection: Option<Entity>, requested_selection: Option<Entity>,
pending_selection: Option<&[Entity]>,
) { ) {
let selected_after_ui = selected_entities.as_slice().first().copied(); if selected_entities.as_slice() != selected_before_ui {
if requested_selection == selected_before_ui || selected_after_ui != 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; return;
} }
selected_entities.clear();
if let Some(entity) = requested_selection { 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::<material_library::MaterialLibraryState>() .init_resource::<material_library::MaterialLibraryState>()
.init_resource::<material_library::MaterialLibraryCatalog>() .init_resource::<material_library::MaterialLibraryCatalog>()
.init_resource::<DockTabRequest>() .init_resource::<DockTabRequest>()
.init_resource::<PendingUiSelection>()
.init_resource::<inspector::InspectorClipboard>() .init_resource::<inspector::InspectorClipboard>()
.init_resource::<inspector::InspectorPanelState>() .init_resource::<inspector::InspectorPanelState>()
.init_resource::<animation_inspector::AnimationInspectorState>() .init_resource::<animation_inspector::AnimationInspectorState>()
@ -476,7 +539,7 @@ mod tests {
let mut selected = SelectedEntities::default(); let mut selected = SelectedEntities::default();
selected.select_replace(before); 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]); assert_eq!(selected.as_slice(), &[spawned]);
} }
@ -489,8 +552,62 @@ mod tests {
let mut selected = SelectedEntities::default(); let mut selected = SelectedEntities::default();
selected.select_replace(clicked); 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]); 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::<SelectedEntity>();
world.init_resource::<PendingUiSelection>();
world.resource_mut::<SelectedEntity>().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::<UiState, _>(|world, mut ui_state| {
assert!(!world.contains_resource::<UiState>());
request_ui_selection(world, &[first_duplicate, second_duplicate]);
let pending = world.resource_mut::<PendingUiSelection>().0.take();
reconcile_operator_selection(
&mut ui_state.selected_entities,
&[before],
world.resource::<SelectedEntity>().0,
pending.as_deref(),
);
assert_eq!(
ui_state.selected_entities.as_slice(),
&[first_duplicate, second_duplicate]
);
});
}
} }

View File

@ -858,10 +858,16 @@ pub enum NavigationActorType {
Link, Link,
} }
pub fn spawn_navigation_actor(world: &mut World, actor_type: NavigationActorType) -> Entity { pub fn spawn_navigation_actor(
let rect = world.resource::<crate::ui::UiState>().viewport_rect; world: &mut World,
actor_type: NavigationActorType,
viewport_rect: egui::Rect,
) -> Entity {
let settings = world.resource::<ViewportSettings>().clone(); let settings = world.resource::<ViewportSettings>().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 { let (name, bounds, obstacle, area, link) = match actor_type {
NavigationActorType::Bounds => ( NavigationActorType::Bounds => (
"Navigation Bounds", "Navigation Bounds",
@ -1019,8 +1025,49 @@ pub fn sync_navigation_state(world: &mut World) {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::history::EditorHistory;
use crate::scene_io::SceneIo;
use crate::selection::SelectedEntity; use crate::selection::SelectedEntity;
use crate::ui::{PendingUiSelection, UiState};
use scene::navigation::NavigationGeometryInput; 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::<EditorHistory>();
world.init_resource::<SceneIo>();
world.init_resource::<SelectedEntity>();
world.init_resource::<PendingUiSelection>();
world.init_resource::<ViewportSettings>();
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::<UiState, _>(|world, mut ui_state| {
assert!(!world.contains_resource::<UiState>());
let entity =
spawn_navigation_actor(world, NavigationActorType::Bounds, ui_state.viewport_rect);
let pending = world.resource_mut::<PendingUiSelection>().0.take();
super::super::reconcile_operator_selection(
&mut ui_state.selected_entities,
&[],
world.resource::<SelectedEntity>().0,
pending.as_deref(),
);
assert_eq!(ui_state.selected_entities.as_slice(), &[entity]);
entity
});
assert!(world.contains_resource::<UiState>());
assert!(world.get::<LevelObject>(entity).is_some());
assert!(world.get::<NavigationBounds>(entity).is_some());
assert_eq!(world.get::<ActorKind>(entity), Some(&ActorKind::Navigation));
assert_eq!(world.resource::<SelectedEntity>().0, Some(entity));
assert_eq!(world.resource::<EditorHistory>().undo_depth(), 1);
}
#[test] #[test]
fn world_affine_uses_current_authored_parent_chain() { fn world_affine_uses_current_authored_parent_chain() {

View File

@ -8,7 +8,6 @@ use egui_phosphor_icons::icons;
use crate::history::spawn_with_history; use crate::history::spawn_with_history;
use crate::scene_io::{SceneIo, SceneIoRequest}; use crate::scene_io::{SceneIo, SceneIoRequest};
use crate::state::{EditorMode, PlayPaused, PlayPossession}; use crate::state::{EditorMode, PlayPaused, PlayPossession};
use crate::ui::UiState;
use crate::viewport::brush_tool::start_draw_brush_tool; use crate::viewport::brush_tool::start_draw_brush_tool;
use crate::viewport::{editor_spawn_ground_position, snap_translation, ViewportSettings}; use crate::viewport::{editor_spawn_ground_position, snap_translation, ViewportSettings};
use shared::PrimitiveShape; use shared::PrimitiveShape;
@ -30,15 +29,21 @@ pub fn editor_toolbar_panel(
world: &mut World, world: &mut World,
root_ui: &mut egui::Ui, root_ui: &mut egui::Ui,
selected: &mut SelectedEntities, selected: &mut SelectedEntities,
viewport_rect: egui::Rect,
) { ) {
egui::Panel::top("editor_toolbar") egui::Panel::top("editor_toolbar")
.exact_size(TOOLBAR_HEIGHT) .exact_size(TOOLBAR_HEIGHT)
.show_inside(root_ui, |ui| { .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( let (toolbar_rect, _) = ui.allocate_exact_size(
egui::vec2(ui.available_width(), TOOLBAR_HEIGHT - 2.0), egui::vec2(ui.available_width(), TOOLBAR_HEIGHT - 2.0),
egui::Sense::hover(), 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)), .layout(egui::Layout::left_to_right(egui::Align::Center)),
); );
left_ui.spacing_mut().item_spacing.x = 6.0; 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( let mut transport_ui = ui.new_child(
egui::UiBuilder::new() 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| { brand_mark_frame().show(ui, |ui| {
ui.set_min_width(118.0); ui.set_min_width(118.0);
ui.set_min_height(30.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| { toolbar_group_frame().show(ui, |ui| {
panel_toolbar_row(ui, |ui| { panel_toolbar_row(ui, |ui| {
if icon_button(ui, icons::CUBE, "Spawn cube").clicked() { 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() { 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() { 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() { 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)); spawn_with_history(world, light_snapshot("Point Light", pos));
} }
if icon_button(ui, icons::PENCIL_SIMPLE_LINE, "Draw brush (B)").clicked() { 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( super::navigation_inspector::spawn_navigation_actor(
world, world,
super::navigation_inspector::NavigationActorType::Bounds, 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 { fn camera_ground_spawn(world: &mut World, viewport_rect: egui::Rect) -> Vec3 {
let rect = world.resource::<UiState>().viewport_rect;
let settings = world.resource::<ViewportSettings>().clone(); let settings = world.resource::<ViewportSettings>().clone();
let pos = editor_spawn_ground_position(world, rect); let pos = editor_spawn_ground_position(world, viewport_rect);
snap_translation(pos, &settings) snap_translation(pos, &settings)
} }
fn spawn_primitive_at_camera(world: &mut World, shape: PrimitiveShape, name: &str) { fn spawn_primitive_at_camera(
let pos = camera_ground_spawn(world); world: &mut World,
spawn_with_history(world, primitive_snapshot(name, shape, pos)); 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::<EditorHistory>();
world.init_resource::<SceneIo>();
world.init_resource::<SelectedEntity>();
world.init_resource::<PendingUiSelection>();
world.init_resource::<ViewportSettings>();
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::<UiState, _>(|world, mut ui_state| {
assert!(!world.contains_resource::<UiState>());
let entity = spawn_primitive_at_camera(
world,
PrimitiveShape::Box,
"Cube",
ui_state.viewport_rect,
);
let pending = world.resource_mut::<PendingUiSelection>().0.take();
super::super::reconcile_operator_selection(
&mut ui_state.selected_entities,
&[],
world.resource::<SelectedEntity>().0,
pending.as_deref(),
);
assert_eq!(ui_state.selected_entities.as_slice(), &[entity]);
entity
});
assert!(world.contains_resource::<UiState>());
assert!(world.get::<LevelObject>(entity).is_some());
assert!(world.get::<Primitive>(entity).is_some());
assert_eq!(world.resource::<SelectedEntity>().0, Some(entity));
assert_eq!(world.resource::<EditorHistory>().undo_depth(), 1);
}
} }