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