//! 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 ); } }