From 417e4c9b111cf5e66eaf5d6002ba8fe5f05f3f06 Mon Sep 17 00:00:00 2001 From: lobo Date: Wed, 11 Mar 2026 22:09:36 +0100 Subject: [PATCH 1/3] fix: clamp layout rects to terminal bounds to prevent panic on small terminals compute_dual_layout assigned full panel heights even when the terminal was shorter than the total needed, causing ratatui buffer out-of-bounds panic (e.g. y=22 in a 22-row terminal). Now each panel rect is clamped to remaining space, preventing overflow. Co-Authored-By: Claude Opus 4.6 --- src/ui/layout.rs | 51 +++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 42 insertions(+), 9 deletions(-) diff --git a/src/ui/layout.rs b/src/ui/layout.rs index ebe545b..b97c84f 100644 --- a/src/ui/layout.rs +++ b/src/ui/layout.rs @@ -138,30 +138,37 @@ 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]; + let h = heights[i].min(y_max.saturating_sub(y)); + if h == 0 { + // Panel falls entirely outside the terminal — leave as default (empty) + continue; + } let rect = Rect::new(x, y, w, h); if p.is_visible { - panel_rects[i] = (rect, Rect::default()); // expanded, no collapsed + 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 +309,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 From e0695d0f027e7c1b2166dfbb50f0c356692a0116 Mon Sep 17 00:00:00 2001 From: lobo Date: Wed, 11 Mar 2026 22:10:54 +0100 Subject: [PATCH 2/3] fix: auto-collapse panels when terminal is too small Instead of just clamping rects (which hid panels silently), auto-collapse panels in priority order (waveform first, drum grid last) until the layout fits the terminal height. This keeps the app usable on small terminals instead of showing empty space. Co-Authored-By: Claude Opus 4.6 --- src/ui/layout.rs | 51 +++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 42 insertions(+), 9 deletions(-) diff --git a/src/ui/layout.rs b/src/ui/layout.rs index b97c84f..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; @@ -151,14 +185,13 @@ pub fn compute_dual_layout(total: Rect, vis: &PanelVisibility) -> DualSynthLayou // 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() { + for i in 0..panels.len() { let h = heights[i].min(y_max.saturating_sub(y)); if h == 0 { - // Panel falls entirely outside the terminal — leave as default (empty) continue; } let rect = Rect::new(x, y, w, h); - if p.is_visible { + if vis_effective[i] { panel_rects[i] = (rect, Rect::default()); } else { panel_rects[i] = (Rect::default(), rect); From fa278204b1ff2ef9eb47ad391d22479a86f9f4cd Mon Sep 17 00:00:00 2001 From: lobo Date: Wed, 11 Mar 2026 22:33:03 +0100 Subject: [PATCH 3/3] feat: restore pattern/kit selector strips in transport bar Bring back the visual q w e r t y u i o p pattern selector and 1 2 3 4 5 6 7 8 kit selector for SA/SB/DR in the transport bar, with active slot highlighted in cyan and queued in gold. Also show Pat/Kit info in collapsed panel bars. Co-Authored-By: Claude Opus 4.6 --- src/ui/mod.rs | 18 ++++++++---- src/ui/transport_bar.rs | 61 +++++++++++++++++++++++------------------ 2 files changed, 47 insertions(+), 32 deletions(-) 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) }