use ratatui::layout::{Alignment, Constraint, Layout, Rect}; use ratatui::style::{Color, Modifier, Style}; use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, Borders, 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; use crate::widgets::{render_scroll_indicators, IndicatorAlign}; const MIN_ROW_HEIGHT: u16 = 1; fn pulse_value(phase: f32) -> f32 { phase.sin() * 0.5 + 0.5 } fn pulse_color(from: Color, to: Color, t: f32) -> Color { match (from, to) { (Color::Rgb(r1, g1, b1), Color::Rgb(r2, g2, b2)) => { let l = |a: u8, b: u8| (a as f32 + (b as f32 - a as f32) * t) as u8; Color::Rgb(l(r1, r2), l(g1, g2), l(b1, b2)) } _ => from, } } /// Replaces the background color of spans beyond `filled_cols` with `unfilled_bg`. fn apply_progress_bg(spans: Vec>, filled_cols: usize, unfilled_bg: Color) -> Vec> { let mut result = Vec::with_capacity(spans.len() + 1); let mut col = 0usize; for span in spans { let span_width = span.content.chars().count(); if col + span_width <= filled_cols { result.push(span); } else if col >= filled_cols { result.push(Span::styled(span.content, span.style.bg(unfilled_bg))); } else { let split_at = filled_cols - col; let byte_offset = span.content.char_indices() .nth(split_at) .map_or(span.content.len(), |(i, _)| i); let (left, right) = span.content.split_at(byte_offset); result.push(Span::styled(left.to_string(), span.style)); result.push(Span::styled(right.to_string(), span.style.bg(unfilled_bg))); } col += span_width; } result } pub fn layout(area: Rect) -> [Rect; 3] { let [top_area, _bottom_area] = Layout::vertical([Constraint::Fill(1), Constraint::Length(8)]).areas(area); let [banks_area, patterns_area] = Layout::horizontal([Constraint::Length(18), Constraint::Fill(1)]).areas(top_area); [banks_area, patterns_area, _bottom_area] } pub fn render(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, area: Rect) { let theme = theme::get(); let [banks_area, patterns_area, bottom_area] = layout(area); let [steps_area, props_area] = Layout::horizontal([Constraint::Fill(1), Constraint::Length(22)]).areas(bottom_area); render_banks(frame, app, snapshot, banks_area); let armed_summary = app.playback.armed_summary(); let (patterns_main, launch_bar_area) = if armed_summary.is_some() { let [main, bar] = Layout::vertical([Constraint::Fill(1), Constraint::Length(1)]).areas(patterns_area); (main, Some(bar)) } else { (patterns_area, None) }; render_patterns(frame, app, snapshot, patterns_main); if let (Some(bar_area), Some(summary)) = (launch_bar_area, armed_summary) { let pulse = pulse_value(app.ui.pulse_phase); let pulsed_fg = pulse_color(theme.list.staged_play_fg, theme.list.staged_play_bg, pulse * 0.6); let text = format!("\u{25b6} {summary} \u{2014} c to launch"); let bar = Paragraph::new(text) .alignment(Alignment::Center) .style( Style::new() .fg(pulsed_fg) .bg(theme.list.staged_play_bg) .add_modifier(Modifier::BOLD), ); frame.render_widget(bar, bar_area); } let bank = app.patterns_nav.bank_cursor; let pattern_idx = app.patterns_nav.pattern_cursor; // Steps block let steps_block = Block::default() .borders(Borders::ALL) .border_style(Style::new().fg(theme.ui.border)) .title(" Steps ") .title_style(Style::new().fg(theme.ui.unfocused)); let steps_inner = steps_block.inner(steps_area); frame.render_widget(steps_block, steps_area); render_mini_tile_grid(frame, app, snapshot, steps_inner, bank, pattern_idx); // Properties block let props_block = Block::default() .borders(Borders::ALL) .border_style(Style::new().fg(theme.ui.border)) .title(" Properties ") .title_style(Style::new().fg(theme.ui.unfocused)); let props_inner = props_block.inner(props_area); frame.render_widget(props_block, props_area); render_properties(frame, app, props_inner, bank, pattern_idx); } fn render_banks(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, area: Rect) { let theme = theme::get(); let pulse = pulse_value(app.ui.pulse_phase); let is_focused = matches!(app.patterns_nav.column, PatternsColumn::Banks); let border_color = if is_focused { theme.ui.header } else { theme.ui.border }; let title_style = if is_focused { Style::new().fg(theme.ui.header) } else { Style::new().fg(theme.ui.unfocused) }; let block = Block::default() .borders(Borders::ALL) .border_style(Style::new().fg(border_color)) .title(" Banks ") .title_style(title_style); let inner = block.inner(area); frame.render_widget(block, 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); let is_in_range = is_focused && app .patterns_nav .bank_selection_range() .is_some_and(|r| r.contains(&idx)); let has_muted = (0..MAX_PATTERNS).any(|p| app.playback.is_muted(idx, p)); let has_soloed = (0..MAX_PATTERNS).any(|p| app.playback.is_soloed(idx, p)); 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_in_range { (theme.selection.in_range_bg, theme.selection.in_range_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 bank_ref = &app.project_state.project.banks[idx]; let name = bank_ref.name.as_deref().unwrap_or(""); let content_count = bank_ref.content_pattern_count(); let idx_part = format!("{}{:02}", prefix, idx + 1); let count_part = format!("{}", content_count); let available_for_name = (row_area.width as usize) .saturating_sub(idx_part.len() + 1 + count_part.len()); let label = if name.is_empty() { let pad = " ".repeat(available_for_name); format!("{idx_part}{pad}{count_part}") } else { let name_display: String = name.chars().take(available_for_name.saturating_sub(1)).collect(); let used = name_display.chars().count() + 1; let pad = " ".repeat(available_for_name.saturating_sub(used)); format!("{idx_part} {name_display}{pad}{count_part}") }; let style = Style::new().bg(bg).fg(fg); let style = if is_playing || is_staged { style.add_modifier(Modifier::BOLD) } else { style }; let style = if (is_staged || has_staged_mute_solo) && !is_cursor && !is_in_range { let pulsed = pulse_color(fg, bg, pulse * 0.6); style.fg(pulsed) } else { style }; 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); } render_scroll_indicators( frame, inner, scroll_offset, visible_count, MAX_BANKS, theme.ui.text_muted, IndicatorAlign::Center, ); } fn render_patterns(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, area: Rect) { use crate::model::PatternSpeed; let pulse = pulse_value(app.ui.pulse_phase); let theme = theme::get(); let is_focused = matches!(app.patterns_nav.column, PatternsColumn::Patterns); 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 border_color = if is_focused { theme.ui.header } else { theme.ui.border }; let title_style = if is_focused { Style::new().fg(theme.ui.header) } else { Style::new().fg(theme.ui.unfocused) }; let block = Block::default() .borders(Borders::ALL) .border_style(Style::new().fg(border_color)) .title(title_text) .title_style(title_style); let inner = block.inner(area); frame.render_widget(block, 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 available = inner.height as usize; let max_visible = available.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 mut y = inner.y; for visible_idx in 0..visible_count { let idx = scroll_offset + visible_idx; if y >= inner.y + inner.height { break; } let row_area = Rect { x: inner.x, y, width: inner.width, height: 1u16.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 is_in_range = is_focused && app .patterns_nav .pattern_selection_range() .is_some_and(|r| r.contains(&idx)); let is_muted = app.playback.is_muted(bank, idx); let is_soloed = app.playback.is_soloed(bank, idx); let has_staged_mute = app.playback.has_staged_mute(bank, idx); let has_staged_solo = app.playback.has_staged_solo(bank, idx); let has_staged_props = app.playback.has_staged_props(bank, idx); let preview_muted = is_muted ^ has_staged_mute; let preview_soloed = is_soloed ^ has_staged_solo; let is_effectively_muted = app.playback.is_effectively_muted(bank, idx); let (bg, fg, prefix) = if is_cursor { (theme.selection.cursor, theme.selection.cursor_fg, "") } else if is_in_range { (theme.selection.in_range_bg, theme.selection.in_range_fg, "") } else if is_playing { if is_staged_stop { (theme.list.staged_stop_bg, theme.list.staged_stop_fg, "- ") } else if has_staged_solo { 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 { 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 { 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 { 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); let bg_block = Block::default().style(Style::new().bg(bg)); frame.render_widget(bg_block, row_area); let content_area = row_area; let text_area = Rect { x: content_area.x, y: content_area.y, width: content_area.width, height: 1, }; let name_style = if is_playing || is_staged_play { bold_style } else { base_style }; let dim_style = base_style.remove_modifier(Modifier::BOLD); let is_armed = is_staged_play || is_staged_stop || has_staged_mute || has_staged_solo || has_staged_props; let (name_style, dim_style) = if is_armed && !is_cursor && !is_in_range { let pulsed = pulse_color(fg, bg, pulse * 0.6); (name_style.fg(pulsed), dim_style.fg(pulsed)) } else { (name_style, dim_style) }; 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)); } let content_count = pattern.content_step_count(); let speed_str = if speed != PatternSpeed::NORMAL { format!(" {}", speed.label()) } else { String::new() }; let props_indicator = if has_staged_props { "~" } else { "" }; let quant_sync = if is_selected { format!("{} ", pattern.quantization.short_label()) } else { String::new() }; let right_info = if content_count > 0 { format!("{quant_sync}{props_indicator}{content_count}/{length}{speed_str}") } else { format!("{quant_sync}{props_indicator} {length}{speed_str}") }; let left_width: usize = spans.iter().map(|s| s.content.chars().count()).sum(); let right_width = right_info.chars().count(); let gap = (text_area.width as usize).saturating_sub(left_width + right_width + 1); if let Some(desc) = pattern.description.as_deref().filter(|d| !d.is_empty() && gap > 4) { let budget = gap - 2; let char_count = desc.chars().count(); if char_count <= budget { spans.push(Span::styled(format!(" {desc}"), dim_style)); spans.push(Span::styled(" ".repeat(gap - char_count - 1), dim_style)); } else { let truncated: String = desc.chars().take(budget - 1).collect(); spans.push(Span::styled(format!(" {truncated}\u{2026}"), dim_style)); spans.push(Span::styled(" ", dim_style)); } } else { spans.push(Span::styled(" ".repeat(gap), dim_style)); } spans.push(Span::styled(right_info, dim_style)); let spans = if is_playing && !is_cursor && !is_in_range { let ratio = snapshot.get_smooth_progress(bank, idx, length, speed.multiplier()).unwrap_or(0.0); let filled = (ratio * text_area.width as f64).min(text_area.width as f64) as usize; apply_progress_bg(spans, filled, theme.ui.bg) } else { spans }; frame.render_widget(Paragraph::new(Line::from(spans)), text_area); y += row_area.height; } render_scroll_indicators( frame, inner, scroll_offset, visible_count, MAX_PATTERNS, theme.ui.text_muted, IndicatorAlign::Center, ); } fn render_mini_tile_grid( frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, area: Rect, bank: usize, pattern_idx: usize, ) { let pattern = &app.project_state.project.banks[bank].patterns[pattern_idx]; let length = pattern.length; if length == 0 || area.height == 0 || area.width < 8 { return; } let playing_step = snapshot.get_step(bank, pattern_idx); let num_rows = length.div_ceil(8); let row_gap: u16 = 1; let max_tile_height: u16 = 4; let max_rows = (area.height as usize + row_gap as usize) / (1 + row_gap as usize); let num_rows = num_rows.min(max_rows.max(1)); let available_for_rows = area.height.saturating_sub((num_rows.saturating_sub(1) as u16) * row_gap); let tile_height = (available_for_rows / num_rows as u16).min(max_tile_height).max(1); let total_grid_height = (num_rows as u16) * tile_height + (num_rows.saturating_sub(1) as u16) * row_gap; let y_offset = area.height.saturating_sub(total_grid_height) / 2; let grid_area = Rect { x: area.x, y: area.y + y_offset, width: area.width, height: total_grid_height.min(area.height), }; let mut row_constraints: Vec = Vec::new(); for i in 0..num_rows { row_constraints.push(Constraint::Length(tile_height)); if i < num_rows - 1 { row_constraints.push(Constraint::Length(row_gap)); } } let row_areas = Layout::vertical(row_constraints).split(grid_area); for row_idx in 0..num_rows { if row_idx * 2 >= row_areas.len() { break; } let row_area = row_areas[row_idx * 2]; let start_step = row_idx * 8; let end_step = (start_step + 8).min(length); let cols_in_row = end_step - start_step; let mut col_constraints: Vec = Vec::new(); for col in 0..cols_in_row { col_constraints.push(Constraint::Fill(1)); if col < cols_in_row - 1 { if (col + 1) % 4 == 0 { col_constraints.push(Constraint::Length(2)); } else { col_constraints.push(Constraint::Length(1)); } } } let col_areas = Layout::horizontal(col_constraints).split(row_area); for col_idx in 0..cols_in_row { let step_idx = start_step + col_idx; let cell_area = col_areas[col_idx * 2]; render_mini_tile(frame, pattern, cell_area, step_idx, playing_step); } } } fn render_mini_tile( frame: &mut Frame, pattern: &cagire_project::Pattern, area: Rect, step_idx: usize, playing_step: Option, ) { let theme = theme::get(); let step = pattern.step(step_idx); let is_active = step.map(|s| s.active).unwrap_or(false); let has_content = step.map(|s| s.has_content()).unwrap_or(false); let source_idx = step.and_then(|s| s.source); let is_playing = playing_step == Some(step_idx); let (bg, fg) = if is_playing { if is_active { (theme.tile.playing_active_bg, theme.tile.playing_active_fg) } else { (theme.tile.playing_inactive_bg, theme.tile.playing_inactive_fg) } } else if let Some(src) = source_idx { let i = src as usize % 5; let (r, g, b) = theme.tile.link_dim[i]; (Color::Rgb(r, g, b), theme.tile.active_fg) } else if has_content { (theme.tile.content_bg, theme.tile.active_fg) } else if is_active { (theme.tile.active_bg, theme.tile.active_fg) } else { (theme.tile.inactive_bg, theme.tile.inactive_fg) }; let symbol = if is_playing { "\u{25b6}".to_string() } else if let Some(src) = source_idx { format!("\u{2192}{:02}", src + 1) } else if has_content { format!("\u{00b7}{:02}\u{00b7}", step_idx + 1) } else { format!("{:02}", step_idx + 1) }; let bg_fill = Paragraph::new("").style(Style::new().bg(bg)); frame.render_widget(bg_fill, area); let center_y = area.y + area.height / 2; let step_name = if let Some(src) = source_idx { pattern.step(src as usize).and_then(|s| s.name.as_ref()) } else { step.and_then(|s| s.name.as_ref()) }; if let Some(name) = step_name { if center_y > area.y { let name_area = Rect { x: area.x, y: center_y - 1, width: area.width, height: 1, }; let name_widget = Paragraph::new(name.as_str()) .alignment(Alignment::Center) .style(Style::new().bg(bg).fg(fg).add_modifier(Modifier::BOLD)); frame.render_widget(name_widget, name_area); } } let symbol_area = Rect { x: area.x, y: center_y, width: area.width, height: 1, }; let symbol_widget = Paragraph::new(symbol) .alignment(Alignment::Center) .style(Style::new().bg(bg).fg(fg).add_modifier(Modifier::BOLD)); frame.render_widget(symbol_widget, symbol_area); if has_content && center_y + 1 < area.y + area.height { let script = pattern.resolve_script(step_idx).unwrap_or(""); if let Some(first_token) = script.split_whitespace().next() { let hint_area = Rect { x: area.x, y: center_y + 1, width: area.width, height: 1, }; let hint_widget = Paragraph::new(first_token) .alignment(Alignment::Center) .style(Style::new().bg(bg).fg(theme.ui.text_dim)); frame.render_widget(hint_widget, hint_area); } } } fn render_properties( frame: &mut Frame, app: &App, area: Rect, bank: usize, pattern_idx: usize, ) { use cagire_project::FollowUp; let theme = theme::get(); let pattern = &app.project_state.project.banks[bank].patterns[pattern_idx]; let name = pattern.name.as_deref().unwrap_or("-"); let desc = pattern.description.as_deref().unwrap_or("-"); let content_count = pattern.content_step_count(); let steps_label = format!("{}/{}", content_count, pattern.length); let speed_label = pattern.speed.label(); let quant_label = pattern.quantization.label(); let label_style = Style::new().fg(theme.ui.text_muted); let value_style = Style::new().fg(theme.ui.text_primary); let mut rows: Vec = vec![ Line::from(vec![ Span::styled(" Name ", label_style), Span::styled(name, value_style), ]), Line::from(vec![ Span::styled(" Desc ", label_style), Span::styled(desc, value_style), ]), Line::from(vec![ Span::styled(" Steps ", label_style), Span::styled(steps_label, value_style), ]), Line::from(vec![ Span::styled(" Speed ", label_style), Span::styled(speed_label, value_style), ]), Line::from(vec![ Span::styled(" Quant ", label_style), Span::styled(quant_label, value_style), ]), ]; if pattern.follow_up != FollowUp::Loop { let follow_label = match pattern.follow_up { FollowUp::Loop => unreachable!(), FollowUp::Stop => "Stop".to_string(), FollowUp::Chain { bank: b, pattern: p } => format!("Chain B{:02}:P{:02}", b + 1, p + 1), }; rows.push(Line::from(vec![ Span::styled(" After ", label_style), Span::styled(follow_label, value_style), ])); } frame.render_widget(Paragraph::new(rows), area); }