Skip to main content

phosphor_app/state/
clip_view.rs

1//! Clip view state — ClipViewState, focus, tabs, piano roll.
2
3/// Which sub-panel of the clip view has focus.
4#[derive(Debug, Clone, Copy, PartialEq, Eq)]
5pub enum ClipViewFocus {
6    FxPanel,
7    PianoRoll,
8}
9
10/// Tab in the FX panel (left side of clip view).
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12pub enum FxPanelTab {
13    TrackFx,
14    Synth,
15}
16
17impl FxPanelTab {
18    pub fn label(self) -> &'static str {
19        match self {
20            Self::TrackFx => "trk fx",
21            Self::Synth => "synth",
22        }
23    }
24
25    pub fn next(self) -> Self {
26        match self {
27            Self::TrackFx => Self::Synth,
28            Self::Synth => Self::TrackFx,
29        }
30    }
31}
32
33/// Tab in the piano roll / clip area (right side of clip view).
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35pub enum ClipTab {
36    InstConfig,
37    PianoRoll,
38    Settings,
39}
40
41impl ClipTab {
42    pub fn label(self) -> &'static str {
43        match self {
44            Self::InstConfig => "inst",
45            Self::PianoRoll => "piano",
46            Self::Settings => "settings",
47        }
48    }
49
50    pub fn next(self) -> Self {
51        match self {
52            Self::InstConfig => Self::PianoRoll,
53            Self::PianoRoll => Self::Settings,
54            Self::Settings => Self::InstConfig,
55        }
56    }
57
58    pub const ALL: &[ClipTab] = &[Self::InstConfig, Self::PianoRoll, Self::Settings];
59}
60
61// ── Grid Resolution ──
62
63#[derive(Debug, Clone, Copy, PartialEq, Eq)]
64pub enum GridResolution {
65    Quarter,
66    Eighth,
67    Sixteenth,
68    ThirtySecond,
69    QuarterT,
70    EighthT,
71    SixteenthT,
72}
73
74impl GridResolution {
75    /// Fraction of a bar (4/4 time, 1 bar = column_count columns).
76    /// This returns fraction relative to the full clip (0.0..1.0) when multiplied
77    /// by (beats_per_bar / total_beats).
78    pub fn subdivisions_per_beat(self) -> f64 {
79        match self {
80            Self::Quarter => 1.0,
81            Self::Eighth => 2.0,
82            Self::Sixteenth => 4.0,
83            Self::ThirtySecond => 8.0,
84            Self::QuarterT => 1.5,    // 3 in the space of 2
85            Self::EighthT => 3.0,
86            Self::SixteenthT => 6.0,
87        }
88    }
89
90    /// Grid step as a fraction of the total clip, given total beats.
91    pub fn step_frac(self, total_beats: usize) -> f64 {
92        if total_beats == 0 { return 0.25; }
93        1.0 / (total_beats as f64 * self.subdivisions_per_beat())
94    }
95
96    /// Snap a fractional position to the nearest grid line.
97    pub fn snap(self, frac: f64, total_beats: usize) -> f64 {
98        let step = self.step_frac(total_beats);
99        if step <= 0.0 { return frac; }
100        (frac / step).round() * step
101    }
102
103    pub fn label(self) -> &'static str {
104        match self {
105            Self::Quarter => "1/4",
106            Self::Eighth => "1/8",
107            Self::Sixteenth => "1/16",
108            Self::ThirtySecond => "1/32",
109            Self::QuarterT => "1/4T",
110            Self::EighthT => "1/8T",
111            Self::SixteenthT => "1/16T",
112        }
113    }
114
115    pub fn next(self) -> Self {
116        match self {
117            Self::Quarter => Self::Eighth,
118            Self::Eighth => Self::Sixteenth,
119            Self::Sixteenth => Self::ThirtySecond,
120            Self::ThirtySecond => Self::QuarterT,
121            Self::QuarterT => Self::EighthT,
122            Self::EighthT => Self::SixteenthT,
123            Self::SixteenthT => Self::Quarter,
124        }
125    }
126
127    pub fn prev(self) -> Self {
128        match self {
129            Self::Quarter => Self::SixteenthT,
130            Self::Eighth => Self::Quarter,
131            Self::Sixteenth => Self::Eighth,
132            Self::ThirtySecond => Self::Sixteenth,
133            Self::QuarterT => Self::ThirtySecond,
134            Self::EighthT => Self::QuarterT,
135            Self::SixteenthT => Self::EighthT,
136        }
137    }
138}
139
140// ── Edit Mode Sub-States ──
141
142#[derive(Debug, Clone, Copy, PartialEq, Eq)]
143pub enum EditSubMode {
144    /// Navigating between notes by proximity.
145    Navigate,
146    /// Shift held: extending selection.
147    Selecting,
148    /// Notes selected. Plain h/l/j/k = move. Shift+h/l = stretch right edge. Shift+j/k = stretch left edge.
149    Moving,
150}
151
152#[derive(Debug)]
153pub struct ClipViewState {
154    pub focus: ClipViewFocus,
155    pub fx_panel_tab: FxPanelTab,
156    pub clip_tab: ClipTab,
157    pub piano_roll: PianoRollState,
158    pub fx_cursor: usize,
159    pub synth_param_cursor: usize,
160    /// Cursor position within the inst config panel.
161    pub inst_config_cursor: usize,
162}
163
164impl Default for ClipViewState {
165    fn default() -> Self { Self::new() }
166}
167
168impl ClipViewState {
169    pub fn new() -> Self {
170        Self {
171            focus: ClipViewFocus::PianoRoll,
172            fx_panel_tab: FxPanelTab::TrackFx,
173            clip_tab: ClipTab::PianoRoll,
174            piano_roll: PianoRollState::new(),
175            fx_cursor: 0,
176            synth_param_cursor: 0,
177            inst_config_cursor: 0,
178        }
179    }
180}
181
182// ── Piano Roll Navigation ──
183//
184// Focus hierarchy (Enter goes deeper, Esc goes back):
185//   Browsing → Column selected → Row selected
186//
187// Browsing: j/k scrolls notes, h/l scrolls horizontally
188// Column selected: h/l moves between columns, j/k moves rows within column
189//   h/l (no shift) = adjust left edge of all notes in column
190//   H/L (shift)    = adjust right edge of all notes in column
191// Row selected: same h/l/H/L but affects only the single note
192
193/// What level of the piano roll is focused.
194/// Follows the Right Left Trick Controls pattern:
195///   Navigation → Selected (column) → Row (individual note)
196#[derive(Debug, Clone, Copy, PartialEq, Eq)]
197pub enum PianoRollFocus {
198    /// h/l navigates columns, number keys jump, j/k scrolls view.
199    /// Enter selects the current column.
200    Navigation,
201    /// Column selected. h/l = left edge, H/L = right edge of ALL notes.
202    /// j/k drops to Row mode. Esc back to Navigation.
203    Selected,
204    /// Single note. h/l = left edge, H/L = right edge of ONE note.
205    /// j/k moves between notes. Esc back to Selected.
206    Row,
207}
208
209#[derive(Debug)]
210pub struct PianoRollState {
211    pub cursor_note: u8,
212    pub scroll_x: usize,
213    pub view_bottom_note: u8,
214    pub view_height: u8,
215    /// Current focus level.
216    pub focus: PianoRollFocus,
217    /// Currently selected column (0-based). Columns map to time subdivisions.
218    pub column: usize,
219    /// Total number of columns in the grid (set by renderer).
220    pub column_count: usize,
221    /// Total beats in the clip (e.g. 4 for a 1-bar clip).
222    pub total_beats: usize,
223    /// Indices of notes that belong to the selected column (set on Enter).
224    /// Edits operate on these indices so notes don't "escape" the column.
225    pub selected_note_indices: Vec<usize>,
226    /// Number input buffer for typing column numbers.
227    column_digits: String,
228    /// Highlight range for bulk selection (Shift+h/l in Navigation mode).
229    /// When set, columns from highlight_start..=highlight_end are selected.
230    pub highlight_start: Option<usize>,
231    pub highlight_end: Option<usize>,
232    /// Number of columns visible on screen (set by renderer each frame).
233    pub visible_columns: usize,
234    /// Yanked (copied) notes buffer. Notes stored with start_frac relative to
235    /// the yank origin (leftmost yanked column), so they can be pasted at any position.
236    pub yank_buffer: Vec<phosphor_core::clip::NoteSnapshot>,
237    /// Width of the yanked region in columns, so paste knows the source span.
238    pub yank_columns: usize,
239    /// Row highlight range (Shift+j/k). Stores MIDI note numbers (low..=high).
240    pub row_highlight_low: Option<u8>,
241    pub row_highlight_high: Option<u8>,
242    /// Whether highlights are locked for stretching (Enter while highlights exist).
243    pub highlight_locked: bool,
244    // ── Edit mode ──
245    pub edit_mode: bool,
246    /// Index into the clip's notes vec — the "cursor" note.
247    pub edit_cursor: usize,
248    /// Indices of selected notes (for multi-select + move).
249    pub edit_selected: Vec<usize>,
250    pub edit_sub: EditSubMode,
251    // ── Grid / snap ──
252    pub grid: GridResolution,
253    pub snap_enabled: bool,
254    pub default_velocity: u8,
255    /// Settings panel cursor (for the Settings tab).
256    pub settings_cursor: usize,
257}
258
259impl Default for PianoRollState {
260    fn default() -> Self { Self::new() }
261}
262
263impl PianoRollState {
264    pub fn new() -> Self {
265        Self {
266            cursor_note: 60,
267            scroll_x: 0,
268            view_bottom_note: 48,
269            view_height: 24,
270            focus: PianoRollFocus::Navigation,
271            column: 0,
272            column_count: 16,
273            total_beats: 4,
274            selected_note_indices: Vec::new(),
275            column_digits: String::new(),
276            highlight_start: None,
277            highlight_end: None,
278            visible_columns: 16,
279            row_highlight_low: None,
280            row_highlight_high: None,
281            yank_buffer: Vec::new(),
282            yank_columns: 0,
283            highlight_locked: false,
284            edit_mode: false,
285            edit_cursor: 0,
286            edit_selected: Vec::new(),
287            edit_sub: EditSubMode::Navigate,
288            grid: GridResolution::Eighth,
289            snap_enabled: true,
290            default_velocity: 100,
291            settings_cursor: 0,
292        }
293    }
294
295    // ── Focus transitions ──
296
297    /// Enter the next focus level. `note_indices` are the indices of notes
298    /// in the current column (captured at selection time so they don't drift).
299    pub fn enter(&mut self, note_indices: Vec<usize>) {
300        match self.focus {
301            PianoRollFocus::Navigation => {
302                self.focus = PianoRollFocus::Selected;
303                self.selected_note_indices = note_indices;
304            }
305            PianoRollFocus::Selected | PianoRollFocus::Row => {}
306        }
307    }
308
309    /// Enter row mode for the current cursor note (called when j/k finds a note).
310    pub fn enter_row(&mut self) {
311        self.focus = PianoRollFocus::Row;
312    }
313
314    pub fn escape(&mut self) {
315        match self.focus {
316            PianoRollFocus::Row => {
317                self.focus = PianoRollFocus::Selected;
318            }
319            PianoRollFocus::Selected => {
320                self.focus = PianoRollFocus::Navigation;
321                self.column_digits.clear();
322            }
323            PianoRollFocus::Navigation => {
324                // Handled by parent (exits clip view)
325            }
326        }
327    }
328
329    /// Returns true if escape was handled internally.
330    pub fn can_escape(&self) -> bool {
331        self.focus != PianoRollFocus::Navigation
332    }
333
334    // ── Note scrolling (browsing + column mode) ──
335
336    pub fn move_up(&mut self) {
337        if self.cursor_note < 127 {
338            self.cursor_note += 1;
339            let top = self.view_bottom_note.saturating_add(self.view_height);
340            if self.cursor_note >= top {
341                self.view_bottom_note = self.cursor_note - self.view_height + 1;
342            }
343        }
344    }
345
346    pub fn move_down(&mut self) {
347        if self.cursor_note > 0 {
348            self.cursor_note -= 1;
349            if self.cursor_note < self.view_bottom_note {
350                self.view_bottom_note = self.cursor_note;
351            }
352        }
353    }
354
355    // ── Column navigation ──
356
357    pub fn move_column_left(&mut self) {
358        if self.column > 0 {
359            self.column -= 1;
360            // Auto-scroll left
361            if self.column < self.scroll_x {
362                self.scroll_x = self.column;
363            }
364        }
365    }
366
367    pub fn move_column_right(&mut self) {
368        if self.column + 1 < self.column_count {
369            self.column += 1;
370            // Auto-scroll right (visible_columns is set by renderer)
371            if self.column >= self.scroll_x + self.visible_columns && self.visible_columns > 0 {
372                self.scroll_x = self.column + 1 - self.visible_columns;
373            }
374        }
375    }
376
377    /// Type a digit for column number jump. Returns true if the column was set.
378    pub fn type_digit(&mut self, ch: char) -> bool {
379        self.column_digits.push(ch);
380        if let Ok(num) = self.column_digits.parse::<usize>() {
381            if num >= 1 && num <= self.column_count {
382                // If no further digit could make a valid larger number, resolve now
383                let could_grow = num * 10 <= self.column_count;
384                if !could_grow || self.column_digits.len() >= 2 {
385                    self.column = num - 1;
386                    self.column_digits.clear();
387                    // Auto-scroll to show the jumped-to column
388                    self.ensure_column_visible();
389                    return true;
390                }
391                // Single digit but could be prefix of larger number — wait
392                return false;
393            }
394        }
395        // Invalid — clear
396        self.column_digits.clear();
397        false
398    }
399
400    /// Force-resolve whatever is in the digit buffer.
401    pub fn commit_digits(&mut self) -> bool {
402        if let Ok(num) = self.column_digits.parse::<usize>() {
403            if num >= 1 && num <= self.column_count {
404                self.column = num - 1;
405                self.column_digits.clear();
406                self.ensure_column_visible();
407                return true;
408            }
409        }
410        self.column_digits.clear();
411        false
412    }
413
414    /// Scroll to make the current column visible.
415    pub fn ensure_column_visible(&mut self) {
416        if self.visible_columns == 0 { return; }
417        if self.column < self.scroll_x {
418            self.scroll_x = self.column;
419        } else if self.column >= self.scroll_x + self.visible_columns {
420            self.scroll_x = self.column + 1 - self.visible_columns;
421        }
422    }
423
424    pub fn column_digits_display(&self) -> &str {
425        &self.column_digits
426    }
427
428    // ── Highlight (Shift+h/l range selection) ──
429
430    /// Begin or cancel highlighting at the current column.
431    /// If already highlighting and range is just the anchor column, cancel.
432    pub fn start_highlight(&mut self) {
433        if let (Some(s), Some(e)) = (self.highlight_start, self.highlight_end) {
434            if s == e && s == self.column {
435                // Pressing shift on the same single column again = cancel
436                self.clear_highlight();
437                return;
438            }
439        }
440        if self.highlight_start.is_none() {
441            self.highlight_start = Some(self.column);
442            self.highlight_end = Some(self.column);
443        }
444    }
445
446    /// Expand highlight left (Shift+h while highlighting).
447    pub fn highlight_left(&mut self) {
448        if let (Some(start), Some(end)) = (self.highlight_start, self.highlight_end) {
449            if self.column > 0 {
450                self.column -= 1;
451            }
452            // Adjust range to include current column
453            let new_start = self.column.min(start);
454            let new_end = self.column.max(end);
455            self.highlight_start = Some(new_start);
456            self.highlight_end = Some(new_end);
457            // If we moved back past our anchor, shrink from the other side
458            if self.column >= start {
459                self.highlight_end = Some(self.column);
460            } else {
461                self.highlight_start = Some(self.column);
462            }
463        }
464    }
465
466    /// Expand highlight right (Shift+l while highlighting).
467    pub fn highlight_right(&mut self) {
468        if let (Some(start), Some(end)) = (self.highlight_start, self.highlight_end) {
469            if self.column + 1 < self.column_count {
470                self.column += 1;
471            }
472            let new_start = self.column.min(start);
473            let new_end = self.column.max(end);
474            self.highlight_start = Some(new_start);
475            self.highlight_end = Some(new_end);
476            if self.column <= end {
477                self.highlight_start = Some(self.column);
478            } else {
479                self.highlight_end = Some(self.column);
480            }
481        }
482    }
483
484    /// Clear the column highlight.
485    pub fn clear_highlight(&mut self) {
486        self.highlight_start = None;
487        self.highlight_end = None;
488    }
489
490    // ── Row highlight (Shift+j/k) ──
491
492    /// Begin or cancel row highlighting at the current cursor note.
493    pub fn start_row_highlight(&mut self) {
494        if let (Some(lo), Some(hi)) = (self.row_highlight_low, self.row_highlight_high) {
495            if lo == hi && lo == self.cursor_note {
496                self.clear_row_highlight();
497                return;
498            }
499        }
500        if self.row_highlight_low.is_none() {
501            self.row_highlight_low = Some(self.cursor_note);
502            self.row_highlight_high = Some(self.cursor_note);
503        }
504    }
505
506    /// Expand row highlight downward (Shift+j).
507    pub fn highlight_down(&mut self) {
508        self.start_row_highlight();
509        if self.cursor_note > 0 {
510            self.cursor_note -= 1;
511            if self.cursor_note < self.view_bottom_note {
512                self.view_bottom_note = self.cursor_note;
513            }
514        }
515        if let Some(lo) = self.row_highlight_low {
516            self.row_highlight_low = Some(self.cursor_note.min(lo));
517        }
518        if let Some(hi) = self.row_highlight_high {
519            self.row_highlight_high = Some(self.cursor_note.max(hi));
520        }
521    }
522
523    /// Expand row highlight upward (Shift+k).
524    pub fn highlight_up(&mut self) {
525        self.start_row_highlight();
526        if self.cursor_note < 127 {
527            self.cursor_note += 1;
528            let top = self.view_bottom_note.saturating_add(self.view_height);
529            if self.cursor_note >= top {
530                self.view_bottom_note = self.cursor_note - self.view_height + 1;
531            }
532        }
533        if let Some(lo) = self.row_highlight_low {
534            self.row_highlight_low = Some(self.cursor_note.min(lo));
535        }
536        if let Some(hi) = self.row_highlight_high {
537            self.row_highlight_high = Some(self.cursor_note.max(hi));
538        }
539    }
540
541    pub fn clear_row_highlight(&mut self) {
542        self.row_highlight_low = None;
543        self.row_highlight_high = None;
544    }
545
546    /// Check if a MIDI note is within the row highlight range.
547    pub fn is_row_highlighted(&self, note: u8) -> bool {
548        if let (Some(lo), Some(hi)) = (self.row_highlight_low, self.row_highlight_high) {
549            note >= lo && note <= hi
550        } else {
551            false
552        }
553    }
554
555    /// Get the highlighted row range as (low_note, high_note).
556    pub fn row_highlight_range(&self) -> Option<(u8, u8)> {
557        match (self.row_highlight_low, self.row_highlight_high) {
558            (Some(lo), Some(hi)) => Some((lo, hi)),
559            _ => None,
560        }
561    }
562
563    /// Clear both column and row highlights.
564    pub fn clear_all_highlights(&mut self) {
565        self.clear_highlight();
566        self.clear_row_highlight();
567        self.highlight_locked = false;
568    }
569
570    /// Check if a column is within the highlight range.
571    pub fn is_highlighted(&self, col: usize) -> bool {
572        if let (Some(start), Some(end)) = (self.highlight_start, self.highlight_end) {
573            col >= start && col <= end
574        } else {
575            false
576        }
577    }
578
579    /// Get the highlighted column range, if any.
580    pub fn highlight_range(&self) -> Option<(usize, usize)> {
581        match (self.highlight_start, self.highlight_end) {
582            (Some(s), Some(e)) => Some((s.min(e), s.max(e))),
583            _ => None,
584        }
585    }
586
587    pub fn set_view_height(&mut self, h: u8) {
588        self.view_height = h.max(1);
589    }
590
591    pub fn set_column_count(&mut self, count: usize) {
592        self.column_count = count.max(1);
593        if self.column >= self.column_count {
594            self.column = self.column_count - 1;
595        }
596    }
597
598    /// Recalculate column_count from total_beats and grid resolution.
599    pub fn update_column_count(&mut self) {
600        let cols = (self.total_beats as f64 * self.grid.subdivisions_per_beat()).round() as usize;
601        self.column_count = cols.max(1);
602        if self.column >= self.column_count {
603            self.column = self.column_count.saturating_sub(1);
604        }
605    }
606
607    /// Returns true if any column or row highlights are active.
608    pub fn has_highlights(&self) -> bool {
609        self.highlight_start.is_some() || self.row_highlight_low.is_some()
610    }
611
612    /// The 1-based column number for display.
613    pub fn column_display(&self) -> usize {
614        self.column + 1
615    }
616}
617
618#[cfg(test)]
619mod tests {
620    use super::*;
621
622    #[test]
623    fn focus_hierarchy() {
624        let mut pr = PianoRollState::new();
625        assert_eq!(pr.focus, PianoRollFocus::Navigation);
626
627        pr.enter(vec![]);
628        assert_eq!(pr.focus, PianoRollFocus::Selected);
629
630        // Enter in column mode does nothing — j/k finds notes and enters row mode
631        pr.enter(vec![]);
632        assert_eq!(pr.focus, PianoRollFocus::Selected);
633
634        // Manually enter row mode (simulating finding a note)
635        pr.enter_row();
636        assert_eq!(pr.focus, PianoRollFocus::Row);
637
638        pr.escape();
639        assert_eq!(pr.focus, PianoRollFocus::Selected);
640
641        pr.escape();
642        assert_eq!(pr.focus, PianoRollFocus::Navigation);
643    }
644
645    #[test]
646    fn column_navigation() {
647        let mut pr = PianoRollState::new();
648        pr.column_count = 16;
649        pr.column = 0;
650
651        pr.move_column_right();
652        assert_eq!(pr.column, 1);
653
654        pr.move_column_left();
655        assert_eq!(pr.column, 0);
656
657        pr.move_column_left();
658        assert_eq!(pr.column, 0); // can't go below 0
659
660        pr.column = 15;
661        pr.move_column_right();
662        assert_eq!(pr.column, 15); // can't go past last
663    }
664
665    #[test]
666    fn digit_jump() {
667        let mut pr = PianoRollState::new();
668        pr.column_count = 16;
669
670        // Single digit > max prefix: resolves immediately
671        // '5' could be prefix of nothing valid (50 > 16), so resolves
672        assert!(pr.type_digit('5'));
673        assert_eq!(pr.column, 4); // 0-based
674
675        // '1' could be prefix of 10-16, so it waits
676        assert!(!pr.type_digit('1'));
677        // '2' makes it 12, resolves
678        assert!(pr.type_digit('2'));
679        assert_eq!(pr.column, 11); // column 12 = index 11
680
681        // Single '9' — 9*10=90 > 16, resolves immediately
682        assert!(pr.type_digit('9'));
683        assert_eq!(pr.column, 8);
684
685        // Single '1' then commit
686        pr.type_digit('1');
687        assert!(pr.commit_digits());
688        assert_eq!(pr.column, 0);
689    }
690
691    #[test]
692    fn can_escape() {
693        let mut pr = PianoRollState::new();
694        assert!(!pr.can_escape()); // browsing — parent handles esc
695
696        pr.enter(vec![]);
697        assert!(pr.can_escape()); // column mode — internal
698
699        pr.enter(vec![]);
700        assert!(pr.can_escape()); // row mode — internal
701    }
702
703    #[test]
704    fn note_scroll() {
705        let mut pr = PianoRollState::new();
706        pr.view_height = 10;
707        pr.view_bottom_note = 50;
708        pr.cursor_note = 55;
709
710        // Move up past visible area
711        for _ in 0..10 {
712            pr.move_up();
713        }
714        // Cursor should have scrolled the view
715        assert!(pr.cursor_note >= pr.view_bottom_note);
716        assert!(pr.cursor_note < pr.view_bottom_note + pr.view_height);
717    }
718}