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::{LinkState, SequencerSnapshot}; use crate::page::Page; use crate::state::{Modal, PatternField}; use crate::views::highlight; use crate::widgets::{ConfirmModal, ModalFrame, TextInputModal}; use super::{audio_view, doc_view, main_view, patterns_view, title_view}; pub fn render(frame: &mut Frame, app: &mut App, link: &LinkState, snapshot: &SequencerSnapshot) { let term = frame.area(); if app.ui.show_title { title_view::render(frame, term, &mut app.ui); return; } let padded = Rect { x: term.x + 1, y: term.y + 1, width: term.width.saturating_sub(2), height: term.height.saturating_sub(2), }; let [header_area, _padding, body_area, footer_area] = Layout::vertical([ Constraint::Length(1), Constraint::Length(1), Constraint::Fill(1), Constraint::Length(3), ]) .areas(padded); render_header(frame, app, link, snapshot, header_area); match app.page { Page::Main => main_view::render(frame, app, snapshot, body_area), Page::Patterns => patterns_view::render(frame, app, snapshot, body_area), Page::Audio => audio_view::render(frame, app, link, body_area), Page::Doc => doc_view::render(frame, app, body_area), } render_footer(frame, app, footer_area); render_modal(frame, app, snapshot, term); } fn render_header( frame: &mut Frame, app: &App, link: &LinkState, snapshot: &SequencerSnapshot, area: Rect, ) { use crate::model::PatternSpeed; let bank = &app.project_state.project.banks[app.editor_ctx.bank]; let pattern = &bank.patterns[app.editor_ctx.pattern]; // Layout: [Transport] [Live] [Tempo] [Bank] [Pattern] [Stats] let [transport_area, live_area, tempo_area, bank_area, pattern_area, stats_area] = Layout::horizontal([ Constraint::Min(12), Constraint::Length(9), Constraint::Min(14), Constraint::Fill(1), Constraint::Fill(2), Constraint::Min(20), ]) .areas(area); // Transport block let (transport_bg, transport_text) = if app.playback.playing { (Color::Rgb(30, 80, 30), " ▶ PLAYING ") } else { (Color::Rgb(80, 30, 30), " ■ STOPPED ") }; let transport_style = Style::new().bg(transport_bg).fg(Color::White); frame.render_widget( Paragraph::new(transport_text) .style(transport_style) .alignment(Alignment::Center), transport_area, ); // Fill indicator let fill = app.live_keys.fill(); let fill_style = if fill { Style::new().bg(Color::Rgb(30, 30, 35)).fg(Color::Rgb(100, 220, 100)) } else { Style::new().bg(Color::Rgb(30, 30, 35)).fg(Color::Rgb(60, 60, 70)) }; frame.render_widget( Paragraph::new(if fill { "F" } else { "·" }) .style(fill_style) .alignment(Alignment::Center), live_area, ); // Tempo block let tempo_style = Style::new() .bg(Color::Rgb(60, 30, 60)) .fg(Color::White) .add_modifier(Modifier::BOLD); frame.render_widget( Paragraph::new(format!(" {:.1} BPM ", link.tempo())) .style(tempo_style) .alignment(Alignment::Center), tempo_area, ); // Bank block let bank_name = bank .name .as_deref() .map(|n| format!(" {n} ")) .unwrap_or_else(|| format!(" Bank {:02} ", app.editor_ctx.bank + 1)); let bank_style = Style::new().bg(Color::Rgb(30, 60, 70)).fg(Color::White); frame.render_widget( Paragraph::new(bank_name) .style(bank_style) .alignment(Alignment::Center), bank_area, ); // Pattern block (name + length + speed + iter) let default_pattern_name = format!("Pattern {:02}", app.editor_ctx.pattern + 1); let pattern_name = pattern.name.as_deref().unwrap_or(&default_pattern_name); let speed_info = if pattern.speed != PatternSpeed::Normal { format!(" · {}", pattern.speed.label()) } else { String::new() }; let iter_info = snapshot .get_iter(app.editor_ctx.bank, app.editor_ctx.pattern) .map(|iter| format!(" · #{}", iter + 1)) .unwrap_or_default(); let pattern_text = format!( " {} · {} steps{}{} ", pattern_name, pattern.length, speed_info, iter_info ); let pattern_style = Style::new().bg(Color::Rgb(30, 50, 50)).fg(Color::White); frame.render_widget( Paragraph::new(pattern_text) .style(pattern_style) .alignment(Alignment::Center), pattern_area, ); // Stats block let cpu_pct = (app.metrics.cpu_load * 100.0).min(100.0); let peers = link.peers(); let voices = app.metrics.active_voices; let stats_text = format!(" CPU {cpu_pct:.0}% V:{voices} L:{peers} "); let stats_style = Style::new() .bg(Color::Rgb(35, 35, 40)) .fg(Color::Rgb(150, 150, 160)); frame.render_widget( Paragraph::new(stats_text) .style(stats_style) .alignment(Alignment::Right), stats_area, ); } fn render_footer(frame: &mut Frame, app: &App, area: Rect) { let block = Block::default().borders(Borders::ALL); let inner = block.inner(area); let available_width = inner.width as usize; let page_indicator = match app.page { Page::Main => "[MAIN]", Page::Patterns => "[PATTERNS]", Page::Audio => "[AUDIO]", Page::Doc => "[DOC]", }; let content = if let Some(ref msg) = app.ui.status_message { Line::from(vec![ Span::styled( page_indicator.to_string(), Style::new().fg(Color::White).add_modifier(Modifier::DIM), ), Span::raw(" "), Span::styled(msg.clone(), Style::new().fg(Color::Yellow)), ]) } else { let bindings: Vec<(&str, &str)> = match app.page { Page::Main => vec![ ("←→↑↓", "nav"), ("t", "toggle"), ("Enter", "edit"), ("<>", "len"), ("[]", "spd"), ("f", "fill"), ], Page::Patterns => vec![ ("←→↑↓", "nav"), ("Enter", "select"), ("Space", "play"), ("Esc", "back"), ], Page::Audio => vec![ ("q", "quit"), ("h", "hush"), ("p", "panic"), ("r", "reset"), ("t", "test"), ("C-←→", "page"), ], Page::Doc => vec![("j/k", "topic"), ("PgUp/Dn", "scroll"), ("C-←→", "page")], }; let page_width = page_indicator.chars().count(); let bindings_content_width: usize = bindings .iter() .map(|(k, a)| k.chars().count() + 1 + a.chars().count()) .sum(); let n = bindings.len(); let total_content = page_width + bindings_content_width; let total_gaps = available_width.saturating_sub(total_content); let gap_count = n + 1; let base_gap = total_gaps / gap_count; let extra = total_gaps % gap_count; let mut spans = vec![ Span::styled( page_indicator.to_string(), Style::new().fg(Color::White).add_modifier(Modifier::DIM), ), Span::raw(" ".repeat(base_gap + if extra > 0 { 1 } else { 0 })), ]; for (i, (key, action)) in bindings.into_iter().enumerate() { spans.push(Span::styled( key.to_string(), Style::new().fg(Color::Yellow), )); spans.push(Span::styled( format!(" {action}"), Style::new().fg(Color::Rgb(120, 125, 135)), )); if i < n - 1 { let gap = base_gap + if i + 1 < extra { 1 } else { 0 }; spans.push(Span::raw(" ".repeat(gap))); } } Line::from(spans) }; let footer = Paragraph::new(content).block(block); frame.render_widget(footer, area); } fn render_modal(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, term: Rect) { match &app.ui.modal { Modal::None => {} Modal::ConfirmQuit { selected } => { ConfirmModal::new("Confirm", "Quit?", *selected).render_centered(frame, term); } Modal::ConfirmDeleteStep { step, selected, .. } => { ConfirmModal::new("Confirm", &format!("Delete step {}?", step + 1), *selected) .render_centered(frame, term); } Modal::ConfirmResetPattern { pattern, selected, .. } => { ConfirmModal::new( "Confirm", &format!("Reset pattern {}?", pattern + 1), *selected, ) .render_centered(frame, term); } Modal::ConfirmResetBank { bank, selected } => { ConfirmModal::new("Confirm", &format!("Reset bank {}?", bank + 1), *selected) .render_centered(frame, term); } Modal::SaveAs(path) => { TextInputModal::new("Save As (Enter to confirm, Esc to cancel)", path) .width(60) .border_color(Color::Green) .render_centered(frame, term); } Modal::LoadFrom(path) => { TextInputModal::new("Load From (Enter to confirm, Esc to cancel)", path) .width(60) .border_color(Color::Blue) .render_centered(frame, term); } Modal::RenameBank { bank, name } => { TextInputModal::new(&format!("Rename Bank {:02}", bank + 1), name) .width(40) .border_color(Color::Magenta) .render_centered(frame, term); } Modal::RenamePattern { bank, pattern, name, } => { TextInputModal::new( &format!("Rename B{:02}:P{:02}", bank + 1, pattern + 1), name, ) .width(40) .border_color(Color::Magenta) .render_centered(frame, term); } Modal::SetPattern { field, input } => { let (title, hint) = match field { PatternField::Length => ("Set Length (2-32)", "Enter number"), PatternField::Speed => ("Set Speed", "1/8x, 1/4x, 1/2x, 1x, 2x, 4x, 8x"), }; TextInputModal::new(title, input) .hint(hint) .width(45) .border_color(Color::Yellow) .render_centered(frame, term); } Modal::SetTempo(input) => { TextInputModal::new("Set Tempo (20-300 BPM)", input) .hint("Enter BPM") .width(30) .border_color(Color::Magenta) .render_centered(frame, term); } Modal::AddSamplePath(path) => { TextInputModal::new("Add Sample Path", path) .hint("Enter directory path containing samples") .width(60) .border_color(Color::Magenta) .render_centered(frame, term); } Modal::Editor => { let width = (term.width * 80 / 100).max(40); let height = (term.height * 60 / 100).max(10); let step_num = app.editor_ctx.step + 1; let border_color = if app.ui.is_flashing() { Color::Green } else { Color::Rgb(100, 160, 180) }; let inner = ModalFrame::new(&format!("Step {step_num:02} Script")) .width(width) .height(height) .border_color(border_color) .render_centered(frame, term); let (cursor_row, cursor_col) = app.editor_ctx.text.cursor(); let runtime_spans = if app.ui.runtime_highlight && app.playback.playing { snapshot.get_trace(app.editor_ctx.bank, app.editor_ctx.pattern) } else { None }; let text_lines = app.editor_ctx.text.lines(); let mut line_offsets: Vec = Vec::with_capacity(text_lines.len()); let mut offset = 0; for line in text_lines.iter() { line_offsets.push(offset); offset += line.len() + 1; } let lines: Vec = text_lines .iter() .enumerate() .map(|(row, line)| { let mut spans: Vec = Vec::new(); let line_start = line_offsets[row]; let line_end = line_start + line.len(); let adjusted_spans: Vec = runtime_spans .map(|rs| { rs.iter() .filter_map(|s| { if s.start < line_end && s.end > line_start { Some(crate::model::SourceSpan { start: s.start.saturating_sub(line_start), end: s.end.saturating_sub(line_start).min(line.len()), }) } else { None } }) .collect() }) .unwrap_or_default(); let tokens = highlight::highlight_line_with_runtime(line, &adjusted_spans); if row == cursor_row { let mut col = 0; for (style, text) in tokens { let text_len = text.chars().count(); if cursor_col >= col && cursor_col < col + text_len { let before = text.chars().take(cursor_col - col).collect::(); let cursor_char = text.chars().nth(cursor_col - col).unwrap_or(' '); let after = text.chars().skip(cursor_col - col + 1).collect::(); if !before.is_empty() { spans.push(Span::styled(before, style)); } spans.push(Span::styled( cursor_char.to_string(), Style::default().bg(Color::White).fg(Color::Black), )); if !after.is_empty() { spans.push(Span::styled(after, style)); } } else { spans.push(Span::styled(text, style)); } col += text_len; } if cursor_col >= col { spans.push(Span::styled( " ", Style::default().bg(Color::White).fg(Color::Black), )); } } else { for (style, text) in tokens { spans.push(Span::styled(text, style)); } } Line::from(spans) }) .collect(); let paragraph = Paragraph::new(lines); frame.render_widget(paragraph, inner); } } }