//! Shared lifecycle for editor actions that mutate or drive tools. use bevy::prelude::*; use crate::history::EditorHistory; #[cfg(test)] pub(crate) mod test_harness; #[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), } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum OperatorAction { Commit, ContinuePreview, } 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) } /// Runs an editor command that may either complete immediately or hand ownership to a modal tool. /// Modal starters retain the preview status installed by their production entry point. pub fn run_operator_action( world: &mut World, id: &str, label: &str, availability: OperatorAvailability, action: impl FnOnce(&mut World) -> Result, ) -> bool { if let OperatorAvailability::Disabled(reason) = availability { set_operator_status( world, OperatorStatus::new(id, label, OperatorPhase::Blocked, reason), ); return false; } set_operator_status( world, OperatorStatus::new(id, label, OperatorPhase::Preview, "Starting"), ); match action(world) { Ok(OperatorAction::Commit) => { set_operator_status( world, OperatorStatus::new( id, label, OperatorPhase::Committed, format!("Completed {label}"), ), ); true } Ok(OperatorAction::ContinuePreview) => true, Err(error) => { set_operator_status( world, OperatorStatus::new(id, label, OperatorPhase::Canceled, error), ); false } } } 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::*; use crate::history::EditorHistory; use crate::operators::test_harness::OperatorInvariantHarness; use crate::scene_io::SceneIo; #[derive(Resource, Default)] struct Counter(i32); #[derive(Component)] struct PreviewHelper; struct CounterOperator { before: i32, helper: Option, } 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; self.helper = Some(world.spawn(PreviewHelper).id()); 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> { self.remove_helper(world); Ok(()) } fn cancel(&mut self, world: &mut World) { world.resource_mut::().0 = self.before; self.remove_helper(world); } } impl CounterOperator { fn remove_helper(&mut self, world: &mut World) { if let Some(helper) = self.helper.take() { if let Ok(entity) = world.get_entity_mut(helper) { entity.despawn(); } } } } fn test_world() -> World { let mut world = World::new(); world.init_resource::(); world.init_resource::(); world.init_resource::(); world.init_resource::(); world } #[test] fn operator_commit_keeps_previewed_change() { let mut world = test_world(); let harness = OperatorInvariantHarness::capture(&mut world); let mut operator = CounterOperator { before: 0, helper: None, }; assert!(run_operator(&mut world, &mut operator)); assert_eq!(world.resource::().0, 1); harness.assert_committed(&mut world, 0, 0); harness.assert_no_helpers::(&mut world); } #[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 = test_world(); let harness = OperatorInvariantHarness::capture(&mut world); let mut operator = FailingCounterOperator(CounterOperator { before: 0, helper: None, }); assert!(!run_operator(&mut world, &mut operator)); assert_eq!(world.resource::().0, 0); harness.assert_canceled(&mut world); harness.assert_no_helpers::(&mut world); } #[test] fn preview_failure_runs_cancel_and_cleans_helpers() { struct PreviewFailure(CounterOperator); impl EditorOperator for PreviewFailure { 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)?; Err("preview failed".to_string()) } fn commit(&mut self, _world: &mut World) -> Result<(), String> { panic!("commit must not run after preview failure") } fn cancel(&mut self, world: &mut World) { self.0.cancel(world); } } let mut world = test_world(); let harness = OperatorInvariantHarness::capture(&mut world); let mut operator = PreviewFailure(CounterOperator { before: 0, helper: None, }); assert!(!run_operator(&mut world, &mut operator)); harness.assert_canceled(&mut world); harness.assert_status(&world, "test.counter", OperatorPhase::Canceled); harness.assert_no_helpers::(&mut world); assert_eq!(world.resource::().0, 0); } #[test] fn blocked_operator_never_begins_or_mutates_world() { let mut world = test_world(); let harness = OperatorInvariantHarness::capture(&mut world); let result = run_immediate_operator( &mut world, "test.blocked", "Blocked Operator", OperatorAvailability::Disabled("fixture unavailable".to_string()), |world| { world.resource_mut::().0 += 1; Ok(()) }, ); assert!(!result); assert_eq!(world.resource::().0, 0); harness.assert_blocked(&mut world); } }