//! Editor extensibility: command registry, inspector sections, and extension hooks. use bevy::prelude::*; use bevy_egui::{egui, EguiContexts, EguiPrimaryContextPass}; use crate::history::group_selection_with_history; use crate::operators::{run_operator_action, OperatorAction, OperatorAvailability}; use crate::state::{EditorMode, PlayPossession}; use crate::ui::helpers::{ reset_scene_lighting_to_project_defaults, toggle_play_mode, toggle_play_paused, }; use crate::ui::selection_ops::{focus_editor_camera_on_selection, reset_selected_transforms}; use crate::ui::theme::{apply_editor_theme, PANEL_BG, TEXT, TEXT_DIM}; use crate::ui::UiState; use crate::viewport::brush_tool::start_draw_brush_tool; use crate::viewport::{ intersect_selected_brushes, merge_selected_brushes, selected_brush_count, subtract_selected_brushes, }; /// Named editor command invokable from the palette and BRP. pub trait EditorCommand: Send + Sync { fn name(&self) -> &str; fn label(&self) -> &str { self.name() } fn disabled_reason(&self, _world: &World) -> Option { None } fn execute(&self, world: &mut World) -> Result; } #[derive(Resource, Default)] pub struct EditorCommandRegistry { commands: Vec>, } #[derive(Debug, Clone, PartialEq, Eq)] pub struct EditorCommandEntry { pub name: String, pub label: String, } impl EditorCommandRegistry { pub fn register(&mut self, command: Box) { self.commands.push(command); } pub fn names(&self) -> Vec { self.commands .iter() .map(|cmd| cmd.name().to_string()) .collect() } pub fn entries(&self) -> Vec { self.commands .iter() .map(|command| EditorCommandEntry { name: command.name().to_string(), label: command.label().to_string(), }) .collect() } pub fn command_state(&self, world: &World, name: &str) -> Option<(String, Option)> { self.commands .iter() .find(|cmd| cmd.name() == name) .map(|cmd| (cmd.label().to_string(), cmd.disabled_reason(world))) } pub fn run(&self, world: &mut World, name: &str) -> Option> { self.commands .iter() .find(|command| command.name() == name) .map(|command| command.execute(world)) } } /// Custom inspector body for level objects (game crates register via [`register_actor_inspector_section`]). pub trait ActorInspectorSection: Send + Sync { fn id(&self) -> &str; fn title(&self) -> &str; fn order(&self) -> i32; fn applies_to(&self, world: &World, entity: Entity) -> bool; fn ui(&self, world: &mut World, ui: &mut egui::Ui, entity: Entity); } #[derive(Resource, Default)] pub struct ActorInspectorSectionRegistry { sections: Vec>, } impl ActorInspectorSectionRegistry { pub fn register(&mut self, section: Box) { self.sections.push(section); } pub fn sections_for(&self, world: &World, entity: Entity) -> Vec<&dyn ActorInspectorSection> { let mut matched: Vec<_> = self .sections .iter() .filter(|section| section.applies_to(world, entity)) .map(|section| section.as_ref()) .collect(); matched.sort_by_key(|section| section.order()); matched } pub fn draw_sections(&self, world: &mut World, ui: &mut egui::Ui, entity: Entity) { let mut matched: Vec<_> = self .sections .iter() .filter(|section| section.applies_to(world, entity)) .collect(); matched.sort_by_key(|section| section.order()); for section in matched { section.ui(world, ui, entity); } } } pub fn register_actor_inspector_section(app: &mut App, section: Box) { if !app .world() .contains_resource::() { app.init_resource::(); } app.world_mut() .resource_mut::() .register(section); } /// Optional extension point for registering editor panels and menu hooks. pub trait EditorExtension: Send + Sync { fn name(&self) -> &str; fn register(&self, _app: &mut App) {} } /// Formal editor plugin surface for game-specific panels (H5). pub trait EditorPlugin: Send + Sync { fn name(&self) -> &str; fn register(&self, app: &mut App); } #[derive(Resource, Default)] pub struct EditorExtensions { pub extensions: Vec>, } #[derive(Resource, Default)] pub struct RegisteredEditorPlugins { pub plugins: Vec>, } #[derive(Resource, Default)] pub struct CommandPalette { pub open: bool, pub filter: String, pub pending_run: Option, pub focus_filter: bool, pub selected_index: usize, } pub struct ExtensibilityPlugin; impl Plugin for ExtensibilityPlugin { fn build(&self, app: &mut App) { app.init_resource::() .init_resource::() .init_resource::() .init_resource::() .init_resource::() .init_resource::() .add_systems(Startup, register_builtin_commands) .add_systems(Update, run_palette_commands) .add_systems(EguiPrimaryContextPass, command_palette_ui); } } pub fn register_editor_plugin(app: &mut App, plugin: Box) { plugin.register(app); app.world_mut() .resource_mut::() .plugins .push(plugin); } fn register_builtin_commands(mut registry: ResMut) { populate_builtin_commands(&mut registry); } fn populate_builtin_commands(registry: &mut EditorCommandRegistry) { registry.register(Box::new(TogglePlayCommand)); registry.register(Box::new(TogglePlayPausedCommand)); registry.register(Box::new(TogglePossessionCommand)); registry.register(Box::new(ResetLightingCommand)); registry.register(Box::new(GroupSelectionCommand)); registry.register(Box::new(FocusSelectionCommand)); registry.register(Box::new(ResetSelectionTransformCommand)); registry.register(Box::new(CreatePostProcessVolumeCommand)); registry.register(Box::new(FocusActiveVolumesCommand)); registry.register(Box::new(SelectVolumesAtCameraCommand)); registry.register(Box::new(DrawBrushCommand)); registry.register(Box::new(IntersectBrushesCommand)); registry.register(Box::new(MergeBrushesCommand)); registry.register(Box::new(SubtractBrushesCommand)); } fn run_palette_commands(world: &mut World) { let pending = world.resource_mut::().pending_run.take(); if let Some(name) = pending { dispatch_editor_command(world, &name); } let names = std::mem::take( &mut world .resource_mut::() .queue, ); for name in names { dispatch_editor_command(world, &name); } } fn dispatch_editor_command(world: &mut World, name: &str) { let Some((label, disabled_reason)) = world.resource_scope(|world, registry: Mut| { registry.command_state(world, name) }) else { warn!("unknown editor command: {name}"); return; }; let name_for_commit = name.to_string(); let name_for_status = name.to_string(); let label_for_commit = label.clone(); run_operator_action( world, &name_for_status, &label, match disabled_reason { Some(reason) => OperatorAvailability::Disabled(reason), None => OperatorAvailability::Ready, }, move |world| { let result = world.resource_scope(|world, registry: Mut| { registry.run(world, &name_for_commit) }); result.unwrap_or_else(|| Err(format!("Unknown editor command: {name_for_commit}"))) }, ); trace!("dispatched editor operator command {label_for_commit}"); } struct TogglePlayCommand; impl EditorCommand for TogglePlayCommand { fn name(&self) -> &str { "play.toggle" } fn label(&self) -> &str { "Toggle Play / Edit" } fn execute(&self, world: &mut World) -> Result { toggle_play_mode(world); Ok(OperatorAction::Commit) } } struct TogglePlayPausedCommand; impl EditorCommand for TogglePlayPausedCommand { fn name(&self) -> &str { "play.toggle_pause" } fn label(&self) -> &str { "Pause / Resume Simulation" } fn disabled_reason(&self, world: &World) -> Option { (*world.resource::>().get() != EditorMode::Playing) .then(|| "Enter Play mode before pausing the simulation".to_string()) } fn execute(&self, world: &mut World) -> Result { toggle_play_paused(world); Ok(OperatorAction::Commit) } } struct TogglePossessionCommand; impl EditorCommand for TogglePossessionCommand { fn name(&self) -> &str { "play.toggle_possession" } fn label(&self) -> &str { "Possess / Eject Player" } fn disabled_reason(&self, world: &World) -> Option { (*world.resource::>().get() != EditorMode::Playing) .then(|| "Enter Play mode before toggling possession".to_string()) } fn execute(&self, world: &mut World) -> Result { let next = match *world.resource::() { PlayPossession::Possessed => PlayPossession::Ejected, PlayPossession::Ejected => PlayPossession::Possessed, }; *world.resource_mut::() = next; Ok(OperatorAction::Commit) } } struct ResetLightingCommand; impl EditorCommand for ResetLightingCommand { fn name(&self) -> &str { "scene.reset_lighting" } fn label(&self) -> &str { "Reset Scene Lighting" } fn execute(&self, world: &mut World) -> Result { reset_scene_lighting_to_project_defaults(world); Ok(OperatorAction::Commit) } } struct GroupSelectionCommand; impl EditorCommand for GroupSelectionCommand { fn name(&self) -> &str { "selection.group" } fn label(&self) -> &str { "Group Selection" } fn disabled_reason(&self, world: &World) -> Option { world .resource::() .selected_entities .as_slice() .is_empty() .then(|| "Select at least one actor to group".to_string()) } fn execute(&self, world: &mut World) -> Result { let selected: Vec = world .resource::() .selected_entities .iter() .collect(); group_selection_with_history(world, &selected); Ok(OperatorAction::Commit) } } struct FocusSelectionCommand; impl EditorCommand for FocusSelectionCommand { fn name(&self) -> &str { "selection.focus" } fn label(&self) -> &str { "Focus Selection" } fn execute(&self, world: &mut World) -> Result { focus_editor_camera_on_selection(world); Ok(OperatorAction::Commit) } } struct ResetSelectionTransformCommand; impl EditorCommand for ResetSelectionTransformCommand { fn name(&self) -> &str { "selection.reset_transform" } fn label(&self) -> &str { "Reset Selection Transform" } fn execute(&self, world: &mut World) -> Result { reset_selected_transforms(world); Ok(OperatorAction::Commit) } } struct DrawBrushCommand; impl EditorCommand for DrawBrushCommand { fn name(&self) -> &str { "brush.draw" } fn label(&self) -> &str { "Draw Brush" } fn disabled_reason(&self, world: &World) -> Option { (*world.resource::>().get() != EditorMode::Editing) .then(|| "Enter Edit mode before drawing brushes".to_string()) } fn execute(&self, world: &mut World) -> Result { start_draw_brush_tool(world); Ok(OperatorAction::ContinuePreview) } } struct IntersectBrushesCommand; impl EditorCommand for IntersectBrushesCommand { fn name(&self) -> &str { "brush.intersect" } fn label(&self) -> &str { "Brush Intersect" } fn disabled_reason(&self, world: &World) -> Option { (selected_brush_count(world) < 2).then(|| "Select at least two brushes".to_string()) } fn execute(&self, world: &mut World) -> Result { intersect_selected_brushes(world)?; Ok(OperatorAction::ContinuePreview) } } struct MergeBrushesCommand; impl EditorCommand for MergeBrushesCommand { fn name(&self) -> &str { "brush.merge_convex" } fn label(&self) -> &str { "Brush Convex Merge" } fn disabled_reason(&self, world: &World) -> Option { (selected_brush_count(world) < 2).then(|| "Select at least two brushes".to_string()) } fn execute(&self, world: &mut World) -> Result { merge_selected_brushes(world)?; Ok(OperatorAction::ContinuePreview) } } struct SubtractBrushesCommand; impl EditorCommand for SubtractBrushesCommand { fn name(&self) -> &str { "brush.subtract" } fn label(&self) -> &str { "Brush Subtract" } fn disabled_reason(&self, world: &World) -> Option { (selected_brush_count(world) < 2).then(|| "Select at least two brushes".to_string()) } fn execute(&self, world: &mut World) -> Result { subtract_selected_brushes(world)?; Ok(OperatorAction::ContinuePreview) } } struct CreatePostProcessVolumeCommand; impl EditorCommand for CreatePostProcessVolumeCommand { fn name(&self) -> &str { "rendering.create_volume" } fn label(&self) -> &str { "Create Post-process Volume" } fn execute(&self, world: &mut World) -> Result { crate::rendering_diagnostics::spawn_post_process_volume_at_camera(world); Ok(OperatorAction::Commit) } } struct FocusActiveVolumesCommand; impl EditorCommand for FocusActiveVolumesCommand { fn name(&self) -> &str { "rendering.focus_active_volumes" } fn label(&self) -> &str { "Focus Active Post-process Volumes" } fn execute(&self, world: &mut World) -> Result { crate::rendering_diagnostics::select_volumes_at_camera(world); focus_editor_camera_on_selection(world); Ok(OperatorAction::Commit) } } struct SelectVolumesAtCameraCommand; impl EditorCommand for SelectVolumesAtCameraCommand { fn name(&self) -> &str { "rendering.select_volumes_at_camera" } fn label(&self) -> &str { "Select Volumes at Camera" } fn execute(&self, world: &mut World) -> Result { crate::rendering_diagnostics::select_volumes_at_camera(world); Ok(OperatorAction::Commit) } } fn command_palette_ui( mut contexts: EguiContexts, mut palette: ResMut, registry: Res, ) -> Result { let Ok(ctx) = contexts.ctx_mut() else { return Ok(()); }; apply_editor_theme(ctx); if ctx.input(|input| input.modifiers.command && input.key_pressed(egui::Key::P)) { palette.open = true; palette.filter.clear(); palette.focus_filter = true; palette.selected_index = 0; } if !palette.open { return Ok(()); } let mut open = palette.open; let palette_width = (ctx.content_rect().width() - 32.0).clamp(320.0, 520.0); egui::Window::new("Command Palette") .open(&mut open) .anchor(egui::Align2::CENTER_TOP, egui::vec2(0.0, 64.0)) .collapsible(false) .resizable(false) .default_width(palette_width) .min_width(palette_width) .max_width(palette_width) .frame(egui::Frame::window(&ctx.global_style()).fill(PANEL_BG)) .show(ctx, |ui| { let filter_response = ui.add( egui::TextEdit::singleline(&mut palette.filter) .hint_text("Search commands...") .desired_width(f32::INFINITY), ); if palette.focus_filter { filter_response.request_focus(); palette.focus_filter = false; } if filter_response.changed() { palette.selected_index = 0; } let filtered_entries = filtered_command_entries(®istry, &palette.filter); if !filtered_entries.is_empty() { palette.selected_index = palette.selected_index.min(filtered_entries.len() - 1); } else { palette.selected_index = 0; } if ui.input_mut(|input| input.consume_key(egui::Modifiers::NONE, egui::Key::ArrowDown)) && !filtered_entries.is_empty() { palette.selected_index = (palette.selected_index + 1) % filtered_entries.len(); } if ui.input_mut(|input| input.consume_key(egui::Modifiers::NONE, egui::Key::ArrowUp)) && !filtered_entries.is_empty() { palette.selected_index = if palette.selected_index == 0 { filtered_entries.len() - 1 } else { palette.selected_index - 1 }; } if ui.input_mut(|input| input.consume_key(egui::Modifiers::NONE, egui::Key::Enter)) { if let Some(entry) = filtered_entries.get(palette.selected_index) { palette.pending_run = Some(entry.name.clone()); palette.open = false; ui.close(); } } if ui.input_mut(|input| input.consume_key(egui::Modifiers::NONE, egui::Key::Escape)) { palette.open = false; ui.close(); } ui.separator(); egui::ScrollArea::vertical() .max_height(320.0) .show(ui, |ui| { if filtered_entries.is_empty() { ui.weak("No matching commands"); } for (index, entry) in filtered_entries.iter().enumerate() { let selected = index == palette.selected_index; let response = ui.add_sized( [ui.available_width(), 40.0], egui::Button::selectable(selected, command_palette_row(entry)), ); if selected { response.scroll_to_me(Some(egui::Align::Center)); } if response.clicked() { palette.selected_index = index; palette.pending_run = Some(entry.name.clone()); palette.open = false; ui.close(); } } }); }); palette.open = open; Ok(()) } fn filtered_command_entries( registry: &EditorCommandRegistry, filter: &str, ) -> Vec { let filter = filter.trim().to_lowercase(); registry .entries() .into_iter() .filter(|entry| { filter.is_empty() || entry.name.to_lowercase().contains(&filter) || entry.label.to_lowercase().contains(&filter) }) .collect() } fn command_palette_row(entry: &EditorCommandEntry) -> egui::WidgetText { let mut job = egui::text::LayoutJob::default(); job.append( &entry.label, 0.0, egui::TextFormat { font_id: egui::FontId::new(13.0, egui::FontFamily::Proportional), color: TEXT, ..Default::default() }, ); job.append( &format!("\n{}", entry.name), 0.0, egui::TextFormat { font_id: egui::FontId::new(11.0, egui::FontFamily::Monospace), color: TEXT_DIM, ..Default::default() }, ); job.into() } #[cfg(test)] mod tests { use super::*; use crate::history::{apply_command_redo, apply_command_undo, EditorHistory}; use crate::operators::test_harness::OperatorInvariantHarness; use crate::operators::{ActiveOperator, OperatorPhase}; use crate::scene_io::SceneIo; use crate::selection::{SelectedEntity, ViewportClick}; use crate::viewport::brush_tool::BrushToolState; use shared::{ ActorKind, AuthoringLightKind, BrushDesc, EditorVisibility, HierarchySiblingIndex, LevelObject, LightDesc, }; fn command_world() -> World { let mut world = World::new(); world.init_resource::(); world.init_resource::(); world.init_resource::(); world.init_resource::(); world.init_resource::(); world.init_resource::(); world.init_resource::(); world.insert_resource(State::new(EditorMode::Editing)); world.insert_resource(UiState::default_layout()); let mut registry = EditorCommandRegistry::default(); populate_builtin_commands(&mut registry); world.insert_resource(registry); world } fn hierarchy_projection(world: &mut World) -> Vec<(String, Option, i32)> { let mut query = world.query_filtered::<(Entity, &Name), With>(); let mut result = query .iter(world) .map(|(entity, name)| { let parent = world .get::(entity) .and_then(|child| world.get::(child.parent())) .map(|name| name.as_str().to_string()); let sibling_index = world .get::(entity) .map(|index| index.0) .unwrap_or_default(); (name.as_str().to_string(), parent, sibling_index) }) .collect::>(); result.sort(); result } #[test] fn command_filter_matches_human_label_and_stable_id() { let mut registry = EditorCommandRegistry::default(); registry.register(Box::new(TogglePlayCommand)); registry.register(Box::new(ResetLightingCommand)); let by_label = filtered_command_entries(®istry, "scene lighting"); assert_eq!(by_label.len(), 1); assert_eq!(by_label[0].name, "scene.reset_lighting"); let by_id = filtered_command_entries(®istry, "play.toggle"); assert_eq!(by_id.len(), 1); assert_eq!(by_id[0].label, "Toggle Play / Edit"); } #[test] fn empty_command_filter_preserves_registration_order() { let mut registry = EditorCommandRegistry::default(); registry.register(Box::new(TogglePlayCommand)); registry.register(Box::new(ResetLightingCommand)); let entries = filtered_command_entries(®istry, " "); assert_eq!(entries[0].name, "play.toggle"); assert_eq!(entries[1].name, "scene.reset_lighting"); } #[test] fn grouping_dispatch_is_one_history_transaction_and_round_trips() { let mut world = command_world(); let first = world .spawn(( Name::new("First"), LevelObject, ActorKind::Empty, Transform::from_xyz(1.0, 0.0, 0.0), GlobalTransform::default(), HierarchySiblingIndex(0), EditorVisibility::default(), )) .id(); let second = world .spawn(( Name::new("Second"), LevelObject, ActorKind::Empty, Transform::from_xyz(2.0, 0.0, 0.0), GlobalTransform::default(), HierarchySiblingIndex(1), EditorVisibility::default(), )) .id(); let unselected = world .spawn(( Name::new("Unselected"), LevelObject, ActorKind::Empty, Transform::from_xyz(3.0, 0.0, 0.0), GlobalTransform::default(), HierarchySiblingIndex(2), EditorVisibility::default(), )) .id(); world .resource_mut::() .selected_entities .select_replace(first); world .resource_mut::() .selected_entities .select_maybe_add(second, true); let initial = hierarchy_projection(&mut world); let harness = OperatorInvariantHarness::capture(&mut world); dispatch_editor_command(&mut world, "selection.group"); harness.assert_committed(&mut world, 1, 1); harness.assert_status(&world, "selection.group", OperatorPhase::Committed); let committed = hierarchy_projection(&mut world); assert!(committed .iter() .any(|(name, parent, _)| name == "First" && parent.as_deref() == Some("Group"))); assert!(committed .iter() .any(|(name, parent, _)| name == "Second" && parent.as_deref() == Some("Group"))); apply_command_undo(&mut world); assert_eq!(hierarchy_projection(&mut world), initial); apply_command_redo(&mut world); assert_eq!(hierarchy_projection(&mut world), committed); assert!(world.get::(unselected).is_none()); apply_command_undo(&mut world); assert_eq!(hierarchy_projection(&mut world), initial); apply_command_redo(&mut world); assert_eq!(hierarchy_projection(&mut world), committed); assert!(world.get::(unselected).is_none()); } #[test] fn reset_lighting_dispatch_is_one_undoable_group() { let mut world = command_world(); let point = world .spawn(( LevelObject, ActorKind::Light, LightDesc { kind: AuthoringLightKind::Point, ..Default::default() }, )) .id(); let directional = world .spawn(( LevelObject, ActorKind::Light, LightDesc { kind: AuthoringLightKind::Directional, ..Default::default() }, )) .id(); let harness = OperatorInvariantHarness::capture(&mut world); dispatch_editor_command(&mut world, "scene.reset_lighting"); harness.assert_committed(&mut world, 1, 0); harness.assert_status(&world, "scene.reset_lighting", OperatorPhase::Committed); assert!(world.get::(point).is_none()); assert!(world.get::(directional).is_none()); apply_command_undo(&mut world); assert!(world.get::(point).is_some()); assert!(world.get::(directional).is_some()); apply_command_redo(&mut world); assert!(world.get::(point).is_none()); assert!(world.get::(directional).is_none()); } #[test] fn modal_command_dispatch_retains_preview_ownership() { let mut world = command_world(); let harness = OperatorInvariantHarness::capture(&mut world); dispatch_editor_command(&mut world, "brush.draw"); harness.assert_status(&world, "brush.draw", OperatorPhase::Preview); harness.assert_projection_unchanged(&mut world, 0, |world| { world.resource::().undo_depth() }); assert!(!world.resource::().dirty); assert!(world.resource::().active); } #[test] fn failed_csg_dispatch_terminates_without_preview_or_history() { let mut world = command_world(); let first = world .spawn(( LevelObject, BrushDesc::cuboid(Vec3::splat(1.0)), Transform::IDENTITY, )) .id(); let mut invalid = BrushDesc::cuboid(Vec3::splat(1.0)); invalid.faces.clear(); let second = world .spawn((LevelObject, invalid, Transform::IDENTITY)) .id(); world .resource_mut::() .selected_entities .select_replace(first); world .resource_mut::() .selected_entities .select_maybe_add(second, true); let harness = OperatorInvariantHarness::capture(&mut world); dispatch_editor_command(&mut world, "brush.intersect"); harness.assert_canceled(&mut world); harness.assert_status(&world, "brush.intersect", OperatorPhase::Canceled); assert!(!world.resource::().dirty); } #[test] fn csg_dispatch_blocks_read_only_selection_without_history() { let mut world = command_world(); let first = world .spawn(( LevelObject, BrushDesc::cuboid(Vec3::splat(1.0)), Transform::IDENTITY, )) .id(); let second = world .spawn(( LevelObject, BrushDesc::cuboid(Vec3::splat(1.0)), Transform::from_xyz(0.5, 0.0, 0.0), )) .id(); let mut hierarchy = crate::ui::hierarchy_state::HierarchyPanelState::default(); hierarchy.locked.insert(second); world.insert_resource(hierarchy); world .resource_mut::() .selected_entities .select_replace(first); world .resource_mut::() .selected_entities .select_maybe_add(second, true); let harness = OperatorInvariantHarness::capture(&mut world); dispatch_editor_command(&mut world, "brush.merge_convex"); harness.assert_blocked(&mut world); harness.assert_status(&world, "brush.merge_convex", OperatorPhase::Blocked); assert!(!world.resource::().dirty); } #[test] fn disabled_command_dispatch_is_a_blocked_no_op() { let mut world = command_world(); let harness = OperatorInvariantHarness::capture(&mut world); dispatch_editor_command(&mut world, "selection.group"); harness.assert_blocked(&mut world); harness.assert_status(&world, "selection.group", OperatorPhase::Blocked); } }