Skip to content

Commit cc028c8

Browse files
committed
Wire undo/redo to viewport toolbar and Edit menu
The viewport toolbar's undo/redo buttons were rendered dimmed and the Edit menu items were no-ops. Hooked them up to the existing command- based undo system. - Extract undo_once / redo_once as public functions in renzora_undo so callers can drive the stack directly without bouncing through the message bus. Fixes a frame-timing bug where clicks queued in EguiPrimaryContextPass wrote RequestUndo messages that were rotated out before handle_undo's iter_current_update_messages could see them. - Viewport toolbar buttons now render enabled/disabled based on UndoStacks::can_undo/can_redo and call undo_once/redo_once via EditorCommands on click. - Added TitleBarAction::Undo / Redo variants; Edit -> Undo / Redo buttons now fire them. - To dispatch undo/redo from renzora_editor_framework without adding a circular dep on renzora_undo, introduced EditorActionHooks { undo, redo } in the editor framework. UndoPlugin registers undo_once / redo_once as hooks on build (init_resource first so plugin ordering doesn't matter). - Route Ctrl+Z / Ctrl+Y through KeyBindings::just_pressed(EditorAction:: Undo/Redo) instead of hardcoded KeyCodes. User rebinds in Settings -> Shortcuts and command palette dispatches both work now. - Mesh draw spawn is undoable: SpawnDrawnMeshCmd implements UndoCommand. execute spawns and captures the entity, undo despawns, redo respawns.
1 parent c811e2d commit cc028c8

5 files changed

Lines changed: 167 additions & 38 deletions

File tree

crates/core/renzora_undo/src/lib.rs

Lines changed: 44 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -121,30 +121,47 @@ impl Plugin for UndoPlugin {
121121
.add_message::<RequestUndo>()
122122
.add_message::<RequestRedo>()
123123
.add_message::<UndoExhausted>()
124+
// Ensure the hooks resource exists regardless of plugin order —
125+
// RenzoraEditorPlugin also initialises it, but we can't rely on
126+
// that running first.
127+
.init_resource::<renzora_editor_framework::EditorActionHooks>()
124128
.add_systems(Update, (shortcut_input, handle_undo, handle_redo).chain());
129+
130+
// Register undo/redo as late-bound hooks so the editor framework's
131+
// title bar / menu handlers can invoke them without taking a
132+
// dependency on this crate (which would create a cycle).
133+
let mut hooks = app
134+
.world_mut()
135+
.resource_mut::<renzora_editor_framework::EditorActionHooks>();
136+
hooks.undo = Some(undo_once);
137+
hooks.redo = Some(redo_once);
125138
}
126139
}
127140

