Skip to main content

koan_core/player/
undo.rs

1use std::collections::VecDeque;
2
3use super::state::{PlaylistItem, QueueItemId};
4
5/// Maximum number of undo entries retained.
6const MAX_UNDO_DEPTH: usize = 100;
7
8/// A reversible playlist operation. Stores enough state to undo/redo.
9///
10/// Each variant describes an action to reverse. `apply_entry` executes the
11/// reversal and returns the inverse entry for the opposite stack.
12#[derive(Debug, Clone)]
13pub enum UndoEntry {
14    /// Items were added (append). Undo = remove by IDs.
15    Added { ids: Vec<QueueItemId> },
16
17    /// Items were removed. Undo = re-add them at their original positions.
18    /// Each tuple: (item, id-of-predecessor or None if was first).
19    Removed {
20        items: Vec<(Box<PlaylistItem>, Option<QueueItemId>)>,
21    },
22
23    /// Items were inserted after a specific item. Undo = remove by IDs.
24    Inserted { ids: Vec<QueueItemId> },
25
26    /// A single item was moved. Undo = move it back.
27    Moved {
28        id: QueueItemId,
29        was_after: Option<QueueItemId>,
30    },
31
32    /// Multiple items were moved. Undo = restore original positions.
33    MovedBatch {
34        entries: Vec<(QueueItemId, Option<QueueItemId>)>,
35    },
36
37    /// Playlist was cleared / replaced. Undo = restore this snapshot.
38    Replaced {
39        items: Vec<PlaylistItem>,
40        cursor: Option<QueueItemId>,
41    },
42
43    /// Multiple operations batched as a single undo step (e.g. drag reorder).
44    Batch(Vec<UndoEntry>),
45}
46
47/// Standard undo/redo stack with bounded depth.
48///
49/// Lives on the Player struct — single-threaded, only the player command loop
50/// touches it. New actions clear the redo stack (standard semantics).
51///
52/// Uses `VecDeque` so evicting the oldest entry (front) is O(1) instead of
53/// the O(n) `Vec::remove(0)`.
54#[derive(Debug, Default)]
55pub struct UndoStack {
56    undo: VecDeque<UndoEntry>,
57    redo: VecDeque<UndoEntry>,
58}
59
60impl UndoStack {
61    pub fn new() -> Self {
62        Self::default()
63    }
64
65    /// Push an undo entry. Clears the redo stack.
66    pub fn push(&mut self, entry: UndoEntry) {
67        self.redo.clear();
68        self.undo.push_back(entry);
69        if self.undo.len() > MAX_UNDO_DEPTH {
70            self.undo.pop_front();
71        }
72    }
73
74    /// Pop the most recent undo entry (for Ctrl+Z).
75    pub fn pop_undo(&mut self) -> Option<UndoEntry> {
76        self.undo.pop_back()
77    }
78
79    /// Pop the most recent redo entry (for Ctrl+Y / Ctrl+Shift+Z).
80    pub fn pop_redo(&mut self) -> Option<UndoEntry> {
81        self.redo.pop_back()
82    }
83
84    /// Push an entry onto the redo stack (called when undoing).
85    pub fn push_redo(&mut self, entry: UndoEntry) {
86        self.redo.push_back(entry);
87    }
88
89    /// Push an entry onto the undo stack without clearing redo
90    /// (called when redoing).
91    pub fn push_undo_keep_redo(&mut self, entry: UndoEntry) {
92        self.undo.push_back(entry);
93        if self.undo.len() > MAX_UNDO_DEPTH {
94            self.undo.pop_front();
95        }
96    }
97
98    pub fn can_undo(&self) -> bool {
99        !self.undo.is_empty()
100    }
101
102    pub fn can_redo(&self) -> bool {
103        !self.redo.is_empty()
104    }
105
106    pub fn undo_len(&self) -> usize {
107        self.undo.len()
108    }
109
110    pub fn redo_len(&self) -> usize {
111        self.redo.len()
112    }
113}
114
115#[cfg(test)]
116mod tests {
117    use super::*;
118
119    fn dummy_id() -> QueueItemId {
120        QueueItemId::new()
121    }
122
123    #[test]
124    fn push_and_pop_undo() {
125        let mut stack = UndoStack::new();
126        let id = dummy_id();
127        stack.push(UndoEntry::Added { ids: vec![id] });
128        assert!(stack.can_undo());
129        assert!(!stack.can_redo());
130
131        let entry = stack.pop_undo().unwrap();
132        assert!(matches!(entry, UndoEntry::Added { .. }));
133        assert!(!stack.can_undo());
134    }
135
136    #[test]
137    fn new_action_clears_redo() {
138        let mut stack = UndoStack::new();
139        let id = dummy_id();
140        stack.push(UndoEntry::Added { ids: vec![id] });
141        let entry = stack.pop_undo().unwrap();
142        stack.push_redo(entry);
143        assert!(stack.can_redo());
144
145        // New action should clear redo
146        stack.push(UndoEntry::Added { ids: vec![id] });
147        assert!(!stack.can_redo());
148    }
149
150    #[test]
151    fn max_depth_enforced() {
152        let mut stack = UndoStack::new();
153        for _ in 0..150 {
154            stack.push(UndoEntry::Added {
155                ids: vec![dummy_id()],
156            });
157        }
158        assert_eq!(stack.undo_len(), MAX_UNDO_DEPTH);
159    }
160
161    #[test]
162    fn push_undo_keep_redo_preserves_redo() {
163        let mut stack = UndoStack::new();
164        let id = dummy_id();
165        stack.push_redo(UndoEntry::Added { ids: vec![id] });
166        assert!(stack.can_redo());
167
168        stack.push_undo_keep_redo(UndoEntry::Added { ids: vec![id] });
169        assert!(stack.can_undo());
170        assert!(stack.can_redo()); // redo NOT cleared
171    }
172}