diff --git a/src/layout.rs b/src/layout.rs index 8273878..aac57e0 100644 --- a/src/layout.rs +++ b/src/layout.rs @@ -2,7 +2,7 @@ use std::collections::{HashSet, VecDeque}; use crate::seed; -#[derive(Debug, Clone)] +#[derive(Debug, Clone, PartialEq, Eq)] pub struct Room { pub x: usize, pub y: usize, @@ -17,7 +17,7 @@ impl Room { } } -#[derive(Debug, Clone)] +#[derive(Debug, Clone, PartialEq, Eq)] pub struct Corridor { #[allow(dead_code)] pub id: u64, @@ -27,7 +27,7 @@ pub struct Corridor { pub width: usize, } -#[derive(Debug, Clone)] +#[derive(Debug, Clone, PartialEq, Eq)] pub struct Door { pub from: (usize, usize), pub to: (usize, usize), @@ -39,7 +39,7 @@ pub struct Door { pub manual: bool, } -#[derive(Debug, Clone)] +#[derive(Debug, Clone, PartialEq, Eq)] pub struct Window { pub cell: (usize, usize), pub side: WindowSide, @@ -74,7 +74,7 @@ pub struct WindowSettings { pub allow_internal_windows: bool, } -#[derive(Debug, Clone)] +#[derive(Debug, Clone, PartialEq, Eq)] pub struct DungeonLayout { pub rooms: Vec, pub corridors: Vec, diff --git a/src/main.rs b/src/main.rs index 53896a9..a711edc 100644 --- a/src/main.rs +++ b/src/main.rs @@ -23,6 +23,13 @@ enum ManualDoorKind { Secret, } +#[derive(Debug, Clone, PartialEq, Eq)] +struct AppSnapshot { + settings: UiSettings, + layout: DungeonLayout, + suppressed_auto_door_edges: HashSet<((usize, usize), (usize, usize))>, +} + // Boot the native app and start the egui event loop. fn main() -> eframe::Result<()> { let options = eframe::NativeOptions::default(); @@ -38,6 +45,8 @@ struct DungeonApp { settings: UiSettings, layout: DungeonLayout, suppressed_auto_door_edges: HashSet<((usize, usize), (usize, usize))>, + undo_stack: Vec, + redo_stack: Vec, drag_state: Option, export_rx: Option>>, pending_delete: bool, @@ -72,6 +81,8 @@ impl Default for DungeonApp { settings, layout, suppressed_auto_door_edges: HashSet::new(), + undo_stack: Vec::new(), + redo_stack: Vec::new(), drag_state: None, export_rx: None, pending_delete: false, @@ -97,6 +108,11 @@ impl eframe::App for DungeonApp { // Render UI, handle input, and update app state each frame. fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { let panel_result = draw_side_panel(ctx, &mut self.settings, self.export_rx.is_some()); + let undo_requested = ctx.input(|i| i.modifiers.command && i.key_pressed(egui::Key::Z)); + let redo_requested = ctx.input(|i| { + (i.modifiers.command && i.key_pressed(egui::Key::Y)) + || (i.modifiers.command && i.modifiers.shift && i.key_pressed(egui::Key::Z)) + }); if self.settings.min_room_size > self.settings.max_room_size { self.settings.max_room_size = self.settings.min_room_size; @@ -111,9 +127,17 @@ impl eframe::App for DungeonApp { self.settings.max_window_width = self.settings.min_window_width; } + if undo_requested { + self.undo(); + } else if redo_requested { + self.redo(); + } + if panel_result.clear_clicked { + self.push_undo_snapshot(); self.clear_layout(); } else if panel_result.reset_clicked || panel_result.settings_changed { + self.push_undo_snapshot(); self.regenerate_layout(); } if ctx.input(|i| i.key_pressed(egui::Key::Delete) || i.key_pressed(egui::Key::Backspace)) { @@ -192,6 +216,8 @@ impl eframe::App for DungeonApp { ui.heading("Keybinds"); ui.add_space(6.0); ui.label("Delete / Backspace: Remove hovered room or corridor"); + ui.label("Ctrl+Z: Undo"); + ui.label("Ctrl+Y: Redo"); ui.label("Right click: Cancel add tool"); ui.label("Right click + drag: Resize hovered room"); }); @@ -228,6 +254,55 @@ impl eframe::App for DungeonApp { } impl DungeonApp { + fn snapshot(&self) -> AppSnapshot { + AppSnapshot { + settings: self.settings.clone(), + layout: self.layout.clone(), + suppressed_auto_door_edges: self.suppressed_auto_door_edges.clone(), + } + } + + fn push_undo_snapshot(&mut self) { + let snapshot = self.snapshot(); + if self.undo_stack.last() == Some(&snapshot) { + return; + } + self.undo_stack.push(snapshot); + if self.undo_stack.len() > 100 { + self.undo_stack.remove(0); + } + self.redo_stack.clear(); + } + + fn restore_snapshot(&mut self, snapshot: AppSnapshot) { + self.settings = snapshot.settings; + self.layout = snapshot.layout; + self.suppressed_auto_door_edges = snapshot.suppressed_auto_door_edges; + self.drag_state = None; + self.add_corridor_drag = None; + self.resize_state = None; + self.hover_room_idx = None; + self.hover_corridor_idx = None; + self.hover_door_idx = None; + self.pending_delete = false; + } + + fn undo(&mut self) { + let Some(snapshot) = self.undo_stack.pop() else { + return; + }; + self.redo_stack.push(self.snapshot()); + self.restore_snapshot(snapshot); + } + + fn redo(&mut self) { + let Some(snapshot) = self.redo_stack.pop() else { + return; + }; + self.undo_stack.push(self.snapshot()); + self.restore_snapshot(snapshot); + } + // Clear the current layout and reset transient interaction state. fn clear_layout(&mut self) { self.layout = DungeonLayout { @@ -284,6 +359,7 @@ impl DungeonApp { && let Some((room_idx, offset_x, offset_y)) = self.room_at_pointer(pointer_pos, geometry) { + self.push_undo_snapshot(); self.drag_state = Some(DragState::Room(RoomDragState { room_idx, offset_x, @@ -293,6 +369,7 @@ impl DungeonApp { && let Some(pointer_pos) = response.interact_pointer_pos() && let Some(corridor_drag) = self.corridor_drag_at_pointer(pointer_pos, geometry) { + self.push_undo_snapshot(); self.drag_state = Some(DragState::Corridor(corridor_drag)); } @@ -568,6 +645,7 @@ impl DungeonApp { // Add a room anchored to the given grid cell if space allows. fn add_room_at_cell(&mut self, cell: (usize, usize)) { + self.push_undo_snapshot(); let width = self.settings.min_room_size.max(1); let height = self.settings.min_room_size.max(1); if cell.0 >= self.settings.cols || cell.1 >= self.settings.rows { @@ -603,6 +681,7 @@ impl DungeonApp { // Add a corridor between two room cells using a shortest path. fn add_corridor_between(&mut self, start: (usize, usize), end: (usize, usize)) { + self.push_undo_snapshot(); if start == end { return; } @@ -694,6 +773,7 @@ impl DungeonApp { if let Some(pointer_pos) = response.interact_pointer_pos() { if let Some(room_idx) = self.resize_room_at_pointer(pointer_pos, geometry) { if let Some(state) = self.start_resize(room_idx, pointer_pos, geometry) { + self.push_undo_snapshot(); self.resize_state = Some(state); return true; } @@ -956,6 +1036,7 @@ impl DungeonApp { .iter() .any(|e| normalized_edge(e.0, e.1) == target) }) { + self.push_undo_snapshot(); if !self.layout.doors[idx].manual { for edge in door_edges_for( &self.layout.doors[idx], @@ -1010,12 +1091,14 @@ impl DungeonApp { } if let Some((room_idx, _, _)) = self.room_at_pointer(pointer_pos, geometry) { + self.push_undo_snapshot(); self.delete_room(room_idx); return; } if let Some(corridor_drag) = self.corridor_drag_at_pointer(pointer_pos, geometry) { if corridor_drag.corridor_index < self.layout.corridors.len() { + self.push_undo_snapshot(); self.layout.corridors.remove(corridor_drag.corridor_index); self.drag_state = None; self.refresh_doors(); @@ -1051,6 +1134,7 @@ impl DungeonApp { geometry: &GridGeometry, kind: ManualDoorKind, ) { + self.push_undo_snapshot(); let Some(edge) = self.door_edge_at_pointer(pointer_pos, geometry) else { return; }; diff --git a/src/ui.rs b/src/ui.rs index 9bfb904..f921634 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -96,7 +96,7 @@ impl MaskFormat { } } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(default)] pub struct UiSettings { pub seed: u64,