1mod 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#[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
84pub const MAX_VISIBLE_TRACKS: usize = 5;
87pub 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 pub clip_view_target: Option<(usize, usize)>,
103 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 pub input_modal: InputModal,
111 pub confirm_modal: ConfirmModal,
113 pub undo_stack: undo::UndoStack,
115 pub quantize_modal: QuantizeModal,
117 pub element_locked: bool,
126 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); 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(); nav.move_right(); assert_eq!(nav.track_element, TrackElement::Fx);
254 nav.enter(); assert!(nav.fx_menu.open);
256
257 nav.escape(); 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(); nav.enter(); nav.enter(); 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 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(); 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 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 assert_eq!(nav.clip_view.focus, ClipViewFocus::PianoRoll);
301 assert_eq!(nav.clip_view.clip_tab, ClipTab::InstConfig);
302 nav.cycle_tab();
303 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 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); 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 let action = nav.enter();
363 assert!(action.is_none()); 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); }
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 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 use phosphor_core::project::{TrackConfig, TrackHandle};
428
429 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 #[test]
446 fn fader_steps_one_db_per_press() {
447 let mut t = live_track();
448 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 #[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 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 #[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 t.adjust_volume(1);
500 assert!(t.volume > 0.0, "fader stuck at silence");
501 }
502
503 #[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 #[test]
524 fn fader_snaps_a_loaded_position_onto_the_grid() {
525 let mut t = live_track();
526 t.volume = 0.6234; 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 #[test]
535 fn enter_locks_the_fader_only_on_tracks_that_have_one() {
536 let mut nav = NavState::new(initial_tracks()); nav.enter();
538 nav.move_right(); nav.move_right(); 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 nav.escape();
551 assert!(!nav.element_locked);
552 assert_eq!(nav.track_element, TrackElement::Volume);
553 }
554
555 #[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}