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    /// Quantize modal state.
116    pub quantize_modal: QuantizeModal,
117    /// User preset browser for the track under the cursor.
118    pub preset_modal: PresetModal,
119    /// Whether the selected track element is "locked" for editing — Enter
120    /// locks, Esc releases. While locked, h/l edits that element instead of
121    /// navigating between elements, which is the same shape as the
122    /// transport's BPM field and the loop editor.
123    ///
124    /// One flag rather than one per element: `track_element` already says
125    /// *which* element the keys go to, so a second flag would only make it
126    /// possible to have two things locked at once.
127    pub element_locked: bool,
128    /// Grace counter: set to the number of armed tracks when recording stops.
129    /// Decremented as each valid snapshot is accepted. Prevents stale snapshots
130    /// while allowing final recording commits from all tracks to come through.
131    pub recording_grace: usize,
132}
133
134impl NavState {
135    pub fn new(tracks: Vec<TrackState>) -> Self {
136        Self {
137            focused_pane: Pane::Tracks,
138            track_cursor: 0,
139            track_scroll: 0,
140            track_selected: false,
141            track_element: TrackElement::Label,
142            number_buf: NumberBuffer::new(),
143            space_menu: SpaceMenu::new(),
144            clip_view: ClipViewState::new(),
145            clip_view_visible: false,
146            clip_view_target: None,
147            fx_menu: FxMenu::new(),
148            instrument_modal: InstrumentModal::new(),
149            loop_editor: LoopEditor::new(),
150            transport_ui: TransportUiState::new(),
151            tracks,
152            input_modal: InputModal::new(),
153            confirm_modal: ConfirmModal::new(),
154            undo_stack: undo::UndoStack::new(),
155            quantize_modal: QuantizeModal::new(),
156            preset_modal: PresetModal::new(),
157            element_locked: false,
158            recording_grace: 0,
159        }
160    }
161    pub fn visible_tracks(&self) -> &[TrackState] {
162        let end = (self.track_scroll + MAX_VISIBLE_TRACKS).min(self.tracks.len());
163        &self.tracks[self.track_scroll..end]
164    }
165
166    pub fn can_scroll_up(&self) -> bool { self.track_scroll > 0 }
167
168    pub fn can_scroll_down(&self) -> bool {
169        self.track_scroll + MAX_VISIBLE_TRACKS < self.tracks.len()
170    }
171
172    pub fn current_track(&self) -> Option<&TrackState> { self.tracks.get(self.track_cursor) }
173
174    pub fn current_track_mut(&mut self) -> Option<&mut TrackState> {
175        self.tracks.get_mut(self.track_cursor)
176    }
177
178    pub fn active_clip(&self) -> Option<&Clip> {
179        let (ti, ci) = self.clip_view_target?;
180        self.tracks.get(ti)?.clips.get(ci)
181    }
182
183    pub fn active_clip_mut(&mut self) -> Option<&mut Clip> {
184        let (ti, ci) = self.clip_view_target?;
185        self.tracks.get_mut(ti)?.clips.get_mut(ci)
186    }
187
188    pub fn active_clip_track(&self) -> Option<&TrackState> {
189        let (ti, _) = self.clip_view_target?;
190        self.tracks.get(ti)
191    }
192}
193
194#[cfg(test)]
195mod tests {
196    use super::*;
197
198    #[test]
199    fn pane_numbers() {
200        assert_eq!(Pane::Transport.number(), 1);
201        assert_eq!(Pane::Tracks.number(), 2);
202        assert_eq!(Pane::ClipView.number(), 3);
203        assert_eq!(Pane::from_number(1), Some(Pane::Transport));
204        assert_eq!(Pane::from_number(2), Some(Pane::Tracks));
205        assert_eq!(Pane::from_number(3), Some(Pane::ClipView));
206        assert_eq!(Pane::from_number(9), None);
207    }
208
209    #[test]
210    fn track_element_navigation_full() {
211        let e = TrackElement::Label;
212        assert_eq!(e.move_right(3), TrackElement::Fx);
213        assert_eq!(TrackElement::Fx.move_right(3), TrackElement::Volume);
214        assert_eq!(TrackElement::Volume.move_right(3), TrackElement::Mute);
215        assert_eq!(TrackElement::Mute.move_right(3), TrackElement::Solo);
216        assert_eq!(TrackElement::Solo.move_right(3), TrackElement::RecordArm);
217        assert_eq!(TrackElement::RecordArm.move_right(3), TrackElement::Clip(0));
218        assert_eq!(TrackElement::Clip(2).move_right(3), TrackElement::Clip(2));
219    }
220
221    #[test]
222    fn track_element_left_full() {
223        assert_eq!(TrackElement::Clip(0).move_left(), TrackElement::RecordArm);
224        assert_eq!(TrackElement::RecordArm.move_left(), TrackElement::Solo);
225        assert_eq!(TrackElement::Solo.move_left(), TrackElement::Mute);
226        assert_eq!(TrackElement::Mute.move_left(), TrackElement::Volume);
227        assert_eq!(TrackElement::Volume.move_left(), TrackElement::Fx);
228        assert_eq!(TrackElement::Fx.move_left(), TrackElement::Label);
229        assert_eq!(TrackElement::Label.move_left(), TrackElement::Label);
230    }
231
232    #[test]
233    fn initial_tracks_has_sends_and_master() {
234        let tracks = initial_tracks();
235        assert_eq!(tracks.len(), 3); // send A + send B + master
236        assert_eq!(tracks[0].kind, TrackKind::SendA);
237        assert_eq!(tracks[1].kind, TrackKind::SendB);
238        assert_eq!(tracks[2].kind, TrackKind::Master);
239    }
240
241    #[test]
242    fn sends_are_at_end() {
243        let mut nav = NavState::new(initial_tracks());
244        nav.move_down();
245        nav.move_down();
246        assert_eq!(nav.track_cursor, 2);
247        assert_eq!(nav.tracks[nav.track_cursor].kind, TrackKind::Master);
248    }
249
250    #[test]
251    fn fx_menu_opens_and_closes() {
252        let mut nav = NavState::new(initial_tracks());
253        nav.enter(); // select track
254        // Navigate to FX
255        nav.move_right(); // -> Fx
256        assert_eq!(nav.track_element, TrackElement::Fx);
257        nav.enter(); // open FX menu
258        assert!(nav.fx_menu.open);
259
260        nav.escape(); // close menu
261        assert!(!nav.fx_menu.open);
262    }
263
264    #[test]
265    fn fx_menu_add_effect() {
266        let mut nav = NavState::new(initial_tracks());
267        let initial_count = nav.tracks[0].fx_chain.len();
268        nav.enter();
269        nav.move_right(); // -> Fx
270        nav.enter(); // open menu
271        nav.enter(); // select first item (Reverb)
272        assert!(!nav.fx_menu.open);
273        assert_eq!(nav.tracks[0].fx_chain.len(), initial_count + 1);
274        assert_eq!(nav.tracks[0].fx_chain.last().unwrap().fx_type, FxType::Reverb);
275    }
276
277    #[test]
278    fn clip_view_focus_toggle() {
279        let mut nav = NavState::new(initial_tracks());
280        // Manually set up clip view (simulating an instrument track being selected)
281        nav.clip_view_visible = true;
282        nav.clip_view_target = Some((0, 0));
283
284        nav.focus_pane(Pane::ClipView);
285        assert_eq!(nav.clip_view.focus, ClipViewFocus::PianoRoll);
286
287        nav.move_left(); // -> FxPanel
288        assert_eq!(nav.clip_view.focus, ClipViewFocus::FxPanel);
289    }
290
291    #[test]
292    fn clip_view_tabs_cycle() {
293        let mut nav = NavState::new(initial_tracks());
294        nav.focused_pane = Pane::ClipView;
295        nav.clip_view.focus = ClipViewFocus::FxPanel;
296
297        // Tab cycles: trk fx → synth → inst config → piano → auto → trk fx
298        assert_eq!(nav.clip_view.fx_panel_tab, FxPanelTab::TrackFx);
299        nav.cycle_tab();
300        assert_eq!(nav.clip_view.fx_panel_tab, FxPanelTab::Synth);
301        nav.cycle_tab();
302        // Now switches to inst config
303        assert_eq!(nav.clip_view.focus, ClipViewFocus::PianoRoll);
304        assert_eq!(nav.clip_view.clip_tab, ClipTab::InstConfig);
305        nav.cycle_tab();
306        // Now switches to piano roll
307        assert_eq!(nav.clip_view.clip_tab, ClipTab::PianoRoll);
308        nav.cycle_tab();
309        assert_eq!(nav.clip_view.clip_tab, ClipTab::Settings);
310        nav.cycle_tab();
311        // Back to FX panel
312        assert_eq!(nav.clip_view.focus, ClipViewFocus::FxPanel);
313        assert_eq!(nav.clip_view.fx_panel_tab, FxPanelTab::TrackFx);
314    }
315
316    #[test]
317    fn arm_toggle() {
318        let mut nav = NavState::new(initial_tracks());
319        assert!(!nav.tracks[0].armed); // bus tracks start unarmed
320        nav.toggle_arm();
321        assert!(nav.tracks[0].armed);
322        nav.toggle_arm();
323        assert!(!nav.tracks[0].armed);
324    }
325
326    #[test]
327    fn space_menu_toggle() {
328        let mut nav = NavState::new(initial_tracks());
329        assert!(!nav.space_menu.open);
330        nav.toggle_space_menu();
331        assert!(nav.space_menu.open);
332        nav.toggle_space_menu();
333        assert!(!nav.space_menu.open);
334    }
335
336    #[test]
337    fn space_menu_handle_pane_jump() {
338        let mut nav = NavState::new(initial_tracks());
339        nav.toggle_space_menu();
340        let action = nav.space_menu_handle('2');
341        assert_eq!(nav.focused_pane, Pane::Tracks);
342        assert!(action.is_none());
343        assert!(!nav.space_menu.open);
344
345        nav.toggle_space_menu();
346        let action = nav.space_menu_handle('1');
347        assert_eq!(nav.focused_pane, Pane::Transport);
348        assert!(action.is_none());
349    }
350
351    #[test]
352    fn space_menu_handle_play_pause() {
353        let mut nav = NavState::new(initial_tracks());
354        nav.toggle_space_menu();
355        let action = nav.space_menu_handle('p');
356        assert_eq!(action, Some(SpaceAction::PlayPause));
357        assert!(!nav.space_menu.open);
358    }
359
360    #[test]
361    fn space_menu_enter_select() {
362        let mut nav = NavState::new(initial_tracks());
363        nav.toggle_space_menu();
364        // cursor at 0 = "spc+1" = tracks
365        let action = nav.enter();
366        assert!(action.is_none()); // pane jump
367        assert!(!nav.space_menu.open);
368    }
369
370    #[test]
371    fn space_menu_nav_and_help() {
372        let mut nav = NavState::new(initial_tracks());
373        nav.toggle_space_menu();
374        assert_eq!(nav.space_menu.section, SpaceMenuSection::Actions);
375        nav.space_menu.switch_section();
376        assert_eq!(nav.space_menu.section, SpaceMenuSection::Help);
377        assert_eq!(nav.space_menu.cursor, 0);
378    }
379
380    #[test]
381    fn number_buffer_commit() {
382        let mut buf = NumberBuffer::new();
383        buf.push_digit('1');
384        assert_eq!(buf.commit(), Some(1));
385        buf.push_digit('1');
386        buf.push_digit('2');
387        assert_eq!(buf.commit(), Some(12));
388    }
389
390    #[test]
391    fn number_buffer_empty_commit() {
392        assert_eq!(NumberBuffer::new().commit(), None);
393    }
394
395    #[test]
396    fn nav_cursor_bounds() {
397        let mut nav = NavState::new(initial_tracks());
398        for _ in 0..20 { nav.move_down(); }
399        assert_eq!(nav.track_cursor, 2); // 3 bus tracks
400    }
401
402    #[test]
403    fn enter_escape_track() {
404        let mut nav = NavState::new(initial_tracks());
405        nav.enter();
406        assert!(nav.track_selected);
407        nav.escape();
408        assert!(!nav.track_selected);
409    }
410
411    #[test]
412    fn mute_solo_toggle() {
413        let mut nav = NavState::new(initial_tracks());
414        nav.toggle_mute();
415        assert!(nav.tracks[0].muted);
416        nav.toggle_solo();
417        assert!(nav.tracks[0].soloed);
418    }
419
420    #[test]
421    fn volume_element_in_chain() {
422        // Ensure volume is navigable
423        let e = TrackElement::Fx;
424        assert_eq!(e.move_right(1), TrackElement::Volume);
425        assert_eq!(TrackElement::Volume.move_left(), TrackElement::Fx);
426    }
427
428    // ── Fader ──
429
430    use phosphor_core::project::{TrackConfig, TrackHandle};
431
432    /// A track wired to an audio-thread handle, so the tests can check the
433    /// fader reaches it rather than only the UI mirror.
434    fn live_track() -> TrackState {
435        let mut t = TrackState::new("t", 0, false, TrackKind::Instrument, vec![]);
436        t.handle = Some(std::sync::Arc::new(TrackHandle::new(0, TrackKind::Instrument)));
437        t.mixer_id = Some(0);
438        t
439    }
440
441    fn handle_volume(t: &TrackState) -> f32 {
442        t.handle.as_ref().unwrap().config.get_volume()
443    }
444
445    /// Every press moves the readout by exactly one dB. This is the property
446    /// the dB-stepping exists for: a linear step would round to the same
447    /// displayed number several presses in a row.
448    #[test]
449    fn fader_steps_one_db_per_press() {
450        let mut t = live_track();
451        // The default is -2.5 dB, off the grid; the first press snaps onto it.
452        t.adjust_volume(1);
453        let start = t.volume_db().unwrap().round();
454        for i in 1..=6 {
455            t.adjust_volume(1);
456            let db = t.volume_db().unwrap();
457            assert!(
458                (db - (start + i as f32)).abs() < 0.01,
459                "press {i} landed at {db:.3} dB, expected {:.3}",
460                start + i as f32
461            );
462        }
463    }
464
465    /// The fader reaches unity exactly, so "no gain change" is a position the
466    /// user can actually select rather than one they can only get near.
467    #[test]
468    fn fader_lands_exactly_on_unity() {
469        let mut t = live_track();
470        for _ in 0..40 {
471            t.adjust_volume(1);
472        }
473        // At the top; walk back down to 0 dB.
474        while t.volume_db().unwrap() > 0.5 {
475            t.adjust_volume(-1);
476        }
477        assert!(
478            (t.volume - TrackConfig::UNITY_VOLUME).abs() < 1.0e-3,
479            "fader stopped at {} instead of unity",
480            t.volume
481        );
482    }
483
484    /// The travel has ends. Holding `l` cannot push the track past +6 dB, and
485    /// holding `h` reaches silence rather than an ever-smaller number.
486    #[test]
487    fn fader_travel_is_bounded_at_both_ends() {
488        let mut t = live_track();
489        for _ in 0..200 {
490            t.adjust_volume(1);
491        }
492        assert_eq!(t.volume, TrackConfig::MAX_VOLUME);
493        assert_eq!(handle_volume(&t), TrackConfig::MAX_VOLUME);
494
495        for _ in 0..200 {
496            t.adjust_volume(-1);
497        }
498        assert_eq!(t.volume, TrackConfig::MIN_VOLUME);
499        assert_eq!(handle_volume(&t), TrackConfig::MIN_VOLUME);
500
501        // And it comes back off the bottom rather than sticking there.
502        t.adjust_volume(1);
503        assert!(t.volume > 0.0, "fader stuck at silence");
504    }
505
506    /// Every press pushes the new position to the audio thread. Without this
507    /// the fader moves on screen and nothing happens in the speakers, which
508    /// is the state this control was in before.
509    #[test]
510    fn fader_syncs_to_the_audio_thread() {
511        let mut t = live_track();
512        for steps in [1, 1, -1, 3, -7] {
513            t.adjust_volume(steps);
514            assert_eq!(
515                handle_volume(&t),
516                t.volume,
517                "audio thread has {} while the UI shows {}",
518                handle_volume(&t),
519                t.volume
520            );
521        }
522    }
523
524    /// A position loaded from a session that is not on the dB grid snaps onto
525    /// it on the first press instead of carrying the offset forever.
526    #[test]
527    fn fader_snaps_a_loaded_position_onto_the_grid() {
528        let mut t = live_track();
529        t.volume = 0.6234; // -4.1 dB, as if hand-edited into a .phos file
530        t.adjust_volume(1);
531        let db = t.volume_db().unwrap();
532        assert!((db - db.round()).abs() < 0.01, "off the grid at {db:.3} dB");
533    }
534
535    /// Enter locks the fader so h/l edits it, and only on tracks that have
536    /// one — a bus track's header does not draw a fader.
537    #[test]
538    fn enter_locks_the_fader_only_on_tracks_that_have_one() {
539        let mut nav = NavState::new(initial_tracks()); // bus tracks only
540        nav.enter();
541        nav.move_right(); // Label -> Fx
542        nav.move_right(); // Fx -> Volume
543        assert_eq!(nav.track_element, TrackElement::Volume);
544        nav.enter();
545        assert!(!nav.element_locked, "locked the fader on a bus track");
546
547        nav.tracks.push(live_track());
548        nav.track_cursor = nav.tracks.len() - 1;
549        nav.enter();
550        assert!(nav.element_locked, "did not lock the fader on an instrument track");
551
552        // Esc releases, leaving the element selected.
553        nav.escape();
554        assert!(!nav.element_locked);
555        assert_eq!(nav.track_element, TrackElement::Volume);
556    }
557
558    /// The fader is not undoable — it is a continuous control, and neither
559    /// mute, solo, arm nor the synth parameters push onto the stack either.
560    #[test]
561    fn fader_does_not_push_onto_the_undo_stack() {
562        let mut nav = NavState::new(vec![live_track()]);
563        assert!(!nav.undo_stack.can_undo());
564        nav.adjust_volume(3);
565        nav.adjust_volume(-1);
566        assert!(
567            !nav.undo_stack.can_undo(),
568            "the fader pushed an undo entry; mute and solo do not"
569        );
570    }
571}