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
111#[cfg(test)]
112mod tests {
113    use super::*;
114
115    fn dummy_id() -> QueueItemId {
116        QueueItemId::new()
117    }
118
119    #[test]
120    fn push_and_pop_undo() {
121        let mut stack = UndoStack::new();
122        let id = dummy_id();
123        stack.push(UndoEntry::Added { ids: vec![id] });
124        assert!(stack.can_undo());
125        assert!(!stack.can_redo());
126
127        let entry = stack.pop_undo().unwrap();
128        assert!(matches!(entry, UndoEntry::Added { .. }));
129        assert!(!stack.can_undo());
130    }
131
132    #[test]
133    fn new_action_clears_redo() {
134        let mut stack = UndoStack::new();
135        let id = dummy_id();
136        stack.push(UndoEntry::Added { ids: vec![id] });
137        let entry = stack.pop_undo().unwrap();
138        stack.push_redo(entry);
139        assert!(stack.can_redo());
140
141        // New action should clear redo
142        stack.push(UndoEntry::Added { ids: vec![id] });
143        assert!(!stack.can_redo());
144    }
145
146    #[test]
147    fn max_depth_enforced() {
148        let mut stack = UndoStack::new();
149        for _ in 0..150 {
150            stack.push(UndoEntry::Added {
151                ids: vec![dummy_id()],
152            });
153        }
154        assert_eq!(stack.undo_len(), MAX_UNDO_DEPTH);
155    }
156
157    #[test]
158    fn push_undo_keep_redo_preserves_redo() {
159        let mut stack = UndoStack::new();
160        let id = dummy_id();
161        stack.push_redo(UndoEntry::Added { ids: vec![id] });
162        assert!(stack.can_redo());
163
164        stack.push_undo_keep_redo(UndoEntry::Added { ids: vec![id] });
165        assert!(stack.can_undo());
166        assert!(stack.can_redo()); // redo NOT cleared
167    }
168}