koan-core 0.20.0

Core library for koan — bit-perfect music player. Audio engine, player, database, format strings.
Documentation
use std::collections::VecDeque;

use super::state::{PlaylistItem, QueueItemId};

/// Maximum number of undo entries retained.
const MAX_UNDO_DEPTH: usize = 100;

/// Maximum number of sub-entries allowed in a single `UndoEntry::Batch`.
const MAX_BATCH_SIZE: usize = 500;

/// A reversible playlist operation. Stores enough state to undo/redo.
///
/// Each variant describes an action to reverse. `apply_entry` executes the
/// reversal and returns the inverse entry for the opposite stack.
#[derive(Debug, Clone)]
pub enum UndoEntry {
    /// Items were added (append). Undo = remove by IDs.
    Added { ids: Vec<QueueItemId> },

    /// Items were removed. Undo = re-add them at their original positions.
    /// Each tuple: (item, id-of-predecessor or None if was first).
    Removed {
        items: Vec<(Box<PlaylistItem>, Option<QueueItemId>)>,
    },

    /// Items were inserted after a specific item. Undo = remove by IDs.
    Inserted { ids: Vec<QueueItemId> },

    /// A single item was moved. Undo = move it back.
    Moved {
        id: QueueItemId,
        was_after: Option<QueueItemId>,
    },

    /// Multiple items were moved. Undo = restore original positions.
    MovedBatch {
        entries: Vec<(QueueItemId, Option<QueueItemId>)>,
    },

    /// Playlist was cleared / replaced. Undo = restore this snapshot.
    Replaced {
        items: Vec<PlaylistItem>,
        cursor: Option<QueueItemId>,
    },

    /// Multiple operations batched as a single undo step (e.g. drag reorder).
    Batch(Vec<UndoEntry>),
}

/// Standard undo/redo stack with bounded depth.
///
/// Lives on the Player struct — single-threaded, only the player command loop
/// touches it. New actions clear the redo stack (standard semantics).
///
/// Uses `VecDeque` so evicting the oldest entry (front) is O(1) instead of
/// the O(n) `Vec::remove(0)`.
#[derive(Debug, Default)]
pub struct UndoStack {
    undo: VecDeque<UndoEntry>,
    redo: VecDeque<UndoEntry>,
}

impl UndoStack {
    pub fn new() -> Self {
        Self::default()
    }

    /// Push an undo entry. Clears the redo stack.
    /// Batch entries exceeding `MAX_BATCH_SIZE` are truncated.
    pub fn push(&mut self, entry: UndoEntry) {
        self.redo.clear();
        self.undo.push_back(Self::clamp_batch(entry));
        if self.undo.len() > MAX_UNDO_DEPTH {
            self.undo.pop_front();
        }
    }

    /// Pop the most recent undo entry (for Ctrl+Z).
    pub fn pop_undo(&mut self) -> Option<UndoEntry> {
        self.undo.pop_back()
    }

    /// Pop the most recent redo entry (for Ctrl+Y / Ctrl+Shift+Z).
    pub fn pop_redo(&mut self) -> Option<UndoEntry> {
        self.redo.pop_back()
    }

    /// Push an entry onto the redo stack (called when undoing).
    pub fn push_redo(&mut self, entry: UndoEntry) {
        self.redo.push_back(entry);
    }

    /// Push an entry onto the undo stack without clearing redo
    /// (called when redoing).
    pub fn push_undo_keep_redo(&mut self, entry: UndoEntry) {
        self.undo.push_back(Self::clamp_batch(entry));
        if self.undo.len() > MAX_UNDO_DEPTH {
            self.undo.pop_front();
        }
    }

    /// Truncate `Batch` entries that exceed `MAX_BATCH_SIZE`.
    fn clamp_batch(entry: UndoEntry) -> UndoEntry {
        match entry {
            UndoEntry::Batch(mut items) => {
                items.truncate(MAX_BATCH_SIZE);
                UndoEntry::Batch(items)
            }
            other => other,
        }
    }

    pub fn can_undo(&self) -> bool {
        !self.undo.is_empty()
    }

    pub fn can_redo(&self) -> bool {
        !self.redo.is_empty()
    }

    pub fn undo_len(&self) -> usize {
        self.undo.len()
    }

    pub fn redo_len(&self) -> usize {
        self.redo.len()
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn dummy_id() -> QueueItemId {
        QueueItemId::new()
    }

    #[test]
    fn push_and_pop_undo() {
        let mut stack = UndoStack::new();
        let id = dummy_id();
        stack.push(UndoEntry::Added { ids: vec![id] });
        assert!(stack.can_undo());
        assert!(!stack.can_redo());

        let entry = stack.pop_undo().unwrap();
        assert!(matches!(entry, UndoEntry::Added { .. }));
        assert!(!stack.can_undo());
    }

    #[test]
    fn new_action_clears_redo() {
        let mut stack = UndoStack::new();
        let id = dummy_id();
        stack.push(UndoEntry::Added { ids: vec![id] });
        let entry = stack.pop_undo().unwrap();
        stack.push_redo(entry);
        assert!(stack.can_redo());

        // New action should clear redo
        stack.push(UndoEntry::Added { ids: vec![id] });
        assert!(!stack.can_redo());
    }

    #[test]
    fn max_depth_enforced() {
        let mut stack = UndoStack::new();
        for _ in 0..150 {
            stack.push(UndoEntry::Added {
                ids: vec![dummy_id()],
            });
        }
        assert_eq!(stack.undo_len(), MAX_UNDO_DEPTH);
    }

    #[test]
    fn push_undo_keep_redo_preserves_redo() {
        let mut stack = UndoStack::new();
        let id = dummy_id();
        stack.push_redo(UndoEntry::Added { ids: vec![id] });
        assert!(stack.can_redo());

        stack.push_undo_keep_redo(UndoEntry::Added { ids: vec![id] });
        assert!(stack.can_undo());
        assert!(stack.can_redo()); // redo NOT cleared
    }
}