449 lines
16 KiB
Rust
449 lines
16 KiB
Rust
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<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 lines: Vec<Line> = text_lines
|
|
.iter()
|
|
.enumerate()
|
|
.map(|(row, line)| {
|
|
let mut spans: Vec<Span> = Vec::new();
|
|
|
|
let line_start = line_offsets[row];
|
|
let line_end = line_start + line.len();
|
|
let adjusted_spans: Vec<crate::model::SourceSpan> = 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::<String>();
|
|
let cursor_char = text.chars().nth(cursor_col - col).unwrap_or(' ');
|
|
let after =
|
|
text.chars().skip(cursor_col - col + 1).collect::<String>();
|
|
|
|
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);
|
|
}
|
|
}
|
|
}
|