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 preset_modal: PresetModal,
119 pub element_locked: bool,
128 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); 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(); nav.move_right(); assert_eq!(nav.track_element, TrackElement::Fx);
257 nav.enter(); assert!(nav.fx_menu.open);
259
260 nav.escape(); 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(); nav.enter(); nav.enter(); 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 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(); 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 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 assert_eq!(nav.clip_view.focus, ClipViewFocus::PianoRoll);
304 assert_eq!(nav.clip_view.clip_tab, ClipTab::InstConfig);
305 nav.cycle_tab();
306 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 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); 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 let action = nav.enter();
366 assert!(action.is_none()); 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); }
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 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 use phosphor_core::project::{TrackConfig, TrackHandle};
431
432 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 #[test]
449 fn fader_steps_one_db_per_press() {
450 let mut t = live_track();
451 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 #[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 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 #[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 t.adjust_volume(1);
503 assert!(t.volume > 0.0, "fader stuck at silence");
504 }
505
506 #[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 #[test]
527 fn fader_snaps_a_loaded_position_onto_the_grid() {
528 let mut t = live_track();
529 t.volume = 0.6234; 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 #[test]
538 fn enter_locks_the_fader_only_on_tracks_that_have_one() {
539 let mut nav = NavState::new(initial_tracks()); nav.enter();
541 nav.move_right(); nav.move_right(); 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 nav.escape();
554 assert!(!nav.element_locked);
555 assert_eq!(nav.track_element, TrackElement::Volume);
556 }
557
558 #[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}