flesh out sequencer

This commit is contained in:
2026-01-20 14:37:03 +01:00
parent 276107433a
commit ebb82b6f7d
25 changed files with 2069 additions and 771 deletions

View File

@@ -1,37 +1,36 @@
use ratatui::layout::{Alignment, Constraint, Layout, Rect};
use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Paragraph};
use ratatui::Frame;
use crate::app::App;
use crate::engine::SequencerSnapshot;
use crate::state::PatternsViewLevel;
use crate::state::PatternsColumn;
pub fn render(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, area: Rect) {
match app.patterns_view_level {
PatternsViewLevel::Banks => render_banks(frame, app, snapshot, area),
PatternsViewLevel::Patterns { bank } => render_patterns(frame, app, snapshot, area, bank),
}
let [banks_area, patterns_area] =
Layout::horizontal([Constraint::Fill(1), Constraint::Fill(1)]).areas(area);
render_banks(frame, app, snapshot, banks_area);
render_patterns(frame, app, snapshot, patterns_area);
}
fn render_banks(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, area: Rect) {
let is_focused = matches!(app.patterns_nav.column, PatternsColumn::Banks);
let border_color = if is_focused {
Color::Rgb(100, 160, 180)
} else {
Color::Rgb(70, 75, 85)
};
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::new().fg(Color::Rgb(100, 160, 180)))
.border_style(Style::new().fg(border_color))
.title("Banks");
let inner = block.inner(area);
frame.render_widget(block, area);
if inner.width < 50 {
let msg = Paragraph::new("Terminal too narrow")
.alignment(Alignment::Center)
.style(Style::new().fg(Color::Rgb(120, 125, 135)));
frame.render_widget(msg, inner);
return;
}
let banks_with_playback: Vec<usize> = snapshot
.slot_data
.iter()
@@ -39,57 +38,85 @@ fn render_banks(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, area
.map(|s| s.bank)
.collect();
let bank_names: Vec<Option<&str>> = app
.project_state
.project
.banks
let banks_with_queued: Vec<usize> = app
.playback
.queued_changes
.iter()
.map(|b| b.name.as_deref())
.filter_map(|c| match c {
crate::engine::SlotChange::Add { bank, .. } => Some(*bank),
_ => None,
})
.collect();
render_grid(
frame,
inner,
app.patterns_cursor,
app.editor_ctx.bank,
&banks_with_playback,
&bank_names,
);
let rows: Vec<Constraint> = (0..16).map(|_| Constraint::Length(1)).collect();
let row_areas = Layout::vertical(rows).split(inner);
for idx in 0..16 {
if idx >= row_areas.len() {
break;
}
let row_area = row_areas[idx];
let is_cursor = is_focused && idx == app.patterns_nav.bank_cursor;
let is_selected = idx == app.patterns_nav.bank_cursor;
let is_edit = idx == app.editor_ctx.bank;
let is_playing = banks_with_playback.contains(&idx);
let is_queued = banks_with_queued.contains(&idx);
let (bg, fg, prefix) = match (is_cursor, is_playing, is_queued) {
(true, _, _) => (Color::Cyan, Color::Black, ""),
(false, true, _) => (Color::Rgb(45, 80, 45), Color::Green, "> "),
(false, false, true) => (Color::Rgb(80, 80, 45), Color::Yellow, "? "),
(false, false, false) if is_selected => (Color::Rgb(60, 65, 75), Color::White, ""),
(false, false, false) if is_edit => (Color::Rgb(45, 106, 95), Color::White, ""),
(false, false, false) => (Color::Reset, Color::Rgb(120, 125, 135), ""),
};
let name = app.project_state.project.banks[idx]
.name
.as_deref()
.unwrap_or("");
let label = if name.is_empty() {
format!("{}{:02}", prefix, idx + 1)
} else {
format!("{}{:02} {}", prefix, idx + 1, name)
};
let style = Style::new().bg(bg).fg(fg);
let style = if is_playing || is_queued {
style.add_modifier(Modifier::BOLD)
} else {
style
};
let para = Paragraph::new(label).style(style);
frame.render_widget(para, row_area);
}
}
fn render_patterns(
frame: &mut Frame,
app: &App,
snapshot: &SequencerSnapshot,
area: Rect,
bank: usize,
) {
let bank_name = app.project_state.project.banks[bank].name.as_deref();
let title_text = match bank_name {
Some(name) => format!("{name} Patterns"),
None => format!("Bank {:02} Patterns", bank + 1),
fn render_patterns(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, area: Rect) {
let is_focused = matches!(app.patterns_nav.column, PatternsColumn::Patterns);
let border_color = if is_focused {
Color::Rgb(100, 160, 180)
} else {
Color::Rgb(70, 75, 85)
};
let bank = app.patterns_nav.bank_cursor;
let bank_name = app.project_state.project.banks[bank].name.as_deref();
let title = match bank_name {
Some(name) => format!("Patterns ({name})"),
None => format!("Patterns (Bank {:02})", bank + 1),
};
let title = Line::from(vec![
Span::raw(title_text),
Span::styled(" [Esc]←", Style::new().fg(Color::Rgb(120, 125, 135))),
]);
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::new().fg(Color::Rgb(100, 160, 180)))
.border_style(Style::new().fg(border_color))
.title(title);
let inner = block.inner(area);
frame.render_widget(block, area);
if inner.width < 50 {
let msg = Paragraph::new("Terminal too narrow")
.alignment(Alignment::Center)
.style(Style::new().fg(Color::Rgb(120, 125, 135)));
frame.render_widget(msg, inner);
return;
}
let playing_patterns: Vec<usize> = snapshot
.slot_data
.iter()
@@ -97,173 +124,85 @@ fn render_patterns(
.map(|s| s.pattern)
.collect();
let edit_pattern = if app.editor_ctx.bank == bank {
app.editor_ctx.pattern
} else {
usize::MAX
};
let pattern_names: Vec<Option<&str>> = app.project_state.project.banks[bank]
.patterns
let queued_to_play: Vec<usize> = app
.playback
.queued_changes
.iter()
.map(|p| p.name.as_deref())
.filter_map(|c| match c {
crate::engine::SlotChange::Add {
bank: b, pattern, ..
} if *b == bank => Some(*pattern),
_ => None,
})
.collect();
render_pattern_grid(
frame,
app,
snapshot,
inner,
bank,
app.patterns_cursor,
edit_pattern,
&playing_patterns,
&pattern_names,
);
}
fn render_grid(
frame: &mut Frame,
area: Rect,
cursor: usize,
edit_pos: usize,
playing_positions: &[usize],
names: &[Option<&str>],
) {
let rows = Layout::vertical([
Constraint::Fill(1),
Constraint::Fill(1),
Constraint::Fill(1),
Constraint::Fill(1),
])
.split(area);
for row in 0..4 {
let cols = Layout::horizontal(vec![Constraint::Fill(1); 4]).split(rows[row]);
for col in 0..4 {
let idx = row * 4 + col;
let is_cursor = idx == cursor;
let is_edit = idx == edit_pos;
let is_playing = playing_positions.contains(&idx);
let (bg, fg) = match (is_cursor, is_edit, is_playing) {
(true, _, _) => (Color::Cyan, Color::Black),
(false, true, _) => (Color::Rgb(45, 106, 95), Color::White),
(false, false, true) => (Color::Rgb(45, 80, 45), Color::Green),
(false, false, false) => (Color::Rgb(45, 48, 55), Color::Rgb(120, 125, 135)),
};
let name = names.get(idx).and_then(|n| *n).unwrap_or("");
let number = format!("{:02}", idx + 1);
let cell = cols[col];
// Fill background
frame.render_widget(Block::default().style(Style::new().bg(bg)), cell);
let top_area = Rect::new(cell.x, cell.y, cell.width, 1);
let center_y = cell.y + cell.height / 2;
let center_area = Rect::new(cell.x, center_y, cell.width, 1);
if name.is_empty() {
// Number centered
frame.render_widget(
Paragraph::new(number)
.alignment(Alignment::Center)
.style(Style::new().fg(fg).add_modifier(Modifier::BOLD)),
center_area,
);
} else {
// Number centered at top
frame.render_widget(
Paragraph::new(number)
.alignment(Alignment::Center)
.style(Style::new().fg(fg).add_modifier(Modifier::DIM)),
top_area,
);
// Name centered in middle
frame.render_widget(
Paragraph::new(name)
.alignment(Alignment::Center)
.style(Style::new().fg(fg).add_modifier(Modifier::BOLD)),
center_area,
);
}
}
}
}
fn render_pattern_grid(
frame: &mut Frame,
app: &App,
snapshot: &SequencerSnapshot,
area: Rect,
bank: usize,
cursor: usize,
edit_pos: usize,
playing_positions: &[usize],
names: &[Option<&str>],
) {
let rows = Layout::vertical([
Constraint::Fill(1),
Constraint::Fill(1),
Constraint::Fill(1),
Constraint::Fill(1),
])
.split(area);
for row in 0..4 {
let cols = Layout::horizontal(vec![Constraint::Fill(1); 4]).split(rows[row]);
for col in 0..4 {
let idx = row * 4 + col;
let is_cursor = idx == cursor;
let is_edit = idx == edit_pos;
let is_playing = playing_positions.contains(&idx);
let queued = app.is_pattern_queued(bank, idx, snapshot);
let (bg, fg, prefix) = match (is_cursor, is_playing, queued) {
(true, _, _) => (Color::Cyan, Color::Black, ""),
(false, true, Some(false)) => (Color::Rgb(120, 90, 30), Color::Yellow, "×"),
(false, true, _) => (Color::Rgb(45, 80, 45), Color::Green, ""),
(false, false, Some(true)) => (Color::Rgb(80, 80, 45), Color::Yellow, "?"),
(false, false, _) if is_edit => (Color::Rgb(45, 106, 95), Color::White, ""),
(false, false, _) => (Color::Rgb(45, 48, 55), Color::Rgb(120, 125, 135), ""),
};
let name = names.get(idx).and_then(|n| *n).unwrap_or("");
let number = format!("{}{:02}", prefix, idx + 1);
let cell = cols[col];
// Fill background
frame.render_widget(Block::default().style(Style::new().bg(bg)), cell);
let top_area = Rect::new(cell.x, cell.y, cell.width, 1);
let center_y = cell.y + cell.height / 2;
let center_area = Rect::new(cell.x, center_y, cell.width, 1);
if name.is_empty() {
// Number centered
frame.render_widget(
Paragraph::new(number)
.alignment(Alignment::Center)
.style(Style::new().fg(fg).add_modifier(Modifier::BOLD)),
center_area,
);
} else {
// Number centered at top
frame.render_widget(
Paragraph::new(number)
.alignment(Alignment::Center)
.style(Style::new().fg(fg).add_modifier(Modifier::DIM)),
top_area,
);
// Name centered in middle
frame.render_widget(
Paragraph::new(name)
.alignment(Alignment::Center)
.style(Style::new().fg(fg).add_modifier(Modifier::BOLD)),
center_area,
);
let queued_to_stop: Vec<usize> = app
.playback
.queued_changes
.iter()
.filter_map(|c| match c {
crate::engine::SlotChange::Remove { slot } => {
let s = snapshot.slot_data[*slot];
if s.active && s.bank == bank {
Some(s.pattern)
} else {
None
}
}
_ => None,
})
.collect();
let edit_pattern = if app.editor_ctx.bank == bank {
Some(app.editor_ctx.pattern)
} else {
None
};
let rows: Vec<Constraint> = (0..16).map(|_| Constraint::Length(1)).collect();
let row_areas = Layout::vertical(rows).split(inner);
for idx in 0..16 {
if idx >= row_areas.len() {
break;
}
let row_area = row_areas[idx];
let is_cursor = is_focused && idx == app.patterns_nav.pattern_cursor;
let is_selected = idx == app.patterns_nav.pattern_cursor;
let is_edit = edit_pattern == Some(idx);
let is_playing = playing_patterns.contains(&idx);
let is_queued_play = queued_to_play.contains(&idx);
let is_queued_stop = queued_to_stop.contains(&idx);
let (bg, fg, prefix) = match (is_cursor, is_playing, is_queued_play, is_queued_stop) {
(true, _, _, _) => (Color::Cyan, Color::Black, ""),
(false, true, _, true) => (Color::Rgb(120, 90, 30), Color::Yellow, "x "),
(false, true, _, false) => (Color::Rgb(45, 80, 45), Color::Green, "> "),
(false, false, true, _) => (Color::Rgb(80, 80, 45), Color::Yellow, "? "),
(false, false, false, _) if is_selected => (Color::Rgb(60, 65, 75), Color::White, ""),
(false, false, false, _) if is_edit => (Color::Rgb(45, 106, 95), Color::White, ""),
(false, false, false, _) => (Color::Reset, Color::Rgb(120, 125, 135), ""),
};
let name = app.project_state.project.banks[bank].patterns[idx]
.name
.as_deref()
.unwrap_or("");
let label = if name.is_empty() {
format!("{}{:02}", prefix, idx + 1)
} else {
format!("{}{:02} {}", prefix, idx + 1, name)
};
let style = Style::new().bg(bg).fg(fg);
let style = if is_playing || is_queued_play {
style.add_modifier(Modifier::BOLD)
} else {
style
};
let para = Paragraph::new(label).style(style);
frame.render_widget(para, row_area);
}
}