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    /// Shuffle was turned on or off. Undo = put the queue back in `order`,
44    /// positions before shuffling included, and shuffle back to `shuffle`.
45    Shuffled {
46        shuffle: bool,
47        order: Vec<(QueueItemId, Option<u32>)>,
48    },
49
50    /// Multiple operations batched as a single undo step (e.g. drag reorder).
51    Batch(Vec<UndoEntry>),
52}
53
54/// Standard undo/redo stack with bounded depth.
55///
56/// Lives on the Player struct — single-threaded, only the player command loop
57/// touches it. New actions clear the redo stack (standard semantics).
58///
59/// Uses `VecDeque` so evicting the oldest entry (front) is O(1) instead of
60/// the O(n) `Vec::remove(0)`.
61#[derive(Debug, Default)]
62pub struct UndoStack {
63    undo: VecDeque<UndoEntry>,
64    redo: VecDeque<UndoEntry>,
65}
66
67impl UndoStack {
68    pub fn new() -> Self {
69        Self::default()
70    }
71
72    /// Push an undo entry. Clears the redo stack.
73    pub fn push(&mut self, entry: UndoEntry) {
74        self.redo.clear();
75        self.undo.push_back(entry);
76        if self.undo.len() > MAX_UNDO_DEPTH {
77            self.undo.pop_front();
78        }
79    }
80
81    /// Pop the most recent undo entry (for Ctrl+Z).
82    pub fn pop_undo(&mut self) -> Option<UndoEntry> {
83        self.undo.pop_back()
84    }
85
86    /// Pop the most recent redo entry (for Ctrl+Y / Ctrl+Shift+Z).
87    pub fn pop_redo(&mut self) -> Option<UndoEntry> {
88        self.redo.pop_back()
89    }
90
91    /// Push an entry onto the redo stack (called when undoing).
92    pub fn push_redo(&mut self, entry: UndoEntry) {
93        self.redo.push_back(entry);
94    }
95
96    /// Push an entry onto the undo stack without clearing redo
97    /// (called when redoing).
98    pub fn push_undo_keep_redo(&mut self, entry: UndoEntry) {
99        self.undo.push_back(entry);
100        if self.undo.len() > MAX_UNDO_DEPTH {
101            self.undo.pop_front();
102        }
103    }
104
105    pub fn can_undo(&self) -> bool {
106        !self.undo.is_empty()
107    }
108
109    pub fn can_redo(&self) -> bool {
110        !self.redo.is_empty()
111    }
112
113    pub fn undo_len(&self) -> usize {
114        self.undo.len()
115    }
116}
117
118#[cfg(test)]
119mod tests {
120    use super::*;
121
122    fn dummy_id() -> QueueItemId {
123        QueueItemId::new()
124    }
125
126    #[test]
127    fn push_and_pop_undo() {
128        let mut stack = UndoStack::new();
129        let id = dummy_id();
130        stack.push(UndoEntry::Added { ids: vec![id] });
131        assert!(stack.can_undo());
132        assert!(!stack.can_redo());
133
134        let entry = stack.pop_undo().unwrap();
135        assert!(matches!(entry, UndoEntry::Added { .. }));
136        assert!(!stack.can_undo());
137    }
138
139    #[test]
140    fn new_action_clears_redo() {
141        let mut stack = UndoStack::new();
142        let id = dummy_id();
143        stack.push(UndoEntry::Added { ids: vec![id] });
144        let entry = stack.pop_undo().unwrap();
145        stack.push_redo(entry);
146        assert!(stack.can_redo());
147
148        // New action should clear redo
149        stack.push(UndoEntry::Added { ids: vec![id] });
150        assert!(!stack.can_redo());
151    }
152
153    #[test]
154    fn max_depth_enforced() {
155        let mut stack = UndoStack::new();
156        for _ in 0..150 {
157            stack.push(UndoEntry::Added {
158                ids: vec![dummy_id()],
159            });
160        }
161        assert_eq!(stack.undo_len(), MAX_UNDO_DEPTH);
162    }
163
164    #[test]
165    fn push_undo_keep_redo_preserves_redo() {
166        let mut stack = UndoStack::new();
167        let id = dummy_id();
168        stack.push_redo(UndoEntry::Added { ids: vec![id] });
169        assert!(stack.can_redo());
170
171        stack.push_undo_keep_redo(UndoEntry::Added { ids: vec![id] });
172        assert!(stack.can_undo());
173        assert!(stack.can_redo()); // redo NOT cleared
174    }
175}