From d77cc6a40e7e57053a3826f3ffc338b15717ff53 Mon Sep 17 00:00:00 2001 From: Rbanh Date: Sat, 6 Jun 2026 04:07:58 -0400 Subject: [PATCH] Add editor operator lifecycle bridge --- crates/editor/src/ext/extensibility.rs | 58 ++++- crates/editor/src/history/commands.rs | 29 +++ crates/editor/src/history/mod.rs | 19 +- crates/editor/src/lib.rs | 3 + crates/editor/src/operators.rs | 335 +++++++++++++++++++++++++ crates/editor/src/ui/status_bar.rs | 5 + docs/editor/README.md | 1 + docs/editor/architecture.md | 5 + 8 files changed, 447 insertions(+), 8 deletions(-) create mode 100644 crates/editor/src/operators.rs diff --git a/crates/editor/src/ext/extensibility.rs b/crates/editor/src/ext/extensibility.rs index 8a85bcc..b64db20 100644 --- a/crates/editor/src/ext/extensibility.rs +++ b/crates/editor/src/ext/extensibility.rs @@ -4,6 +4,7 @@ use bevy::prelude::*; use bevy_egui::{egui, EguiContexts, EguiPrimaryContextPass}; use crate::history::group_selection_with_history; +use crate::operators::{run_immediate_operator, OperatorAvailability}; use crate::state::{EditorMode, PlayPossession}; use crate::ui::helpers::{ reset_scene_lighting_to_project_defaults, toggle_play_mode, toggle_play_paused, @@ -15,6 +16,12 @@ use crate::ui::UiState; /// 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); } @@ -35,6 +42,13 @@ impl EditorCommandRegistry { .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) -> bool { if let Some(command) = self.commands.iter().find(|cmd| cmd.name() == name) { command.execute(world); @@ -184,11 +198,39 @@ fn run_palette_commands(world: &mut World) { } fn dispatch_editor_command(world: &mut World, name: &str) { - let ran = world - .resource_scope(|world, registry: Mut| registry.run(world, name)); - if !ran { + 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_immediate_operator( + world, + &name_for_status, + &label, + match disabled_reason { + Some(reason) => OperatorAvailability::Disabled(reason), + None => OperatorAvailability::Ready, + }, + move |world| { + let ran = world.resource_scope(|world, registry: Mut| { + registry.run(world, &name_for_commit) + }); + if ran { + Ok(()) + } else { + Err(format!("Unknown editor command: {name_for_commit}")) + } + }, + ); + + trace!("dispatched editor operator command {label_for_commit}"); } struct TogglePlayCommand; @@ -222,10 +264,12 @@ impl EditorCommand for TogglePossessionCommand { "play.toggle_possession" } + 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) { - if *world.resource::>().get() != EditorMode::Playing { - return; - } let next = match *world.resource::() { PlayPossession::Possessed => PlayPossession::Ejected, PlayPossession::Ejected => PlayPossession::Possessed, diff --git a/crates/editor/src/history/commands.rs b/crates/editor/src/history/commands.rs index dc32fc4..f37cec1 100644 --- a/crates/editor/src/history/commands.rs +++ b/crates/editor/src/history/commands.rs @@ -149,3 +149,32 @@ pub enum EditorCommand { snapshot: EditorEntitySnapshot, }, } + +impl EditorCommand { + pub fn label(&self) -> &'static str { + match self { + EditorCommand::Spawn { .. } => "Spawn", + EditorCommand::Despawn { .. } => "Delete", + EditorCommand::Duplicate { .. } => "Duplicate", + EditorCommand::Rename { .. } => "Rename", + EditorCommand::SetTransform { .. } => "Move", + EditorCommand::SetMaterial { .. } => "Set Material", + EditorCommand::SetMaterialOverride { .. } => "Set Material Override", + EditorCommand::SetLight { .. } => "Set Light", + EditorCommand::SetPhysics { .. } => "Set Physics", + EditorCommand::SetRigidBody { .. } => "Set Rigid Body", + EditorCommand::SetCollider { .. } => "Set Collider", + EditorCommand::SetPrimitive { .. } => "Set Primitive", + EditorCommand::SetStaticMeshRenderer { .. } => "Set Static Mesh Renderer", + EditorCommand::SetPostProcessVolume { .. } => "Set Post Process Volume", + EditorCommand::SetTransformGroup { .. } => "Move Selection", + EditorCommand::Reparent { .. } => "Reparent", + EditorCommand::SetActorKind { .. } => "Set Actor Kind", + EditorCommand::ReorderSibling { .. } => "Reorder Hierarchy", + EditorCommand::SetEditorVisibility { .. } => "Set Visibility", + EditorCommand::SetInspectorOrder { .. } => "Set Component Order", + EditorCommand::AddComponent { .. } => "Add Component", + EditorCommand::RemoveComponent { .. } => "Remove Component", + } + } +} diff --git a/crates/editor/src/history/mod.rs b/crates/editor/src/history/mod.rs index 535ae73..265e339 100644 --- a/crates/editor/src/history/mod.rs +++ b/crates/editor/src/history/mod.rs @@ -27,9 +27,10 @@ pub struct EditorHistory { impl EditorHistory { pub fn push(&mut self, command: EditorCommand) { + let label = command.label(); self.undo_stack.push(command); self.redo_stack.clear(); - self.status = format!("Undo: {} command(s), redo cleared", self.undo_stack.len()); + self.status = format!("Undo: {label}"); } pub fn can_undo(&self) -> bool { @@ -40,6 +41,18 @@ impl EditorHistory { !self.redo_stack.is_empty() } + pub fn undo_depth(&self) -> usize { + self.undo_stack.len() + } + + pub fn set_undo_status(&mut self, label: impl Into) { + self.status = format!("Undo: {}", label.into()); + } + + fn set_redo_status(&mut self, label: impl Into) { + self.status = format!("Redo: {}", label.into()); + } + fn pop_undo(&mut self) -> Option { self.undo_stack.pop() } @@ -695,8 +708,10 @@ pub fn apply_command_undo(world: &mut World) { let Some(mut command) = world.resource_mut::().pop_undo() else { return; }; + let label = command.label(); undo_command(world, &mut command); world.resource_mut::().push_redo(command); + world.resource_mut::().set_redo_status(label); mark_dirty(world); } @@ -704,8 +719,10 @@ pub fn apply_command_redo(world: &mut World) { let Some(mut command) = world.resource_mut::().pop_redo() else { return; }; + let label = command.label(); redo_command(world, &mut command); world.resource_mut::().push_undo(command); + world.resource_mut::().set_undo_status(label); mark_dirty(world); } diff --git a/crates/editor/src/lib.rs b/crates/editor/src/lib.rs index 670a43a..53dacf4 100644 --- a/crates/editor/src/lib.rs +++ b/crates/editor/src/lib.rs @@ -5,6 +5,7 @@ pub mod assets; pub mod ext; pub mod history; pub mod infra; +pub mod operators; pub mod play; pub mod project; pub mod render_target; @@ -51,6 +52,7 @@ use extensibility::ExtensibilityPlugin; use gizmos::EditorGizmoPlugin; use history::EditorHistoryPlugin; use infra::EditorInfraPlugin; +use operators::OperatorPlugin; use play::PlaySessionPlugin; use project_io::ProjectIoPlugin; use render_view::RenderViewPlugin; @@ -77,6 +79,7 @@ impl PluginGroup for EditorPluginGroup { .add(scene_schema::SceneSchemaPlugin) .add(net_editor::NetEditorPlugin) .add(AssetDbPlugin) + .add(OperatorPlugin) .add(ExtensibilityPlugin) .add(SettingsUiPlugin) .add(assets::EditorAssetsPlugin) diff --git a/crates/editor/src/operators.rs b/crates/editor/src/operators.rs new file mode 100644 index 0000000..4f033cb --- /dev/null +++ b/crates/editor/src/operators.rs @@ -0,0 +1,335 @@ +//! Shared lifecycle for editor actions that mutate or drive tools. + +use bevy::prelude::*; + +use crate::history::EditorHistory; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum OperatorPhase { + Idle, + Preview, + Committed, + Canceled, + Blocked, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct OperatorStatus { + pub id: String, + pub label: String, + pub phase: OperatorPhase, + pub hint: String, + pub warnings: Vec, +} + +impl OperatorStatus { + fn new(id: &str, label: &str, phase: OperatorPhase, hint: impl Into) -> Self { + Self { + id: id.to_string(), + label: label.to_string(), + phase, + hint: hint.into(), + warnings: Vec::new(), + } + } +} + +#[derive(Resource, Debug, Default)] +pub struct ActiveOperator { + pub status: Option, +} + +impl ActiveOperator { + pub fn label(&self) -> Option { + self.status + .as_ref() + .map(|status| match status.hint.is_empty() { + true => format!("Tool: {}", status.label), + false => format!("Tool: {} - {}", status.label, status.hint), + }) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum OperatorAvailability { + Ready, + Disabled(String), +} + +pub trait EditorOperator { + fn id(&self) -> &str; + fn label(&self) -> &str; + + fn can_start(&self, _world: &World) -> OperatorAvailability { + OperatorAvailability::Ready + } + + fn begin(&mut self, _world: &mut World) -> Result<(), String> { + Ok(()) + } + + fn preview(&mut self, _world: &mut World) -> Result<(), String> { + Ok(()) + } + + fn commit(&mut self, world: &mut World) -> Result<(), String>; + + fn cancel(&mut self, _world: &mut World) {} + + fn undo_label(&self) -> String { + self.label().to_string() + } +} + +pub struct OperatorPlugin; + +impl Plugin for OperatorPlugin { + fn build(&self, app: &mut App) { + app.init_resource::(); + } +} + +pub fn run_operator(world: &mut World, operator: &mut dyn EditorOperator) -> bool { + let undo_depth_before = world + .get_resource::() + .map(EditorHistory::undo_depth) + .unwrap_or_default(); + + match operator.can_start(world) { + OperatorAvailability::Ready => {} + OperatorAvailability::Disabled(reason) => { + set_operator_status( + world, + OperatorStatus::new( + operator.id(), + operator.label(), + OperatorPhase::Blocked, + reason, + ), + ); + return false; + } + } + + set_operator_status( + world, + OperatorStatus::new( + operator.id(), + operator.label(), + OperatorPhase::Preview, + "Preview", + ), + ); + + if let Err(err) = operator.begin(world).and_then(|_| operator.preview(world)) { + operator.cancel(world); + set_operator_status( + world, + OperatorStatus::new( + operator.id(), + operator.label(), + OperatorPhase::Canceled, + err, + ), + ); + return false; + } + + match operator.commit(world) { + Ok(()) => { + let status = OperatorStatus::new( + operator.id(), + operator.label(), + OperatorPhase::Committed, + format!("Committed {}", operator.undo_label()), + ); + set_operator_status(world, status); + let undo_depth_after = world + .get_resource::() + .map(EditorHistory::undo_depth) + .unwrap_or_default(); + if undo_depth_after > undo_depth_before { + let Some(label) = world.resource::().label() else { + return true; + }; + world.resource_mut::().set_undo_status(label); + } + true + } + Err(err) => { + operator.cancel(world); + set_operator_status( + world, + OperatorStatus::new( + operator.id(), + operator.label(), + OperatorPhase::Canceled, + err, + ), + ); + false + } + } +} + +pub fn run_immediate_operator( + world: &mut World, + id: &str, + label: &str, + availability: OperatorAvailability, + commit: impl FnOnce(&mut World) -> Result<(), String>, +) -> bool { + struct ImmediateOperator { + id: String, + label: String, + availability: OperatorAvailability, + commit: Option, + } + + impl EditorOperator for ImmediateOperator + where + F: FnOnce(&mut World) -> Result<(), String>, + { + fn id(&self) -> &str { + &self.id + } + + fn label(&self) -> &str { + &self.label + } + + fn can_start(&self, _world: &World) -> OperatorAvailability { + self.availability.clone() + } + + fn commit(&mut self, world: &mut World) -> Result<(), String> { + self.commit + .take() + .expect("immediate operator commit must run once")(world) + } + } + + let mut operator = ImmediateOperator { + id: id.to_string(), + label: label.to_string(), + availability, + commit: Some(commit), + }; + run_operator(world, &mut operator) +} + +fn set_operator_status(world: &mut World, status: OperatorStatus) { + if let Some(mut active) = world.get_resource_mut::() { + active.status = Some(status); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[derive(Resource, Default)] + struct Counter(i32); + + struct CounterOperator { + before: i32, + } + + impl EditorOperator for CounterOperator { + fn id(&self) -> &str { + "test.counter" + } + + fn label(&self) -> &str { + "Increment Counter" + } + + fn begin(&mut self, world: &mut World) -> Result<(), String> { + self.before = world.resource::().0; + Ok(()) + } + + fn preview(&mut self, world: &mut World) -> Result<(), String> { + world.resource_mut::().0 += 1; + Ok(()) + } + + fn commit(&mut self, _world: &mut World) -> Result<(), String> { + Ok(()) + } + + fn cancel(&mut self, world: &mut World) { + world.resource_mut::().0 = self.before; + } + } + + #[test] + fn operator_commit_keeps_previewed_change() { + let mut world = World::new(); + world.init_resource::(); + world.init_resource::(); + + let mut operator = CounterOperator { before: 0 }; + + assert!(run_operator(&mut world, &mut operator)); + assert_eq!(world.resource::().0, 1); + assert_eq!( + world + .resource::() + .status + .as_ref() + .unwrap() + .phase, + OperatorPhase::Committed + ); + } + + #[test] + fn operator_cancel_restores_previewed_change_on_commit_error() { + struct FailingCounterOperator(CounterOperator); + + impl EditorOperator for FailingCounterOperator { + fn id(&self) -> &str { + self.0.id() + } + + fn label(&self) -> &str { + self.0.label() + } + + fn begin(&mut self, world: &mut World) -> Result<(), String> { + self.0.begin(world) + } + + fn preview(&mut self, world: &mut World) -> Result<(), String> { + self.0.preview(world) + } + + fn commit(&mut self, _world: &mut World) -> Result<(), String> { + Err("commit failed".to_string()) + } + + fn cancel(&mut self, world: &mut World) { + self.0.cancel(world); + } + } + + let mut world = World::new(); + world.init_resource::(); + world.init_resource::(); + + let mut operator = FailingCounterOperator(CounterOperator { before: 0 }); + + assert!(!run_operator(&mut world, &mut operator)); + assert_eq!(world.resource::().0, 0); + assert_eq!( + world + .resource::() + .status + .as_ref() + .unwrap() + .phase, + OperatorPhase::Canceled + ); + } +} diff --git a/crates/editor/src/ui/status_bar.rs b/crates/editor/src/ui/status_bar.rs index 36e2f65..074e4bc 100644 --- a/crates/editor/src/ui/status_bar.rs +++ b/crates/editor/src/ui/status_bar.rs @@ -5,6 +5,7 @@ use bevy_egui::egui; use bevy_inspector_egui::bevy_inspector::hierarchy::SelectedEntities; use crate::history::EditorHistory; +use crate::operators::ActiveOperator; use crate::scene_io::SceneIo; use crate::state::{EditorMode, PlayPaused}; @@ -51,6 +52,10 @@ pub fn status_bar_ui( .color(TEXT), ); + if let Some(operator_label) = world.resource::().label() { + ui.label(egui::RichText::new(operator_label).color(TEXT)); + } + #[cfg(feature = "hot-reload")] if let Some(hot) = world.get_resource::() { ui.label(egui::RichText::new(hot.label.clone()).color(TEXT)); diff --git a/docs/editor/README.md b/docs/editor/README.md index aa0632c..4790173 100644 --- a/docs/editor/README.md +++ b/docs/editor/README.md @@ -29,6 +29,7 @@ Docs for the in-process egui editor (`crates/editor/`). Update this index when a | `history/` | Undo/redo command stack | README controls | | `crates/scene` | Schema stamp/migrate/validate (shared) | ADR 0006, ADR 0008 | | `ext/extensibility.rs` | Command registry, `ActorInspectorSection`, palette | Phase 6 | +| `operators.rs` | Operator lifecycle/status bridge for commands and future modal tools | BS-JD-004 | | `ext/game_inspector.rs` | Game-registered inspector sections | Phase 6 | ## Design intentions (stable) diff --git a/docs/editor/architecture.md b/docs/editor/architecture.md index 8925508..8ac4b26 100644 --- a/docs/editor/architecture.md +++ b/docs/editor/architecture.md @@ -29,6 +29,7 @@ Native-only dependencies (`rfd`, BRP HTTP) stay in `editor`. `settings`, `shared | `ActorIconsPlugin` | Selectable actor root billboard icons | | `EditorVisualizersPlugin` | Collider/light/spawn/prefab/runtime visualizers and selectable proxies | | `SceneIoPlugin` | Level New/Open/Save, recent paths | +| `OperatorPlugin` | Active operator lifecycle/status for commands and modal tools | | `SettingsUiPlugin` | egui Project Settings panel | | `EditorUiPlugin` | Dock, themed egui shell, viewport overlays, menus, status bar | | `BrpPlugin` | Bevy Remote Protocol HTTP (editor-only) | @@ -160,6 +161,10 @@ On play enter/exit the editor snapshots **player sim state only** (transform, ve ## Undo / history Structural edits (spawn, delete, transform, rename) go through `EditorHistory` command objects in `history.rs`. +User-facing actions route through the operator lifecycle in `operators.rs`: begin, preview, +commit, cancel, disabled reason, and status text. The first bridge wraps command-palette and queued +editor commands as immediate operators, while existing `EditorHistory` commands remain the undo +payload. Modal tools and asset drops should use the same operator status path as they migrate. PIE stop restores player simulation state only; authored `LevelObject` edits made during PIE remain in the scene.