use ratatui::layout::{Constraint, Layout, Rect}; use ratatui::style::{Color, 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; 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 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 { Color::Rgb(100, 160, 180) } else { Color::Rgb(70, 75, 85) }; 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 row_height = (inner.height / MAX_BANKS as u16).max(1); let total_needed = row_height * MAX_BANKS as u16; let top_padding = if inner.height > total_needed { (inner.height - total_needed) / 2 } else { 0 }; for idx in 0..MAX_BANKS { let y = inner.y + top_padding + (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); let (bg, fg, prefix) = match (is_cursor, is_playing, is_staged) { (true, _, _) => (Color::Cyan, Color::Black, ""), (false, true, _) => (Color::Rgb(45, 80, 45), Color::Green, "> "), (false, false, true) => (Color::Rgb(80, 60, 100), Color::Magenta, "+ "), (false, false, false) if is_selected => (Color::Rgb(60, 65, 75), Color::White, ""), (false, false, false) if is_edit => (Color::Rgb(45, 106, 95), Color::White, ""), (false, false, false) => (Color::Reset, Color::Rgb(120, 125, 135), ""), }; 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); } } fn render_patterns(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, area: Rect) { use crate::model::PatternSpeed; 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 { Color::Rgb(100, 160, 180) } else { Color::Rgb(70, 75, 85) }; 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 row_height = (inner.height / MAX_PATTERNS as u16).max(1); let total_needed = row_height * MAX_PATTERNS as u16; let top_padding = if inner.height > total_needed { (inner.height - total_needed) / 2 } else { 0 }; for idx in 0..MAX_PATTERNS { let y = inner.y + top_padding + (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); let (bg, fg, prefix) = match (is_cursor, is_playing, is_staged_play, is_staged_stop) { (true, _, _, _) => (Color::Cyan, Color::Black, ""), (false, true, _, true) => (Color::Rgb(120, 60, 80), Color::Magenta, "- "), (false, true, _, false) => (Color::Rgb(45, 80, 45), Color::Green, "> "), (false, false, true, _) => (Color::Rgb(80, 60, 100), Color::Magenta, "+ "), (false, false, false, _) if is_selected => (Color::Rgb(60, 65, 75), Color::White, ""), (false, false, false, _) if is_edit => (Color::Rgb(45, 106, 95), Color::White, ""), (false, false, false, _) => (Color::Reset, Color::Rgb(120, 125, 135), ""), }; 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 }; // Split row into columns: [index+name] [length] [speed] let speed_width: u16 = 14; // "Speed: 1/4x " let length_width: u16 = 13; // "Length: 16 " let name_width = row_area .width .saturating_sub(speed_width + length_width + 2); let [name_area, length_area, speed_area] = Layout::horizontal([ Constraint::Length(name_width), Constraint::Length(length_width), Constraint::Length(speed_width), ]) .areas(Rect { x: row_area.x, y: text_y, width: row_area.width, height: 1, }); // Column 1: prefix + index + name (left-aligned) let name_text = if name.is_empty() { format!("{}{:02}", prefix, idx + 1) } else { format!("{}{:02} {}", prefix, idx + 1, name) }; let name_style = if is_playing || is_staged_play { bold_style } else { base_style }; frame.render_widget(Paragraph::new(name_text).style(name_style), name_area); // Column 2: length let length_line = Line::from(vec![ Span::styled("Length: ", bold_style), Span::styled(format!("{length}"), base_style), ]); frame.render_widget(Paragraph::new(length_line), length_area); // Column 3: speed (only if non-default) if speed != PatternSpeed::NORMAL { let speed_line = Line::from(vec![ Span::styled("Speed: ", bold_style), Span::styled(speed.label(), base_style), ]); frame.render_widget(Paragraph::new(speed_line), speed_area); } } }