973 lines
31 KiB
Rust
973 lines
31 KiB
Rust
//! 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<String> {
|
|
None
|
|
}
|
|
fn execute(&self, world: &mut World) -> Result<OperatorAction, String>;
|
|
}
|
|
|
|
#[derive(Resource, Default)]
|
|
pub struct EditorCommandRegistry {
|
|
commands: Vec<Box<dyn EditorCommand>>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct EditorCommandEntry {
|
|
pub name: String,
|
|
pub label: String,
|
|
}
|
|
|
|
impl EditorCommandRegistry {
|
|
pub fn register(&mut self, command: Box<dyn EditorCommand>) {
|
|
self.commands.push(command);
|
|
}
|
|
|
|
pub fn names(&self) -> Vec<String> {
|
|
self.commands
|
|
.iter()
|
|
.map(|cmd| cmd.name().to_string())
|
|
.collect()
|
|
}
|
|
|
|
pub fn entries(&self) -> Vec<EditorCommandEntry> {
|
|
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<String>)> {
|
|
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<Result<OperatorAction, String>> {
|
|
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<Box<dyn ActorInspectorSection>>,
|
|
}
|
|
|
|
impl ActorInspectorSectionRegistry {
|
|
pub fn register(&mut self, section: Box<dyn ActorInspectorSection>) {
|
|
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<dyn ActorInspectorSection>) {
|
|
if !app
|
|
.world()
|
|
.contains_resource::<ActorInspectorSectionRegistry>()
|
|
{
|
|
app.init_resource::<ActorInspectorSectionRegistry>();
|
|
}
|
|
app.world_mut()
|
|
.resource_mut::<ActorInspectorSectionRegistry>()
|
|
.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<Box<dyn EditorExtension>>,
|
|
}
|
|
|
|
#[derive(Resource, Default)]
|
|
pub struct RegisteredEditorPlugins {
|
|
pub plugins: Vec<Box<dyn EditorPlugin>>,
|
|
}
|
|
|
|
#[derive(Resource, Default)]
|
|
pub struct CommandPalette {
|
|
pub open: bool,
|
|
pub filter: String,
|
|
pub pending_run: Option<String>,
|
|
pub focus_filter: bool,
|
|
pub selected_index: usize,
|
|
}
|
|
|
|
pub struct ExtensibilityPlugin;
|
|
|
|
impl Plugin for ExtensibilityPlugin {
|
|
fn build(&self, app: &mut App) {
|
|
app.init_resource::<EditorCommandRegistry>()
|
|
.init_resource::<ActorInspectorSectionRegistry>()
|
|
.init_resource::<EditorExtensions>()
|
|
.init_resource::<RegisteredEditorPlugins>()
|
|
.init_resource::<CommandPalette>()
|
|
.init_resource::<crate::command_queue::PendingEditorCommands>()
|
|
.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<dyn EditorPlugin>) {
|
|
plugin.register(app);
|
|
app.world_mut()
|
|
.resource_mut::<RegisteredEditorPlugins>()
|
|
.plugins
|
|
.push(plugin);
|
|
}
|
|
|
|
fn register_builtin_commands(mut registry: ResMut<EditorCommandRegistry>) {
|
|
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::<CommandPalette>().pending_run.take();
|
|
if let Some(name) = pending {
|
|
dispatch_editor_command(world, &name);
|
|
}
|
|
let names = std::mem::take(
|
|
&mut world
|
|
.resource_mut::<crate::command_queue::PendingEditorCommands>()
|
|
.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<EditorCommandRegistry>| {
|
|
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<EditorCommandRegistry>| {
|
|
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<OperatorAction, String> {
|
|
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<String> {
|
|
(*world.resource::<State<EditorMode>>().get() != EditorMode::Playing)
|
|
.then(|| "Enter Play mode before pausing the simulation".to_string())
|
|
}
|
|
|
|
fn execute(&self, world: &mut World) -> Result<OperatorAction, String> {
|
|
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<String> {
|
|
(*world.resource::<State<EditorMode>>().get() != EditorMode::Playing)
|
|
.then(|| "Enter Play mode before toggling possession".to_string())
|
|
}
|
|
|
|
fn execute(&self, world: &mut World) -> Result<OperatorAction, String> {
|
|
let next = match *world.resource::<PlayPossession>() {
|
|
PlayPossession::Possessed => PlayPossession::Ejected,
|
|
PlayPossession::Ejected => PlayPossession::Possessed,
|
|
};
|
|
*world.resource_mut::<PlayPossession>() = 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<OperatorAction, String> {
|
|
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<String> {
|
|
world
|
|
.resource::<UiState>()
|
|
.selected_entities
|
|
.as_slice()
|
|
.is_empty()
|
|
.then(|| "Select at least one actor to group".to_string())
|
|
}
|
|
|
|
fn execute(&self, world: &mut World) -> Result<OperatorAction, String> {
|
|
let selected: Vec<Entity> = world
|
|
.resource::<UiState>()
|
|
.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<OperatorAction, String> {
|
|
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<OperatorAction, String> {
|
|
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<String> {
|
|
(*world.resource::<State<EditorMode>>().get() != EditorMode::Editing)
|
|
.then(|| "Enter Edit mode before drawing brushes".to_string())
|
|
}
|
|
|
|
fn execute(&self, world: &mut World) -> Result<OperatorAction, String> {
|
|
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<String> {
|
|
(selected_brush_count(world) < 2).then(|| "Select at least two brushes".to_string())
|
|
}
|
|
|
|
fn execute(&self, world: &mut World) -> Result<OperatorAction, String> {
|
|
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<String> {
|
|
(selected_brush_count(world) < 2).then(|| "Select at least two brushes".to_string())
|
|
}
|
|
|
|
fn execute(&self, world: &mut World) -> Result<OperatorAction, String> {
|
|
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<String> {
|
|
(selected_brush_count(world) < 2).then(|| "Select at least two brushes".to_string())
|
|
}
|
|
|
|
fn execute(&self, world: &mut World) -> Result<OperatorAction, String> {
|
|
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<OperatorAction, String> {
|
|
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<OperatorAction, String> {
|
|
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<OperatorAction, String> {
|
|
crate::rendering_diagnostics::select_volumes_at_camera(world);
|
|
Ok(OperatorAction::Commit)
|
|
}
|
|
}
|
|
|
|
fn command_palette_ui(
|
|
mut contexts: EguiContexts,
|
|
mut palette: ResMut<CommandPalette>,
|
|
registry: Res<EditorCommandRegistry>,
|
|
) -> 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<EditorCommandEntry> {
|
|
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::<ActiveOperator>();
|
|
world.init_resource::<EditorHistory>();
|
|
world.init_resource::<SceneIo>();
|
|
world.init_resource::<SelectedEntity>();
|
|
world.init_resource::<ViewportClick>();
|
|
world.init_resource::<BrushToolState>();
|
|
world.init_resource::<crate::viewport::brush_csg::BrushCsgPreview>();
|
|
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<String>, i32)> {
|
|
let mut query = world.query_filtered::<(Entity, &Name), With<LevelObject>>();
|
|
let mut result = query
|
|
.iter(world)
|
|
.map(|(entity, name)| {
|
|
let parent = world
|
|
.get::<ChildOf>(entity)
|
|
.and_then(|child| world.get::<Name>(child.parent()))
|
|
.map(|name| name.as_str().to_string());
|
|
let sibling_index = world
|
|
.get::<HierarchySiblingIndex>(entity)
|
|
.map(|index| index.0)
|
|
.unwrap_or_default();
|
|
(name.as_str().to_string(), parent, sibling_index)
|
|
})
|
|
.collect::<Vec<_>>();
|
|
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::<UiState>()
|
|
.selected_entities
|
|
.select_replace(first);
|
|
world
|
|
.resource_mut::<UiState>()
|
|
.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::<ChildOf>(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::<ChildOf>(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::<LightDesc>(point).is_none());
|
|
assert!(world.get::<LightDesc>(directional).is_none());
|
|
apply_command_undo(&mut world);
|
|
assert!(world.get::<LightDesc>(point).is_some());
|
|
assert!(world.get::<LightDesc>(directional).is_some());
|
|
apply_command_redo(&mut world);
|
|
assert!(world.get::<LightDesc>(point).is_none());
|
|
assert!(world.get::<LightDesc>(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::<EditorHistory>().undo_depth()
|
|
});
|
|
assert!(!world.resource::<SceneIo>().dirty);
|
|
assert!(world.resource::<BrushToolState>().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::<UiState>()
|
|
.selected_entities
|
|
.select_replace(first);
|
|
world
|
|
.resource_mut::<UiState>()
|
|
.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::<SceneIo>().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::<UiState>()
|
|
.selected_entities
|
|
.select_replace(first);
|
|
world
|
|
.resource_mut::<UiState>()
|
|
.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::<SceneIo>().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);
|
|
}
|
|
}
|