khora_core/ui/editor/
command.rs1use super::state::PropertyEdit;
21
22#[derive(Debug, Clone)]
24pub struct EditorCommand {
25 pub description: String,
27 pub forward: PropertyEdit,
29 pub reverse: PropertyEdit,
31}
32
33#[derive(Debug, Clone)]
39pub struct CommandHistory {
40 undo_stack: Vec<EditorCommand>,
41 redo_stack: Vec<EditorCommand>,
42 max_size: usize,
43}
44
45impl Default for CommandHistory {
46 fn default() -> Self {
47 Self {
48 undo_stack: Vec::new(),
49 redo_stack: Vec::new(),
50 max_size: 256,
51 }
52 }
53}
54
55impl CommandHistory {
56 pub fn new(max_size: usize) -> Self {
58 Self {
59 undo_stack: Vec::new(),
60 redo_stack: Vec::new(),
61 max_size,
62 }
63 }
64
65 pub fn push(&mut self, cmd: EditorCommand) {
67 self.redo_stack.clear();
68 if self.undo_stack.len() >= self.max_size {
69 self.undo_stack.remove(0);
70 }
71 self.undo_stack.push(cmd);
72 }
73
74 pub fn undo(&mut self) -> Option<PropertyEdit> {
76 let cmd = self.undo_stack.pop()?;
77 let reverse = cmd.reverse.clone();
78 self.redo_stack.push(cmd);
79 Some(reverse)
80 }
81
82 pub fn redo(&mut self) -> Option<PropertyEdit> {
84 let cmd = self.redo_stack.pop()?;
85 let forward = cmd.forward.clone();
86 self.undo_stack.push(cmd);
87 Some(forward)
88 }
89
90 pub fn can_undo(&self) -> bool {
92 !self.undo_stack.is_empty()
93 }
94
95 pub fn can_redo(&self) -> bool {
97 !self.redo_stack.is_empty()
98 }
99
100 pub fn undo_description(&self) -> Option<&str> {
102 self.undo_stack.last().map(|c| c.description.as_str())
103 }
104
105 pub fn redo_description(&self) -> Option<&str> {
107 self.redo_stack.last().map(|c| c.description.as_str())
108 }
109
110 pub fn undo_depth(&self) -> usize {
113 self.undo_stack.len()
114 }
115}
116
117#[cfg(test)]
118mod tests {
119 use super::*;
120 use crate::ecs::entity::EntityId;
121
122 fn cmd(label: &str) -> EditorCommand {
123 let e = EntityId {
124 index: 0,
125 generation: 0,
126 };
127 EditorCommand {
128 description: label.to_owned(),
129 forward: PropertyEdit::SetName(e, format!("{label}-fwd")),
130 reverse: PropertyEdit::SetName(e, format!("{label}-rev")),
131 }
132 }
133
134 #[test]
135 fn push_is_bounded_and_evicts_oldest() {
136 let mut history = CommandHistory::new(4);
137 for i in 0..10 {
138 history.push(cmd(&format!("cmd{i}")));
139 }
140 assert_eq!(history.undo_depth(), 4);
143 assert_eq!(history.undo_description(), Some("cmd9"));
144 }
145
146 #[test]
147 fn push_clears_the_redo_stack() {
148 let mut history = CommandHistory::new(8);
149 history.push(cmd("a"));
150 history.undo();
151 assert!(history.can_redo());
152 history.push(cmd("b"));
154 assert!(!history.can_redo());
155 }
156
157 #[test]
158 fn undo_then_redo_roundtrips() {
159 let mut history = CommandHistory::new(8);
160 history.push(cmd("a"));
161 assert!(history.can_undo() && !history.can_redo());
162
163 let reverse = history.undo().expect("a command to undo");
164 assert!(matches!(reverse, PropertyEdit::SetName(_, ref s) if s == "a-rev"));
165 assert!(!history.can_undo() && history.can_redo());
166
167 let forward = history.redo().expect("a command to redo");
168 assert!(matches!(forward, PropertyEdit::SetName(_, ref s) if s == "a-fwd"));
169 assert!(history.can_undo() && !history.can_redo());
170 }
171
172 #[test]
173 fn undo_redo_on_empty_history_is_none() {
174 let mut history = CommandHistory::new(4);
175 assert!(history.undo().is_none());
176 assert!(history.redo().is_none());
177 }
178}