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