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    /// The step grid. Only reachable on a track that has a sequencer on it —
40    /// [`ClipTab::next`] steps over it everywhere else, and the tab strip
41    /// leaves it out.
42    Sequencer,
43}
44
45impl ClipTab {
46    pub fn label(self) -> &'static str {
47        match self {
48            Self::InstConfig => "inst",
49            Self::PianoRoll => "piano",
50            Self::Settings => "settings",
51            Self::Sequencer => "seq",
52        }
53    }
54
55    pub fn next(self) -> Self {
56        match self {
57            Self::InstConfig => Self::PianoRoll,
58            Self::PianoRoll => Self::Settings,
59            Self::Settings => Self::InstConfig,
60            Self::Sequencer => Self::InstConfig,
61        }
62    }
63
64    pub const ALL: &[ClipTab] = &[Self::InstConfig, Self::PianoRoll, Self::Settings];
65}
66
67// ── Grid Resolution ──
68
69#[derive(Debug, Clone, Copy, PartialEq, Eq)]
70pub enum GridResolution {
71    Quarter,
72    Eighth,
73    Sixteenth,
74    ThirtySecond,
75    QuarterT,
76    EighthT,
77    SixteenthT,
78}
79
80impl GridResolution {
81    /// Fraction of a bar (4/4 time, 1 bar = column_count columns).
82    /// This returns fraction relative to the full clip (0.0..1.0) when multiplied
83    /// by (beats_per_bar / total_beats).
84    pub fn subdivisions_per_beat(self) -> f64 {
85        match self {
86            Self::Quarter => 1.0,
87            Self::Eighth => 2.0,
88            Self::Sixteenth => 4.0,
89            Self::ThirtySecond => 8.0,
90            Self::QuarterT => 1.5,    // 3 in the space of 2
91            Self::EighthT => 3.0,
92            Self::SixteenthT => 6.0,
93        }
94    }
95
96    /// Grid step as a fraction of the total clip, given total beats.
97    pub fn step_frac(self, total_beats: usize) -> f64 {
98        if total_beats == 0 { return 0.25; }
99        1.0 / (total_beats as f64 * self.subdivisions_per_beat())
100    }
101
102    /// Snap a fractional position to the nearest grid line.
103    pub fn snap(self, frac: f64, total_beats: usize) -> f64 {
104        let step = self.step_frac(total_beats);
105        if step <= 0.0 { return frac; }
106        (frac / step).round() * step
107    }
108
109    pub fn label(self) -> &'static str {
110        match self {
111            Self::Quarter => "1/4",
112            Self::Eighth => "1/8",
113            Self::Sixteenth => "1/16",
114            Self::ThirtySecond => "1/32",
115            Self::QuarterT => "1/4T",
116            Self::EighthT => "1/8T",
117            Self::SixteenthT => "1/16T",
118        }
119    }
120
121    pub fn next(self) -> Self {
122        match self {
123            Self::Quarter => Self::Eighth,
124            Self::Eighth => Self::Sixteenth,
125            Self::Sixteenth => Self::ThirtySecond,
126            Self::ThirtySecond => Self::QuarterT,
127            Self::QuarterT => Self::EighthT,
128            Self::EighthT => Self::SixteenthT,
129            Self::SixteenthT => Self::Quarter,
130        }
131    }
132
133    pub fn prev(self) -> Self {
134        match self {
135            Self::Quarter => Self::SixteenthT,
136            Self::Eighth => Self::Quarter,
137            Self::Sixteenth => Self::Eighth,
138            Self::ThirtySecond => Self::Sixteenth,
139            Self::QuarterT => Self::ThirtySecond,
140            Self::EighthT => Self::QuarterT,
141            Self::SixteenthT => Self::EighthT,
142        }
143    }
144}
145
146// ── Edit Mode Sub-States ──
147
148#[derive(Debug, Clone, Copy, PartialEq, Eq)]
149pub enum EditSubMode {
150    /// Navigating between notes by proximity.
151    Navigate,
152    /// Shift held: extending selection.
153    Selecting,
154    /// Notes selected. Plain h/l/j/k = move. Shift+h/l = stretch right edge. Shift+j/k = stretch left edge.
155    Moving,
156}
157
158#[derive(Debug)]
159pub struct ClipViewState {
160    pub focus: ClipViewFocus,
161    pub fx_panel_tab: FxPanelTab,
162    pub clip_tab: ClipTab,
163    pub piano_roll: PianoRollState,
164    pub fx_cursor: usize,
165    pub synth_param_cursor: usize,
166    /// Cursor position within the inst config panel.
167    pub inst_config_cursor: usize,
168    /// Where the cursor is standing in the step grid, and whether a control
169    /// is locked. Only ever cursors: what a sequencer *contains* lives in
170    /// [`crate::sequencer::SequencerState`] and is edited through its ops.
171    pub sequencer: SequencerView,
172}
173
174impl Default for ClipViewState {
175    fn default() -> Self { Self::new() }
176}
177
178impl ClipViewState {
179    pub fn new() -> Self {
180        Self {
181            focus: ClipViewFocus::PianoRoll,
182            fx_panel_tab: FxPanelTab::TrackFx,
183            clip_tab: ClipTab::PianoRoll,
184            piano_roll: PianoRollState::new(),
185            fx_cursor: 0,
186            synth_param_cursor: 0,
187            inst_config_cursor: 0,
188            sequencer: SequencerView::new(),
189        }
190    }
191}
192
193// ── Sequencer view ──
194
195/// Which horizontal band of the step grid view has the cursor.
196///
197/// `j`/`k` walk this list; `h`/`l` move inside whichever band is on. The step
198/// cursor, the lane and the selected slot are not here — those are edits, and
199/// live in the sequencer itself so that a controller changing one moves the
200/// same cursor a key does.
201#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
202pub enum SeqBand {
203    /// The step row. `h`/`l` walks steps, `n` writes one.
204    #[default]
205    Grid,
206    /// The controls belonging to the step under the cursor.
207    Step,
208    /// The controls belonging to the pattern.
209    Pattern,
210    /// The eight slots and the chain.
211    Slots,
212}
213
214impl SeqBand {
215    pub const ALL: [SeqBand; 4] = [Self::Grid, Self::Step, Self::Pattern, Self::Slots];
216
217    pub fn index(self) -> usize {
218        match self {
219            Self::Grid => 0,
220            Self::Step => 1,
221            Self::Pattern => 2,
222            Self::Slots => 3,
223        }
224    }
225
226    pub fn label(self) -> &'static str {
227        match self {
228            Self::Grid => "grid",
229            Self::Step => "step",
230            Self::Pattern => "pattern",
231            Self::Slots => "slots",
232        }
233    }
234
235    /// One band along, stopping at the ends rather than wrapping: a list that
236    /// wraps makes `j` at the bottom jump back to the top, which reads as the
237    /// cursor having been lost.
238    pub fn stepped(self, delta: i32) -> Self {
239        let target = (self.index() as i32 + delta).clamp(0, Self::ALL.len() as i32 - 1);
240        Self::ALL[target as usize]
241    }
242}
243
244/// One control on the step grid's panels.
245///
246/// Named here rather than in either of the two places that use it, because
247/// both have to agree: the key handler turns a press on one of these into an
248/// op, and the renderer draws the same list in the same order. A knob the
249/// keys know about and the panel does not is a knob nobody can reach.
250#[derive(Debug, Clone, Copy, PartialEq, Eq)]
251pub enum SeqKnob {
252    // ── The step under the cursor ──
253    /// The one pitch control: semitones, or scale degrees in a mode.
254    Pitch,
255    Chord,
256    Voicing,
257    /// Double the root an octave down.
258    RootBelow,
259    Gate,
260    // ── The lane, when it is pinned to a drum voice ──
261    /// Which kit sound this lane plays.
262    Voice,
263    Mute,
264    Solo,
265    // ── The pattern ──
266    Length,
267    Rate,
268    Swing,
269    /// What a newly written step's gate starts at.
270    DefaultGate,
271    BaseVelocity,
272    AccentVelocity,
273    Mode,
274    Tonic,
275    /// When a queued switch happens.
276    Switch,
277}
278
279impl SeqKnob {
280    pub fn label(self) -> &'static str {
281        match self {
282            Self::Pitch => "pitch",
283            Self::Chord => "chord",
284            Self::Voicing => "voicing",
285            Self::RootBelow => "root\u{2193}",
286            Self::Gate | Self::DefaultGate => "gate",
287            Self::Voice => "sound",
288            Self::Mute => "mute",
289            Self::Solo => "solo",
290            Self::Length => "steps",
291            Self::Rate => "rate",
292            Self::Swing => "swing",
293            Self::BaseVelocity => "base",
294            Self::AccentVelocity => "accent",
295            Self::Mode => "mode",
296            Self::Tonic => "key",
297            Self::Switch => "switch",
298        }
299    }
300}
301
302/// The step grid's cursor: which band, which control inside it, and whether
303/// that control has been locked with Enter.
304///
305/// Nothing here is part of a pattern. It is the same separation the piano
306/// roll keeps — [`PianoRollState`] holds a column and a focus level, the clip
307/// holds the notes.
308#[derive(Debug, Clone, PartialEq, Eq, Default)]
309pub struct SequencerView {
310    pub band: SeqBand,
311    /// Which control inside [`SequencerView::band`] the cursor is on.
312    pub knob: usize,
313    /// Enter was pressed on a control: `h`/`l` now adjust it and nothing else
314    /// gets a look at the key. The fader's contract, applied to a knob.
315    pub locked: bool,
316    /// The slot `y` picked up, for `p` to paste.
317    pub copy_from: Option<u8>,
318    /// Digits typed towards a step or slot number.
319    pub digits: String,
320}
321
322impl SequencerView {
323    pub fn new() -> Self {
324        Self::default()
325    }
326
327    /// Move to another band, giving up the lock and the digit buffer with it.
328    pub fn move_band(&mut self, delta: i32) {
329        if self.locked {
330            return;
331        }
332        let next = self.band.stepped(delta);
333        if next != self.band {
334            self.band = next;
335            self.knob = 0;
336            self.digits.clear();
337        }
338    }
339
340    /// Put the cursor on a band directly, as opening the view does.
341    pub fn focus_band(&mut self, band: SeqBand) {
342        self.band = band;
343        self.knob = 0;
344        self.locked = false;
345        self.digits.clear();
346    }
347
348    /// Move the cursor between the controls of the current band. Clamped:
349    /// walking off the end of a knob row and reappearing at the other end is
350    /// how a value gets changed by accident.
351    pub fn move_knob(&mut self, delta: i32, count: usize) {
352        if count == 0 {
353            self.knob = 0;
354            return;
355        }
356        self.knob = (self.knob as i32 + delta).clamp(0, count as i32 - 1) as usize;
357    }
358
359    /// Type a digit towards a number in `1..=max`, and say which one was
360    /// named once it can no longer grow.
361    ///
362    /// The piano roll's rule, because a step grid has the same problem: `1`
363    /// on a 16-step pattern might be step 1 or the front of step 12.
364    pub fn type_digit(&mut self, ch: char, max: usize) -> Option<usize> {
365        self.digits.push(ch);
366        let Ok(number) = self.digits.parse::<usize>() else {
367            self.digits.clear();
368            return None;
369        };
370        if number == 0 || number > max {
371            self.digits.clear();
372            return None;
373        }
374        if number * 10 > max || self.digits.len() >= 2 {
375            self.digits.clear();
376            return Some(number);
377        }
378        None
379    }
380}
381
382// ── Piano Roll Navigation ──
383//
384// Focus hierarchy (Enter goes deeper, Esc goes back):
385//   Browsing → Column selected → Row selected
386//
387// Browsing: j/k scrolls notes, h/l scrolls horizontally
388// Column selected: h/l moves between columns, j/k moves rows within column
389//   h/l (no shift) = adjust left edge of all notes in column
390//   H/L (shift)    = adjust right edge of all notes in column
391// Row selected: same h/l/H/L but affects only the single note
392
393/// What level of the piano roll is focused.
394/// Follows the Right Left Trick Controls pattern:
395///   Navigation → Selected (column) → Row (individual note)
396#[derive(Debug, Clone, Copy, PartialEq, Eq)]
397pub enum PianoRollFocus {
398    /// h/l navigates columns, number keys jump, j/k scrolls view.
399    /// Enter selects the current column.
400    Navigation,
401    /// Column selected. h/l = left edge, H/L = right edge of ALL notes.
402    /// j/k drops to Row mode. Esc back to Navigation.
403    Selected,
404    /// Single note. h/l = left edge, H/L = right edge of ONE note.
405    /// j/k moves between notes. Esc back to Selected.
406    Row,
407}
408
409#[derive(Debug)]
410pub struct PianoRollState {
411    pub cursor_note: u8,
412    pub scroll_x: usize,
413    pub view_bottom_note: u8,
414    pub view_height: u8,
415    /// Current focus level.
416    pub focus: PianoRollFocus,
417    /// Currently selected column (0-based). Columns map to time subdivisions.
418    pub column: usize,
419    /// Total number of columns in the grid (set by renderer).
420    pub column_count: usize,
421    /// Total beats in the clip (e.g. 4 for a 1-bar clip).
422    pub total_beats: usize,
423    /// Indices of notes that belong to the selected column (set on Enter).
424    /// Edits operate on these indices so notes don't "escape" the column.
425    pub selected_note_indices: Vec<usize>,
426    /// Number input buffer for typing column numbers.
427    column_digits: String,
428    /// Highlight range for bulk selection (Shift+h/l in Navigation mode).
429    /// When set, columns from highlight_start..=highlight_end are selected.
430    pub highlight_start: Option<usize>,
431    pub highlight_end: Option<usize>,
432    /// Number of columns visible on screen (set by renderer each frame).
433    pub visible_columns: usize,
434    /// Yanked (copied) notes buffer. Notes stored with start_frac relative to
435    /// the yank origin (leftmost yanked column), so they can be pasted at any position.
436    pub yank_buffer: Vec<phosphor_core::clip::NoteSnapshot>,
437    /// Width of the yanked region in columns, so paste knows the source span.
438    pub yank_columns: usize,
439    /// Row highlight range (Shift+j/k). Stores MIDI note numbers (low..=high).
440    pub row_highlight_low: Option<u8>,
441    pub row_highlight_high: Option<u8>,
442    /// Whether highlights are locked for stretching (Enter while highlights exist).
443    pub highlight_locked: bool,
444    // ── Edit mode ──
445    pub edit_mode: bool,
446    /// Index into the clip's notes vec — the "cursor" note.
447    pub edit_cursor: usize,
448    /// Indices of selected notes (for multi-select + move).
449    pub edit_selected: Vec<usize>,
450    pub edit_sub: EditSubMode,
451    // ── Grid / snap ──
452    pub grid: GridResolution,
453    pub snap_enabled: bool,
454    pub default_velocity: u8,
455    /// Settings panel cursor (for the Settings tab).
456    pub settings_cursor: usize,
457}
458
459impl Default for PianoRollState {
460    fn default() -> Self { Self::new() }
461}
462
463impl PianoRollState {
464    pub fn new() -> Self {
465        Self {
466            cursor_note: 60,
467            scroll_x: 0,
468            view_bottom_note: 48,
469            view_height: 24,
470            focus: PianoRollFocus::Navigation,
471            column: 0,
472            column_count: 16,
473            total_beats: 4,
474            selected_note_indices: Vec::new(),
475            column_digits: String::new(),
476            highlight_start: None,
477            highlight_end: None,
478            visible_columns: 16,
479            row_highlight_low: None,
480            row_highlight_high: None,
481            yank_buffer: Vec::new(),
482            yank_columns: 0,
483            highlight_locked: false,
484            edit_mode: false,
485            edit_cursor: 0,
486            edit_selected: Vec::new(),
487            edit_sub: EditSubMode::Navigate,
488            grid: GridResolution::Eighth,
489            snap_enabled: true,
490            default_velocity: 100,
491            settings_cursor: 0,
492        }
493    }
494
495    // ── Focus transitions ──
496
497    /// Enter the next focus level. `note_indices` are the indices of notes
498    /// in the current column (captured at selection time so they don't drift).
499    pub fn enter(&mut self, note_indices: Vec<usize>) {
500        match self.focus {
501            PianoRollFocus::Navigation => {
502                self.focus = PianoRollFocus::Selected;
503                self.selected_note_indices = note_indices;
504            }
505            PianoRollFocus::Selected | PianoRollFocus::Row => {}
506        }
507    }
508
509    /// Enter row mode for the current cursor note (called when j/k finds a note).
510    pub fn enter_row(&mut self) {
511        self.focus = PianoRollFocus::Row;
512    }
513
514    pub fn escape(&mut self) {
515        match self.focus {
516            PianoRollFocus::Row => {
517                self.focus = PianoRollFocus::Selected;
518            }
519            PianoRollFocus::Selected => {
520                self.focus = PianoRollFocus::Navigation;
521                self.column_digits.clear();
522            }
523            PianoRollFocus::Navigation => {
524                // Handled by parent (exits clip view)
525            }
526        }
527    }
528
529    /// Returns true if escape was handled internally.
530    pub fn can_escape(&self) -> bool {
531        self.focus != PianoRollFocus::Navigation
532    }
533
534    // ── Note scrolling (browsing + column mode) ──
535
536    pub fn move_up(&mut self) {
537        if self.cursor_note < 127 {
538            self.cursor_note += 1;
539            let top = self.view_bottom_note.saturating_add(self.view_height);
540            if self.cursor_note >= top {
541                self.view_bottom_note = self.cursor_note - self.view_height + 1;
542            }
543        }
544    }
545
546    pub fn move_down(&mut self) {
547        if self.cursor_note > 0 {
548            self.cursor_note -= 1;
549            if self.cursor_note < self.view_bottom_note {
550                self.view_bottom_note = self.cursor_note;
551            }
552        }
553    }
554
555    // ── Column navigation ──
556
557    pub fn move_column_left(&mut self) {
558        if self.column > 0 {
559            self.column -= 1;
560            // Auto-scroll left
561            if self.column < self.scroll_x {
562                self.scroll_x = self.column;
563            }
564        }
565    }
566
567    pub fn move_column_right(&mut self) {
568        if self.column + 1 < self.column_count {
569            self.column += 1;
570            // Auto-scroll right (visible_columns is set by renderer)
571            if self.column >= self.scroll_x + self.visible_columns && self.visible_columns > 0 {
572                self.scroll_x = self.column + 1 - self.visible_columns;
573            }
574        }
575    }
576
577    /// Type a digit for column number jump. Returns true if the column was set.
578    pub fn type_digit(&mut self, ch: char) -> bool {
579        self.column_digits.push(ch);
580        if let Ok(num) = self.column_digits.parse::<usize>() {
581            if num >= 1 && num <= self.column_count {
582                // If no further digit could make a valid larger number, resolve now
583                let could_grow = num * 10 <= self.column_count;
584                if !could_grow || self.column_digits.len() >= 2 {
585                    self.column = num - 1;
586                    self.column_digits.clear();
587                    // Auto-scroll to show the jumped-to column
588                    self.ensure_column_visible();
589                    return true;
590                }
591                // Single digit but could be prefix of larger number — wait
592                return false;
593            }
594        }
595        // Invalid — clear
596        self.column_digits.clear();
597        false
598    }
599
600    /// Force-resolve whatever is in the digit buffer.
601    pub fn commit_digits(&mut self) -> bool {
602        if let Ok(num) = self.column_digits.parse::<usize>() {
603            if num >= 1 && num <= self.column_count {
604                self.column = num - 1;
605                self.column_digits.clear();
606                self.ensure_column_visible();
607                return true;
608            }
609        }
610        self.column_digits.clear();
611        false
612    }
613
614    /// Scroll to make the current column visible.
615    pub fn ensure_column_visible(&mut self) {
616        if self.visible_columns == 0 { return; }
617        if self.column < self.scroll_x {
618            self.scroll_x = self.column;
619        } else if self.column >= self.scroll_x + self.visible_columns {
620            self.scroll_x = self.column + 1 - self.visible_columns;
621        }
622    }
623
624    pub fn column_digits_display(&self) -> &str {
625        &self.column_digits
626    }
627
628    // ── Highlight (Shift+h/l range selection) ──
629
630    /// Begin or cancel highlighting at the current column.
631    /// If already highlighting and range is just the anchor column, cancel.
632    pub fn start_highlight(&mut self) {
633        if let (Some(s), Some(e)) = (self.highlight_start, self.highlight_end) {
634            if s == e && s == self.column {
635                // Pressing shift on the same single column again = cancel
636                self.clear_highlight();
637                return;
638            }
639        }
640        if self.highlight_start.is_none() {
641            self.highlight_start = Some(self.column);
642            self.highlight_end = Some(self.column);
643        }
644    }
645
646    /// Expand highlight left (Shift+h while highlighting).
647    pub fn highlight_left(&mut self) {
648        if let (Some(start), Some(end)) = (self.highlight_start, self.highlight_end) {
649            if self.column > 0 {
650                self.column -= 1;
651            }
652            // Adjust range to include current column
653            let new_start = self.column.min(start);
654            let new_end = self.column.max(end);
655            self.highlight_start = Some(new_start);
656            self.highlight_end = Some(new_end);
657            // If we moved back past our anchor, shrink from the other side
658            if self.column >= start {
659                self.highlight_end = Some(self.column);
660            } else {
661                self.highlight_start = Some(self.column);
662            }
663        }
664    }
665
666    /// Expand highlight right (Shift+l while highlighting).
667    pub fn highlight_right(&mut self) {
668        if let (Some(start), Some(end)) = (self.highlight_start, self.highlight_end) {
669            if self.column + 1 < self.column_count {
670                self.column += 1;
671            }
672            let new_start = self.column.min(start);
673            let new_end = self.column.max(end);
674            self.highlight_start = Some(new_start);
675            self.highlight_end = Some(new_end);
676            if self.column <= end {
677                self.highlight_start = Some(self.column);
678            } else {
679                self.highlight_end = Some(self.column);
680            }
681        }
682    }
683
684    /// Clear the column highlight.
685    pub fn clear_highlight(&mut self) {
686        self.highlight_start = None;
687        self.highlight_end = None;
688    }
689
690    // ── Row highlight (Shift+j/k) ──
691
692    /// Begin or cancel row highlighting at the current cursor note.
693    pub fn start_row_highlight(&mut self) {
694        if let (Some(lo), Some(hi)) = (self.row_highlight_low, self.row_highlight_high) {
695            if lo == hi && lo == self.cursor_note {
696                self.clear_row_highlight();
697                return;
698            }
699        }
700        if self.row_highlight_low.is_none() {
701            self.row_highlight_low = Some(self.cursor_note);
702            self.row_highlight_high = Some(self.cursor_note);
703        }
704    }
705
706    /// Expand row highlight downward (Shift+j).
707    pub fn highlight_down(&mut self) {
708        self.start_row_highlight();
709        if self.cursor_note > 0 {
710            self.cursor_note -= 1;
711            if self.cursor_note < self.view_bottom_note {
712                self.view_bottom_note = self.cursor_note;
713            }
714        }
715        if let Some(lo) = self.row_highlight_low {
716            self.row_highlight_low = Some(self.cursor_note.min(lo));
717        }
718        if let Some(hi) = self.row_highlight_high {
719            self.row_highlight_high = Some(self.cursor_note.max(hi));
720        }
721    }
722
723    /// Expand row highlight upward (Shift+k).
724    pub fn highlight_up(&mut self) {
725        self.start_row_highlight();
726        if self.cursor_note < 127 {
727            self.cursor_note += 1;
728            let top = self.view_bottom_note.saturating_add(self.view_height);
729            if self.cursor_note >= top {
730                self.view_bottom_note = self.cursor_note - self.view_height + 1;
731            }
732        }
733        if let Some(lo) = self.row_highlight_low {
734            self.row_highlight_low = Some(self.cursor_note.min(lo));
735        }
736        if let Some(hi) = self.row_highlight_high {
737            self.row_highlight_high = Some(self.cursor_note.max(hi));
738        }
739    }
740
741    pub fn clear_row_highlight(&mut self) {
742        self.row_highlight_low = None;
743        self.row_highlight_high = None;
744    }
745
746    /// Check if a MIDI note is within the row highlight range.
747    pub fn is_row_highlighted(&self, note: u8) -> bool {
748        if let (Some(lo), Some(hi)) = (self.row_highlight_low, self.row_highlight_high) {
749            note >= lo && note <= hi
750        } else {
751            false
752        }
753    }
754
755    /// Get the highlighted row range as (low_note, high_note).
756    pub fn row_highlight_range(&self) -> Option<(u8, u8)> {
757        match (self.row_highlight_low, self.row_highlight_high) {
758            (Some(lo), Some(hi)) => Some((lo, hi)),
759            _ => None,
760        }
761    }
762
763    /// Clear both column and row highlights.
764    pub fn clear_all_highlights(&mut self) {
765        self.clear_highlight();
766        self.clear_row_highlight();
767        self.highlight_locked = false;
768    }
769
770    /// Check if a column is within the highlight range.
771    pub fn is_highlighted(&self, col: usize) -> bool {
772        if let (Some(start), Some(end)) = (self.highlight_start, self.highlight_end) {
773            col >= start && col <= end
774        } else {
775            false
776        }
777    }
778
779    /// Get the highlighted column range, if any.
780    pub fn highlight_range(&self) -> Option<(usize, usize)> {
781        match (self.highlight_start, self.highlight_end) {
782            (Some(s), Some(e)) => Some((s.min(e), s.max(e))),
783            _ => None,
784        }
785    }
786
787    pub fn set_view_height(&mut self, h: u8) {
788        self.view_height = h.max(1);
789    }
790
791    pub fn set_column_count(&mut self, count: usize) {
792        self.column_count = count.max(1);
793        if self.column >= self.column_count {
794            self.column = self.column_count - 1;
795        }
796    }
797
798    /// Recalculate column_count from total_beats and grid resolution.
799    pub fn update_column_count(&mut self) {
800        let cols = (self.total_beats as f64 * self.grid.subdivisions_per_beat()).round() as usize;
801        self.column_count = cols.max(1);
802        if self.column >= self.column_count {
803            self.column = self.column_count.saturating_sub(1);
804        }
805    }
806
807    /// Returns true if any column or row highlights are active.
808    pub fn has_highlights(&self) -> bool {
809        self.highlight_start.is_some() || self.row_highlight_low.is_some()
810    }
811
812    /// The 1-based column number for display.
813    pub fn column_display(&self) -> usize {
814        self.column + 1
815    }
816}
817
818#[cfg(test)]
819mod tests {
820    use super::*;
821
822    #[test]
823    fn focus_hierarchy() {
824        let mut pr = PianoRollState::new();
825        assert_eq!(pr.focus, PianoRollFocus::Navigation);
826
827        pr.enter(vec![]);
828        assert_eq!(pr.focus, PianoRollFocus::Selected);
829
830        // Enter in column mode does nothing — j/k finds notes and enters row mode
831        pr.enter(vec![]);
832        assert_eq!(pr.focus, PianoRollFocus::Selected);
833
834        // Manually enter row mode (simulating finding a note)
835        pr.enter_row();
836        assert_eq!(pr.focus, PianoRollFocus::Row);
837
838        pr.escape();
839        assert_eq!(pr.focus, PianoRollFocus::Selected);
840
841        pr.escape();
842        assert_eq!(pr.focus, PianoRollFocus::Navigation);
843    }
844
845    #[test]
846    fn column_navigation() {
847        let mut pr = PianoRollState::new();
848        pr.column_count = 16;
849        pr.column = 0;
850
851        pr.move_column_right();
852        assert_eq!(pr.column, 1);
853
854        pr.move_column_left();
855        assert_eq!(pr.column, 0);
856
857        pr.move_column_left();
858        assert_eq!(pr.column, 0); // can't go below 0
859
860        pr.column = 15;
861        pr.move_column_right();
862        assert_eq!(pr.column, 15); // can't go past last
863    }
864
865    #[test]
866    fn digit_jump() {
867        let mut pr = PianoRollState::new();
868        pr.column_count = 16;
869
870        // Single digit > max prefix: resolves immediately
871        // '5' could be prefix of nothing valid (50 > 16), so resolves
872        assert!(pr.type_digit('5'));
873        assert_eq!(pr.column, 4); // 0-based
874
875        // '1' could be prefix of 10-16, so it waits
876        assert!(!pr.type_digit('1'));
877        // '2' makes it 12, resolves
878        assert!(pr.type_digit('2'));
879        assert_eq!(pr.column, 11); // column 12 = index 11
880
881        // Single '9' — 9*10=90 > 16, resolves immediately
882        assert!(pr.type_digit('9'));
883        assert_eq!(pr.column, 8);
884
885        // Single '1' then commit
886        pr.type_digit('1');
887        assert!(pr.commit_digits());
888        assert_eq!(pr.column, 0);
889    }
890
891    #[test]
892    fn can_escape() {
893        let mut pr = PianoRollState::new();
894        assert!(!pr.can_escape()); // browsing — parent handles esc
895
896        pr.enter(vec![]);
897        assert!(pr.can_escape()); // column mode — internal
898
899        pr.enter(vec![]);
900        assert!(pr.can_escape()); // row mode — internal
901    }
902
903    #[test]
904    fn note_scroll() {
905        let mut pr = PianoRollState::new();
906        pr.view_height = 10;
907        pr.view_bottom_note = 50;
908        pr.cursor_note = 55;
909
910        // Move up past visible area
911        for _ in 0..10 {
912            pr.move_up();
913        }
914        // Cursor should have scrolled the view
915        assert!(pr.cursor_note >= pr.view_bottom_note);
916        assert!(pr.cursor_note < pr.view_bottom_note + pr.view_height);
917    }
918}