use ratatui::layout::{Constraint, Layout, Rect}; use ratatui::style::{Modifier, Style}; use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, Paragraph}; use ratatui::Frame; use crate::app::App; use crate::engine::SequencerSnapshot; use crate::model::{MAX_BANKS, MAX_PATTERNS}; use crate::state::PatternsColumn; use crate::theme; const MIN_ROW_HEIGHT: u16 = 1; pub fn render(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, area: Rect) { let [banks_area, gap, patterns_area] = Layout::horizontal([ Constraint::Fill(1), Constraint::Length(1), Constraint::Fill(1), ]) .areas(area); render_banks(frame, app, snapshot, banks_area); // gap is just empty space let _ = gap; render_patterns(frame, app, snapshot, patterns_area); } fn render_banks(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, area: Rect) { let theme = theme::get(); let is_focused = matches!(app.patterns_nav.column, PatternsColumn::Banks); let [title_area, inner] = Layout::vertical([Constraint::Length(1), Constraint::Fill(1)]).areas(area); let title_color = if is_focused { theme.ui.header } else { theme.ui.unfocused }; let title = Paragraph::new("Banks") .style(Style::new().fg(title_color)) .alignment(ratatui::layout::Alignment::Center); frame.render_widget(title, title_area); let banks_with_playback: Vec = snapshot .active_patterns .iter() .map(|p| p.bank) .collect(); let banks_with_staged: Vec = app .playback .staged_changes .iter() .filter_map(|c| match &c.change { crate::engine::PatternChange::Start { bank, .. } => Some(*bank), _ => None, }) .collect(); let cursor = app.patterns_nav.bank_cursor; let max_visible = (inner.height / MIN_ROW_HEIGHT) as usize; let max_visible = max_visible.max(1); let scroll_offset = if MAX_BANKS <= max_visible { 0 } else { cursor .saturating_sub(max_visible / 2) .min(MAX_BANKS - max_visible) }; let visible_count = MAX_BANKS.min(max_visible); let row_height = inner.height / visible_count as u16; let row_height = row_height.max(MIN_ROW_HEIGHT); for visible_idx in 0..visible_count { let idx = scroll_offset + visible_idx; let y = inner.y + (visible_idx as u16) * row_height; if y >= inner.y + inner.height { break; } let row_area = Rect { x: inner.x, y, width: inner.width, height: row_height.min(inner.y + inner.height - y), }; let is_cursor = is_focused && idx == app.patterns_nav.bank_cursor; let is_selected = idx == app.patterns_nav.bank_cursor; let is_edit = idx == app.editor_ctx.bank; let is_playing = banks_with_playback.contains(&idx); let is_staged = banks_with_staged.contains(&idx); // Check if any pattern in this bank is muted/soloed (applied) let has_muted = (0..MAX_PATTERNS).any(|p| app.mute.is_muted(idx, p)); let has_soloed = (0..MAX_PATTERNS).any(|p| app.mute.is_soloed(idx, p)); // Check if any pattern in this bank has staged mute/solo let has_staged_mute = (0..MAX_PATTERNS).any(|p| app.playback.has_staged_mute(idx, p)); let has_staged_solo = (0..MAX_PATTERNS).any(|p| app.playback.has_staged_solo(idx, p)); let has_staged_mute_solo = has_staged_mute || has_staged_solo; let (bg, fg, prefix) = if is_cursor { (theme.selection.cursor, theme.selection.cursor_fg, "") } else if is_playing { if has_staged_mute_solo { (theme.list.staged_play_bg, theme.list.staged_play_fg, ">*") } else if has_soloed { (theme.list.soloed_bg, theme.list.soloed_fg, ">S") } else if has_muted { (theme.list.muted_bg, theme.list.muted_fg, ">M") } else { (theme.list.playing_bg, theme.list.playing_fg, "> ") } } else if is_staged { (theme.list.staged_play_bg, theme.list.staged_play_fg, "+ ") } else if has_staged_mute_solo { (theme.list.staged_play_bg, theme.list.staged_play_fg, " *") } else if has_soloed && is_selected { (theme.list.soloed_bg, theme.list.soloed_fg, " S") } else if has_muted && is_selected { (theme.list.muted_bg, theme.list.muted_fg, " M") } else if is_selected { (theme.list.hover_bg, theme.list.hover_fg, "") } else if is_edit { (theme.list.edit_bg, theme.list.edit_fg, "") } else { (theme.ui.bg, theme.ui.text_muted, "") }; let name = app.project_state.project.banks[idx] .name .as_deref() .unwrap_or(""); let label = if name.is_empty() { format!("{}{:02}", prefix, idx + 1) } else { format!("{}{:02} {}", prefix, idx + 1, name) }; let style = Style::new().bg(bg).fg(fg); let style = if is_playing || is_staged { style.add_modifier(Modifier::BOLD) } else { style }; // Fill the entire row with background color let bg_block = Block::default().style(Style::new().bg(bg)); frame.render_widget(bg_block, row_area); let text_y = if row_height > 1 { row_area.y + (row_height - 1) / 2 } else { row_area.y }; let text_area = Rect { x: row_area.x, y: text_y, width: row_area.width, height: 1, }; let para = Paragraph::new(label).style(style); frame.render_widget(para, text_area); } // Scroll indicators let indicator_style = Style::new().fg(theme.ui.text_muted); if scroll_offset > 0 { let indicator = Paragraph::new("▲") .style(indicator_style) .alignment(ratatui::layout::Alignment::Center); frame.render_widget(indicator, Rect { height: 1, ..inner }); } if scroll_offset + visible_count < MAX_BANKS { let y = inner.y + inner.height.saturating_sub(1); let indicator = Paragraph::new("▼") .style(indicator_style) .alignment(ratatui::layout::Alignment::Center); frame.render_widget(indicator, Rect { y, height: 1, ..inner }); } } fn render_patterns(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, area: Rect) { use crate::model::PatternSpeed; let theme = theme::get(); let is_focused = matches!(app.patterns_nav.column, PatternsColumn::Patterns); let [title_area, inner] = Layout::vertical([Constraint::Length(1), Constraint::Fill(1)]).areas(area); let title_color = if is_focused { theme.ui.header } else { theme.ui.unfocused }; let bank = app.patterns_nav.bank_cursor; let bank_name = app.project_state.project.banks[bank].name.as_deref(); let title_text = match bank_name { Some(name) => format!("Patterns ({name})"), None => format!("Patterns (Bank {:02})", bank + 1), }; let title = Paragraph::new(title_text) .style(Style::new().fg(title_color)) .alignment(ratatui::layout::Alignment::Center); frame.render_widget(title, title_area); let playing_patterns: Vec = snapshot .active_patterns .iter() .filter(|p| p.bank == bank) .map(|p| p.pattern) .collect(); let staged_to_play: Vec = app .playback .staged_changes .iter() .filter_map(|c| match &c.change { crate::engine::PatternChange::Start { bank: b, pattern, .. } if *b == bank => Some(*pattern), _ => None, }) .collect(); let staged_to_stop: Vec = app .playback .staged_changes .iter() .filter_map(|c| match &c.change { crate::engine::PatternChange::Stop { bank: b, pattern, } if *b == bank => Some(*pattern), _ => None, }) .collect(); let edit_pattern = if app.editor_ctx.bank == bank { Some(app.editor_ctx.pattern) } else { None }; let cursor = app.patterns_nav.pattern_cursor; let max_visible = (inner.height / MIN_ROW_HEIGHT) as usize; let max_visible = max_visible.max(1); let scroll_offset = if MAX_PATTERNS <= max_visible { 0 } else { cursor .saturating_sub(max_visible / 2) .min(MAX_PATTERNS - max_visible) }; let visible_count = MAX_PATTERNS.min(max_visible); let row_height = inner.height / visible_count as u16; let row_height = row_height.max(MIN_ROW_HEIGHT); for visible_idx in 0..visible_count { let idx = scroll_offset + visible_idx; let y = inner.y + (visible_idx as u16) * row_height; if y >= inner.y + inner.height { break; } let row_area = Rect { x: inner.x, y, width: inner.width, height: row_height.min(inner.y + inner.height - y), }; let is_cursor = is_focused && idx == app.patterns_nav.pattern_cursor; let is_selected = idx == app.patterns_nav.pattern_cursor; let is_edit = edit_pattern == Some(idx); let is_playing = playing_patterns.contains(&idx); let is_staged_play = staged_to_play.contains(&idx); let is_staged_stop = staged_to_stop.contains(&idx); // Current applied mute/solo state let is_muted = app.mute.is_muted(bank, idx); let is_soloed = app.mute.is_soloed(bank, idx); // Staged mute/solo (will toggle on commit) let has_staged_mute = app.playback.has_staged_mute(bank, idx); let has_staged_solo = app.playback.has_staged_solo(bank, idx); // Preview state (what it will be after commit) let preview_muted = is_muted ^ has_staged_mute; let preview_soloed = is_soloed ^ has_staged_solo; let is_effectively_muted = app.mute.is_effectively_muted(bank, idx); let (bg, fg, prefix) = if is_cursor { (theme.selection.cursor, theme.selection.cursor_fg, "") } else if is_playing { // Playing patterns if is_staged_stop { (theme.list.staged_stop_bg, theme.list.staged_stop_fg, "- ") } else if has_staged_solo { // Staged solo toggle on playing pattern if preview_soloed { (theme.list.soloed_bg, theme.list.soloed_fg, "+S") } else { (theme.list.playing_bg, theme.list.playing_fg, "-S") } } else if has_staged_mute { // Staged mute toggle on playing pattern if preview_muted { (theme.list.muted_bg, theme.list.muted_fg, "+M") } else { (theme.list.playing_bg, theme.list.playing_fg, "-M") } } else if is_soloed { (theme.list.soloed_bg, theme.list.soloed_fg, ">S") } else if is_muted { (theme.list.muted_bg, theme.list.muted_fg, ">M") } else if is_effectively_muted { (theme.list.muted_bg, theme.list.muted_fg, "> ") } else { (theme.list.playing_bg, theme.list.playing_fg, "> ") } } else if is_staged_play { (theme.list.staged_play_bg, theme.list.staged_play_fg, "+ ") } else if has_staged_solo { // Staged solo on non-playing pattern if preview_soloed { (theme.list.soloed_bg, theme.list.soloed_fg, "+S") } else { (theme.ui.bg, theme.ui.text_muted, "-S") } } else if has_staged_mute { // Staged mute on non-playing pattern if preview_muted { (theme.list.muted_bg, theme.list.muted_fg, "+M") } else { (theme.ui.bg, theme.ui.text_muted, "-M") } } else if is_soloed { (theme.list.soloed_bg, theme.list.soloed_fg, " S") } else if is_muted { (theme.list.muted_bg, theme.list.muted_fg, " M") } else if is_selected { (theme.list.hover_bg, theme.list.hover_fg, "") } else if is_edit { (theme.list.edit_bg, theme.list.edit_fg, "") } else { (theme.ui.bg, theme.ui.text_muted, "") }; let pattern = &app.project_state.project.banks[bank].patterns[idx]; let name = pattern.name.as_deref().unwrap_or(""); let length = pattern.length; let speed = pattern.speed; let base_style = Style::new().bg(bg).fg(fg); let bold_style = base_style.add_modifier(Modifier::BOLD); // Fill the entire row with background color let bg_block = Block::default().style(Style::new().bg(bg)); frame.render_widget(bg_block, row_area); let text_y = if row_height > 1 { row_area.y + (row_height - 1) / 2 } else { row_area.y }; let text_area = Rect { x: row_area.x, y: text_y, width: row_area.width, height: 1, }; // Build the line: [prefix][idx] [name] ... [length] [speed] let name_style = if is_playing || is_staged_play { bold_style } else { base_style }; let dim_style = base_style.remove_modifier(Modifier::BOLD); let mut spans = vec![Span::styled(format!("{}{:02}", prefix, idx + 1), name_style)]; if !name.is_empty() { spans.push(Span::styled(format!(" {name}"), name_style)); } // Right-aligned info: length and speed let speed_str = if speed != PatternSpeed::NORMAL { format!(" {}", speed.label()) } else { String::new() }; let right_info = format!("{length}{speed_str}"); let left_width: usize = spans.iter().map(|s| s.content.chars().count()).sum(); let right_width = right_info.chars().count(); let padding = (text_area.width as usize).saturating_sub(left_width + right_width + 1); spans.push(Span::raw(" ".repeat(padding))); spans.push(Span::styled(right_info, dim_style)); frame.render_widget(Paragraph::new(Line::from(spans)), text_area); } // Scroll indicators let indicator_style = Style::new().fg(theme.ui.text_muted); if scroll_offset > 0 { let indicator = Paragraph::new("▲") .style(indicator_style) .alignment(ratatui::layout::Alignment::Center); frame.render_widget(indicator, Rect { height: 1, ..inner }); } if scroll_offset + visible_count < MAX_PATTERNS { let y = inner.y + inner.height.saturating_sub(1); let indicator = Paragraph::new("▼") .style(indicator_style) .alignment(ratatui::layout::Alignment::Center); frame.render_widget(indicator, Rect { y, height: 1, ..inner }); } }