Blacksite/crates/editor/src/operators.rs
Rbanh d77cc6a40e
Some checks are pending
CI / Format, lint, test, build (push) Waiting to run
Add editor operator lifecycle bridge
2026-06-06 04:07:58 -04:00

336 lines
8.5 KiB
Rust

//! 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<String>,
}
impl OperatorStatus {
fn new(id: &str, label: &str, phase: OperatorPhase, hint: impl Into<String>) -> 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<OperatorStatus>,
}
impl ActiveOperator {
pub fn label(&self) -> Option<String> {
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::<ActiveOperator>();
}
}
pub fn run_operator(world: &mut World, operator: &mut dyn EditorOperator) -> bool {
let undo_depth_before = world
.get_resource::<EditorHistory>()
.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::<EditorHistory>()
.map(EditorHistory::undo_depth)
.unwrap_or_default();
if undo_depth_after > undo_depth_before {
let Some(label) = world.resource::<ActiveOperator>().label() else {
return true;
};
world.resource_mut::<EditorHistory>().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<F> {
id: String,
label: String,
availability: OperatorAvailability,
commit: Option<F>,
}
impl<F> EditorOperator for ImmediateOperator<F>
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::<ActiveOperator>() {
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::<Counter>().0;
Ok(())
}
fn preview(&mut self, world: &mut World) -> Result<(), String> {
world.resource_mut::<Counter>().0 += 1;
Ok(())
}
fn commit(&mut self, _world: &mut World) -> Result<(), String> {
Ok(())
}
fn cancel(&mut self, world: &mut World) {
world.resource_mut::<Counter>().0 = self.before;
}
}
#[test]
fn operator_commit_keeps_previewed_change() {
let mut world = World::new();
world.init_resource::<ActiveOperator>();
world.init_resource::<Counter>();
let mut operator = CounterOperator { before: 0 };
assert!(run_operator(&mut world, &mut operator));
assert_eq!(world.resource::<Counter>().0, 1);
assert_eq!(
world
.resource::<ActiveOperator>()
.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::<ActiveOperator>();
world.init_resource::<Counter>();
let mut operator = FailingCounterOperator(CounterOperator { before: 0 });
assert!(!run_operator(&mut world, &mut operator));
assert_eq!(world.resource::<Counter>().0, 0);
assert_eq!(
world
.resource::<ActiveOperator>()
.status
.as_ref()
.unwrap()
.phase,
OperatorPhase::Canceled
);
}
}