Skip to main content

khora_core/ui/editor/
command.rs

1// Copyright 2025 eraflo
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! Undo/redo command history for editor operations.
16//!
17//! Each user action (property edit, spawn, delete) is wrapped in an
18//! [`EditorCommand`] and pushed onto the [`CommandHistory`] stack.
19
20use super::state::PropertyEdit;
21
22/// A reversible editor operation.
23#[derive(Debug, Clone)]
24pub struct EditorCommand {
25    /// Human-readable label (e.g. "Set Transform", "Rename Entity").
26    pub description: String,
27    /// The forward edit.
28    pub forward: PropertyEdit,
29    /// The reverse edit to undo `forward`.
30    pub reverse: PropertyEdit,
31}
32
33/// Fixed-capacity undo/redo stack.
34///
35/// New commands push onto `undo_stack` and clear `redo_stack`.
36/// Undo pops from `undo_stack`, applies `reverse`, pushes onto `redo_stack`.
37/// Redo pops from `redo_stack`, applies `forward`, pushes onto `undo_stack`.
38#[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    /// Creates a new history with the given maximum stack depth.
57    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    /// Push a command after executing its forward edit.
66    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    /// Undo the last command. Returns the reverse `PropertyEdit` to apply.
75    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    /// Redo the last undone command. Returns the forward `PropertyEdit` to apply.
83    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    /// Whether there is anything to undo.
91    pub fn can_undo(&self) -> bool {
92        !self.undo_stack.is_empty()
93    }
94
95    /// Whether there is anything to redo.
96    pub fn can_redo(&self) -> bool {
97        !self.redo_stack.is_empty()
98    }
99
100    /// Description of the next undoable command (for UI display).
101    pub fn undo_description(&self) -> Option<&str> {
102        self.undo_stack.last().map(|c| c.description.as_str())
103    }
104
105    /// Description of the next redoable command (for UI display).
106    pub fn redo_description(&self) -> Option<&str> {
107        self.redo_stack.last().map(|c| c.description.as_str())
108    }
109
110    /// Number of commands currently on the undo stack. Bounded by the
111    /// configured maximum depth.
112    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        // The stack never grows past its cap, and the oldest entries are the
141        // ones dropped — the newest command is still on top.
142        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        // A fresh edit after an undo discards the redo branch.
153        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}