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    /// Which instrument the sequencer drives.
278    Child,
279}
280
281impl SeqKnob {
282    pub fn label(self) -> &'static str {
283        match self {
284            Self::Pitch => "pitch",
285            Self::Chord => "chord",
286            Self::Voicing => "voicing",
287            Self::RootBelow => "root\u{2193}",
288            Self::Gate | Self::DefaultGate => "gate",
289            Self::Voice => "sound",
290            Self::Mute => "mute",
291            Self::Solo => "solo",
292            Self::Length => "steps",
293            Self::Rate => "rate",
294            Self::Swing => "swing",
295            Self::BaseVelocity => "base",
296            Self::AccentVelocity => "accent",
297            Self::Mode => "mode",
298            Self::Tonic => "key",
299            Self::Switch => "switch",
300            Self::Child => "child",
301        }
302    }
303}
304
305/// The step grid's cursor: which band, which control inside it, and whether
306/// that control has been locked with Enter.
307///
308/// Nothing here is part of a pattern. It is the same separation the piano
309/// roll keeps — [`PianoRollState`] holds a column and a focus level, the clip
310/// holds the notes.
311#[derive(Debug, Clone, PartialEq, Eq, Default)]
312pub struct SequencerView {
313    pub band: SeqBand,
314    /// Which control inside [`SequencerView::band`] the cursor is on.
315    pub knob: usize,
316    /// Enter was pressed on a control: `h`/`l` now adjust it and nothing else
317    /// gets a look at the key. The fader's contract, applied to a knob.
318    pub locked: bool,
319    /// The slot `y` picked up, for `p` to paste.
320    pub copy_from: Option<u8>,
321    /// Digits typed towards a step or slot number.
322    pub digits: String,
323}
324
325impl SequencerView {
326    pub fn new() -> Self {
327        Self::default()
328    }
329
330    /// Move to another band, giving up the lock and the digit buffer with it.
331    pub fn move_band(&mut self, delta: i32) {
332        if self.locked {
333            return;
334        }
335        let next = self.band.stepped(delta);
336        if next != self.band {
337            self.band = next;
338            self.knob = 0;
339            self.digits.clear();
340        }
341    }
342
343    /// Put the cursor on a band directly, as opening the view does.
344    pub fn focus_band(&mut self, band: SeqBand) {
345        self.band = band;
346        self.knob = 0;
347        self.locked = false;
348        self.digits.clear();
349    }
350
351    /// Move the cursor between the controls of the current band. Clamped:
352    /// walking off the end of a knob row and reappearing at the other end is
353    /// how a value gets changed by accident.
354    pub fn move_knob(&mut self, delta: i32, count: usize) {
355        if count == 0 {
356            self.knob = 0;
357            return;
358        }
359        self.knob = (self.knob as i32 + delta).clamp(0, count as i32 - 1) as usize;
360    }
361
362    /// Type a digit towards a number in `1..=max`, and say which one was
363    /// named once it can no longer grow.
364    ///
365    /// The piano roll's rule, because a step grid has the same problem: `1`
366    /// on a 16-step pattern might be step 1 or the front of step 12.
367    pub fn type_digit(&mut self, ch: char, max: usize) -> Option<usize> {
368        self.digits.push(ch);
369        let Ok(number) = self.digits.parse::<usize>() else {
370            self.digits.clear();
371            return None;
372        };
373        if number == 0 || number > max {
374            self.digits.clear();
375            return None;
376        }
377        if number * 10 > max || self.digits.len() >= 2 {
378            self.digits.clear();
379            return Some(number);
380        }
381        None
382    }
383}
384
385// ── Piano Roll Navigation ──
386//
387// Focus hierarchy (Enter goes deeper, Esc goes back):
388//   Browsing → Column selected → Row selected
389//
390// Browsing: j/k scrolls notes, h/l scrolls horizontally
391// Column selected: h/l moves between columns, j/k moves rows within column
392//   h/l (no shift) = adjust left edge of all notes in column
393//   H/L (shift)    = adjust right edge of all notes in column
394// Row selected: same h/l/H/L but affects only the single note
395
396/// What level of the piano roll is focused.
397/// Follows the Right Left Trick Controls pattern:
398///   Navigation → Selected (column) → Row (individual note)
399#[derive(Debug, Clone, Copy, PartialEq, Eq)]
400pub enum PianoRollFocus {
401    /// h/l navigates columns, number keys jump, j/k scrolls view.
402    /// Enter selects the current column.
403    Navigation,
404    /// Column selected. h/l = left edge, H/L = right edge of ALL notes.
405    /// j/k drops to Row mode. Esc back to Navigation.
406    Selected,
407    /// Single note. h/l = left edge, H/L = right edge of ONE note.
408    /// j/k moves between notes. Esc back to Selected.
409    Row,
410}
411
412#[derive(Debug)]
413pub struct PianoRollState {
414    pub cursor_note: u8,
415    pub scroll_x: usize,
416    pub view_bottom_note: u8,
417    pub view_height: u8,
418    /// Current focus level.
419    pub focus: PianoRollFocus,
420    /// Currently selected column (0-based). Columns map to time subdivisions.
421    pub column: usize,
422    /// Total number of columns in the grid (set by renderer).
423    pub column_count: usize,
424    /// Total beats in the clip (e.g. 4 for a 1-bar clip).
425    pub total_beats: usize,
426    /// Indices of notes that belong to the selected column (set on Enter).
427    /// Edits operate on these indices so notes don't "escape" the column.
428    pub selected_note_indices: Vec<usize>,
429    /// Number input buffer for typing column numbers.
430    column_digits: String,
431    /// Highlight range for bulk selection (Shift+h/l in Navigation mode).
432    /// When set, columns from highlight_start..=highlight_end are selected.
433    pub highlight_start: Option<usize>,
434    pub highlight_end: Option<usize>,
435    /// Number of columns visible on screen (set by renderer each frame).
436    pub visible_columns: usize,
437    /// Yanked (copied) notes buffer. Notes stored with start_frac relative to
438    /// the yank origin (leftmost yanked column), so they can be pasted at any position.
439    pub yank_buffer: Vec<phosphor_core::clip::NoteSnapshot>,
440    /// Width of the yanked region in columns, so paste knows the source span.
441    pub yank_columns: usize,
442    /// Row highlight range (Shift+j/k). Stores MIDI note numbers (low..=high).
443    pub row_highlight_low: Option<u8>,
444    pub row_highlight_high: Option<u8>,
445    /// Whether highlights are locked for stretching (Enter while highlights exist).
446    pub highlight_locked: bool,
447    // ── Edit mode ──
448    pub edit_mode: bool,
449    /// Index into the clip's notes vec — the "cursor" note.
450    pub edit_cursor: usize,
451    /// Indices of selected notes (for multi-select + move).
452    pub edit_selected: Vec<usize>,
453    pub edit_sub: EditSubMode,
454    // ── Grid / snap ──
455    pub grid: GridResolution,
456    pub snap_enabled: bool,
457    pub default_velocity: u8,
458    /// Settings panel cursor (for the Settings tab).
459    pub settings_cursor: usize,
460}
461
462impl Default for PianoRollState {
463    fn default() -> Self { Self::new() }
464}
465
466impl PianoRollState {
467    pub fn new() -> Self {
468        Self {
469            cursor_note: 60,
470            scroll_x: 0,
471            view_bottom_note: 48,
472            view_height: 24,
473            focus: PianoRollFocus::Navigation,
474            column: 0,
475            column_count: 16,
476            total_beats: 4,
477            selected_note_indices: Vec::new(),
478            column_digits: String::new(),
479            highlight_start: None,
480            highlight_end: None,
481            visible_columns: 16,
482            row_highlight_low: None,
483            row_highlight_high: None,
484            yank_buffer: Vec::new(),
485            yank_columns: 0,
486            highlight_locked: false,
487            edit_mode: false,
488            edit_cursor: 0,
489            edit_selected: Vec::new(),
490            edit_sub: EditSubMode::Navigate,
491            grid: GridResolution::Eighth,
492            snap_enabled: true,
493            default_velocity: 100,
494            settings_cursor: 0,
495        }
496    }
497
498    // ── Focus transitions ──
499
500    /// Enter the next focus level. `note_indices` are the indices of notes
501    /// in the current column (captured at selection time so they don't drift).
502    pub fn enter(&mut self, note_indices: Vec<usize>) {
503        match self.focus {
504            PianoRollFocus::Navigation => {
505                self.focus = PianoRollFocus::Selected;
506                self.selected_note_indices = note_indices;
507            }
508            PianoRollFocus::Selected | PianoRollFocus::Row => {}
509        }
510    }
511
512    /// Enter row mode for the current cursor note (called when j/k finds a note).
513    pub fn enter_row(&mut self) {
514        self.focus = PianoRollFocus::Row;
515    }
516
517    pub fn escape(&mut self) {
518        match self.focus {
519            PianoRollFocus::Row => {
520                self.focus = PianoRollFocus::Selected;
521            }
522            PianoRollFocus::Selected => {
523                self.focus = PianoRollFocus::Navigation;
524                self.column_digits.clear();
525            }
526            PianoRollFocus::Navigation => {
527                // Handled by parent (exits clip view)
528            }
529        }
530    }
531
532    /// Returns true if escape was handled internally.
533    pub fn can_escape(&self) -> bool {
534        self.focus != PianoRollFocus::Navigation
535    }
536
537    // ── Note scrolling (browsing + column mode) ──
538
539    pub fn move_up(&mut self) {
540        if self.cursor_note < 127 {
541            self.cursor_note += 1;
542            let top = self.view_bottom_note.saturating_add(self.view_height);
543            if self.cursor_note >= top {
544                self.view_bottom_note = self.cursor_note - self.view_height + 1;
545            }
546        }
547    }
548
549    pub fn move_down(&mut self) {
550        if self.cursor_note > 0 {
551            self.cursor_note -= 1;
552            if self.cursor_note < self.view_bottom_note {
553                self.view_bottom_note = self.cursor_note;
554            }
555        }
556    }
557
558    // ── Column navigation ──
559
560    pub fn move_column_left(&mut self) {
561        if self.column > 0 {
562            self.column -= 1;
563            // Auto-scroll left
564            if self.column < self.scroll_x {
565                self.scroll_x = self.column;
566            }
567        }
568    }
569
570    pub fn move_column_right(&mut self) {
571        if self.column + 1 < self.column_count {
572            self.column += 1;
573            // Auto-scroll right (visible_columns is set by renderer)
574            if self.column >= self.scroll_x + self.visible_columns && self.visible_columns > 0 {
575                self.scroll_x = self.column + 1 - self.visible_columns;
576            }
577        }
578    }
579
580    /// Type a digit for column number jump. Returns true if the column was set.
581    pub fn type_digit(&mut self, ch: char) -> bool {
582        self.column_digits.push(ch);
583        if let Ok(num) = self.column_digits.parse::<usize>() {
584            if num >= 1 && num <= self.column_count {
585                // If no further digit could make a valid larger number, resolve now
586                let could_grow = num * 10 <= self.column_count;
587                if !could_grow || self.column_digits.len() >= 2 {
588                    self.column = num - 1;
589                    self.column_digits.clear();
590                    // Auto-scroll to show the jumped-to column
591                    self.ensure_column_visible();
592                    return true;
593                }
594                // Single digit but could be prefix of larger number — wait
595                return false;
596            }
597        }
598        // Invalid — clear
599        self.column_digits.clear();
600        false
601    }
602
603    /// Force-resolve whatever is in the digit buffer.
604    pub fn commit_digits(&mut self) -> bool {
605        if let Ok(num) = self.column_digits.parse::<usize>() {
606            if num >= 1 && num <= self.column_count {
607                self.column = num - 1;
608                self.column_digits.clear();
609                self.ensure_column_visible();
610                return true;
611            }
612        }
613        self.column_digits.clear();
614        false
615    }
616
617    /// Scroll to make the current column visible.
618    pub fn ensure_column_visible(&mut self) {
619        if self.visible_columns == 0 { return; }
620        if self.column < self.scroll_x {
621            self.scroll_x = self.column;
622        } else if self.column >= self.scroll_x + self.visible_columns {
623            self.scroll_x = self.column + 1 - self.visible_columns;
624        }
625    }
626
627    pub fn column_digits_display(&self) -> &str {
628        &self.column_digits
629    }
630
631    // ── Highlight (Shift+h/l range selection) ──
632
633    /// Begin or cancel highlighting at the current column.
634    /// If already highlighting and range is just the anchor column, cancel.
635    pub fn start_highlight(&mut self) {
636        if let (Some(s), Some(e)) = (self.highlight_start, self.highlight_end) {
637            if s == e && s == self.column {
638                // Pressing shift on the same single column again = cancel
639                self.clear_highlight();
640                return;
641            }
642        }
643        if self.highlight_start.is_none() {
644            self.highlight_start = Some(self.column);
645            self.highlight_end = Some(self.column);
646        }
647    }
648
649    /// Expand highlight left (Shift+h while highlighting).
650    pub fn highlight_left(&mut self) {
651        if let (Some(start), Some(end)) = (self.highlight_start, self.highlight_end) {
652            if self.column > 0 {
653                self.column -= 1;
654            }
655            // Adjust range to include current column
656            let new_start = self.column.min(start);
657            let new_end = self.column.max(end);
658            self.highlight_start = Some(new_start);
659            self.highlight_end = Some(new_end);
660            // If we moved back past our anchor, shrink from the other side
661            if self.column >= start {
662                self.highlight_end = Some(self.column);
663            } else {
664                self.highlight_start = Some(self.column);
665            }
666        }
667    }
668
669    /// Expand highlight right (Shift+l while highlighting).
670    pub fn highlight_right(&mut self) {
671        if let (Some(start), Some(end)) = (self.highlight_start, self.highlight_end) {
672            if self.column + 1 < self.column_count {
673                self.column += 1;
674            }
675            let new_start = self.column.min(start);
676            let new_end = self.column.max(end);
677            self.highlight_start = Some(new_start);
678            self.highlight_end = Some(new_end);
679            if self.column <= end {
680                self.highlight_start = Some(self.column);
681            } else {
682                self.highlight_end = Some(self.column);
683            }
684        }
685    }
686
687    /// Clear the column highlight.
688    pub fn clear_highlight(&mut self) {
689        self.highlight_start = None;
690        self.highlight_end = None;
691    }
692
693    // ── Row highlight (Shift+j/k) ──
694
695    /// Begin or cancel row highlighting at the current cursor note.
696    pub fn start_row_highlight(&mut self) {
697        if let (Some(lo), Some(hi)) = (self.row_highlight_low, self.row_highlight_high) {
698            if lo == hi && lo == self.cursor_note {
699                self.clear_row_highlight();
700                return;
701            }
702        }
703        if self.row_highlight_low.is_none() {
704            self.row_highlight_low = Some(self.cursor_note);
705            self.row_highlight_high = Some(self.cursor_note);
706        }
707    }
708
709    /// Expand row highlight downward (Shift+j).
710    pub fn highlight_down(&mut self) {
711        self.start_row_highlight();
712        if self.cursor_note > 0 {
713            self.cursor_note -= 1;
714            if self.cursor_note < self.view_bottom_note {
715                self.view_bottom_note = self.cursor_note;
716            }
717        }
718        if let Some(lo) = self.row_highlight_low {
719            self.row_highlight_low = Some(self.cursor_note.min(lo));
720        }
721        if let Some(hi) = self.row_highlight_high {
722            self.row_highlight_high = Some(self.cursor_note.max(hi));
723        }
724    }
725
726    /// Expand row highlight upward (Shift+k).
727    pub fn highlight_up(&mut self) {
728        self.start_row_highlight();
729        if self.cursor_note < 127 {
730            self.cursor_note += 1;
731            let top = self.view_bottom_note.saturating_add(self.view_height);
732            if self.cursor_note >= top {
733                self.view_bottom_note = self.cursor_note - self.view_height + 1;
734            }
735        }
736        if let Some(lo) = self.row_highlight_low {
737            self.row_highlight_low = Some(self.cursor_note.min(lo));
738        }
739        if let Some(hi) = self.row_highlight_high {
740            self.row_highlight_high = Some(self.cursor_note.max(hi));
741        }
742    }
743
744    pub fn clear_row_highlight(&mut self) {
745        self.row_highlight_low = None;
746        self.row_highlight_high = None;
747    }
748
749    /// Check if a MIDI note is within the row highlight range.
750    pub fn is_row_highlighted(&self, note: u8) -> bool {
751        if let (Some(lo), Some(hi)) = (self.row_highlight_low, self.row_highlight_high) {
752            note >= lo && note <= hi
753        } else {
754            false
755        }
756    }
757
758    /// Get the highlighted row range as (low_note, high_note).
759    pub fn row_highlight_range(&self) -> Option<(u8, u8)> {
760        match (self.row_highlight_low, self.row_highlight_high) {
761            (Some(lo), Some(hi)) => Some((lo, hi)),
762            _ => None,
763        }
764    }
765
766    /// Clear both column and row highlights.
767    pub fn clear_all_highlights(&mut self) {
768        self.clear_highlight();
769        self.clear_row_highlight();
770        self.highlight_locked = false;
771    }
772
773    /// Check if a column is within the highlight range.
774    pub fn is_highlighted(&self, col: usize) -> bool {
775        if let (Some(start), Some(end)) = (self.highlight_start, self.highlight_end) {
776            col >= start && col <= end
777        } else {
778            false
779        }
780    }
781
782    /// Get the highlighted column range, if any.
783    pub fn highlight_range(&self) -> Option<(usize, usize)> {
784        match (self.highlight_start, self.highlight_end) {
785            (Some(s), Some(e)) => Some((s.min(e), s.max(e))),
786            _ => None,
787        }
788    }
789
790    pub fn set_view_height(&mut self, h: u8) {
791        self.view_height = h.max(1);
792    }
793
794    pub fn set_column_count(&mut self, count: usize) {
795        self.column_count = count.max(1);
796        if self.column >= self.column_count {
797            self.column = self.column_count - 1;
798        }
799    }
800
801    /// Recalculate column_count from total_beats and grid resolution.
802    pub fn update_column_count(&mut self) {
803        let cols = (self.total_beats as f64 * self.grid.subdivisions_per_beat()).round() as usize;
804        self.column_count = cols.max(1);
805        if self.column >= self.column_count {
806            self.column = self.column_count.saturating_sub(1);
807        }
808    }
809
810    /// Returns true if any column or row highlights are active.
811    pub fn has_highlights(&self) -> bool {
812        self.highlight_start.is_some() || self.row_highlight_low.is_some()
813    }
814
815    /// The 1-based column number for display.
816    pub fn column_display(&self) -> usize {
817        self.column + 1
818    }
819}
820
821#[cfg(test)]
822mod tests {
823    use super::*;
824
825    #[test]
826    fn focus_hierarchy() {
827        let mut pr = PianoRollState::new();
828        assert_eq!(pr.focus, PianoRollFocus::Navigation);
829
830        pr.enter(vec![]);
831        assert_eq!(pr.focus, PianoRollFocus::Selected);
832
833        // Enter in column mode does nothing — j/k finds notes and enters row mode
834        pr.enter(vec![]);
835        assert_eq!(pr.focus, PianoRollFocus::Selected);
836
837        // Manually enter row mode (simulating finding a note)
838        pr.enter_row();
839        assert_eq!(pr.focus, PianoRollFocus::Row);
840
841        pr.escape();
842        assert_eq!(pr.focus, PianoRollFocus::Selected);
843
844        pr.escape();
845        assert_eq!(pr.focus, PianoRollFocus::Navigation);
846    }
847
848    #[test]
849    fn column_navigation() {
850        let mut pr = PianoRollState::new();
851        pr.column_count = 16;
852        pr.column = 0;
853
854        pr.move_column_right();
855        assert_eq!(pr.column, 1);
856
857        pr.move_column_left();
858        assert_eq!(pr.column, 0);
859
860        pr.move_column_left();
861        assert_eq!(pr.column, 0); // can't go below 0
862
863        pr.column = 15;
864        pr.move_column_right();
865        assert_eq!(pr.column, 15); // can't go past last
866    }
867
868    #[test]
869    fn digit_jump() {
870        let mut pr = PianoRollState::new();
871        pr.column_count = 16;
872
873        // Single digit > max prefix: resolves immediately
874        // '5' could be prefix of nothing valid (50 > 16), so resolves
875        assert!(pr.type_digit('5'));
876        assert_eq!(pr.column, 4); // 0-based
877
878        // '1' could be prefix of 10-16, so it waits
879        assert!(!pr.type_digit('1'));
880        // '2' makes it 12, resolves
881        assert!(pr.type_digit('2'));
882        assert_eq!(pr.column, 11); // column 12 = index 11
883
884        // Single '9' — 9*10=90 > 16, resolves immediately
885        assert!(pr.type_digit('9'));
886        assert_eq!(pr.column, 8);
887
888        // Single '1' then commit
889        pr.type_digit('1');
890        assert!(pr.commit_digits());
891        assert_eq!(pr.column, 0);
892    }
893
894    #[test]
895    fn can_escape() {
896        let mut pr = PianoRollState::new();
897        assert!(!pr.can_escape()); // browsing — parent handles esc
898
899        pr.enter(vec![]);
900        assert!(pr.can_escape()); // column mode — internal
901
902        pr.enter(vec![]);
903        assert!(pr.can_escape()); // row mode — internal
904    }
905
906    #[test]
907    fn note_scroll() {
908        let mut pr = PianoRollState::new();
909        pr.view_height = 10;
910        pr.view_bottom_note = 50;
911        pr.cursor_note = 55;
912
913        // Move up past visible area
914        for _ in 0..10 {
915            pr.move_up();
916        }
917        // Cursor should have scrolled the view
918        assert!(pr.cursor_note >= pr.view_bottom_note);
919        assert!(pr.cursor_note < pr.view_bottom_note + pr.view_height);
920    }
921}