Skip to main content

phosphor_app/state/
mod.rs

1//! TUI navigation state — focus, cursors, selection, leader keys, FX.
2//!
3//! Navigation:
4//!   Space+N  → jump to component (1=Tracks, 2=ClipView)
5//!   Tab      → cycle focus between components
6//!   j/k      → vertical nav
7//!   h/l      → horizontal nav
8//!   Enter    → select / activate / open menus
9//!   Esc      → back out one level
10
11mod clip_view;
12mod input;
13mod loop_editor;
14mod menu;
15mod track;
16mod transport_ui;
17pub mod undo;
18
19pub use clip_view::*;
20pub use input::*;
21pub use loop_editor::*;
22pub use menu::*;
23pub use track::*;
24pub use transport_ui::*;
25mod navigation;
26mod params;
27mod track_ops;
28pub use track_ops::initial_tracks;
29
30use phosphor_core::project::TrackKind;
31
32// ── Panes ──
33
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35pub enum Pane {
36    Transport,
37    Tracks,
38    ClipView,
39}
40
41impl Pane {
42    pub fn number(self) -> u8 {
43        match self {
44            Self::Transport => 1,
45            Self::Tracks => 2,
46            Self::ClipView => 3,
47        }
48    }
49
50    pub fn from_number(n: u8) -> Option<Self> {
51        match n {
52            1 => Some(Self::Transport),
53            2 => Some(Self::Tracks),
54            3 => Some(Self::ClipView),
55            _ => None,
56        }
57    }
58
59    pub fn next(self) -> Self {
60        match self {
61            Self::Transport => Self::Tracks,
62            Self::Tracks => Self::ClipView,
63            Self::ClipView => Self::Transport,
64        }
65    }
66
67    pub fn prev(self) -> Self {
68        match self {
69            Self::Transport => Self::ClipView,
70            Self::Tracks => Self::Transport,
71            Self::ClipView => Self::Tracks,
72        }
73    }
74
75    pub fn label(self) -> &'static str {
76        match self {
77            Self::Transport => "transport",
78            Self::Tracks => "tracks",
79            Self::ClipView => "clip",
80        }
81    }
82}
83
84// ── Full Nav State ──
85
86pub const MAX_VISIBLE_TRACKS: usize = 5;
87/// Total number of parameters in the inst config panel (LFO:4 + Filter:4 + Envelope:4 + Pitch:3).
88pub const INST_CONFIG_PARAM_COUNT: usize = 15;
89
90#[derive(Debug)]
91pub struct NavState {
92    pub focused_pane: Pane,
93    pub track_cursor: usize,
94    pub track_scroll: usize,
95    pub track_selected: bool,
96    pub track_element: TrackElement,
97    pub number_buf: NumberBuffer,
98    pub space_menu: SpaceMenu,
99    pub clip_view: ClipViewState,
100    pub clip_view_visible: bool,
101    /// (track_idx, clip_idx) shown in clip view.
102    pub clip_view_target: Option<(usize, usize)>,
103    /// FX menu state (per-track fx button).
104    pub fx_menu: FxMenu,
105    pub instrument_modal: InstrumentModal,
106    pub loop_editor: LoopEditor,
107    pub transport_ui: TransportUiState,
108    pub tracks: Vec<TrackState>,
109    /// Text input modal (for save/open file paths).
110    pub input_modal: InputModal,
111    /// Confirmation modal (for delete actions).
112    pub confirm_modal: ConfirmModal,
113    /// Undo/redo stack.
114    pub undo_stack: undo::UndoStack,
115    /// Whether a clip is "locked" for editing (Enter locks, Esc unlocks).
116    /// When locked, h/l moves the clip instead of navigating between elements.
117    pub clip_locked: bool,
118    /// Grace counter: set to the number of armed tracks when recording stops.
119    /// Decremented as each valid snapshot is accepted. Prevents stale snapshots
120    /// while allowing final recording commits from all tracks to come through.
121    pub recording_grace: usize,
122}
123
124impl NavState {
125    pub fn new(tracks: Vec<TrackState>) -> Self {
126        Self {
127            focused_pane: Pane::Tracks,
128            track_cursor: 0,
129            track_scroll: 0,
130            track_selected: false,
131            track_element: TrackElement::Label,
132            number_buf: NumberBuffer::new(),
133            space_menu: SpaceMenu::new(),
134            clip_view: ClipViewState::new(),
135            clip_view_visible: false,
136            clip_view_target: None,
137            fx_menu: FxMenu::new(),
138            instrument_modal: InstrumentModal::new(),
139            loop_editor: LoopEditor::new(),
140            transport_ui: TransportUiState::new(),
141            tracks,
142            input_modal: InputModal::new(),
143            confirm_modal: ConfirmModal::new(),
144            undo_stack: undo::UndoStack::new(),
145            clip_locked: false,
146            recording_grace: 0,
147        }
148    }
149    pub fn visible_tracks(&self) -> &[TrackState] {
150        let end = (self.track_scroll + MAX_VISIBLE_TRACKS).min(self.tracks.len());
151        &self.tracks[self.track_scroll..end]
152    }
153
154    pub fn can_scroll_up(&self) -> bool { self.track_scroll > 0 }
155
156    pub fn can_scroll_down(&self) -> bool {
157        self.track_scroll + MAX_VISIBLE_TRACKS < self.tracks.len()
158    }
159
160    pub fn current_track(&self) -> Option<&TrackState> { self.tracks.get(self.track_cursor) }
161
162    pub fn current_track_mut(&mut self) -> Option<&mut TrackState> {
163        self.tracks.get_mut(self.track_cursor)
164    }
165
166    pub fn active_clip(&self) -> Option<&Clip> {
167        let (ti, ci) = self.clip_view_target?;
168        self.tracks.get(ti)?.clips.get(ci)
169    }
170
171    pub fn active_clip_mut(&mut self) -> Option<&mut Clip> {
172        let (ti, ci) = self.clip_view_target?;
173        self.tracks.get_mut(ti)?.clips.get_mut(ci)
174    }
175
176    pub fn active_clip_track(&self) -> Option<&TrackState> {
177        let (ti, _) = self.clip_view_target?;
178        self.tracks.get(ti)
179    }
180}
181
182#[cfg(test)]
183mod tests {
184    use super::*;
185
186    #[test]
187    fn pane_numbers() {
188        assert_eq!(Pane::Transport.number(), 1);
189        assert_eq!(Pane::Tracks.number(), 2);
190        assert_eq!(Pane::ClipView.number(), 3);
191        assert_eq!(Pane::from_number(1), Some(Pane::Transport));
192        assert_eq!(Pane::from_number(2), Some(Pane::Tracks));
193        assert_eq!(Pane::from_number(3), Some(Pane::ClipView));
194        assert_eq!(Pane::from_number(9), None);
195    }
196
197    #[test]
198    fn track_element_navigation_full() {
199        let e = TrackElement::Label;
200        assert_eq!(e.move_right(3), TrackElement::Fx);
201        assert_eq!(TrackElement::Fx.move_right(3), TrackElement::Volume);
202        assert_eq!(TrackElement::Volume.move_right(3), TrackElement::Mute);
203        assert_eq!(TrackElement::Mute.move_right(3), TrackElement::Solo);
204        assert_eq!(TrackElement::Solo.move_right(3), TrackElement::RecordArm);
205        assert_eq!(TrackElement::RecordArm.move_right(3), TrackElement::Clip(0));
206        assert_eq!(TrackElement::Clip(2).move_right(3), TrackElement::Clip(2));
207    }
208
209    #[test]
210    fn track_element_left_full() {
211        assert_eq!(TrackElement::Clip(0).move_left(), TrackElement::RecordArm);
212        assert_eq!(TrackElement::RecordArm.move_left(), TrackElement::Solo);
213        assert_eq!(TrackElement::Solo.move_left(), TrackElement::Mute);
214        assert_eq!(TrackElement::Mute.move_left(), TrackElement::Volume);
215        assert_eq!(TrackElement::Volume.move_left(), TrackElement::Fx);
216        assert_eq!(TrackElement::Fx.move_left(), TrackElement::Label);
217        assert_eq!(TrackElement::Label.move_left(), TrackElement::Label);
218    }
219
220    #[test]
221    fn initial_tracks_has_sends_and_master() {
222        let tracks = initial_tracks();
223        assert_eq!(tracks.len(), 3); // send A + send B + master
224        assert_eq!(tracks[0].kind, TrackKind::SendA);
225        assert_eq!(tracks[1].kind, TrackKind::SendB);
226        assert_eq!(tracks[2].kind, TrackKind::Master);
227    }
228
229    #[test]
230    fn sends_are_at_end() {
231        let mut nav = NavState::new(initial_tracks());
232        nav.move_down();
233        nav.move_down();
234        assert_eq!(nav.track_cursor, 2);
235        assert_eq!(nav.tracks[nav.track_cursor].kind, TrackKind::Master);
236    }
237
238    #[test]
239    fn fx_menu_opens_and_closes() {
240        let mut nav = NavState::new(initial_tracks());
241        nav.enter(); // select track
242        // Navigate to FX
243        nav.move_right(); // -> Fx
244        assert_eq!(nav.track_element, TrackElement::Fx);
245        nav.enter(); // open FX menu
246        assert!(nav.fx_menu.open);
247
248        nav.escape(); // close menu
249        assert!(!nav.fx_menu.open);
250    }
251
252    #[test]
253    fn fx_menu_add_effect() {
254        let mut nav = NavState::new(initial_tracks());
255        let initial_count = nav.tracks[0].fx_chain.len();
256        nav.enter();
257        nav.move_right(); // -> Fx
258        nav.enter(); // open menu
259        nav.enter(); // select first item (Reverb)
260        assert!(!nav.fx_menu.open);
261        assert_eq!(nav.tracks[0].fx_chain.len(), initial_count + 1);
262        assert_eq!(nav.tracks[0].fx_chain.last().unwrap().fx_type, FxType::Reverb);
263    }
264
265    #[test]
266    fn clip_view_focus_toggle() {
267        let mut nav = NavState::new(initial_tracks());
268        // Manually set up clip view (simulating an instrument track being selected)
269        nav.clip_view_visible = true;
270        nav.clip_view_target = Some((0, 0));
271
272        nav.focus_pane(Pane::ClipView);
273        assert_eq!(nav.clip_view.focus, ClipViewFocus::PianoRoll);
274
275        nav.move_left(); // -> FxPanel
276        assert_eq!(nav.clip_view.focus, ClipViewFocus::FxPanel);
277    }
278
279    #[test]
280    fn clip_view_tabs_cycle() {
281        let mut nav = NavState::new(initial_tracks());
282        nav.focused_pane = Pane::ClipView;
283        nav.clip_view.focus = ClipViewFocus::FxPanel;
284
285        // Tab cycles: trk fx → synth → inst config → piano → auto → trk fx
286        assert_eq!(nav.clip_view.fx_panel_tab, FxPanelTab::TrackFx);
287        nav.cycle_tab();
288        assert_eq!(nav.clip_view.fx_panel_tab, FxPanelTab::Synth);
289        nav.cycle_tab();
290        // Now switches to inst config
291        assert_eq!(nav.clip_view.focus, ClipViewFocus::PianoRoll);
292        assert_eq!(nav.clip_view.clip_tab, ClipTab::InstConfig);
293        nav.cycle_tab();
294        // Now switches to piano roll
295        assert_eq!(nav.clip_view.clip_tab, ClipTab::PianoRoll);
296        nav.cycle_tab();
297        assert_eq!(nav.clip_view.clip_tab, ClipTab::Settings);
298        nav.cycle_tab();
299        // Back to FX panel
300        assert_eq!(nav.clip_view.focus, ClipViewFocus::FxPanel);
301        assert_eq!(nav.clip_view.fx_panel_tab, FxPanelTab::TrackFx);
302    }
303
304    #[test]
305    fn arm_toggle() {
306        let mut nav = NavState::new(initial_tracks());
307        assert!(!nav.tracks[0].armed); // bus tracks start unarmed
308        nav.toggle_arm();
309        assert!(nav.tracks[0].armed);
310        nav.toggle_arm();
311        assert!(!nav.tracks[0].armed);
312    }
313
314    #[test]
315    fn space_menu_toggle() {
316        let mut nav = NavState::new(initial_tracks());
317        assert!(!nav.space_menu.open);
318        nav.toggle_space_menu();
319        assert!(nav.space_menu.open);
320        nav.toggle_space_menu();
321        assert!(!nav.space_menu.open);
322    }
323
324    #[test]
325    fn space_menu_handle_pane_jump() {
326        let mut nav = NavState::new(initial_tracks());
327        nav.toggle_space_menu();
328        let action = nav.space_menu_handle('2');
329        assert_eq!(nav.focused_pane, Pane::Tracks);
330        assert!(action.is_none());
331        assert!(!nav.space_menu.open);
332
333        nav.toggle_space_menu();
334        let action = nav.space_menu_handle('1');
335        assert_eq!(nav.focused_pane, Pane::Transport);
336        assert!(action.is_none());
337    }
338
339    #[test]
340    fn space_menu_handle_play_pause() {
341        let mut nav = NavState::new(initial_tracks());
342        nav.toggle_space_menu();
343        let action = nav.space_menu_handle('p');
344        assert_eq!(action, Some(SpaceAction::PlayPause));
345        assert!(!nav.space_menu.open);
346    }
347
348    #[test]
349    fn space_menu_enter_select() {
350        let mut nav = NavState::new(initial_tracks());
351        nav.toggle_space_menu();
352        // cursor at 0 = "spc+1" = tracks
353        let action = nav.enter();
354        assert!(action.is_none()); // pane jump
355        assert!(!nav.space_menu.open);
356    }
357
358    #[test]
359    fn space_menu_nav_and_help() {
360        let mut nav = NavState::new(initial_tracks());
361        nav.toggle_space_menu();
362        assert_eq!(nav.space_menu.section, SpaceMenuSection::Actions);
363        nav.space_menu.switch_section();
364        assert_eq!(nav.space_menu.section, SpaceMenuSection::Help);
365        assert_eq!(nav.space_menu.cursor, 0);
366    }
367
368    #[test]
369    fn number_buffer_commit() {
370        let mut buf = NumberBuffer::new();
371        buf.push_digit('1');
372        assert_eq!(buf.commit(), Some(1));
373        buf.push_digit('1');
374        buf.push_digit('2');
375        assert_eq!(buf.commit(), Some(12));
376    }
377
378    #[test]
379    fn number_buffer_empty_commit() {
380        assert_eq!(NumberBuffer::new().commit(), None);
381    }
382
383    #[test]
384    fn nav_cursor_bounds() {
385        let mut nav = NavState::new(initial_tracks());
386        for _ in 0..20 { nav.move_down(); }
387        assert_eq!(nav.track_cursor, 2); // 3 bus tracks
388    }
389
390    #[test]
391    fn enter_escape_track() {
392        let mut nav = NavState::new(initial_tracks());
393        nav.enter();
394        assert!(nav.track_selected);
395        nav.escape();
396        assert!(!nav.track_selected);
397    }
398
399    #[test]
400    fn mute_solo_toggle() {
401        let mut nav = NavState::new(initial_tracks());
402        nav.toggle_mute();
403        assert!(nav.tracks[0].muted);
404        nav.toggle_solo();
405        assert!(nav.tracks[0].soloed);
406    }
407
408    #[test]
409    fn volume_element_in_chain() {
410        // Ensure volume is navigable
411        let e = TrackElement::Fx;
412        assert_eq!(e.move_right(1), TrackElement::Volume);
413        assert_eq!(TrackElement::Volume.move_left(), TrackElement::Fx);
414    }
415}