Files
Cagire/src/views/render.rs
Raphaël Forment 4743c33916
All checks were successful
Deploy Website / deploy (push) Has been skipped
Feat: begin sample explorer overhaul
2026-03-05 00:42:39 +01:00

1430 lines
48 KiB
Rust

//! 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<SourceSpan> {
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<SourceSpan> {
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<String>,
max_lines: usize,
) -> Vec<Line<'static>> {
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<Span> = 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<NavTile> = 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<Vec<f32>> = 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<Rect> {
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<String> = 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<String>,
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<usize> = 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<usize> = 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<DisplayRow> = 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<Row> = 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(" "))
}