128141
fn shortcut_input(
129142
keys: Res<ButtonInput<KeyCode>>,
143+
bindings: Option<Res<renzora_core::keybindings::KeyBindings>>,
130144
mut undo_w: MessageWriter<RequestUndo>,
131145
mut redo_w: MessageWriter<RequestRedo>,
132146
) {
133-
let ctrl = keys.pressed(KeyCode::ControlLeft) || keys.pressed(KeyCode::ControlRight)
134-
|| keys.pressed(KeyCode::SuperLeft) || keys.pressed(KeyCode::SuperRight);
135-
if !ctrl { return; }
136-
let shift = keys.pressed(KeyCode::ShiftLeft) || keys.pressed(KeyCode::ShiftRight);
137-
if keys.just_pressed(KeyCode::KeyZ) {
138-
if shift { redo_w.write(RequestRedo); } else { undo_w.write(RequestUndo); }
139-
} else if keys.just_pressed(KeyCode::KeyY) {
147+
// Route through KeyBindings so:
148+
// - User-rebound Undo/Redo keys are respected
149+
// - Command palette dispatches (KeyBindings::dispatch) fire the messages
150+
let Some(bindings) = bindings else { return };
151+
use renzora_core::keybindings::EditorAction;
152+
if bindings.just_pressed(EditorAction::Undo, &keys) {
153+
undo_w.write(RequestUndo);
154+
}
155+
if bindings.just_pressed(EditorAction::Redo, &keys) {
140156
redo_w.write(RequestRedo);
141157
}
142158
}
143159

144-
fn handle_undo(world: &mut World) {
145-
let count = world.get_resource::<Messages<RequestUndo>>()
146-
.map(|m| m.iter_current_update_messages().count()).unwrap_or(0);
147-
if count == 0 { return; }
160+
/// Undo the most recent action on the active stack. Callable from anywhere
161+
/// with `&mut World` — bypasses the message bus so it works from deferred
162+
/// callers (toolbar clicks, menu items, command palette) without frame-timing
163+
/// concerns.
164+
pub fn undo_once(world: &mut World) {
148165
let active = world.resource::<UndoStacks>().active.clone();
149166
let cmd = world.resource_mut::<UndoStacks>().stacks
150167
.get_mut(&active).and_then(|s| s.undo.pop_back());
@@ -155,10 +172,8 @@ fn handle_undo(world: &mut World) {
155172
}
156173
}
157174

158-
fn handle_redo(world: &mut World) {
159-
let count = world.get_resource::<Messages<RequestRedo>>()
160-
.map(|m| m.iter_current_update_messages().count()).unwrap_or(0);
161-
if count == 0 { return; }
175+
/// Redo the most recently undone action on the active stack.
176+
pub fn redo_once(world: &mut World) {
162177
let active = world.resource::<UndoStacks>().active.clone();
163178
let cmd = world.resource_mut::<UndoStacks>().stacks
164179
.get_mut(&active).and_then(|s| s.redo.pop_back());
@@ -169,6 +184,20 @@ fn handle_redo(world: &mut World) {
169184
}
170185
}
171186

187+
fn handle_undo(world: &mut World) {
188+
let count = world.get_resource::<Messages<RequestUndo>>()
189+
.map(|m| m.iter_current_update_messages().count()).unwrap_or(0);
190+
if count == 0 { return; }
191+
undo_once(world);
192+
}
193+
194+
fn handle_redo(world: &mut World) {
195+
let count = world.get_resource::<Messages<RequestRedo>>()
196+
.map(|m| m.iter_current_update_messages().count()).unwrap_or(0);
197+
if count == 0 { return; }
198+
redo_once(world);
199+
}
200+
172201
// ──────────────────────────────────────────────────────────────────────────
173202
// Built-in commands for common scene operations.
174203
// Plugins may use these or define their own.

crates/editor/renzora_editor/src/lib.rs

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,16 @@ pub use renzora_macros::{Inspectable, post_process};
2727
pub use selection::EditorSelection;
2828
pub use shortcut_registry::{ShortcutEntry, ShortcutRegistry};
2929
pub use toolbar_registry::{ToolEntry, ToolSection, ToolbarRegistry};
30+
31+
/// Late-bound hooks for actions that live in downstream crates the editor
32+
/// framework can't depend on directly (avoids a cycle with `renzora_undo`).
33+
/// Downstream crates install hooks in their `Plugin::build`; the menu /
34+
/// title bar handlers call them when present.
35+
#[derive(bevy::prelude::Resource, Default, Clone)]
36+
pub struct EditorActionHooks {
37+
pub undo: Option<fn(&mut bevy::prelude::World)>,
38+
pub redo: Option<fn(&mut bevy::prelude::World)>,
39+
}
3040
pub use viewport_overlay::{ViewportOverlayDrawer, ViewportOverlayRegistry};
3141
pub use settings::{CustomFonts, EditorSettings, MonoFont, SelectionHighlightMode, SettingsTab, UiFont};
3242

@@ -181,7 +191,8 @@ impl Plugin for RenzoraEditorPlugin {
181191
.init_resource::<ComponentIconRegistry>()
182192
.init_resource::<ViewportOverlayRegistry>()
183193
.init_resource::<ToolbarRegistry>()
184-
.init_resource::<ShortcutRegistry>();
194+
.init_resource::<ShortcutRegistry>()
195+
.init_resource::<EditorActionHooks>();
185196

186197
register_builtin_tools(
187198
&mut app.world_mut().resource_mut::<ToolbarRegistry>(),
@@ -918,6 +929,16 @@ pub fn editor_ui_system(world: &mut World) {
918929
TitleBarAction::StartTutorial => {
919930
world.insert_resource(renzora_core::TutorialRequested);
920931
}
932+
TitleBarAction::Undo => {
933+
if let Some(hook) = world.get_resource::<EditorActionHooks>().and_then(|h| h.undo) {
934+
hook(world);
935+
}
936+
}
937+
TitleBarAction::Redo => {
938+
if let Some(hook) = world.get_resource::<EditorActionHooks>().and_then(|h| h.redo) {
939+
hook(world);
940+
}
941+
}
921942
TitleBarAction::None => {}
922943
}
923944

crates/editor/renzora_mesh_draw/src/lib.rs

Lines changed: 42 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -390,7 +390,11 @@ fn handle_mouse_input(
390390
let current_mode = state.tool_mode;
391391
if let Some(cmds) = cmds {
392392
cmds.push(move |world: &mut World| {
393-
spawn_drawn_mesh(world, recipe);
393+
renzora::undo::execute(
394+
world,
395+
renzora::undo::UndoContext::Scene,
396+
Box::new(SpawnDrawnMeshCmd { recipe, entity: None }),
397+
);
394398
// Auto-deactivate after committing so the next click
395399
// doesn't immediately start another shape.
396400
deactivate_tool(world, current_mode);
@@ -548,8 +552,9 @@ fn draw_cursor_overlay(ui: &mut egui::Ui, world: &World, rect: egui::Rect) {
548552

549553
// ── Commit drawn meshes ────────────────────────────────────────────────────
550554

551-
fn spawn_drawn_mesh(world: &mut World, recipe: MeshDrawRecipe) {
552-
let (mesh, pivot) = build_recipe_mesh(&recipe);
555+
/// Spawn a mesh built from `recipe` and return its entity.
556+
fn spawn_drawn_mesh(world: &mut World, recipe: &MeshDrawRecipe) -> Entity {
557+
let (mesh, pivot) = build_recipe_mesh(recipe);
553558
let is_polygon = matches!(recipe.footprint, Footprint::Polygon { .. });
554559
let mesh_handle = world.resource_mut::<Assets<Mesh>>().add(mesh);
555560
let material_handle = world.resource_mut::<Assets<StandardMaterial>>().add(
@@ -562,14 +567,40 @@ fn spawn_drawn_mesh(world: &mut World, recipe: MeshDrawRecipe) {
562567
..default()
563568
}
564569
);
565-
world.spawn((
566-
Name::new("DrawnMesh"),
567-
Mesh3d(mesh_handle),
568-
MeshMaterial3d(material_handle),
569-
Transform::from_translation(pivot),
570-
Visibility::default(),
571-
recipe,
572-
));
570+
world
571+
.spawn((
572+
Name::new("DrawnMesh"),
573+
Mesh3d(mesh_handle),
574+
MeshMaterial3d(material_handle),
575+
Transform::from_translation(pivot),
576+
Visibility::default(),
577+
recipe.clone(),
578+
))
579+
.id()
580+
}
581+
582+
/// Undoable spawn: `execute` creates the entity and remembers its id;
583+
/// `undo` despawns it; `execute` runs again on redo and respawns with the
584+
/// original recipe.
585+
struct SpawnDrawnMeshCmd {
586+
recipe: MeshDrawRecipe,
587+
entity: Option<Entity>,
588+
}
589+
590+
impl renzora::undo::UndoCommand for SpawnDrawnMeshCmd {
591+
fn label(&self) -> &str { "Draw Mesh" }
592+
593+
fn execute(&mut self, world: &mut World) {
594+
self.entity = Some(spawn_drawn_mesh(world, &self.recipe));
595+
}
596+
597+
fn undo(&mut self, world: &mut World) {
598+
if let Some(entity) = self.entity.take() {
599+
if let Ok(ent) = world.get_entity_mut(entity) {
600+
ent.despawn();
601+
}
602+
}
603+
}
573604
}
574605

575606
/// Build a mesh from a recipe. Returns `(mesh, pivot)` where pivot is the

crates/editor/renzora_viewport/src/toolbar.rs

Lines changed: 55 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -111,21 +111,28 @@ pub fn render_tool_overlay(ctx: &egui::Context, world: &World, content_rect: Rec
111111
// Divider before undo/redo
112112
draw_divider(ui, &mut y, panel_pos.x, panel_w, border_color);
113113

114-
// Undo | Redo (dimmed — no command history yet)
115-
let disabled_color = Color32::from_rgba_unmultiplied(inactive_color.r(), inactive_color.g(), inactive_color.b(), 80);
116-
let disabled_icon_color = Color32::from_white_alpha(40);
114+
// Undo | Redo — enabled when the active undo stack has entries.
115+
let stacks = world.get_resource::<renzora::undo::UndoStacks>();
116+
let (can_undo, can_redo) = match stacks {
117+
Some(s) => (s.can_undo(&s.active), s.can_redo(&s.active)),
118+
None => (false, false),
119+
};
117120

118121
let undo_rect = Rect::from_min_size(Pos2::new(col0_x, y), BTN_SIZE);
119-
ui.allocate_rect(undo_rect, Sense::hover());
120-
ui.painter().rect_filled(undo_rect, CornerRadius::same(3), disabled_color);
121-
ui.painter().text(undo_rect.center(), egui::Align2::CENTER_CENTER, ARROW_U_UP_LEFT, FontId::proportional(16.0), disabled_icon_color);
122-
ui.allocate_rect(undo_rect, Sense::hover()).on_hover_text("Undo (Ctrl+Z)");
122+
let r = undo_redo_button(ui, undo_rect, ARROW_U_UP_LEFT, can_undo,
123+
inactive_color, hovered_color);
124+
if r.clicked() {
125+
cmds.push(|w: &mut World| { renzora::undo::undo_once(w); });
126+
}
127+
r.on_hover_text("Undo (Ctrl+Z)");
123128

124129
let redo_rect = Rect::from_min_size(Pos2::new(col1_x, y), BTN_SIZE);
125-
ui.allocate_rect(redo_rect, Sense::hover());
126-
ui.painter().rect_filled(redo_rect, CornerRadius::same(3), disabled_color);
127-
ui.painter().text(redo_rect.center(), egui::Align2::CENTER_CENTER, ARROW_U_UP_RIGHT, FontId::proportional(16.0), disabled_icon_color);
128-
ui.allocate_rect(redo_rect, Sense::hover()).on_hover_text("Redo (Ctrl+Y)");
130+
let r = undo_redo_button(ui, redo_rect, ARROW_U_UP_RIGHT, can_redo,
131+
inactive_color, hovered_color);
132+
if r.clicked() {
133+
cmds.push(|w: &mut World| { renzora::undo::redo_once(w); });
134+
}
135+
r.on_hover_text("Redo (Ctrl+Y)");
129136
y += row_step;
130137

131138
// Terrain section — only rendered if any entries are currently visible
@@ -273,6 +280,43 @@ fn render_tool_section(
273280
if tools.len() % 2 == 1 { *y += row_step; }
274281
}
275282

283+
/// A toolbar button for undo/redo — enabled state accepts clicks; disabled
284+
/// state renders dimmed and only shows a tooltip.
285+
fn undo_redo_button(
286+
ui: &mut egui::Ui,
287+
rect: Rect,
288+
icon: &str,
289+
enabled: bool,
290+
inactive_color: Color32,
291+
hovered_color: Color32,
292+
) -> egui::Response {
293+
if !enabled {
294+
let resp = ui.allocate_rect(rect, Sense::hover());
295+
if ui.is_rect_visible(rect) {
296+
let dim_bg = Color32::from_rgba_unmultiplied(
297+
inactive_color.r(), inactive_color.g(), inactive_color.b(), 80,
298+
);
299+
ui.painter().rect_filled(rect, CornerRadius::same(3), dim_bg);
300+
ui.painter().text(
301+
rect.center(), egui::Align2::CENTER_CENTER, icon,
302+
FontId::proportional(16.0), Color32::from_white_alpha(40),
303+
);
304+
}
305+
return resp;
306+
}
307+
let resp = ui.allocate_rect(rect, Sense::click());
308+
if resp.hovered() { ui.ctx().set_cursor_icon(CursorIcon::PointingHand); }
309+
if ui.is_rect_visible(rect) {
310+
let bg = if resp.hovered() { hovered_color } else { inactive_color };
311+
ui.painter().rect_filled(rect, CornerRadius::same(3), bg);
312+
ui.painter().text(
313+
rect.center(), egui::Align2::CENTER_CENTER, icon,
314+
FontId::proportional(16.0), Color32::WHITE,
315+
);
316+
}
317+
resp
318+
}
319+
276320
fn viewport_tool_button(
277321
ui: &mut egui::Ui, rect: Rect, icon: &str, active: bool,
278322
active_color: Color32, inactive_color: Color32, hovered_color: Color32,

crates/ui/renzora_ui/src/title_bar.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ pub enum TitleBarAction {
2727
Pause,
2828
ScriptsOnly,
2929
StartTutorial,
30+
Undo,
31+
Redo,
3032
}
3133

3234
const TITLE_BAR_HEIGHT: f32 = 28.0;
@@ -114,9 +116,11 @@ pub fn render_title_bar(
114116

115117
ui.menu_button("Edit", |ui| {
116118
if ui.button("Undo").clicked() {
119+
action = TitleBarAction::Undo;
117120
ui.close();
118121
}
119122
if ui.button("Redo").clicked() {
123+
action = TitleBarAction::Redo;
120124
ui.close();
121125
}
122126
});

0 commit comments

Comments
 (0)