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    /// Indices of notes that belong to the selected column (set on Enter).
222    /// Edits operate on these indices so notes don't "escape" the column.
223    pub selected_note_indices: Vec<usize>,
224    /// Number input buffer for typing column numbers.
225    column_digits: String,
226    /// Highlight range for bulk selection (Shift+h/l in Navigation mode).
227    /// When set, columns from highlight_start..=highlight_end are selected.
228    pub highlight_start: Option<usize>,
229    pub highlight_end: Option<usize>,
230    /// Number of columns visible on screen (set by renderer each frame).
231    pub visible_columns: usize,
232    /// Yanked (copied) notes buffer. Notes stored with start_frac relative to
233    /// the yank origin (leftmost yanked column), so they can be pasted at any position.
234    pub yank_buffer: Vec<phosphor_core::clip::NoteSnapshot>,
235    /// Width of the yanked region in columns, so paste knows the source span.
236    pub yank_columns: usize,
237    /// Row highlight range (Shift+j/k). Stores MIDI note numbers (low..=high).
238    pub row_highlight_low: Option<u8>,
239    pub row_highlight_high: Option<u8>,
240    /// Whether highlights are locked for stretching (Enter while highlights exist).
241    pub highlight_locked: bool,
242    // ── Edit mode ──
243    pub edit_mode: bool,
244    /// Index into the clip's notes vec — the "cursor" note.
245    pub edit_cursor: usize,
246    /// Indices of selected notes (for multi-select + move).
247    pub edit_selected: Vec<usize>,
248    pub edit_sub: EditSubMode,
249    // ── Grid / snap ──
250    pub grid: GridResolution,
251    pub snap_enabled: bool,
252    pub default_velocity: u8,
253    /// Settings panel cursor (for the Settings tab).
254    pub settings_cursor: usize,
255}
256
257impl Default for PianoRollState {
258    fn default() -> Self { Self::new() }
259}
260
261impl PianoRollState {
262    pub fn new() -> Self {
263        Self {
264            cursor_note: 60,
265            scroll_x: 0,
266            view_bottom_note: 48,
267            view_height: 24,
268            focus: PianoRollFocus::Navigation,
269            column: 0,
270            column_count: 16,
271            selected_note_indices: Vec::new(),
272            column_digits: String::new(),
273            highlight_start: None,
274            highlight_end: None,
275            visible_columns: 16,
276            row_highlight_low: None,
277            row_highlight_high: None,
278            yank_buffer: Vec::new(),
279            yank_columns: 0,
280            highlight_locked: false,
281            edit_mode: false,
282            edit_cursor: 0,
283            edit_selected: Vec::new(),
284            edit_sub: EditSubMode::Navigate,
285            grid: GridResolution::Eighth,
286            snap_enabled: true,
287            default_velocity: 100,
288            settings_cursor: 0,
289        }
290    }
291
292    // ── Focus transitions ──
293
294    /// Enter the next focus level. `note_indices` are the indices of notes
295    /// in the current column (captured at selection time so they don't drift).
296    pub fn enter(&mut self, note_indices: Vec<usize>) {
297        match self.focus {
298            PianoRollFocus::Navigation => {
299                self.focus = PianoRollFocus::Selected;
300                self.selected_note_indices = note_indices;
301            }
302            PianoRollFocus::Selected | PianoRollFocus::Row => {}
303        }
304    }
305
306    /// Enter row mode for the current cursor note (called when j/k finds a note).
307    pub fn enter_row(&mut self) {
308        self.focus = PianoRollFocus::Row;
309    }
310
311    pub fn escape(&mut self) {
312        match self.focus {
313            PianoRollFocus::Row => {
314                self.focus = PianoRollFocus::Selected;
315            }
316            PianoRollFocus::Selected => {
317                self.focus = PianoRollFocus::Navigation;
318                self.column_digits.clear();
319            }
320            PianoRollFocus::Navigation => {
321                // Handled by parent (exits clip view)
322            }
323        }
324    }
325
326    /// Returns true if escape was handled internally.
327    pub fn can_escape(&self) -> bool {
328        self.focus != PianoRollFocus::Navigation
329    }
330
331    // ── Note scrolling (browsing + column mode) ──
332
333    pub fn move_up(&mut self) {
334        if self.cursor_note < 127 {
335            self.cursor_note += 1;
336            let top = self.view_bottom_note.saturating_add(self.view_height);
337            if self.cursor_note >= top {
338                self.view_bottom_note = self.cursor_note - self.view_height + 1;
339            }
340        }
341    }
342
343    pub fn move_down(&mut self) {
344        if self.cursor_note > 0 {
345            self.cursor_note -= 1;
346            if self.cursor_note < self.view_bottom_note {
347                self.view_bottom_note = self.cursor_note;
348            }
349        }
350    }
351
352    // ── Column navigation ──
353
354    pub fn move_column_left(&mut self) {
355        if self.column > 0 {
356            self.column -= 1;
357            // Auto-scroll left
358            if self.column < self.scroll_x {
359                self.scroll_x = self.column;
360            }
361        }
362    }
363
364    pub fn move_column_right(&mut self) {
365        if self.column + 1 < self.column_count {
366            self.column += 1;
367            // Auto-scroll right (visible_columns is set by renderer)
368            if self.column >= self.scroll_x + self.visible_columns && self.visible_columns > 0 {
369                self.scroll_x = self.column + 1 - self.visible_columns;
370            }
371        }
372    }
373
374    /// Type a digit for column number jump. Returns true if the column was set.
375    pub fn type_digit(&mut self, ch: char) -> bool {
376        self.column_digits.push(ch);
377        if let Ok(num) = self.column_digits.parse::<usize>() {
378            if num >= 1 && num <= self.column_count {
379                // If no further digit could make a valid larger number, resolve now
380                let could_grow = num * 10 <= self.column_count;
381                if !could_grow || self.column_digits.len() >= 2 {
382                    self.column = num - 1;
383                    self.column_digits.clear();
384                    // Auto-scroll to show the jumped-to column
385                    self.ensure_column_visible();
386                    return true;
387                }
388                // Single digit but could be prefix of larger number — wait
389                return false;
390            }
391        }
392        // Invalid — clear
393        self.column_digits.clear();
394        false
395    }
396
397    /// Force-resolve whatever is in the digit buffer.
398    pub fn commit_digits(&mut self) -> bool {
399        if let Ok(num) = self.column_digits.parse::<usize>() {
400            if num >= 1 && num <= self.column_count {
401                self.column = num - 1;
402                self.column_digits.clear();
403                self.ensure_column_visible();
404                return true;
405            }
406        }
407        self.column_digits.clear();
408        false
409    }
410
411    /// Scroll to make the current column visible.
412    pub fn ensure_column_visible(&mut self) {
413        if self.visible_columns == 0 { return; }
414        if self.column < self.scroll_x {
415            self.scroll_x = self.column;
416        } else if self.column >= self.scroll_x + self.visible_columns {
417            self.scroll_x = self.column + 1 - self.visible_columns;
418        }
419    }
420
421    pub fn column_digits_display(&self) -> &str {
422        &self.column_digits
423    }
424
425    // ── Highlight (Shift+h/l range selection) ──
426
427    /// Begin or cancel highlighting at the current column.
428    /// If already highlighting and range is just the anchor column, cancel.
429    pub fn start_highlight(&mut self) {
430        if let (Some(s), Some(e)) = (self.highlight_start, self.highlight_end) {
431            if s == e && s == self.column {
432                // Pressing shift on the same single column again = cancel
433                self.clear_highlight();
434                return;
435            }
436        }
437        if self.highlight_start.is_none() {
438            self.highlight_start = Some(self.column);
439            self.highlight_end = Some(self.column);
440        }
441    }
442
443    /// Expand highlight left (Shift+h while highlighting).
444    pub fn highlight_left(&mut self) {
445        if let (Some(start), Some(end)) = (self.highlight_start, self.highlight_end) {
446            if self.column > 0 {
447                self.column -= 1;
448            }
449            // Adjust range to include current column
450            let new_start = self.column.min(start);
451            let new_end = self.column.max(end);
452            self.highlight_start = Some(new_start);
453            self.highlight_end = Some(new_end);
454            // If we moved back past our anchor, shrink from the other side
455            if self.column >= start {
456                self.highlight_end = Some(self.column);
457            } else {
458                self.highlight_start = Some(self.column);
459            }
460        }
461    }
462
463    /// Expand highlight right (Shift+l while highlighting).
464    pub fn highlight_right(&mut self) {
465        if let (Some(start), Some(end)) = (self.highlight_start, self.highlight_end) {
466            if self.column + 1 < self.column_count {
467                self.column += 1;
468            }
469            let new_start = self.column.min(start);
470            let new_end = self.column.max(end);
471            self.highlight_start = Some(new_start);
472            self.highlight_end = Some(new_end);
473            if self.column <= end {
474                self.highlight_start = Some(self.column);
475            } else {
476                self.highlight_end = Some(self.column);
477            }
478        }
479    }
480
481    /// Clear the column highlight.
482    pub fn clear_highlight(&mut self) {
483        self.highlight_start = None;
484        self.highlight_end = None;
485    }
486
487    // ── Row highlight (Shift+j/k) ──
488
489    /// Begin or cancel row highlighting at the current cursor note.
490    pub fn start_row_highlight(&mut self) {
491        if let (Some(lo), Some(hi)) = (self.row_highlight_low, self.row_highlight_high) {
492            if lo == hi && lo == self.cursor_note {
493                self.clear_row_highlight();
494                return;
495            }
496        }
497        if self.row_highlight_low.is_none() {
498            self.row_highlight_low = Some(self.cursor_note);
499            self.row_highlight_high = Some(self.cursor_note);
500        }
501    }
502
503    /// Expand row highlight downward (Shift+j).
504    pub fn highlight_down(&mut self) {
505        self.start_row_highlight();
506        if self.cursor_note > 0 {
507            self.cursor_note -= 1;
508            if self.cursor_note < self.view_bottom_note {
509                self.view_bottom_note = self.cursor_note;
510            }
511        }
512        if let Some(lo) = self.row_highlight_low {
513            self.row_highlight_low = Some(self.cursor_note.min(lo));
514        }
515        if let Some(hi) = self.row_highlight_high {
516            self.row_highlight_high = Some(self.cursor_note.max(hi));
517        }
518    }
519
520    /// Expand row highlight upward (Shift+k).
521    pub fn highlight_up(&mut self) {
522        self.start_row_highlight();
523        if self.cursor_note < 127 {
524            self.cursor_note += 1;
525            let top = self.view_bottom_note.saturating_add(self.view_height);
526            if self.cursor_note >= top {
527                self.view_bottom_note = self.cursor_note - self.view_height + 1;
528            }
529        }
530        if let Some(lo) = self.row_highlight_low {
531            self.row_highlight_low = Some(self.cursor_note.min(lo));
532        }
533        if let Some(hi) = self.row_highlight_high {
534            self.row_highlight_high = Some(self.cursor_note.max(hi));
535        }
536    }
537
538    pub fn clear_row_highlight(&mut self) {
539        self.row_highlight_low = None;
540        self.row_highlight_high = None;
541    }
542
543    /// Check if a MIDI note is within the row highlight range.
544    pub fn is_row_highlighted(&self, note: u8) -> bool {
545        if let (Some(lo), Some(hi)) = (self.row_highlight_low, self.row_highlight_high) {
546            note >= lo && note <= hi
547        } else {
548            false
549        }
550    }
551
552    /// Get the highlighted row range as (low_note, high_note).
553    pub fn row_highlight_range(&self) -> Option<(u8, u8)> {
554        match (self.row_highlight_low, self.row_highlight_high) {
555            (Some(lo), Some(hi)) => Some((lo, hi)),
556            _ => None,
557        }
558    }
559
560    /// Clear both column and row highlights.
561    pub fn clear_all_highlights(&mut self) {
562        self.clear_highlight();
563        self.clear_row_highlight();
564        self.highlight_locked = false;
565    }
566
567    /// Check if a column is within the highlight range.
568    pub fn is_highlighted(&self, col: usize) -> bool {
569        if let (Some(start), Some(end)) = (self.highlight_start, self.highlight_end) {
570            col >= start && col <= end
571        } else {
572            false
573        }
574    }
575
576    /// Get the highlighted column range, if any.
577    pub fn highlight_range(&self) -> Option<(usize, usize)> {
578        match (self.highlight_start, self.highlight_end) {
579            (Some(s), Some(e)) => Some((s.min(e), s.max(e))),
580            _ => None,
581        }
582    }
583
584    pub fn set_view_height(&mut self, h: u8) {
585        self.view_height = h.max(1);
586    }
587
588    pub fn set_column_count(&mut self, count: usize) {
589        self.column_count = count.max(1);
590        if self.column >= self.column_count {
591            self.column = self.column_count - 1;
592        }
593    }
594
595    /// Returns true if any column or row highlights are active.
596    pub fn has_highlights(&self) -> bool {
597        self.highlight_start.is_some() || self.row_highlight_low.is_some()
598    }
599
600    /// The 1-based column number for display.
601    pub fn column_display(&self) -> usize {
602        self.column + 1
603    }
604}
605
606#[cfg(test)]
607mod tests {
608    use super::*;
609
610    #[test]
611    fn focus_hierarchy() {
612        let mut pr = PianoRollState::new();
613        assert_eq!(pr.focus, PianoRollFocus::Navigation);
614
615        pr.enter(vec![]);
616        assert_eq!(pr.focus, PianoRollFocus::Selected);
617
618        // Enter in column mode does nothing — j/k finds notes and enters row mode
619        pr.enter(vec![]);
620        assert_eq!(pr.focus, PianoRollFocus::Selected);
621
622        // Manually enter row mode (simulating finding a note)
623        pr.enter_row();
624        assert_eq!(pr.focus, PianoRollFocus::Row);
625
626        pr.escape();
627        assert_eq!(pr.focus, PianoRollFocus::Selected);
628
629        pr.escape();
630        assert_eq!(pr.focus, PianoRollFocus::Navigation);
631    }
632
633    #[test]
634    fn column_navigation() {
635        let mut pr = PianoRollState::new();
636        pr.column_count = 16;
637        pr.column = 0;
638
639        pr.move_column_right();
640        assert_eq!(pr.column, 1);
641
642        pr.move_column_left();
643        assert_eq!(pr.column, 0);
644
645        pr.move_column_left();
646        assert_eq!(pr.column, 0); // can't go below 0
647
648        pr.column = 15;
649        pr.move_column_right();
650        assert_eq!(pr.column, 15); // can't go past last
651    }
652
653    #[test]
654    fn digit_jump() {
655        let mut pr = PianoRollState::new();
656        pr.column_count = 16;
657
658        // Single digit > max prefix: resolves immediately
659        // '5' could be prefix of nothing valid (50 > 16), so resolves
660        assert!(pr.type_digit('5'));
661        assert_eq!(pr.column, 4); // 0-based
662
663        // '1' could be prefix of 10-16, so it waits
664        assert!(!pr.type_digit('1'));
665        // '2' makes it 12, resolves
666        assert!(pr.type_digit('2'));
667        assert_eq!(pr.column, 11); // column 12 = index 11
668
669        // Single '9' — 9*10=90 > 16, resolves immediately
670        assert!(pr.type_digit('9'));
671        assert_eq!(pr.column, 8);
672
673        // Single '1' then commit
674        pr.type_digit('1');
675        assert!(pr.commit_digits());
676        assert_eq!(pr.column, 0);
677    }
678
679    #[test]
680    fn can_escape() {
681        let mut pr = PianoRollState::new();
682        assert!(!pr.can_escape()); // browsing — parent handles esc
683
684        pr.enter(vec![]);
685        assert!(pr.can_escape()); // column mode — internal
686
687        pr.enter(vec![]);
688        assert!(pr.can_escape()); // row mode — internal
689    }
690
691    #[test]
692    fn note_scroll() {
693        let mut pr = PianoRollState::new();
694        pr.view_height = 10;
695        pr.view_bottom_note = 50;
696        pr.cursor_note = 55;
697
698        // Move up past visible area
699        for _ in 0..10 {
700            pr.move_up();
701        }
702        // Cursor should have scrolled the view
703        assert!(pr.cursor_note >= pr.view_bottom_note);
704        assert!(pr.cursor_note < pr.view_bottom_note + pr.view_height);
705    }
706}