Making Edits Undoable
How to route a panel's edits through the shared undo/redo system so they land on the right history and show in the History panel.
The model: one command stack, per-context
Undo lives in renzora_undo. It is a command system, not a snapshot-of-the-world system: every user action is an UndoCommand with an execute and an undo. Commands are stored on per-context stacks (UndoStacks), keyed by UndoContext:
UndoContext::Scene— the viewport, hierarchy, inspector and terrain all share this one.UndoContext::MaterialGraph(path)/Blueprint(path)— a self-contained document editor's own stack.UndoContext::Other(path)— any other asset document.
Ctrl+Z/Ctrl+Y are handled centrally (UndoPlugin) and always act on UndoStacks::active. You do not write a keyboard handler — a plugin that hard-codes its own Ctrl+Z (terrain used to) fights the router and stays invisible to the History panel.
route_undo_context keeps active pointed at the focused document (from EditorContext + the dock's FocusedPanel), so you rarely set it yourself.
The one rule: don't mutate the world directly — record a command
Instead of writing the change straight into the world from your panel, build a command and hand it to renzora_undo:
// You already applied the change during a gesture → record it (no re-apply):
renzora_undo::record(world, ctx, cmd);
// You have NOT applied it yet → execute runs it, then records:
renzora_undo::execute(world, ctx, cmd);
Use renzora_undo::active_context(world) for ctx when the edit belongs to "whatever the user is looking at" (the inspector does this); pass an explicit context for a document editor.
Pick the right command
A field/property edit → reuse FieldChangeCmd. It wraps the inspector's get/set fn-pointer signature and captures old/new. This is how every inspector field is undoable:
renzora_undo::execute(world, renzora_undo::active_context(world),
Box::new(renzora_undo::FieldChangeCmd { entity, field_name, old, new, set_fn }));
FieldChangeCmd::merge folds consecutive edits of the same field into one step, so a drag that fires every frame is a single entry.
A blob edit (heightmaps, tilemap grids, particle/animation assets — anything awkward to express as a fine-grained command) → use SnapshotCmd<S>. Capture a before blob when the gesture starts and an after blob when it ends, plus a restore fn; record it (the mutation already happened live):
renzora_undo::record(world, renzora_undo::UndoContext::Scene,
Box::new(renzora_undo::SnapshotCmd {
label: "Terrain".to_string(), before, after, restore: restore_terrain,
}));
fn restore_terrain(world: &mut World, blob: &TerrainUndoEntry) writes the blob back. undo restores before, redo restores after. See renzora_terrain_editor::systems::terrain_stroke_end_system for a full example.
Deleting entities → call renzora_undo::delete_entities_with_undo(world, &entities). It snapshots each entity's whole subtree (all components + children) to a BSN string before despawning, so undo restores lights, cameras, imported models, 2D nodes and groups faithfully — never hand-roll a despawn for scene entities. (Restoring one component's value — e.g. to undo a component removal — uses renzora::core::reflection::capture_component / insert_component_reflected.)
Several changes that are one user action (multi-reparent, paste) → wrap them in CompoundCmd so they undo as one step.
For anything bespoke, implement UndoCommand directly (see the built-ins in renzora_undo/src/lib.rs).
The change-observer pattern (for single-buffer editors)
When an editor edits one cheaply-Clone "document" through many scattered code paths (a material/blueprint graph, a .particle buffer, a .anim clip), don't wrap every edit site. Instead run one observer system that snapshots the document whenever it changes:
- Keep a shadow copy (in a
Resource) of the document plus a cheap identity of which document it is (path / entity / selection key). - Each frame, compare the current document to the shadow. Use
PartialEqif the type derives it, or a serialized string (e.g.ron::to_string) when it doesn't — full serialization catches every field. - If the identity changed (a different document loaded), reseed the shadow and return — loading is not an edit.
- If the document changed,
recordaSnapshotCmdwithmerge_key: Some(...)so per-frame scrub spam collapses into one step, then update the shadow. - In the
SnapshotCmd'srestore, apply the blob and update the shadow to match — otherwise undo/redo looks like a fresh edit and feeds back into the stack.
This is how the material, blueprint, particle and animation editors are wired (see renzora_material_editor/src/native_graph.rs's material_undo_observer). It covers every present and future edit path from one place.
Tilemaps are the exception: their "document" is a set of child sprite entities, so they snapshot the layer's tiles at paint-stroke boundaries instead (renzora_tilemap_editor's tilemap_stroke_begin/_end), mirroring the terrain stroke pattern.
Seal at gesture boundaries
Merging means two separate drags on the same field would otherwise fold together. renzora_undo seals the active stack automatically on mouse-release and on Enter/Esc, so most gestures are split for free. If your editor has its own commit point, call it explicitly:
renzora_undo::seal(world, &ctx); // the next record starts a fresh step
Checklist for a new panel
- Add
renzora_undoto the crate'sCargo.toml. - Replace direct world mutations in your edit handlers with
record/execute+ the right command. - Push into the correct context (
active_contextfor scene-attached edits; an explicitMaterialGraph/Blueprint/Other(path)for a document editor). sealat your commit boundary if the built-in mouse/keyboard seal doesn't cover it.- Confirm entries appear in the History panel and
Ctrl+Z/Ctrl+Ywalk them.