onedrop_engine/
preset_manager.rs

1//! Preset management and transitions.
2
3use std::collections::{HashMap, VecDeque};
4use std::path::{Path, PathBuf};
5
6use crate::history::History;
7
8/// Random / sequential selection. Cycled by the GUI's `R` key.
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10pub enum SelectionMode {
11    Random,
12    Sequential,
13}
14
15/// Preset manager handling library navigation, mode/lock/ratings,
16/// and crossfade transitions.
17pub struct PresetManager {
18    /// Library queue — populated at startup by walking the preset dir.
19    preset_queue: VecDeque<PathBuf>,
20
21    /// Cursor into `preset_queue`. Sequential navigation walks this;
22    /// random navigation rewrites it before reading.
23    current_index: usize,
24
25    /// Crossfade transition state.
26    transition: TransitionState,
27
28    /// History ring — records every preset actually loaded via
29    /// [`advance`] (and friends) so `Backspace` can walk back through
30    /// what the user has seen, not just the queue order.
31    history: History<PathBuf>,
32
33    /// Scroll Lock state. When `true`, [`advance`] / [`retreat`] return
34    /// `None` so the engine keeps showing the current preset.
35    locked: bool,
36
37    /// Random vs sequential selection mode.
38    mode: SelectionMode,
39
40    /// Per-preset rating in `[0, 5]`. Unrated entries fall back to the
41    /// default 3 when sampling. Sampling weight is `2^rating`.
42    ratings: HashMap<PathBuf, u8>,
43
44    /// Where ratings persist. `None` disables persistence (tests).
45    ratings_path: Option<PathBuf>,
46}
47
48/// Transition state between presets.
49#[derive(Debug, Clone)]
50pub enum TransitionState {
51    /// No transition in progress
52    None,
53
54    /// Transitioning between presets
55    Transitioning {
56        /// Progress (0.0 to 1.0)
57        progress: f32,
58
59        /// Duration in seconds
60        duration: f32,
61
62        /// Elapsed time
63        elapsed: f32,
64    },
65}
66
67/// Default rating for an unrated preset. Picked at the middle of the
68/// `[0, 5]` window so unrated presets sit between user-disliked (0–2)
69/// and user-liked (4–5) when weighted-random sampling.
70pub const DEFAULT_RATING: u8 = 3;
71
72/// Cap for the history ring. Matches MilkDrop's default.
73pub const DEFAULT_HISTORY_SIZE: usize = 64;
74
75impl PresetManager {
76    /// Create a new preset manager with no persistence.
77    pub fn new() -> Self {
78        Self::with_history_size(DEFAULT_HISTORY_SIZE)
79    }
80
81    /// Create a new preset manager with a specific history cap.
82    pub fn with_history_size(history_size: usize) -> Self {
83        Self {
84            preset_queue: VecDeque::new(),
85            current_index: 0,
86            transition: TransitionState::None,
87            history: History::new(history_size.max(1)),
88            locked: false,
89            mode: SelectionMode::Random,
90            ratings: HashMap::new(),
91            ratings_path: None,
92        }
93    }
94
95    /// Point the manager at a TOML file for persisted ratings and load
96    /// whatever's already on disk. Missing / unreadable / unparseable
97    /// files fall back to "no ratings" — the call never fails.
98    pub fn attach_ratings_file(&mut self, path: PathBuf) {
99        self.ratings = load_ratings(&path);
100        self.ratings_path = Some(path);
101    }
102
103    /// Add a preset to the queue.
104    pub fn add_preset<P: AsRef<Path>>(&mut self, path: P) {
105        self.preset_queue.push_back(path.as_ref().to_path_buf());
106    }
107
108    /// Add multiple presets to the queue.
109    pub fn add_presets<P: AsRef<Path>>(&mut self, paths: &[P]) {
110        for path in paths {
111            self.add_preset(path);
112        }
113    }
114
115    /// Get the next preset path (sequential walk, ignores mode / lock).
116    pub fn next_preset(&mut self) -> Option<&Path> {
117        if self.preset_queue.is_empty() {
118            return None;
119        }
120
121        self.current_index = (self.current_index + 1) % self.preset_queue.len();
122        self.preset_queue
123            .get(self.current_index)
124            .map(|p| p.as_path())
125    }
126
127    /// Get the previous preset path (sequential walk, ignores mode / lock).
128    pub fn prev_preset(&mut self) -> Option<&Path> {
129        if self.preset_queue.is_empty() {
130            return None;
131        }
132
133        if self.current_index == 0 {
134            self.current_index = self.preset_queue.len() - 1;
135        } else {
136            self.current_index -= 1;
137        }
138
139        self.preset_queue
140            .get(self.current_index)
141            .map(|p| p.as_path())
142    }
143
144    /// Get the current preset path.
145    pub fn current_preset(&self) -> Option<&Path> {
146        self.preset_queue
147            .get(self.current_index)
148            .map(|p| p.as_path())
149    }
150
151    /// Pick a uniformly-random preset (ignores ratings, mode, lock).
152    /// Used by the beat detector for forced cuts where the user has
153    /// asked for randomness independent of selection mode.
154    pub fn random_preset(&mut self) -> Option<&Path> {
155        if self.preset_queue.is_empty() {
156            return None;
157        }
158        let pick = quick_random_seed() % self.preset_queue.len();
159        self.current_index = pick;
160        self.preset_queue
161            .get(self.current_index)
162            .map(|p| p.as_path())
163    }
164
165    /// Pick a rating-weighted random preset. Probability of selecting
166    /// a preset is proportional to `2^rating(preset)` — a rating-5
167    /// preset is 32× more likely than a rating-0 one. Unrated presets
168    /// use [`DEFAULT_RATING`].
169    pub fn random_weighted_by_rating(&mut self) -> Option<&Path> {
170        if self.preset_queue.is_empty() {
171            return None;
172        }
173
174        let weights: Vec<u64> = self
175            .preset_queue
176            .iter()
177            .map(|p| {
178                let r = self.ratings.get(p).copied().unwrap_or(DEFAULT_RATING);
179                1u64 << r.min(5)
180            })
181            .collect();
182        let total: u64 = weights.iter().sum();
183        if total == 0 {
184            return self.random_preset();
185        }
186        let mut pick = (quick_random_seed() as u64) % total;
187        for (i, &w) in weights.iter().enumerate() {
188            if pick < w {
189                self.current_index = i;
190                return self.preset_queue.get(i).map(|p| p.as_path());
191            }
192            pick -= w;
193        }
194        // Floating-point of sorts — should never reach here with integer
195        // weights, but fall back to last entry just in case.
196        self.current_index = self.preset_queue.len() - 1;
197        self.preset_queue
198            .get(self.current_index)
199            .map(|p| p.as_path())
200    }
201
202    /// High-level "user pressed Space / Right arrow": respect lock,
203    /// dispatch on mode, record the result in history. Returns
204    /// `None` when locked, when the queue is empty, or when the
205    /// current implementation can't produce a candidate.
206    pub fn advance(&mut self) -> Option<PathBuf> {
207        if self.locked {
208            return None;
209        }
210        let picked = match self.mode {
211            SelectionMode::Random => self.random_weighted_by_rating().map(Path::to_path_buf),
212            SelectionMode::Sequential => self.next_preset().map(Path::to_path_buf),
213        };
214        if let Some(ref p) = picked {
215            self.history.push(p.clone());
216        }
217        picked
218    }
219
220    /// History-aware "Backspace": walks the history ring back one
221    /// entry and returns it. Falls back to the sequential
222    /// [`prev_preset`] when the history has no prior entry. Honours
223    /// the lock.
224    pub fn retreat(&mut self) -> Option<PathBuf> {
225        if self.locked {
226            return None;
227        }
228        // History::back() returns the entry one step before the
229        // current cursor when possible. The very first call (cursor
230        // at None) jumps to the tail — which is the just-pushed
231        // current preset — so we shift one more step back to skip it.
232        if self.history.can_go_back() {
233            let back = self.history.back().cloned();
234            // If we landed on the same preset as current, step once more.
235            if let Some(b) = &back
236                && self.current_preset().map(Path::to_path_buf).as_ref() == Some(b)
237                && self.history.can_go_back()
238            {
239                return self.history.back().cloned();
240            }
241            return back;
242        }
243        self.prev_preset().map(Path::to_path_buf)
244    }
245
246    // ----- Lock --------------------------------------------------------
247
248    /// True when preset changes are blocked.
249    pub fn is_locked(&self) -> bool {
250        self.locked
251    }
252
253    /// Set the lock state outright (used by Scroll Lock keybind and by
254    /// the GUI's startup hydration from `Settings::presets.locked`).
255    pub fn set_locked(&mut self, locked: bool) {
256        self.locked = locked;
257    }
258
259    /// Flip the lock state and return the new value.
260    pub fn toggle_lock(&mut self) -> bool {
261        self.locked = !self.locked;
262        self.locked
263    }
264
265    // ----- Mode --------------------------------------------------------
266
267    pub fn mode(&self) -> SelectionMode {
268        self.mode
269    }
270
271    pub fn set_mode(&mut self, mode: SelectionMode) {
272        self.mode = mode;
273    }
274
275    /// `R` keybind: cycle Random ↔ Sequential and return the new mode.
276    pub fn cycle_mode(&mut self) -> SelectionMode {
277        self.mode = match self.mode {
278            SelectionMode::Random => SelectionMode::Sequential,
279            SelectionMode::Sequential => SelectionMode::Random,
280        };
281        self.mode
282    }
283
284    // ----- Ratings -----------------------------------------------------
285
286    /// Rating of an arbitrary preset path. Unrated → [`DEFAULT_RATING`].
287    pub fn rating(&self, path: &Path) -> u8 {
288        self.ratings.get(path).copied().unwrap_or(DEFAULT_RATING)
289    }
290
291    /// Rating of the currently-cursor'd preset.
292    pub fn current_rating(&self) -> Option<u8> {
293        self.current_preset().map(|p| self.rating(p))
294    }
295
296    /// Set a preset's rating (clamped to `[0, 5]`) and persist.
297    pub fn set_rating(&mut self, path: &Path, rating: u8) {
298        let clamped = rating.min(5);
299        self.ratings.insert(path.to_path_buf(), clamped);
300        self.persist_ratings();
301    }
302
303    /// Nudge the current preset's rating by `delta` (+/-), clamped to
304    /// `[0, 5]`. Returns the new rating, or `None` if no current preset.
305    pub fn nudge_current_rating(&mut self, delta: i32) -> Option<u8> {
306        let path = self.current_preset()?.to_path_buf();
307        let cur = self.rating(&path) as i32;
308        let next = (cur + delta).clamp(0, 5) as u8;
309        self.ratings.insert(path, next);
310        self.persist_ratings();
311        Some(next)
312    }
313
314    fn persist_ratings(&self) {
315        let Some(path) = self.ratings_path.as_ref() else {
316            return;
317        };
318        save_ratings(path, &self.ratings);
319    }
320
321    // ----- Transitions (unchanged) ------------------------------------
322
323    /// Start a transition to the next preset.
324    pub fn start_transition(&mut self, duration: f32) {
325        self.transition = TransitionState::Transitioning {
326            progress: 0.0,
327            duration,
328            elapsed: 0.0,
329        };
330    }
331
332    /// Update transition state.
333    pub fn update_transition(&mut self, delta_time: f32) -> bool {
334        match &mut self.transition {
335            TransitionState::Transitioning {
336                progress,
337                duration,
338                elapsed,
339            } => {
340                *elapsed += delta_time;
341                *progress = (*elapsed / *duration).min(1.0);
342
343                if *progress >= 1.0 {
344                    self.transition = TransitionState::None;
345                    true // Transition complete
346                } else {
347                    false
348                }
349            }
350            TransitionState::None => true,
351        }
352    }
353
354    /// Get transition progress (0.0 to 1.0).
355    pub fn transition_progress(&self) -> f32 {
356        match &self.transition {
357            TransitionState::Transitioning { progress, .. } => *progress,
358            TransitionState::None => 1.0,
359        }
360    }
361
362    /// Check if transitioning.
363    pub fn is_transitioning(&self) -> bool {
364        matches!(self.transition, TransitionState::Transitioning { .. })
365    }
366
367    /// Clear all presets.
368    pub fn clear(&mut self) {
369        self.preset_queue.clear();
370        self.current_index = 0;
371        self.transition = TransitionState::None;
372        self.history.clear();
373    }
374
375    /// Get number of presets in queue.
376    pub fn preset_count(&self) -> usize {
377        self.preset_queue.len()
378    }
379
380    /// Shuffle presets.
381    pub fn shuffle(&mut self) {
382        if self.preset_queue.len() <= 1 {
383            return;
384        }
385        let seed = quick_random_seed();
386        let mut indices: Vec<usize> = (0..self.preset_queue.len()).collect();
387        // Fisher-Yates
388        for i in (1..indices.len()).rev() {
389            let j = (seed.wrapping_add(i)) % (i + 1);
390            indices.swap(i, j);
391        }
392        let mut new_queue = VecDeque::with_capacity(self.preset_queue.len());
393        for idx in indices {
394            if let Some(preset) = self.preset_queue.get(idx) {
395                new_queue.push_back(preset.clone());
396            }
397        }
398        self.preset_queue = new_queue;
399        self.current_index = 0;
400    }
401}
402
403impl Default for PresetManager {
404    fn default() -> Self {
405        Self::new()
406    }
407}
408
409/// Simple wall-clock seed for the navigation RNG. The visualiser doesn't
410/// need cryptographic randomness — frame-to-frame variation from the
411/// nanosecond clock is plenty for preset shuffling.
412fn quick_random_seed() -> usize {
413    use std::time::{SystemTime, UNIX_EPOCH};
414    SystemTime::now()
415        .duration_since(UNIX_EPOCH)
416        .map(|d| d.as_nanos() as usize)
417        .unwrap_or(0)
418}
419
420/// On-disk format for ratings: a flat `[ratings]` table of path →
421/// integer. Stored as a `String`-keyed map so paths with non-UTF8
422/// bytes degrade gracefully (skipped, never panic).
423#[derive(Debug, Default, serde::Serialize, serde::Deserialize)]
424struct RatingsFile {
425    #[serde(default)]
426    ratings: HashMap<String, u8>,
427}
428
429fn load_ratings(path: &Path) -> HashMap<PathBuf, u8> {
430    let text = match std::fs::read_to_string(path) {
431        Ok(s) => s,
432        Err(_) => return HashMap::new(),
433    };
434    let parsed: RatingsFile = match toml::from_str(&text) {
435        Ok(r) => r,
436        Err(e) => {
437            log::warn!(
438                "Failed to parse ratings file {}: {} — starting fresh",
439                path.display(),
440                e
441            );
442            return HashMap::new();
443        }
444    };
445    parsed
446        .ratings
447        .into_iter()
448        .map(|(k, v)| (PathBuf::from(k), v.min(5)))
449        .collect()
450}
451
452fn save_ratings(path: &Path, ratings: &HashMap<PathBuf, u8>) {
453    if let Some(parent) = path.parent()
454        && let Err(e) = std::fs::create_dir_all(parent)
455    {
456        log::warn!(
457            "Failed to create {} for ratings save: {}",
458            parent.display(),
459            e
460        );
461        return;
462    }
463    let file = RatingsFile {
464        ratings: ratings
465            .iter()
466            .filter_map(|(k, v)| k.to_str().map(|s| (s.to_string(), *v)))
467            .collect(),
468    };
469    match toml::to_string_pretty(&file) {
470        Ok(s) => {
471            if let Err(e) = std::fs::write(path, s) {
472                log::warn!("Failed to write ratings to {}: {}", path.display(), e);
473            }
474        }
475        Err(e) => log::warn!("Failed to serialise ratings: {}", e),
476    }
477}
478
479#[cfg(test)]
480mod tests {
481    use super::*;
482
483    #[test]
484    fn test_preset_manager_creation() {
485        let manager = PresetManager::new();
486        assert_eq!(manager.preset_count(), 0);
487    }
488
489    #[test]
490    fn test_add_presets() {
491        let mut manager = PresetManager::new();
492
493        manager.add_preset("preset1.milk");
494        manager.add_preset("preset2.milk");
495        manager.add_preset("preset3.milk");
496
497        assert_eq!(manager.preset_count(), 3);
498    }
499
500    #[test]
501    fn test_navigation() {
502        let mut manager = PresetManager::new();
503
504        manager.add_preset("preset1.milk");
505        manager.add_preset("preset2.milk");
506        manager.add_preset("preset3.milk");
507
508        // Current should be first
509        assert_eq!(
510            manager.current_preset().unwrap().to_str().unwrap(),
511            "preset1.milk"
512        );
513
514        // Next
515        manager.next_preset();
516        assert_eq!(
517            manager.current_preset().unwrap().to_str().unwrap(),
518            "preset2.milk"
519        );
520
521        // Next again
522        manager.next_preset();
523        assert_eq!(
524            manager.current_preset().unwrap().to_str().unwrap(),
525            "preset3.milk"
526        );
527
528        // Wrap around
529        manager.next_preset();
530        assert_eq!(
531            manager.current_preset().unwrap().to_str().unwrap(),
532            "preset1.milk"
533        );
534
535        // Previous
536        manager.prev_preset();
537        assert_eq!(
538            manager.current_preset().unwrap().to_str().unwrap(),
539            "preset3.milk"
540        );
541    }
542
543    #[test]
544    fn test_transition() {
545        let mut manager = PresetManager::new();
546
547        manager.start_transition(1.0);
548        assert!(manager.is_transitioning());
549        assert_eq!(manager.transition_progress(), 0.0);
550
551        // Update halfway
552        manager.update_transition(0.5);
553        assert!(manager.is_transitioning());
554        assert!((manager.transition_progress() - 0.5).abs() < 0.01);
555
556        // Complete transition
557        manager.update_transition(0.5);
558        assert!(!manager.is_transitioning());
559        assert_eq!(manager.transition_progress(), 1.0);
560    }
561
562    #[test]
563    fn test_shuffle() {
564        let mut manager = PresetManager::new();
565
566        for i in 0..10 {
567            manager.add_preset(format!("preset{}.milk", i));
568        }
569
570        let _original_first = manager.current_preset().unwrap().to_path_buf();
571
572        manager.shuffle();
573
574        // After shuffle, should still have same count
575        assert_eq!(manager.preset_count(), 10);
576
577        // First preset might be different (not guaranteed, but likely)
578        // Just check that we can still navigate
579        assert!(manager.current_preset().is_some());
580    }
581
582    #[test]
583    fn lock_blocks_advance_and_retreat() {
584        let mut m = PresetManager::new();
585        m.add_preset("a.milk");
586        m.add_preset("b.milk");
587        m.set_locked(true);
588        assert!(m.advance().is_none(), "advance must respect lock");
589        assert!(m.retreat().is_none(), "retreat must respect lock");
590        m.toggle_lock();
591        assert!(!m.is_locked());
592        assert!(m.advance().is_some(), "advance must work once unlocked");
593    }
594
595    #[test]
596    fn cycle_mode_alternates_random_and_sequential() {
597        let mut m = PresetManager::new();
598        assert_eq!(m.mode(), SelectionMode::Random);
599        assert_eq!(m.cycle_mode(), SelectionMode::Sequential);
600        assert_eq!(m.cycle_mode(), SelectionMode::Random);
601    }
602
603    #[test]
604    fn ratings_clamp_to_zero_through_five() {
605        let mut m = PresetManager::new();
606        m.add_preset("a.milk");
607        let path = m.current_preset().unwrap().to_path_buf();
608        m.set_rating(&path, 99);
609        assert_eq!(m.rating(&path), 5);
610        m.set_rating(&path, 0);
611        assert_eq!(m.rating(&path), 0);
612    }
613
614    #[test]
615    fn nudge_current_rating_walks_within_range() {
616        let mut m = PresetManager::new();
617        m.add_preset("a.milk");
618        assert_eq!(m.nudge_current_rating(1), Some(DEFAULT_RATING + 1));
619        assert_eq!(m.nudge_current_rating(-10), Some(0));
620        assert_eq!(m.nudge_current_rating(99), Some(5));
621    }
622
623    #[test]
624    fn sequential_advance_walks_history_forward() {
625        let mut m = PresetManager::new();
626        m.add_preset("a.milk");
627        m.add_preset("b.milk");
628        m.add_preset("c.milk");
629        m.set_mode(SelectionMode::Sequential);
630        let first = m.advance().unwrap();
631        assert_eq!(first.to_str().unwrap(), "b.milk");
632        let second = m.advance().unwrap();
633        assert_eq!(second.to_str().unwrap(), "c.milk");
634        // retreat walks back through history (one before current tail)
635        let back = m.retreat().unwrap();
636        assert_eq!(back.to_str().unwrap(), "b.milk");
637    }
638
639    #[test]
640    fn weighted_random_falls_back_to_uniform_with_empty_ratings() {
641        let mut m = PresetManager::new();
642        m.add_preset("a.milk");
643        m.add_preset("b.milk");
644        // Without ratings every preset uses DEFAULT_RATING, so weights
645        // are equal — picking just returns *some* preset, not None.
646        assert!(m.random_weighted_by_rating().is_some());
647    }
648
649    #[test]
650    fn ratings_roundtrip_through_file() {
651        let tmp =
652            std::env::temp_dir().join(format!("onedrop-test-ratings-{}.toml", quick_random_seed()));
653        // Setup: rate a preset, persist.
654        {
655            let mut m = PresetManager::new();
656            m.attach_ratings_file(tmp.clone());
657            m.add_preset("/abs/path/a.milk");
658            m.set_rating(Path::new("/abs/path/a.milk"), 4);
659        }
660        // Reload: rating must be there.
661        let mut m2 = PresetManager::new();
662        m2.attach_ratings_file(tmp.clone());
663        assert_eq!(m2.rating(Path::new("/abs/path/a.milk")), 4);
664        let _ = std::fs::remove_file(tmp);
665    }
666}