diff --git a/src/ui/layout.rs b/src/ui/layout.rs index ebe545b..2b7cd78 100644 --- a/src/ui/layout.rs +++ b/src/ui/layout.rs @@ -102,10 +102,45 @@ pub fn compute_dual_layout(total: Rect, vis: &PanelVisibility) -> DualSynthLayou PanelSlot { expanded_height: WAVEFORM_HEIGHT, is_visible: vis.waveform, growable: false }, ]; - // Calculate total requested height (before overflow handling) + // Make a mutable copy of visibility so we can auto-collapse on overflow + let mut vis_effective: [bool; 7] = [ + panels[0].is_visible, panels[1].is_visible, + panels[2].is_visible, panels[3].is_visible, + panels[4].is_visible, panels[5].is_visible, + panels[6].is_visible, + ]; + + let available = total.height; + + // Auto-collapse panels when terminal is too small, lowest priority first. + // Priority order for collapsing (first to collapse → last): + // waveform(6), drum_knobs(5), synth_b_grid(3), synth_b_knobs(2), + // synth_a_grid(1), synth_a_knobs(0), drum_grid(4) + let collapse_order: [usize; 7] = [6, 5, 3, 2, 1, 0, 4]; + loop { + let mut used: u16 = fixed; + for (i, p) in panels.iter().enumerate() { + if vis_effective[i] { + used = used.saturating_add(p.expanded_height); + } else { + used = used.saturating_add(COLLAPSED_PANEL_HEIGHT); + } + } + if used <= available { + break; + } + // Find next panel to collapse + if let Some(&idx) = collapse_order.iter().find(|&&i| vis_effective[i]) { + vis_effective[idx] = false; + } else { + break; // everything collapsed, nothing more we can do + } + } + + // Calculate total requested height let mut used: u16 = fixed; - for p in &panels { - if p.is_visible { + for (i, p) in panels.iter().enumerate() { + if vis_effective[i] { used = used.saturating_add(p.expanded_height); } else { used = used.saturating_add(COLLAPSED_PANEL_HEIGHT); @@ -113,18 +148,17 @@ pub fn compute_dual_layout(total: Rect, vis: &PanelVisibility) -> DualSynthLayou } // Compute extra space to distribute to growable panels - let available = total.height; let extra = if available > used { available - used } else { 0 }; // Count growable visible panels - let growable_count = panels.iter().filter(|p| p.is_visible && p.growable).count() as u16; + let growable_count = panels.iter().enumerate().filter(|(i, p)| vis_effective[*i] && p.growable).count() as u16; let extra_per_growable = if growable_count > 0 { extra / growable_count } else { 0 }; let mut extra_remainder = if growable_count > 0 { extra % growable_count } else { 0 }; // Assign heights for each panel let mut heights: [u16; 7] = [0; 7]; for (i, p) in panels.iter().enumerate() { - if p.is_visible { + if vis_effective[i] { heights[i] = p.expanded_height; if p.growable { heights[i] += extra_per_growable; @@ -138,30 +172,36 @@ pub fn compute_dual_layout(total: Rect, vis: &PanelVisibility) -> DualSynthLayou } } - // Build rects by walking y offsets + // Build rects by walking y offsets, clamping to terminal bounds let x = total.x; let w = total.width; + let y_max = total.y + total.height; // first row outside the buffer let mut y = total.y; // Transport - let transport = Rect::new(x, y, w, TRANSPORT_HEIGHT); - y += TRANSPORT_HEIGHT; + let transport_h = TRANSPORT_HEIGHT.min(y_max.saturating_sub(y)); + let transport = Rect::new(x, y, w, transport_h); + y += transport_h; - // Helper: allocate a panel rect and advance y + // Helper: allocate a panel rect and advance y, clamping to bounds let mut panel_rects: [(Rect, Rect); 7] = [(Rect::default(), Rect::default()); 7]; - for (i, p) in panels.iter().enumerate() { - let h = heights[i]; + for i in 0..panels.len() { + let h = heights[i].min(y_max.saturating_sub(y)); + if h == 0 { + continue; + } let rect = Rect::new(x, y, w, h); - if p.is_visible { - panel_rects[i] = (rect, Rect::default()); // expanded, no collapsed + if vis_effective[i] { + panel_rects[i] = (rect, Rect::default()); } else { - panel_rects[i] = (Rect::default(), rect); // no expanded, collapsed + panel_rects[i] = (Rect::default(), rect); } y += h; } - // Activity bar at the bottom - let activity_bar = Rect::new(x, y, w, ACTIVITY_BAR_HEIGHT); + // Activity bar at the bottom (gets whatever is left, may be 0) + let activity_h = ACTIVITY_BAR_HEIGHT.min(y_max.saturating_sub(y)); + let activity_bar = Rect::new(x, y, w, activity_h); DualSynthLayout { transport, @@ -302,6 +342,32 @@ mod tests { assert_eq!(sa_knobs_y, after_transport); } + #[test] + fn small_terminal_no_panic() { + // Terminal too small to fit all panels — must not overflow buffer bounds + let vis = PanelVisibility::default(); + let ly = compute_dual_layout(term(22), &vis); + + // Every rect must stay within [0, 22) + let all_rects = [ + ly.transport, + ly.synth_a_knobs, ly.synth_a_knobs_collapsed, + ly.synth_a_grid, ly.synth_a_grid_collapsed, + ly.synth_b_knobs, ly.synth_b_knobs_collapsed, + ly.synth_b_grid, ly.synth_b_grid_collapsed, + ly.drum_grid, ly.drum_knobs, ly.drum_knobs_collapsed, + ly.waveform, ly.waveform_collapsed, + ly.activity_bar, + ]; + for r in &all_rects { + assert!( + r.y + r.height <= 22, + "rect {:?} extends past terminal height 22", + r, + ); + } + } + #[test] fn default_visibility_layout() { // Default: synth B collapsed, everything else expanded diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 9b2edc6..38a4eae 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -65,7 +65,9 @@ pub fn render(f: &mut Frame, app: &App) { synth_knobs::render_synth_knobs(f, ly.synth_a_knobs, app, SynthId::A); } else { let focused = matches!(app.ui.focus, FocusSection::SynthAControls); - render_collapsed_bar(f, ly.synth_a_knobs_collapsed, "SYNTH A KNOBS", focused); + let label = format!("SYNTH A KNOBS Pat[{}] Kit[{}]", + app.ui.synth_a.active_pattern + 1, app.ui.synth_a.active_kit + 1); + render_collapsed_bar(f, ly.synth_a_knobs_collapsed, &label, focused); } // ── Synth A Grid ───────────────────────────────────────────── @@ -73,7 +75,8 @@ pub fn render(f: &mut Frame, app: &App) { synth_grid::render_synth_grid(f, ly.synth_a_grid, app, SynthId::A); } else { let focused = matches!(app.ui.focus, FocusSection::SynthAGrid); - render_collapsed_bar(f, ly.synth_a_grid_collapsed, "SYNTH A GRID", focused); + let label = format!("SYNTH A GRID Pat[{}]", app.ui.synth_a.active_pattern + 1); + render_collapsed_bar(f, ly.synth_a_grid_collapsed, &label, focused); } // ── Synth B Knobs ──────────────────────────────────────────── @@ -81,7 +84,9 @@ pub fn render(f: &mut Frame, app: &App) { synth_knobs::render_synth_knobs(f, ly.synth_b_knobs, app, SynthId::B); } else { let focused = matches!(app.ui.focus, FocusSection::SynthBControls); - render_collapsed_bar(f, ly.synth_b_knobs_collapsed, "SYNTH B KNOBS", focused); + let label = format!("SYNTH B KNOBS Pat[{}] Kit[{}]", + app.ui.synth_b.active_pattern + 1, app.ui.synth_b.active_kit + 1); + render_collapsed_bar(f, ly.synth_b_knobs_collapsed, &label, focused); } // ── Synth B Grid ───────────────────────────────────────────── @@ -89,7 +94,8 @@ pub fn render(f: &mut Frame, app: &App) { synth_grid::render_synth_grid(f, ly.synth_b_grid, app, SynthId::B); } else { let focused = matches!(app.ui.focus, FocusSection::SynthBGrid); - render_collapsed_bar(f, ly.synth_b_grid_collapsed, "SYNTH B GRID", focused); + let label = format!("SYNTH B GRID Pat[{}]", app.ui.synth_b.active_pattern + 1); + render_collapsed_bar(f, ly.synth_b_grid_collapsed, &label, focused); } // ── Drum Grid ──────────────────────────────────────────────── @@ -102,7 +108,9 @@ pub fn render(f: &mut Frame, app: &App) { knobs::render_knobs(f, ly.drum_knobs, app); } else { let focused = matches!(app.ui.focus, FocusSection::Knobs); - render_collapsed_bar(f, ly.drum_knobs_collapsed, "DRUM KNOBS", focused); + let label = format!("DRUM KNOBS Pat[{}] Kit[{}]", + app.ui.active_pattern + 1, app.ui.active_kit + 1); + render_collapsed_bar(f, ly.drum_knobs_collapsed, &label, focused); } // ── Waveform ───────────────────────────────────────────────── diff --git a/src/ui/transport_bar.rs b/src/ui/transport_bar.rs index 229c0c4..9c3104f 100644 --- a/src/ui/transport_bar.rs +++ b/src/ui/transport_bar.rs @@ -201,7 +201,11 @@ fn gauge_spans<'a>(value: f32, width: usize, fill_style: Style, empty_style: Sty } } -/// Build a compact status line showing: Label Pat[N] Kit[N] Loop[N] +/// Pattern slot key labels (QWERTYUIOP = patterns 1-10). +const PATTERN_KEYS: [&str; 10] = ["q", "w", "e", "r", "t", "y", "u", "i", "o", "p"]; + +/// Build a status line with pattern/kit selectors: +/// SA | Pattern: q w e r t y u i o p | Kit: 1 2 3 4 5 6 7 8 fn status_line<'a>( label: &str, active_pattern: usize, @@ -213,38 +217,41 @@ fn status_line<'a>( ) -> Line<'a> { let mut spans: Vec> = Vec::new(); - // Section label — highlighted when focused + // Section label let label_style = if is_focused { - Style::default() - .fg(theme::CYAN) - .add_modifier(Modifier::BOLD) + Style::default().fg(theme::CYAN).add_modifier(Modifier::BOLD) } else { Style::default().fg(theme::DIM_TEXT) }; - spans.push(Span::styled(format!("{}: ", label), label_style)); + spans.push(Span::styled(format!("{}", label), label_style)); - // Pattern indicator (compact) - let pattern_display = if let Some(queued) = queued_pattern { - format!("Pat[{}→{}]", active_pattern + 1, queued + 1) - } else { - format!("Pat[{}]", active_pattern + 1) - }; - let pattern_style = if is_focused { - Style::default().fg(theme::AMBER) - } else { - Style::default().fg(theme::TEXT) - }; - spans.push(Span::styled(pattern_display, pattern_style)); + spans.push(Span::styled(" Pattern: ", Style::default().fg(theme::DIM_TEXT))); - // Kit indicator (compact) - spans.push(Span::styled( - format!(" Kit[{}]", active_kit + 1), - if is_focused { - Style::default().fg(theme::AMBER) + // Pattern selector: q w e r t y u i o p + for (i, key) in PATTERN_KEYS.iter().enumerate() { + let style = if i == active_pattern { + Style::default().fg(Color::Black).bg(theme::CYAN).add_modifier(Modifier::BOLD) + } else if queued_pattern == Some(i) { + Style::default().fg(Color::Black).bg(theme::GOLD).add_modifier(Modifier::BOLD) } else { - Style::default().fg(theme::TEXT) - }, - )); + Style::default().fg(theme::DIM_TEXT) + }; + spans.push(Span::styled(*key, style)); + if i < 9 { spans.push(Span::raw(" ")); } + } + + spans.push(Span::styled(" \u{2502} Kit: ", Style::default().fg(theme::DIM_TEXT))); + + // Kit selector: 1 2 3 4 5 6 7 8 + for i in 0..8u8 { + let style = if i as usize == active_kit { + Style::default().fg(Color::Black).bg(theme::CYAN).add_modifier(Modifier::BOLD) + } else { + Style::default().fg(theme::DIM_TEXT) + }; + spans.push(Span::styled(format!("{}", i + 1), style)); + if i < 7 { spans.push(Span::raw(" ")); } + } // Loop indicator let loop_style = if loop_info.contains("--") { @@ -254,7 +261,7 @@ fn status_line<'a>( } else { Style::default().fg(theme::TEXT) }; - spans.push(Span::styled(format!(" {}", loop_info), loop_style)); + spans.push(Span::styled(format!(" {}", loop_info), loop_style)); Line::from(spans) }