//! Top-level render dispatch — composes header, page views, modals, and effects each frame. use std::collections::HashSet; use std::time::Duration; use ratatui::layout::{Alignment, Constraint, Layout, Rect}; use ratatui::style::{Modifier, Style}; use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, Borders, Cell, Clear, Padding, Paragraph, Row, Table, Wrap}; use ratatui::Frame; use crate::app::App; use crate::engine::{LinkState, SequencerSnapshot}; use crate::model::{ExecutionTrace, SourceSpan}; use crate::page::Page; use crate::state::{ EditorTarget, EuclideanField, FlashKind, Modal, PanelFocus, PatternField, RenameTarget, SidePanel, }; use crate::theme; use crate::views::highlight::{self, highlight_line_with_runtime}; use crate::widgets::{ hint_line, render_props_form, render_search_bar, ConfirmModal, ModalFrame, NavMinimap, NavTile, SampleBrowser, TextInputModal, }; use super::{ dict_view, engine_view, help_view, main_view, options_view, patterns_view, script_view, title_view, }; fn clip_span(span: SourceSpan, line_start: usize, line_len: usize) -> Option { let ls = line_start as u32; let ll = line_len as u32; if span.end <= ls || span.start >= ls + ll { return None; } Some(SourceSpan { start: span.start.max(ls) - ls, end: span.end.min(ls + ll) - ls, }) } pub fn adjust_spans_for_line( spans: &[SourceSpan], line_start: usize, line_len: usize, ) -> Vec { spans .iter() .filter_map(|s| clip_span(*s, line_start, line_len)) .collect() } pub fn adjust_resolved_for_line( resolved: &[(SourceSpan, String)], line_start: usize, line_len: usize, ) -> Vec<(SourceSpan, String)> { resolved .iter() .filter_map(|(s, display)| { clip_span(*s, line_start, line_len).map(|cs| (cs, display.clone())) }) .collect() } pub fn highlight_script_lines( script: &str, trace: Option<&ExecutionTrace>, user_words: &HashSet, max_lines: usize, ) -> Vec> { let resolved_display: Vec<(SourceSpan, String)> = trace .map(|t| { t.resolved .iter() .map(|(s, v)| (*s, v.display())) .collect() }) .unwrap_or_default(); let mut line_start = 0usize; script .lines() .take(max_lines) .map(|line_str| { let tokens = if let Some(t) = trace { let exec = adjust_spans_for_line(&t.executed_spans, line_start, line_str.len()); let sel = adjust_spans_for_line(&t.selected_spans, line_start, line_str.len()); let res = adjust_resolved_for_line(&resolved_display, line_start, line_str.len()); highlight_line_with_runtime(line_str, &exec, &sel, &res, user_words) } else { highlight_line_with_runtime(line_str, &[], &[], &[], user_words) }; line_start += line_str.len() + 1; let spans: Vec = tokens .into_iter() .map(|(style, text, _)| Span::styled(text, style)) .collect(); Line::from(spans) }) .collect() } pub fn horizontal_padding(width: u16) -> u16 { if width >= 120 { 4 } else if width >= 80 { 2 } else { 1 } } pub fn render( frame: &mut Frame, app: &App, link: &LinkState, snapshot: &SequencerSnapshot, elapsed: Duration, ) { let term = frame.area(); let theme = theme::get(); let bg_color = theme.ui.bg; frame.render_widget(Clear, term); frame.render_widget(Block::new().style(Style::default().bg(bg_color)), term); if app.ui.show_title { title_view::render(frame, term, &app.ui, app.plugin_mode); let mut fx = app.ui.title_fx.borrow_mut(); if let Some(effect) = fx.as_mut() { effect.process(elapsed, frame.buffer_mut(), term); if !effect.running() { *fx = None; } } return; } let h_pad = horizontal_padding(term.width); let padded = Rect { x: term.x + h_pad, y: term.y + 1, width: term.width.saturating_sub(h_pad * 2), height: term.height.saturating_sub(2), }; let perf = app.ui.performance_mode; let [header_area, _padding, body_area, _bottom_padding, footer_area] = Layout::vertical([ Constraint::Length(if perf { 0 } else { header_height(padded.width) }), Constraint::Length(if perf { 0 } else { 1 }), Constraint::Fill(1), Constraint::Length(if perf { 0 } else { 1 }), Constraint::Length(if perf { 0 } else { 3 }), ]) .areas(padded); if !perf { render_header(frame, app, link, snapshot, header_area); } let (page_area, panel_area) = if app.panel.visible && app.panel.side.is_some() { let panel_width = body_area.width * 35 / 100; let [main, side] = Layout::horizontal([Constraint::Fill(1), Constraint::Length(panel_width)]) .areas(body_area); (main, Some(side)) } else { (body_area, None) }; match app.page { Page::Main => main_view::render(frame, app, snapshot, page_area), Page::Patterns => patterns_view::render(frame, app, snapshot, page_area), Page::Engine => engine_view::render(frame, app, link, page_area), Page::Options => options_view::render(frame, app, page_area), Page::Help => help_view::render(frame, app, page_area), Page::Dict => dict_view::render(frame, app, page_area), Page::Script => script_view::render(frame, app, snapshot, page_area), } if let Some(side_area) = panel_area { render_side_panel(frame, app, side_area); } if !perf { render_footer(frame, app, snapshot, footer_area); } let modal_area = render_modal(frame, app, snapshot, term); if app.ui.nav_indicator_visible() { let nav_area = render_nav_indicator(frame, app, term); let mut fx = app.ui.nav_fx.borrow_mut(); if let Some(effect) = fx.as_mut() { effect.process(elapsed, frame.buffer_mut(), nav_area); if !effect.running() { *fx = None; } } } if app.ui.show_minimap() { let tiles: Vec = Page::ALL .iter() .filter(|p| p.visible_in_minimap()) .map(|p| { let (col, row) = p.grid_pos(); NavTile { col, row, name: p.name(), } }) .collect(); let selected = app.page.grid_pos(); NavMinimap::new(&tiles, selected).render_centered(frame, term); } app.ui .effects .borrow_mut() .process_effects(elapsed, frame.buffer_mut(), term); if let Some(area) = modal_area { let mut fx = app.ui.modal_fx.borrow_mut(); if let Some(effect) = fx.as_mut() { effect.process(elapsed, frame.buffer_mut(), area); if !effect.running() { *fx = None; } } } } fn render_nav_indicator(frame: &mut Frame, app: &App, term: Rect) -> Rect { let theme = theme::get(); let bank = &app.project_state.project.banks[app.editor_ctx.bank]; let pattern = &bank.patterns[app.editor_ctx.pattern]; let bank_num = format!("{:02}", app.editor_ctx.bank + 1); let pattern_num = format!("{:02}", app.editor_ctx.pattern + 1); let bank_name = bank.name.as_deref().unwrap_or(""); let pattern_name = pattern.name.as_deref().unwrap_or(""); let inner = ModalFrame::new("") .width(34) .height(5) .border_color(theme.modal.border_accent) .render_centered(frame, term); let bank_style = Style::new().fg(theme.header.bank_fg).bold(); let pattern_style = Style::new().fg(theme.header.pattern_fg).bold(); let dim = Style::new().fg(theme.ui.text_dim); let divider = Style::new().fg(theme.ui.border); let line1 = Line::from(vec![ Span::styled(" BANK ", bank_style), Span::styled("│", divider), Span::styled(" PATTERN ", pattern_style), ]); let line2 = Line::from(vec![ Span::styled(format!(" {bank_num} "), bank_style), Span::styled(format!("{bank_name:<10}"), dim), Span::styled("│", divider), Span::styled(format!(" {pattern_num} "), pattern_style), Span::styled(format!("{pattern_name:<11}"), dim), ]); frame.render_widget( Paragraph::new(line1), Rect::new(inner.x, inner.y, inner.width, 1), ); frame.render_widget( Paragraph::new(line2), Rect::new(inner.x, inner.y + 1, inner.width, 1), ); Rect::new( inner.x.saturating_sub(1), inner.y.saturating_sub(1), inner.width + 2, inner.height + 2, ) } fn header_height(_width: u16) -> u16 { 3 } fn render_side_panel(frame: &mut Frame, app: &App, area: Rect) { let focused = app.panel.focus == PanelFocus::Side; match &app.panel.side { Some(SidePanel::SampleBrowser(state)) => { let [tree_area, preview_area] = Layout::vertical([Constraint::Fill(1), Constraint::Length(6)]).areas(area); // Compute visible height: tree_area minus borders (2), minus search bar (1) if shown let mut vh = tree_area.height.saturating_sub(2) as usize; if state.search_active || !state.search_query.is_empty() { vh = vh.saturating_sub(1); } state.visible_height.set(vh); let entries = state.entries(); SampleBrowser::new(&entries, state.cursor) .scroll_offset(state.scroll_offset) .search(&state.search_query, state.search_active) .focused(focused) .render(frame, tree_area); if let Some(sample) = state .sample_key() .and_then(|key| app.audio.sample_registry.as_ref()?.get(&key)) .filter(|s| s.frame_count >= s.total_frames) { use crate::widgets::Waveform; use std::cell::RefCell; thread_local! { static MONO_BUF: RefCell> = const { RefCell::new(Vec::new()) }; } let [wave_area, info_area] = Layout::vertical([Constraint::Fill(1), Constraint::Length(1)]) .areas(preview_area); MONO_BUF.with(|buf| { let mut buf = buf.borrow_mut(); let channels = sample.channels as usize; let frame_count = sample.frame_count as usize; buf.clear(); buf.reserve(frame_count); for i in 0..frame_count { buf.push(sample.frames[i * channels]); } frame.render_widget(Waveform::new(&buf), wave_area); }); let duration = sample.total_frames as f32 / app.audio.config.sample_rate; let ch_label = if sample.channels == 1 { "mono" } else { "stereo" }; let info = Paragraph::new(format!(" {duration:.1}s · {ch_label}")) .style(Style::new().fg(theme::get().ui.text_dim)); frame.render_widget(info, info_area); } } None => {} } } fn render_header( frame: &mut Frame, app: &App, link: &LinkState, snapshot: &SequencerSnapshot, area: Rect, ) { use crate::model::PatternSpeed; let theme = theme::get(); let bank = &app.project_state.project.banks[app.editor_ctx.bank]; let pattern = &bank.patterns[app.editor_ctx.pattern]; let pad = Padding::vertical(1); let [logo_area, transport_area, live_area, tempo_area, bank_area, pattern_area, stats_area] = Layout::horizontal([ Constraint::Length(5), Constraint::Min(12), Constraint::Length(9), Constraint::Min(14), Constraint::Fill(1), Constraint::Fill(2), Constraint::Min(20), ]) .areas(area); // Logo let logo_style = Style::new() .bg(theme.header.bank_bg) .fg(theme.ui.accent) .add_modifier(Modifier::BOLD); frame.render_widget( Paragraph::new("\u{28ff}") .block(Block::default().padding(pad).style(logo_style)) .alignment(Alignment::Center), logo_area, ); // Transport block let (transport_bg, transport_text) = if app.playback.playing { (theme.status.playing_bg, " ▶ PLAYING ") } else { (theme.status.stopped_bg, " ■ STOPPED ") }; let transport_style = Style::new().bg(transport_bg).fg(theme.ui.text_primary); frame.render_widget( Paragraph::new(transport_text) .block(Block::default().padding(pad).style(transport_style)) .alignment(Alignment::Center), transport_area, ); // Fill indicator let fill = app.live_keys.fill(); let fill_fg = if fill { theme.status.fill_on } else { theme.status.fill_off }; let fill_style = Style::new().bg(theme.status.fill_bg).fg(fill_fg); frame.render_widget( Paragraph::new(if fill { "F" } else { "·" }) .block(Block::default().padding(pad).style(fill_style)) .alignment(Alignment::Center), live_area, ); // Tempo block let tempo_style = Style::new() .bg(theme.header.tempo_bg) .fg(theme.ui.text_primary) .add_modifier(Modifier::BOLD); frame.render_widget( Paragraph::new(format!(" {:.1} BPM ", link.tempo())) .block(Block::default().padding(pad).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(theme.header.bank_bg) .fg(theme.ui.text_primary); frame.render_widget( Paragraph::new(bank_name) .block(Block::default().padding(pad).style(bank_style)) .alignment(Alignment::Center), bank_area, ); // Pattern block (name + length + speed + page + 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 total_pages = pattern.length.div_ceil(32); let page_info = if total_pages > 1 { let current_page = app.editor_ctx.step / 32 + 1; format!(" · {current_page}/{total_pages}") } 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, page_info, iter_info ); let pattern_style = Style::new() .bg(theme.header.pattern_bg) .fg(theme.ui.text_primary); frame.render_widget( Paragraph::new(pattern_text) .block(Block::default().padding(pad).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 cpu_color = if cpu_pct >= 80.0 { theme.flash.error_fg } else if cpu_pct >= 50.0 { theme.ui.accent } else { theme.header.stats_fg }; let dim = Style::new() .bg(theme.header.stats_bg) .fg(theme.header.stats_fg); let stats_line = Line::from(vec![ Span::styled(format!(" CPU {cpu_pct:.0}%"), dim.fg(cpu_color)), Span::styled(format!(" V:{voices} L:{peers} "), dim), ]); let block_style = Style::new().bg(theme.header.stats_bg); frame.render_widget( Paragraph::new(stats_line) .block(Block::default().padding(pad).style(block_style)) .alignment(Alignment::Center), stats_area, ); } fn render_footer(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, area: Rect) { let theme = theme::get(); let block = Block::default() .borders(Borders::ALL) .border_style(Style::new().fg(theme.ui.border)); 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::Engine => " ENGINE ", Page::Options => " OPTIONS ", Page::Help => " HELP ", Page::Dict => " DICT ", Page::Script => " SCRIPT ", }; let content = if let Some(ref msg) = app.ui.status_message { Line::from(vec![ Span::styled( page_indicator.to_string(), Style::new().bg(theme.view_badge.bg).fg(theme.view_badge.fg), ), Span::raw(" "), Span::styled(msg.clone(), Style::new().fg(theme.modal.confirm)), ]) } else if let Some(ref text) = snapshot.print_output { Line::from(vec![ Span::styled( page_indicator.to_string(), Style::new().bg(theme.view_badge.bg).fg(theme.view_badge.fg), ), Span::raw(" "), Span::styled(text.clone(), Style::new().fg(theme.hint.text)), ]) } else { let bindings: Vec<(&str, &str)> = match app.page { Page::Main if app.panel.visible && app.panel.focus == PanelFocus::Side => vec![ ("↑↓", "Navigate"), ("→", "Expand/Play"), ("←", "Collapse"), ("/", "Search"), ("Tab", "Close"), ], Page::Main => vec![ ("Space", "Play"), ("Enter", "Edit"), ("t", "On/Off"), ("Tab", "Samples"), ("?", "Keys"), ], Page::Patterns => vec![ ("Enter", "Select"), ("Space", "Play"), ("c", "Launch"), ("r", "Rename"), ("?", "Keys"), ], Page::Engine => vec![ ("Tab", "Section"), ("←→", "Adjust"), ("R", "Restart"), ("t", "Test"), ("h/p", "Hush/Panic"), ("?", "Keys"), ], Page::Options => vec![ ("Tab", "Next"), ("←→", "Toggle"), ("Space", "Play"), ("?", "Keys"), ], Page::Help => match app.ui.help_focus { crate::state::HelpFocus::Content => vec![ ("n", "Next Example"), ("p", "Previous Example"), ("Enter", "Evaluate"), ("Tab", "Topics"), ], crate::state::HelpFocus::Topics => vec![ ("↑↓", "Navigate"), ("Tab", "Content"), ("/", "Search"), ("?", "Keys"), ], }, Page::Dict => vec![ ("Tab", "Focus"), ("↑↓", "Navigate"), ("/", "Search"), ("?", "Keys"), ], Page::Script => vec![ ("Esc", "Save & Back"), ("C-e", "Eval"), ("[ ]", "Speed"), ("?", "Keys"), ], }; 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().bg(theme.view_badge.bg).fg(theme.view_badge.fg), ), 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(theme.hint.key), )); spans.push(Span::styled( format!(": {action}"), Style::new().fg(theme.hint.text), )); 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, ) -> Option { let theme = theme::get(); let inner = match &app.ui.modal { Modal::None => return None, Modal::Confirm { action, selected } => { ConfirmModal::new("Confirm", &action.message(), *selected).render_centered(frame, term) } Modal::FileBrowser(state) => { use crate::state::file_browser::FileBrowserMode; use crate::widgets::FileBrowserModal; let (title, border_color) = match state.mode { FileBrowserMode::Save => ("Save As", theme.flash.success_fg), FileBrowserMode::Load => ("Load From", theme.browser.directory), }; let entries: Vec<(String, bool, bool)> = state .entries .iter() .map(|e| (e.name.clone(), e.is_dir, e.is_cagire())) .collect(); FileBrowserModal::new(title, &state.input, &entries) .selected(state.selected) .scroll_offset(state.scroll_offset) .border_color(border_color) .width(60) .height(18) .render_centered(frame, term) } Modal::Rename { target, name } => { let border_color = match target { RenameTarget::Step { .. } => theme.modal.input, _ => theme.modal.rename, }; TextInputModal::new(&target.title(), name) .width(40) .border_color(border_color) .render_centered(frame, term) } Modal::SetPattern { field, input } => { let (title, hint) = match field { PatternField::Length => ("Set Length (1-1024)", "Enter number"), PatternField::Speed => ("Set Speed", "e.g. 1/3, 2/5, 1x, 2x"), }; TextInputModal::new(title, input) .hint(hint) .width(45) .border_color(theme.modal.confirm) .render_centered(frame, term) } Modal::SetScript { field, input } => { use crate::state::ScriptField; let (title, hint) = match field { ScriptField::Length => ("Set Script Length (1-256)", "Enter number"), ScriptField::Speed => ("Set Script Speed", "e.g. 1/3, 2/5, 1x, 2x"), }; TextInputModal::new(title, input) .hint(hint) .width(45) .border_color(theme.modal.confirm) .render_centered(frame, term) } Modal::SetTempo(input) => TextInputModal::new("Set Tempo (20-300 BPM)", input) .hint("Enter BPM") .width(30) .border_color(theme.modal.rename) .render_centered(frame, term), Modal::AddSamplePath(state) => { use crate::widgets::FileBrowserModal; let entries: Vec<(String, bool, bool)> = state .entries .iter() .map(|e| (e.name.clone(), e.is_dir, e.is_cagire())) .collect(); let hints = hint_line(&[ ("\u{2190}", "parent"), ("\u{2192}", "enter"), ("Enter", "add"), ("Esc", "cancel"), ]); FileBrowserModal::new("Browse Samples", &state.input, &entries) .selected(state.selected) .scroll_offset(state.scroll_offset) .border_color(theme.modal.rename) .audio_counts(&state.audio_counts) .hints(hints) .color_path() .width(70) .height(20) .render_centered(frame, term) } Modal::Editor => { let user_words: HashSet = app.dict.lock().keys().cloned().collect(); render_modal_editor(frame, app, snapshot, &user_words, term) } Modal::PatternProps { bank, pattern, field, name, description, length, speed, quantization, sync_mode, follow_up, } => { use crate::model::FollowUp; use crate::state::PatternPropsField; let is_chain = matches!(follow_up, FollowUp::Chain { .. }); let modal_height = if is_chain { 18 } else { 16 }; let inner = ModalFrame::new(&format!(" Pattern B{:02}:P{:02} ", bank + 1, pattern + 1)) .width(50) .height(modal_height) .border_color(theme.modal.input) .render_centered(frame, term); let speed_label = speed.label(); let follow_up_label = match follow_up { FollowUp::Loop => "Loop".to_string(), FollowUp::Stop => "Stop".to_string(), FollowUp::Chain { bank: b, pattern: p } => { format!("Chain B{:02}:P{:02}", b + 1, p + 1) } }; let mut fields: Vec<(&str, String, bool)> = vec![ ("Name", name.clone(), *field == PatternPropsField::Name), ("Desc", description.clone(), *field == PatternPropsField::Description), ("Length", length.clone(), *field == PatternPropsField::Length), ("Speed", speed_label, *field == PatternPropsField::Speed), ("Quantization", quantization.label().to_string(), *field == PatternPropsField::Quantization), ("Sync Mode", sync_mode.label().to_string(), *field == PatternPropsField::SyncMode), ("Follow Up", follow_up_label, *field == PatternPropsField::FollowUp), ]; if is_chain { if let FollowUp::Chain { bank: b, pattern: p } = follow_up { fields.push((" Bank", format!("{:02}", b + 1), *field == PatternPropsField::ChainBank)); fields.push((" Pattern", format!("{:02}", p + 1), *field == PatternPropsField::ChainPattern)); } } let fields_ref: Vec<(&str, &str, bool)> = fields.iter().map(|(l, v, s)| (*l, v.as_str(), *s)).collect(); render_props_form(frame, inner, &fields_ref); let hint_area = Rect::new(inner.x, inner.y + inner.height - 1, inner.width, 1); let hints = hint_line(&[ ("↑↓", "nav"), ("←→", "change"), ("Enter", "save"), ("Esc", "cancel"), ]); frame.render_widget(Paragraph::new(hints), hint_area); inner } Modal::Onboarding { page } => { let pages = crate::model::onboarding::for_page(app.page); let page_idx = (*page).min(pages.len().saturating_sub(1)); let (desc, keys) = pages[page_idx]; let page_count = pages.len(); let text_width = 51usize; let desc_lines = { let mut lines = 0u16; for line in desc.split('\n') { let mut col = 0usize; for word in line.split_whitespace() { let wlen = word.len(); if col > 0 && col + 1 + wlen > text_width { lines += 1; col = wlen; } else { col += if col > 0 { 1 + wlen } else { wlen }; } } lines += 1; } lines }; let key_lines = keys.len() as u16; let modal_height = (3 + desc_lines + 1 + key_lines + 2).min(term.height.saturating_sub(4)); let title = if page_count > 1 { format!(" {} ({}/{}) ", app.page.name(), page_idx + 1, page_count) } else { format!(" {} ", app.page.name()) }; let inner = ModalFrame::new(&title) .width(57) .height(modal_height) .border_color(theme.modal.confirm) .render_centered(frame, term); let content_width = inner.width.saturating_sub(4); let mut y = inner.y + 1; let desc_area = Rect::new(inner.x + 2, y, content_width, desc_lines); let body = Paragraph::new(desc) .style(Style::new().fg(theme.ui.text_primary)) .wrap(Wrap { trim: true }); frame.render_widget(body, desc_area); y += desc_lines + 1; for &(key, action) in keys { if y >= inner.y + inner.height - 1 { break; } let line = Line::from(vec![ Span::raw(" "), Span::styled(format!("{:>8}", key), Style::new().fg(theme.hint.key)), Span::styled(format!(" {action}"), Style::new().fg(theme.hint.text)), ]); frame.render_widget( Paragraph::new(line), Rect::new(inner.x + 1, y, inner.width.saturating_sub(2), 1), ); y += 1; } let hint_area = Rect::new(inner.x, inner.y + inner.height - 1, inner.width, 1); let mut hints_vec: Vec<(&str, &str)> = Vec::new(); if page_count > 1 { hints_vec.push(("\u{2190}\u{2192}", "page")); } if app.page.help_topic_index().is_some() { hints_vec.push(("?", "help")); } hints_vec.push(("Enter", "don't show again")); let hints = hint_line(&hints_vec); frame.render_widget( Paragraph::new(hints).alignment(Alignment::Center), hint_area, ); inner } Modal::CommandPalette { input, cursor, scroll } => { render_command_palette(frame, app, input, *cursor, *scroll, term) } Modal::KeybindingsHelp { scroll } => render_modal_keybindings(frame, app, *scroll, term), Modal::EuclideanDistribution { source_step, field, pulses, steps, rotation, .. } => { let inner = ModalFrame::new(&format!( " Euclidean Distribution (Step {:02}) ", source_step + 1 )) .width(50) .height(11) .border_color(theme.modal.input) .render_centered(frame, term); let fields: Vec<(&str, &str, bool)> = vec![ ("Pulses", pulses.as_str(), *field == EuclideanField::Pulses), ("Steps", steps.as_str(), *field == EuclideanField::Steps), ( "Rotation", rotation.as_str(), *field == EuclideanField::Rotation, ), ]; render_props_form(frame, inner, &fields); let preview_y = inner.y + 4; if preview_y < inner.y + inner.height { let pulses_val: usize = pulses.parse().unwrap_or(0); let steps_val: usize = steps.parse().unwrap_or(0); let rotation_val: usize = rotation.parse().unwrap_or(0); let preview = format_euclidean_preview(pulses_val, steps_val, rotation_val); let preview_line = Line::from(vec![ Span::styled("Preview: ", Style::default().fg(theme.ui.text_muted)), Span::styled(preview, Style::default().fg(theme.modal.input)), ]); let preview_area = Rect::new(inner.x + 1, preview_y, inner.width.saturating_sub(2), 1); frame.render_widget(Paragraph::new(preview_line), preview_area); } let hint_area = Rect::new(inner.x, inner.y + inner.height - 1, inner.width, 1); let hints = hint_line(&[ ("↑↓", "nav"), ("←→", "adjust"), ("Enter", "apply"), ("Esc", "cancel"), ]); frame.render_widget(Paragraph::new(hints), hint_area); inner } }; // Expand inner rect to include the border Some(Rect::new( inner.x.saturating_sub(1), inner.y.saturating_sub(1), inner.width + 2, inner.height + 2, )) } fn render_modal_editor( frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, user_words: &HashSet, term: Rect, ) -> Rect { let theme = theme::get(); let width = (term.width * 80 / 100).max(40); let height = (term.height * 60 / 100).max(10); let flash_kind = app.ui.flash_kind(); let border_color = match flash_kind { Some(FlashKind::Error) => theme.flash.error_fg, Some(FlashKind::Info) => theme.ui.text_primary, Some(FlashKind::Success) => theme.flash.success_fg, None => theme.modal.editor, }; let title = match app.editor_ctx.target { EditorTarget::Prelude => "Prelude".to_string(), EditorTarget::Step => { let step_num = app.editor_ctx.step + 1; let step = app.current_edit_pattern().step(app.editor_ctx.step); if let Some(ref name) = step.and_then(|s| s.name.as_ref()) { format!("Step {step_num:02}: {name}") } else { format!("Step {step_num:02} Script") } } }; let inner = ModalFrame::new(&title) .width(width) .height(height) .border_color(border_color) .render_centered(frame, term); let trace = if app.ui.runtime_highlight && app.playback.playing && app.editor_ctx.target == EditorTarget::Step { let source = app .current_edit_pattern() .resolve_source(app.editor_ctx.step); snapshot.get_trace(app.editor_ctx.bank, app.editor_ctx.pattern, source) } else { None }; let text_lines = app.editor_ctx.editor.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 resolved_display: Vec<(SourceSpan, String)> = trace .map(|t| t.resolved.iter().map(|(s, v)| (*s, v.display())).collect()) .unwrap_or_default(); let highlighter = |row: usize, line: &str| -> Vec<(Style, String, bool)> { let line_start = line_offsets[row]; let (exec, sel, res) = match trace { Some(t) => ( adjust_spans_for_line(&t.executed_spans, line_start, line.len()), adjust_spans_for_line(&t.selected_spans, line_start, line.len()), adjust_resolved_for_line(&resolved_display, line_start, line.len()), ), None => (Vec::new(), Vec::new(), Vec::new()), }; highlight::highlight_line_with_runtime(line, &exec, &sel, &res, user_words) }; let show_search = app.editor_ctx.editor.search_active() || !app.editor_ctx.editor.search_query().is_empty(); let reserved_lines = 1 + if show_search { 1 } else { 0 }; let editor_height = inner.height.saturating_sub(reserved_lines); let mut y = inner.y; let search_area = if show_search { let area = Rect::new(inner.x, y, inner.width, 1); y += 1; Some(area) } else { None }; let editor_area = Rect::new(inner.x, y, inner.width, editor_height); y += editor_height; let hint_area = Rect::new(inner.x, y, inner.width, 1); if let Some(sa) = search_area { render_search_bar( frame, sa, app.editor_ctx.editor.search_query(), app.editor_ctx.editor.search_active(), ); } if let Some(kind) = flash_kind { let bg = match kind { FlashKind::Error => theme.flash.error_bg, FlashKind::Info => theme.flash.info_bg, FlashKind::Success => theme.flash.success_bg, }; let flash_block = Block::default().style(Style::default().bg(bg)); frame.render_widget(flash_block, editor_area); } app.editor_ctx .editor .render(frame, editor_area, &highlighter); if app.editor_ctx.editor.search_active() { let hints = hint_line(&[("Enter", "confirm"), ("Esc", "cancel")]); frame.render_widget(Paragraph::new(hints).alignment(Alignment::Right), hint_area); } else { let hints = hint_line(&[ ("Esc", "save"), ("C-e", "eval"), ("C-f", "find"), ("C-b", "samples"), ("C-u", "/"), ("C-r", "undo/redo"), ]); frame.render_widget(Paragraph::new(hints).alignment(Alignment::Right), hint_area); } inner } fn render_command_palette( frame: &mut Frame, app: &App, query: &str, cursor: usize, scroll: usize, term: Rect, ) -> Rect { use crate::model::palette::{palette_entries, CommandEntry}; let theme = theme::get(); let entries = palette_entries(query, app.plugin_mode, app); // On Main page, numeric input prepends a synthetic "Jump to Step" entry let jump_step: Option = if app.page == Page::Main && !query.is_empty() && query.chars().all(|c| c.is_ascii_digit()) { query.parse().ok() } else { None }; // Build display rows: each is either a separator header or a command entry struct DisplayRow<'a> { entry: Option<&'a CommandEntry>, separator: Option<&'static str>, is_jump: bool, jump_label: String, } let mut rows: Vec = Vec::new(); if let Some(n) = jump_step { rows.push(DisplayRow { entry: None, separator: None, is_jump: true, jump_label: format!("Jump to Step {n}"), }); } if query.is_empty() { // Grouped by category with separators let mut last_category = ""; for e in &entries { if e.category != last_category { rows.push(DisplayRow { entry: None, separator: Some(e.category), is_jump: false, jump_label: String::new(), }); last_category = e.category; } rows.push(DisplayRow { entry: Some(e), separator: None, is_jump: false, jump_label: String::new(), }); } } else { for e in &entries { rows.push(DisplayRow { entry: Some(e), separator: None, is_jump: false, jump_label: String::new(), }); } } // Count selectable items (non-separator) let selectable_count = rows.iter().filter(|r| r.separator.is_none()).count(); let cursor = cursor.min(selectable_count.saturating_sub(1)); let width: u16 = 55; let max_height = (term.height as usize * 60 / 100).max(8); let content_height = rows.len() + 4; // input + gap + hint + border padding let height = content_height.min(max_height) as u16; let inner = ModalFrame::new(": Command Palette") .width(width) .height(height) .border_color(theme.modal.confirm) .render_centered(frame, term); let mut y = inner.y; let content_width = inner.width; // Input line let input_line = Line::from(vec![ Span::styled("> ", Style::default().fg(theme.modal.confirm)), Span::styled(query, Style::default().fg(theme.ui.text_primary)), Span::styled("\u{2588}", Style::default().fg(theme.modal.confirm)), ]); frame.render_widget( Paragraph::new(input_line), Rect::new(inner.x, y, content_width, 1), ); y += 1; // Visible area for entries let visible_height = inner.height.saturating_sub(2) as usize; // minus input line and hint line // Auto-scroll let scroll = { let mut s = scroll; // Map cursor (selectable index) to row index for scrolling let mut selectable_idx = 0; let mut cursor_row = 0; for (i, row) in rows.iter().enumerate() { if row.separator.is_some() { continue; } if selectable_idx == cursor { cursor_row = i; break; } selectable_idx += 1; } if cursor_row >= s + visible_height { s = cursor_row + 1 - visible_height; } if cursor_row < s { s = cursor_row; } s }; // Render visible rows let mut selectable_idx = rows.iter().take(scroll).filter(|r| r.separator.is_none()).count(); for row in rows.iter().skip(scroll).take(visible_height) { if y >= inner.y + inner.height - 1 { break; } if let Some(cat) = row.separator { // Category header let pad = content_width.saturating_sub(cat.len() as u16 + 4) / 2; let sep_left = "\u{2500}".repeat(pad as usize); let sep_right = "\u{2500}".repeat(content_width.saturating_sub(pad + cat.len() as u16 + 4) as usize); let line = Line::from(vec![ Span::styled( format!("{sep_left} "), Style::default().fg(theme.ui.text_muted), ), Span::styled(cat, Style::default().fg(theme.ui.text_dim)), Span::styled( format!(" {sep_right}"), Style::default().fg(theme.ui.text_muted), ), ]); frame.render_widget(Paragraph::new(line), Rect::new(inner.x, y, content_width, 1)); y += 1; continue; } let is_selected = selectable_idx == cursor; let (bg, fg) = if is_selected { (theme.selection.cursor_bg, theme.selection.cursor_fg) } else if selectable_idx.is_multiple_of(2) { (theme.table.row_even, theme.ui.text_primary) } else { (theme.table.row_odd, theme.ui.text_primary) }; let (name, keybinding) = if row.is_jump { (row.jump_label.as_str(), "") } else if let Some(e) = row.entry { (e.name, e.keybinding) } else { selectable_idx += 1; y += 1; continue; }; let key_len = keybinding.len() as u16; let name_width = content_width.saturating_sub(key_len + 2); let truncated_name: String = name.chars().take(name_width as usize).collect(); let padding = name_width.saturating_sub(truncated_name.len() as u16); let key_fg = if is_selected { theme.selection.cursor_fg } else { theme.ui.text_dim }; let line = Line::from(vec![ Span::styled(format!(" {truncated_name}"), Style::default().bg(bg).fg(fg)), Span::styled( " ".repeat(padding as usize), Style::default().bg(bg), ), Span::styled( format!("{keybinding} "), Style::default().bg(bg).fg(key_fg), ), ]); frame.render_widget(Paragraph::new(line), Rect::new(inner.x, y, content_width, 1)); selectable_idx += 1; y += 1; } // Empty state if selectable_count == 0 { let msg = "No matching commands"; let empty_y = inner.y + inner.height / 2; if empty_y < inner.y + inner.height - 1 { frame.render_widget( Paragraph::new(msg) .style(Style::default().fg(theme.ui.text_muted)) .alignment(Alignment::Center), Rect::new(inner.x, empty_y, content_width, 1), ); } } // Hint bar let hint_area = Rect::new(inner.x, inner.y + inner.height - 1, inner.width, 1); let hints = if jump_step.is_some() && cursor == 0 { hint_line(&[ ("\u{2191}\u{2193}", "navigate"), ("Enter", "jump to step"), ("Esc", "close"), ]) } else { hint_line(&[ ("\u{2191}\u{2193}", "navigate"), ("Enter", "run"), ("Esc", "close"), ]) }; frame.render_widget(Paragraph::new(hints), hint_area); inner } fn render_modal_keybindings(frame: &mut Frame, app: &App, scroll: usize, term: Rect) -> Rect { let theme = theme::get(); let width = (term.width * 80 / 100).clamp(60, 100); let height = (term.height * 80 / 100).max(15); let title = format!("Keybindings — {}", app.page.name()); let inner = ModalFrame::new(&title) .width(width) .height(height) .border_color(theme.modal.editor) .render_centered(frame, term); let bindings = crate::model::palette::bindings_for(app.page, app.plugin_mode); let visible_rows = inner.height.saturating_sub(2) as usize; let rows: Vec = bindings .iter() .enumerate() .skip(scroll) .take(visible_rows) .map(|(i, (key, name, desc))| { let bg = if i % 2 == 0 { theme.table.row_even } else { theme.table.row_odd }; Row::new(vec![ Cell::from(*key).style(Style::default().fg(theme.modal.confirm)), Cell::from(*name).style(Style::default().fg(theme.modal.input)), Cell::from(*desc).style(Style::default().fg(theme.ui.text_primary)), ]) .style(Style::default().bg(bg)) }) .collect(); let table = Table::new( rows, [ Constraint::Length(14), Constraint::Length(12), Constraint::Fill(1), ], ) .column_spacing(2); let table_area = Rect { x: inner.x, y: inner.y, width: inner.width, height: inner.height.saturating_sub(1), }; frame.render_widget(table, table_area); let hint_area = Rect { x: inner.x, y: inner.y + inner.height.saturating_sub(1), width: inner.width, height: 1, }; let hints = hint_line(&[("↑↓", "scroll"), ("PgUp/Dn", "page"), ("Esc/?", "close")]); frame.render_widget(Paragraph::new(hints).alignment(Alignment::Right), hint_area); inner } fn format_euclidean_preview(pulses: usize, steps: usize, rotation: usize) -> String { if pulses == 0 || steps == 0 || pulses > steps { return "[invalid]".to_string(); } let mut pattern = vec![false; steps]; for i in 0..pulses { let pos = (i * steps) / pulses; pattern[pos] = true; } if rotation > 0 { pattern.rotate_left(rotation % steps); } let chars: Vec<&str> = pattern.iter().map(|&h| if h { "x" } else { "." }).collect(); format!("[{}]", chars.join(" ")) }