Add editor operator lifecycle bridge
Some checks are pending
CI / Format, lint, test, build (push) Waiting to run

This commit is contained in:
Rbanh 2026-06-06 04:07:58 -04:00
parent 2054fa2558
commit d77cc6a40e
8 changed files with 447 additions and 8 deletions

View File

@ -4,6 +4,7 @@ use bevy::prelude::*;
use bevy_egui::{egui, EguiContexts, EguiPrimaryContextPass}; use bevy_egui::{egui, EguiContexts, EguiPrimaryContextPass};
use crate::history::group_selection_with_history; use crate::history::group_selection_with_history;
use crate::operators::{run_immediate_operator, OperatorAvailability};
use crate::state::{EditorMode, PlayPossession}; use crate::state::{EditorMode, PlayPossession};
use crate::ui::helpers::{ use crate::ui::helpers::{
reset_scene_lighting_to_project_defaults, toggle_play_mode, toggle_play_paused, 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. /// Named editor command invokable from the palette and BRP.
pub trait EditorCommand: Send + Sync { pub trait EditorCommand: Send + Sync {
fn name(&self) -> &str; 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); fn execute(&self, world: &mut World);
} }
@ -35,6 +42,13 @@ impl EditorCommandRegistry {
.collect() .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) -> bool { pub fn run(&self, world: &mut World, name: &str) -> bool {
if let Some(command) = self.commands.iter().find(|cmd| cmd.name() == name) { if let Some(command) = self.commands.iter().find(|cmd| cmd.name() == name) {
command.execute(world); command.execute(world);
@ -184,11 +198,39 @@ fn run_palette_commands(world: &mut World) {
} }
fn dispatch_editor_command(world: &mut World, name: &str) { fn dispatch_editor_command(world: &mut World, name: &str) {
let ran = world let Some((label, disabled_reason)) =
.resource_scope(|world, registry: Mut<EditorCommandRegistry>| registry.run(world, name)); world.resource_scope(|world, registry: Mut<EditorCommandRegistry>| {
if !ran { registry.command_state(world, name)
})
else {
warn!("unknown editor command: {name}"); 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<EditorCommandRegistry>| {
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; struct TogglePlayCommand;
@ -222,10 +264,12 @@ impl EditorCommand for TogglePossessionCommand {
"play.toggle_possession" "play.toggle_possession"
} }
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) { fn execute(&self, world: &mut World) {
if *world.resource::<State<EditorMode>>().get() != EditorMode::Playing {
return;
}
let next = match *world.resource::<PlayPossession>() { let next = match *world.resource::<PlayPossession>() {
PlayPossession::Possessed => PlayPossession::Ejected, PlayPossession::Possessed => PlayPossession::Ejected,
PlayPossession::Ejected => PlayPossession::Possessed, PlayPossession::Ejected => PlayPossession::Possessed,

View File

@ -149,3 +149,32 @@ pub enum EditorCommand {
snapshot: EditorEntitySnapshot, 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",
}
}
}

View File

@ -27,9 +27,10 @@ pub struct EditorHistory {
impl EditorHistory { impl EditorHistory {
pub fn push(&mut self, command: EditorCommand) { pub fn push(&mut self, command: EditorCommand) {
let label = command.label();
self.undo_stack.push(command); self.undo_stack.push(command);
self.redo_stack.clear(); 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 { pub fn can_undo(&self) -> bool {
@ -40,6 +41,18 @@ impl EditorHistory {
!self.redo_stack.is_empty() !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<String>) {
self.status = format!("Undo: {}", label.into());
}
fn set_redo_status(&mut self, label: impl Into<String>) {
self.status = format!("Redo: {}", label.into());
}
fn pop_undo(&mut self) -> Option<EditorCommand> { fn pop_undo(&mut self) -> Option<EditorCommand> {
self.undo_stack.pop() self.undo_stack.pop()
} }
@ -695,8 +708,10 @@ pub fn apply_command_undo(world: &mut World) {
let Some(mut command) = world.resource_mut::<EditorHistory>().pop_undo() else { let Some(mut command) = world.resource_mut::<EditorHistory>().pop_undo() else {
return; return;
}; };
let label = command.label();
undo_command(world, &mut command); undo_command(world, &mut command);
world.resource_mut::<EditorHistory>().push_redo(command); world.resource_mut::<EditorHistory>().push_redo(command);
world.resource_mut::<EditorHistory>().set_redo_status(label);
mark_dirty(world); mark_dirty(world);
} }
@ -704,8 +719,10 @@ pub fn apply_command_redo(world: &mut World) {
let Some(mut command) = world.resource_mut::<EditorHistory>().pop_redo() else { let Some(mut command) = world.resource_mut::<EditorHistory>().pop_redo() else {
return; return;
}; };
let label = command.label();
redo_command(world, &mut command); redo_command(world, &mut command);
world.resource_mut::<EditorHistory>().push_undo(command); world.resource_mut::<EditorHistory>().push_undo(command);
world.resource_mut::<EditorHistory>().set_undo_status(label);
mark_dirty(world); mark_dirty(world);
} }

View File

@ -5,6 +5,7 @@ pub mod assets;
pub mod ext; pub mod ext;
pub mod history; pub mod history;
pub mod infra; pub mod infra;
pub mod operators;
pub mod play; pub mod play;
pub mod project; pub mod project;
pub mod render_target; pub mod render_target;
@ -51,6 +52,7 @@ use extensibility::ExtensibilityPlugin;
use gizmos::EditorGizmoPlugin; use gizmos::EditorGizmoPlugin;
use history::EditorHistoryPlugin; use history::EditorHistoryPlugin;
use infra::EditorInfraPlugin; use infra::EditorInfraPlugin;
use operators::OperatorPlugin;
use play::PlaySessionPlugin; use play::PlaySessionPlugin;
use project_io::ProjectIoPlugin; use project_io::ProjectIoPlugin;
use render_view::RenderViewPlugin; use render_view::RenderViewPlugin;
@ -77,6 +79,7 @@ impl PluginGroup for EditorPluginGroup {
.add(scene_schema::SceneSchemaPlugin) .add(scene_schema::SceneSchemaPlugin)
.add(net_editor::NetEditorPlugin) .add(net_editor::NetEditorPlugin)
.add(AssetDbPlugin) .add(AssetDbPlugin)
.add(OperatorPlugin)
.add(ExtensibilityPlugin) .add(ExtensibilityPlugin)
.add(SettingsUiPlugin) .add(SettingsUiPlugin)
.add(assets::EditorAssetsPlugin) .add(assets::EditorAssetsPlugin)

View File

@ -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<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
);
}
}

View File

@ -5,6 +5,7 @@ use bevy_egui::egui;
use bevy_inspector_egui::bevy_inspector::hierarchy::SelectedEntities; use bevy_inspector_egui::bevy_inspector::hierarchy::SelectedEntities;
use crate::history::EditorHistory; use crate::history::EditorHistory;
use crate::operators::ActiveOperator;
use crate::scene_io::SceneIo; use crate::scene_io::SceneIo;
use crate::state::{EditorMode, PlayPaused}; use crate::state::{EditorMode, PlayPaused};
@ -51,6 +52,10 @@ pub fn status_bar_ui(
.color(TEXT), .color(TEXT),
); );
if let Some(operator_label) = world.resource::<ActiveOperator>().label() {
ui.label(egui::RichText::new(operator_label).color(TEXT));
}
#[cfg(feature = "hot-reload")] #[cfg(feature = "hot-reload")]
if let Some(hot) = world.get_resource::<crate::hot_reload::HotReloadState>() { if let Some(hot) = world.get_resource::<crate::hot_reload::HotReloadState>() {
ui.label(egui::RichText::new(hot.label.clone()).color(TEXT)); ui.label(egui::RichText::new(hot.label.clone()).color(TEXT));

View File

@ -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 | | `history/` | Undo/redo command stack | README controls |
| `crates/scene` | Schema stamp/migrate/validate (shared) | ADR 0006, ADR 0008 | | `crates/scene` | Schema stamp/migrate/validate (shared) | ADR 0006, ADR 0008 |
| `ext/extensibility.rs` | Command registry, `ActorInspectorSection`, palette | Phase 6 | | `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 | | `ext/game_inspector.rs` | Game-registered inspector sections | Phase 6 |
## Design intentions (stable) ## Design intentions (stable)

View File

@ -29,6 +29,7 @@ Native-only dependencies (`rfd`, BRP HTTP) stay in `editor`. `settings`, `shared
| `ActorIconsPlugin` | Selectable actor root billboard icons | | `ActorIconsPlugin` | Selectable actor root billboard icons |
| `EditorVisualizersPlugin` | Collider/light/spawn/prefab/runtime visualizers and selectable proxies | | `EditorVisualizersPlugin` | Collider/light/spawn/prefab/runtime visualizers and selectable proxies |
| `SceneIoPlugin` | Level New/Open/Save, recent paths | | `SceneIoPlugin` | Level New/Open/Save, recent paths |
| `OperatorPlugin` | Active operator lifecycle/status for commands and modal tools |
| `SettingsUiPlugin` | egui Project Settings panel | | `SettingsUiPlugin` | egui Project Settings panel |
| `EditorUiPlugin` | Dock, themed egui shell, viewport overlays, menus, status bar | | `EditorUiPlugin` | Dock, themed egui shell, viewport overlays, menus, status bar |
| `BrpPlugin` | Bevy Remote Protocol HTTP (editor-only) | | `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 ## Undo / history
Structural edits (spawn, delete, transform, rename) go through `EditorHistory` command objects in `history.rs`. 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. PIE stop restores player simulation state only; authored `LevelObject` edits made during PIE remain in the scene.