Skip to main content

phosphor_app/state/
loop_editor.rs

1//! Loop region editor — controls the loop start/end markers.
2//!
3//! When active, h/l move the left (start) marker.
4//! Shift+h/l move the right (end) marker.
5//! Both are clamped in bars and cannot cross each other.
6
7use phosphor_core::transport::Transport;
8
9/// One bar in ticks (4/4 time).
10const TICKS_PER_BAR: i64 = Transport::PPQ * 4;
11
12#[derive(Debug)]
13pub struct LoopEditor {
14    /// Whether the loop editor is focused (controls locked to markers).
15    pub active: bool,
16    /// Whether the loop is enabled (playhead loops within the region).
17    pub enabled: bool,
18    /// Start bar (1-based).
19    pub start_bar: u32,
20    /// End bar (1-based, exclusive — loop plays bars start..end).
21    pub end_bar: u32,
22}
23
24impl Default for LoopEditor {
25    fn default() -> Self { Self::new() }
26}
27
28impl LoopEditor {
29    pub fn new() -> Self {
30        Self {
31            active: false,
32            enabled: false,
33            start_bar: 1,
34            end_bar: 5,
35        }
36    }
37
38    /// Focus the editor (lock controls to loop markers).
39    pub fn focus(&mut self) {
40        self.active = true;
41    }
42
43    /// Unfocus the editor (release controls).
44    pub fn unfocus(&mut self) {
45        self.active = false;
46    }
47
48    /// Toggle the loop on/off. Called when user presses Enter on the loop.
49    pub fn toggle_enabled(&mut self) {
50        self.enabled = !self.enabled;
51    }
52
53    /// Move the left (start) marker left by one bar.
54    pub fn move_start_left(&mut self) {
55        if self.start_bar > 1 {
56            self.start_bar -= 1;
57        }
58    }
59
60    /// Move the left (start) marker right by one bar.
61    /// Cannot pass or equal the end marker.
62    pub fn move_start_right(&mut self) {
63        if self.start_bar + 1 < self.end_bar {
64            self.start_bar += 1;
65        }
66    }
67
68    /// Move the right (end) marker left by one bar.
69    /// Cannot pass or equal the start marker.
70    pub fn move_end_left(&mut self) {
71        if self.end_bar > self.start_bar + 1 {
72            self.end_bar -= 1;
73        }
74    }
75
76    /// Move the right (end) marker right by one bar.
77    pub fn move_end_right(&mut self) {
78        self.end_bar += 1;
79    }
80
81    /// Start tick for the transport.
82    pub fn start_ticks(&self) -> i64 {
83        (self.start_bar as i64 - 1) * TICKS_PER_BAR
84    }
85
86    /// End tick for the transport.
87    /// end_bar is exclusive, so bars 1-2 (end_bar=3) ends at the start of bar 3.
88    pub fn end_ticks(&self) -> i64 {
89        (self.end_bar as i64 - 1) * TICKS_PER_BAR
90    }
91
92    /// Number of bars in the loop region.
93    pub fn bar_count(&self) -> u32 {
94        self.end_bar - self.start_bar
95    }
96
97    /// Display string: "1-4" or "3-8" etc.
98    pub fn display(&self) -> String {
99        format!("{}-{}", self.start_bar, self.end_bar - 1)
100    }
101}
102
103#[cfg(test)]
104mod tests {
105    use super::*;
106
107    #[test]
108    fn default_is_4_bars() {
109        let le = LoopEditor::new();
110        assert_eq!(le.start_bar, 1);
111        assert_eq!(le.end_bar, 5);
112        assert_eq!(le.bar_count(), 4);
113        assert_eq!(le.display(), "1-4");
114    }
115
116    #[test]
117    fn start_cant_go_below_1() {
118        let mut le = LoopEditor::new();
119        le.move_start_left();
120        le.move_start_left();
121        le.move_start_left();
122        assert_eq!(le.start_bar, 1);
123    }
124
125    #[test]
126    fn start_cant_pass_end() {
127        let mut le = LoopEditor::new();
128        // end is 5, start should stop at 4
129        for _ in 0..10 {
130            le.move_start_right();
131        }
132        assert_eq!(le.start_bar, 4);
133        assert!(le.start_bar < le.end_bar);
134    }
135
136    #[test]
137    fn end_cant_pass_start() {
138        let mut le = LoopEditor::new();
139        // start is 1, end should stop at 2
140        for _ in 0..10 {
141            le.move_end_left();
142        }
143        assert_eq!(le.end_bar, 2);
144        assert!(le.end_bar > le.start_bar);
145    }
146
147    #[test]
148    fn end_can_grow() {
149        let mut le = LoopEditor::new();
150        le.move_end_right();
151        le.move_end_right();
152        assert_eq!(le.end_bar, 7);
153        assert_eq!(le.display(), "1-6");
154    }
155
156    #[test]
157    fn ticks_correct() {
158        let le = LoopEditor::new();
159        // Default: bars 1-4 (start_bar=1, end_bar=5)
160        assert_eq!(le.start_ticks(), 0);
161        assert_eq!(le.end_ticks(), 4 * TICKS_PER_BAR); // 4 bars
162    }
163
164    #[test]
165    fn ticks_for_two_bars() {
166        let mut le = LoopEditor::new();
167        // Move end to bar 3 (display "1-2" = bars 1 and 2)
168        le.end_bar = 3;
169        assert_eq!(le.display(), "1-2");
170        assert_eq!(le.start_ticks(), 0);
171        assert_eq!(le.end_ticks(), 2 * TICKS_PER_BAR); // 2 bars exactly
172    }
173
174    #[test]
175    fn focus_unfocus() {
176        let mut le = LoopEditor::new();
177        assert!(!le.active);
178        le.focus();
179        assert!(le.active);
180        le.unfocus();
181        assert!(!le.active);
182    }
183
184    #[test]
185    fn enabled_toggle() {
186        let mut le = LoopEditor::new();
187        assert!(!le.enabled);
188        le.toggle_enabled();
189        assert!(le.enabled);
190        le.toggle_enabled();
191        assert!(!le.enabled);
192    }
193
194    #[test]
195    fn focus_does_not_enable() {
196        let mut le = LoopEditor::new();
197        le.focus();
198        assert!(le.active);
199        assert!(!le.enabled);
200    }
201}